Lösungsüberblick
Pleaxy verbindet sich ab Werk mit QuickBooks Online und Xero. Größere Finanzteams arbeiten meist mit etwas anderem: NetSuite, SAP S/4HANA, Microsoft Dynamics 365, Oracle Fusion, Sage Intacct oder Workday. Um Pleaxy mit diesen Systemen zu nutzen, müssen Sie nicht auf einen eigenen Konnektor warten. Die Pleaxy-API und Webhooks bieten alles, was Sie brauchen, um den Kreislauf selbst aufzubauen, mit drei kleinen Codebausteinen.
In diesem Beitrag zeige ich Ihnen Schritt für Schritt, wie Sie Pleaxy mit NetSuite verbinden: Freigegebene Rechnungen werden zu Lieferantenrechnungen, und die Zahlungen, die Ihre Kreditorenbuchhaltung in NetSuite ausführt, fließen zurück in Pleaxy. Anschließend zeige ich, was sich bei den anderen ERPs ändert und was Sie vor dem Livegang beachten sollten.
Pleaxy
Eine Rechnung wird mit ihrer Bestellung und ihrem Vertrag abgeglichen, und die Personen in ihrer Freigabekette geben sie frei.
Ihr Konnektor
Ein Webhook meldet, dass sich die Rechnung geändert hat. Ihr Konnektor liest sie und legt in NetSuite eine Lieferantenrechnung (Vendor Bill) an.
NetSuite
Die Kreditorenbuchhaltung bezahlt die Rechnung in NetSuite, wie jede andere Rechnung auch.
Pleaxy
Ihr Konnektor erfasst die Zahlung in Pleaxy. Die Rechnung steht auf bezahlt, und der Lieferant erhält die Zahlungsmitteilung und das Zahlungsavis.
Nichts wird doppelt eingegeben, und jedes System behält seine Aufgabe: Pleaxy entscheidet, was bezahlt werden soll, NetSuite bucht und bezahlt es.
Voraussetzungen
In Pleaxy (als Administrator des Käufer-Workspace)
- Ihre Workspace-ID aus dem Unternehmensprofil und einen API-Schlüssel, um Rechnungen zu lesen.
- Einen zweiten API-Schlüssel mit dem Scope
payments:record, erstellt mit der Rolle Administrator oder Finanzen. Nur ein Schlüssel mit diesem Scope kann Zahlungen erfassen, halten Sie ihn daher vom ersten getrennt. - Einen Webhook-Endpunkt: Käuferprofil, Tab Integrationen, Webhooks, Endpunkt hinzufügen. Wählen Sie das Ereignis
invoice.updatedund kopieren Sie das Signatur-Secret; es wird nur einmal angezeigt.
In NetSuite
- Aktivierte REST-Webservices und tokenbasierte Authentifizierung, ein Integrationsdatensatz und ein Zugriffstoken für eine Rolle, die Lieferantenrechnungen anlegen und SuiteQL-Abfragen auf Transaktionen ausführen darf.
- Eine Zuordnung jedes Pleaxy-Lieferanten zu seinem NetSuite-Lieferanten sowie die Aufwands- und Steuerkonten, auf die seine Rechnungen gebucht werden. Für den Anfang genügt eine kleine Tabelle in Ihrem Konnektor.
Schritt 1: von freigegebenen Rechnungen erfahren
Zuerst zwei kleine Hilfsfunktionen: Eine ruft die Pleaxy-API mit einem API-Schlüssel auf, die andere die REST-Webservices von NetSuite.
// Pleaxy: your buyer workspace's API base, read with an API key.
const PLEAXY = `${process.env.PLEAXY_API_BASE_URL}/v1/workspaces/${process.env.PLEAXY_WORKSPACE_ID}`;
export async function pleaxy(path, { method = "GET", body, apiKey = process.env.PLEAXY_API_KEY } = {}) {
const res = await fetch(`${PLEAXY}${path}`, {
method,
headers: { "X-Api-Key": apiKey, "Content-Type": "application/json" },
body: body && JSON.stringify(body),
});
if (!res.ok) throw new Error(`Pleaxy ${res.status}: ${await res.text()}`);
return res.json();
}
// NetSuite REST web services. signNetSuite adds the token-based authentication
// header (OAuth 1.0a, HMAC-SHA256) with the OAuth library you already use.
const NETSUITE = `https://${process.env.NS_ACCOUNT_HOST}.suitetalk.api.netsuite.com/services/rest`;
export async function netsuite(method, path, body, headers = {}) {
const url = `${NETSUITE}${path}`;
const res = await fetch(url, {
method,
headers: { ...signNetSuite(method, url), "Content-Type": "application/json", ...headers },
body: body && JSON.stringify(body),
});
if (!res.ok) throw Object.assign(new Error(`NetSuite ${res.status}: ${await res.text()}`), { status: res.status });
return res.status === 204 ? undefined : res.json(); // a created record answers 204
}Wird eine Rechnung freigegeben, sendet Pleaxy per POST ein invoice.updated-Ereignis an Ihren Endpunkt. Das Ereignis nennt die Rechnung, enthält sie aber nicht. So kann eine gefälschte Anfrage nichts in Ihre Bücher bringen: Ihr Konnektor liest die Rechnung selbst, mit seinem eigenen Schlüssel. Prüfen Sie den Header Pleaxy-Signature trotzdem, antworten Sie innerhalb von 10 Sekunden und erledigen Sie die Arbeit danach. Pleaxy wiederholt jede Zustellung, die keine 2xx-Antwort erhält, etwa zwei Tage lang.
import crypto from "node:crypto";
import express from "express";
import { syncInvoice } from "./vendor-bill.mjs";
const app = express();
// Pleaxy signs the raw body, so read it as text and parse it after checking.
app.post("/pleaxy/webhooks", express.text({ type: "application/json" }), (req, res) => {
if (!isFromPleaxy(req.get("Pleaxy-Signature") ?? "", req.body, process.env.PLEAXY_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body);
res.sendStatus(204); // answer within 10 seconds, then do the work
if (event.type === "invoice.updated") {
syncInvoice(event.documentId).catch(console.error); // in production: a job queue
}
});
// Pleaxy-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">
function isFromPleaxy(header, rawBody, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
if (!parts.t || !parts.v1 || Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest();
const actual = Buffer.from(parts.v1, "hex");
return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
}
app.listen(3000);Sie fragen lieber regelmäßig ab oder möchten ein Sicherheitsnetz für den Tag, an dem Ihr Endpunkt ausgefallen war? GET /sync/events listet dieselben Ereignisse, die ältesten zuerst, ab einem Cursor, den Sie speichern. Siehe Dokumentsynchronisation.
Schritt 2: die Lieferantenrechnung buchen
invoice.updated wird auch ausgelöst, wenn eine Rechnung abgelehnt wird oder sich ihre Bankverbindung ändert. Der Konnektor fragt daher zuerst ab, wo die Rechnung steht, und macht nur weiter, wenn sie approved ist. Dann liest er die Rechnung und legt die Lieferantenrechnung an, mit der Rechnungsnummer des Lieferanten, Datumsangaben, Positionen, Rabatten, Zuschlägen und Steuern, sodass die Rechnung genau den Betrag ergibt, den Pleaxy freigegeben hat.
import { pleaxy, netsuite } from "./clients.mjs";
// Your mapping: the NetSuite vendor for each Pleaxy supplier (supplierOrgId),
// and the accounts its spend and tax are booked to.
const VENDORS = {
"org_01k5x8q3m7c2vd9pzt4wbh6ner": { vendorId: "1842", expenseAccountId: "6120" },
};
const TAX_ACCOUNT_ID = "2310";
export async function syncInvoice(invoiceId) {
// invoice.updated also fires for rejections and bank details:
// book only an invoice that is approved and not yet paid.
const status = await pleaxy(`/invoices/${invoiceId}/status`);
if (status.status !== "approved") return;
// Events can arrive twice. The external ID makes the bill unique in NetSuite,
// so check for it first: a second delivery finds the bill and stops.
const externalId = `pleaxy-${invoiceId}`;
if (await billExists(externalId)) return;
const invoice = await pleaxy(`/invoices/${invoiceId}`);
const vendor = VENDORS[invoice.supplierOrgId];
if (!vendor) throw new Error(`No NetSuite vendor mapped for ${invoice.supplier}`);
// Lines, invoice-level discounts and charges, and tax add up to the invoice's total.
const line = (amount, memo, accountId = vendor.expenseAccountId) => ({ account: { id: accountId }, amount, memo });
const items = [
...invoice.lineItems.map((l) => line(l.amount, l.description)),
...(invoice.adjustments ?? []).map((a) => line(a.type === "discount" ? -a.amount : a.amount, a.description)),
...(invoice.taxAmount ? [line(invoice.taxAmount, "Tax", TAX_ACCOUNT_ID)] : []),
];
await netsuite("POST", "/record/v1/vendorBill", {
externalId,
entity: { id: vendor.vendorId },
tranId: status.invoiceNumber, // the supplier's invoice number
tranDate: status.issueDate,
dueDate: status.dueDate,
memo: [`Pleaxy ${invoiceId}`, invoice.po].filter(Boolean).join(" · "),
approvalStatus: { id: "2" }, // Approved: people already approved it in Pleaxy
expense: { items },
});
}
async function billExists(externalId) {
try {
await netsuite("GET", `/record/v1/vendorBill/eid:${externalId}?fields=id`);
return true;
} catch (err) {
if (err.status === 404) return false;
throw err;
}
}- Eine Lieferantenrechnung pro Rechnung. Ereignisse können doppelt eintreffen. Die externe ID der Lieferantenrechnung,
pleaxy-plus die Rechnungs-ID, ist in NetSuite eindeutig. Ein wiederholtes Ereignis findet die Lieferantenrechnung also und bricht ab. - Nur einmal freigegeben. Die Lieferantenrechnung wird als freigegeben angelegt: Die Freigebenden haben bereits in Pleaxy entschieden, nachdem die Rechnung mit Bestellung, Vertrag und Nutzung abgeglichen wurde. Wenn Ihr NetSuite Rechnungen zur Freigabe weiterleitet, lassen Sie diese Angabe weg, dann wird sie dort ein zweites Mal freigegeben.
- Das PDF, wenn Sie es möchten.
GET /invoices/{id}/documentliefert die Rechnung als UBL 2.1, und das PDF der Rechnung lässt sich als Datei an die Lieferantenrechnung anhängen.
Schritt 3: Zahlungen zurückmelden
Die Kreditorenbuchhaltung bezahlt die Rechnung in NetSuite, per Scheck, ACH oder Zahlungslauf. Ein geplanter Job findet anschließend mit einer SuiteQL-Abfrage die vollständig bezahlten Pleaxy-Rechnungen und erfasst sie in Pleaxy mit RecordPayments, bis zu 50 auf einmal.
import { pleaxy, netsuite } from "./clients.mjs";
// Bills that came from Pleaxy, now paid in full, with the bill payment that
// settled them, changed since the last run. Run it every 15 minutes or so.
const paidBills = (since) => `
SELECT DISTINCT bill.externalid AS externalid, pay.id AS paymentid, pay.tranid AS tranid,
TO_CHAR(pay.trandate, 'YYYY-MM-DD') AS paydate
FROM transaction bill
JOIN NextTransactionLineLink link ON link.previousdoc = bill.id
JOIN transaction pay ON pay.id = link.nextdoc
WHERE bill.type = 'VendBill' AND bill.status = 'B' -- B: paid in full
AND pay.type = 'VendPymt'
AND bill.externalid LIKE 'pleaxy-%'
AND pay.lastmodifieddate >= TO_DATE('${since}', 'YYYY-MM-DD HH24:MI:SS')`;
export async function syncPayments(since) {
const { items } = await netsuite("POST", "/query/v1/suiteql", { q: paidBills(since) }, { Prefer: "transient" });
// Pleaxy records one payment for the whole amount due: for a bill paid in
// instalments, send the payment that finished it.
const latest = new Map();
for (const row of items) {
if (!latest.has(row.externalid) || row.paydate >= latest.get(row.externalid).paydate) latest.set(row.externalid, row);
}
const payments = [...latest.values()].map((row) => ({
invoiceId: row.externalid.replace(/^pleaxy-/, ""),
method: "ach", // or map it from the bill payment's payment method
paymentDate: row.paydate,
reference: row.tranid || `NS-${row.paymentid}`, // the supplier sees it: never an account number
}));
// Up to 50 a call, with a key that has the payments:record scope.
// Repeating a payment is safe: it comes back as already_recorded.
for (let i = 0; i < payments.length; i += 50) {
const { results } = await pleaxy("/invoices/payment-records", {
method: "POST",
body: { payments: payments.slice(i, i + 50) },
apiKey: process.env.PLEAXY_PAYMENTS_KEY,
});
for (const r of results.filter((r) => r.outcome === "refused")) {
console.error(`${r.invoiceId}: ${r.code} ${r.message}`);
}
}
}Jede erfasste Rechnung steht in Pleaxy auf bezahlt. Ihr Lieferant erhält eine Zahlungsmitteilung und ein Zahlungsavis mit Ihrer Referenz, und sein eigenes System erhält ein invoice.paid-Ereignis. Das Erfassen einer Zahlung bewegt kein Geld: Es hält fest, was NetSuite bereits bezahlt hat. Den Job zweimal auszuführen ist unbedenklich, denn eine bereits erfasste Zahlung kommt als already_recorded zurück.
Derselbe Kreislauf für SAP, Dynamics und den Rest
Die Pleaxy-Hälfte ändert sich nicht: Der Webhook, die beiden Lesezugriffe und RecordPayments sind bei jedem ERP gleich. Nur die ERP-Hälfte ändert sich: welcher Beleg aus der freigegebenen Rechnung wird und wo der Konnektor erkennt, dass sie bezahlt wurde.
- SAP S/4HANA – eine Lieferantenrechnung, angelegt über die Supplier-Invoice-API; Zahlungen aus den ausgeglichenen Posten der Kreditorenbuchhaltung.
- Microsoft Dynamics 365 Business Central – eine Einkaufsrechnung, angelegt und gebucht über die API; Zahlungen aus den Kreditorenposten.
- Microsoft Dynamics 365 Finance – eine Kreditorenrechnung, über die Datenentitäten; Zahlungen aus den damit ausgeglichenen Kreditorenbuchungen.
- Oracle Fusion Cloud ERP – eine Payables-Rechnung, über die REST-API; Zahlungen aus den darauf geleisteten Payables-Zahlungen.
- Sage Intacct – eine AP-Rechnung (AP Bill); Zahlungen aus den AP-Zahlungen, mit denen sie beglichen wurde.
- Workday – eine Lieferantenrechnung, über die Webservices; Zahlungen aus dem Zahlungsstatus der Lieferantenrechnung.
Ein ERP oder E-Invoicing-Hub, das bereits Standarddokumente verarbeitet, kann sich die Zuordnung sparen: Pleaxy liefert jede Rechnung als UBL 2.1 Invoice, ihren Status als cXML StatusUpdateRequest oder UBL ApplicationResponse und jede Zahlung als cXML PaymentRemittanceRequest oder UBL RemittanceAdvice.
Gut zu wissen
- Freigaben bleiben bei Menschen. Ein API-Schlüssel kann Rechnungen lesen und Zahlungen erfassen, aber niemals eine Rechnung freigeben oder ablehnen: Das tut immer die Person, die der Freigabeschritt nennt, in Pleaxy.
- Zahlungen gelten für den vollen fälligen Betrag. Pleaxy erfasst keine Teilzahlungen. Melden Sie eine Rechnung daher erst, wenn sie vollständig bezahlt ist.
- Ihr Team sieht, was der Schlüssel getan hat. Jeder Aufruf, der Zahlungen erfasst, löst im Workspace eine Benachrichtigung mit dem Schlüssel und den Rechnungen aus, und ein Schlüssel erfasst höchstens 500 Zahlungen pro Tag.
- Zustellprobleme sind sichtbar. Die Liste Zustellungen des Webhooks zeigt jedes Ereignis, seine Versuche und die Antwort Ihres Endpunkts, und Test senden schickt einen signierten Ping.
Fazit
In diesem Beitrag habe ich Ihnen gezeigt, wie Sie Pleaxy mit drei kleinen Codebausteinen an NetSuite anbinden: einem Webhook-Empfänger, einem Job, der freigegebene Rechnungen als Lieferantenrechnungen bucht, und einem Job, der Zahlungen zurückmeldet. Ihre Freigebenden entscheiden weiterhin in Pleaxy, Ihre Kreditorenbuchhaltung bezahlt weiterhin in NetSuite, und Ihre Lieferanten erfahren von jeder Zahlung, ohne dass jemand etwas abtippen muss.
Erstellen Sie für den Einstieg einen API-Schlüssel und einen Webhook-Endpunkt in Ihrem Käufer-Workspace und lesen Sie die Referenzen zu Webhooks und Dokumentsynchronisation. Nutzen Sie ein ERP, das hier nicht behandelt wird, oder möchten Sie Unterstützung bei Ihrem ersten Konnektor? Nehmen Sie Kontakt auf.
Über den Autor
Ryan Brown
Lead Developer, Pleaxy-Plattform
Ryan Brown ist Lead Developer der Pleaxy-Plattform. Er entwickelt die API, die Webhooks und die Integrationen, die Pleaxy mit den Buchhaltungs- und ERP-Systemen verbinden, die Finanzteams bereits nutzen.