Blog de Pleaxy

Connecter Pleaxy à NetSuite, SAP ou tout autre ERP

Un exemple concret avec NetSuite : les factures validées partent sous forme de factures fournisseurs et les paiements reviennent, via l’API et les webhooks de Pleaxy.

par Ryan Brownle dans Intégrations, ERP et API9 min de lecture

Présentation de la solution

Pleaxy se connecte à QuickBooks Online et à Xero sans configuration. Les équipes finance plus importantes utilisent souvent autre chose : NetSuite, SAP S/4HANA, Microsoft Dynamics 365, Oracle Fusion, Sage Intacct ou Workday. Inutile d’attendre un connecteur dédié pour les utiliser avec Pleaxy. L’API et les webhooks de Pleaxy vous donnent tout ce qu’il faut pour construire vous-même l’aller-retour, en trois petits morceaux de code.

Dans cet article, je vous montre pas à pas comment connecter Pleaxy à NetSuite : les factures validées deviennent des factures fournisseurs (vendor bills), et les paiements que votre comptabilité fournisseurs effectue dans NetSuite reviennent dans Pleaxy. Je vous montre ensuite ce qui change pour les autres ERP, et ce qu’il faut garder en tête avant la mise en production.

  1. Pleaxy

    Une facture est vérifiée par rapport à son bon de commande et à son contrat, puis les personnes de son circuit de validation l’approuvent.

  2. Votre connecteur

    Un webhook signale que la facture a changé. Votre connecteur la lit et crée une facture fournisseur (vendor bill) dans NetSuite.

  3. NetSuite

    La comptabilité fournisseurs paie la facture dans NetSuite, comme toutes les autres.

  4. Pleaxy

    Votre connecteur enregistre le paiement dans Pleaxy. La facture apparaît comme payée, et le fournisseur reçoit la notification de paiement et l’avis de paiement.

Rien n’est saisi deux fois, et chaque système garde son rôle : Pleaxy décide de ce qui doit être payé, NetSuite le comptabilise et le paie.

Prérequis

Dans Pleaxy (un administrateur de l’espace de travail acheteur)

  • Votre Workspace ID, dans le profil de l’entreprise, et une clé API pour lire les factures.
  • Une deuxième clé API avec le scope payments:record, créée avec le rôle administrateur ou finance. Seule une clé dotée de ce scope peut enregistrer des paiements : gardez-la séparée de la première.
  • Un endpoint de webhook : Profil acheteur, onglet Intégrations, Webhooks, Ajouter un endpoint. Choisissez l’événement invoice.updated et copiez le secret de signature ; il n’est affiché qu’une fois.

Dans NetSuite

  • Les services web REST et l’authentification par jeton activés, un enregistrement d’intégration, et un jeton d’accès pour un rôle autorisé à créer des factures fournisseurs et à exécuter des requêtes SuiteQL sur les transactions.
  • Une correspondance entre chaque fournisseur Pleaxy et son fournisseur NetSuite, ainsi que les comptes de charges et de taxes sur lesquels leurs factures sont comptabilisées. Une petite table dans votre connecteur suffit pour commencer.

Étape 1 : être averti des factures validées

D’abord, deux petites fonctions utilitaires : l’une appelle l’API Pleaxy avec une clé API, l’autre appelle les services web REST de NetSuite.

clients.mjs
// 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
}

Quand une facture est validée, Pleaxy envoie (POST) un événement invoice.updated à votre endpoint. L’événement désigne la facture sans la contenir : une requête falsifiée ne peut donc rien inscrire dans vos comptes, puisque votre connecteur lit lui-même la facture, avec sa propre clé. Vérifiez quand même l’en-tête Pleaxy-Signature, répondez en moins de 10 secondes et faites le traitement ensuite. Pleaxy relance toute livraison qui ne reçoit pas de 2xx, pendant environ deux jours.

webhook.mjs
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);

Vous préférez interroger l’API à intervalles réguliers, ou voulez un filet de sécurité pour un jour où votre endpoint était indisponible ? GET /sync/events liste les mêmes événements, du plus ancien au plus récent, à partir d’un curseur que vous conservez. Voir Synchronisation des documents.

Étape 2 : comptabiliser la facture fournisseur

invoice.updated se déclenche aussi quand une facture est rejetée ou que ses coordonnées bancaires changent : le connecteur demande donc d’abord où en est la facture et ne continue que si elle est approved. Il lit ensuite la facture et crée la facture fournisseur, avec le numéro de facture du fournisseur, les dates, les lignes, les remises, les frais et les taxes, pour que son total corresponde au montant validé dans Pleaxy.

vendor-bill.mjs
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;
  }
}
  • Une facture fournisseur par facture. Un événement peut arriver deux fois. L’identifiant externe de la facture fournisseur, pleaxy- suivi de l’identifiant de la facture, est unique dans NetSuite : un événement répété retrouve la facture fournisseur et s’arrête.
  • Validée une seule fois. La facture fournisseur est créée comme approuvée : les approbateurs ont déjà décidé dans Pleaxy, avec une facture rapprochée de sa commande, de son contrat et de la consommation. Si votre NetSuite soumet les factures à validation, retirez ce champ et elles seront aussi validées dans NetSuite.
  • Le PDF, si vous le souhaitez. GET /invoices/{id}/document renvoie la facture au format UBL 2.1, et le PDF de la facture peut être joint à la facture fournisseur sous forme de fichier.

Étape 3 : rapatrier les paiements

La comptabilité fournisseurs paie la facture dans NetSuite, par chèque, virement ACH ou campagne de paiement. Une tâche planifiée retrouve ensuite, avec une requête SuiteQL, les factures fournisseurs Pleaxy payées en totalité, et les enregistre dans Pleaxy avec RecordPayments, jusqu’à 50 à la fois.

payments.mjs
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}`);
    }
  }
}

Chaque facture enregistrée apparaît comme payée dans Pleaxy. Son fournisseur reçoit une notification de paiement et un avis de paiement portant votre référence, et son propre système reçoit un événement invoice.paid. Enregistrer un paiement ne déplace aucun argent : cela indique ce que NetSuite a déjà payé. Exécuter la tâche deux fois ne pose aucun problème, car un paiement déjà enregistré revient avec already_recorded.

La même boucle pour SAP, Dynamics et les autres

La partie Pleaxy ne change pas : le webhook, les deux lectures et RecordPayments sont les mêmes pour tous les ERP. Seule la partie ERP change : l’enregistrement que devient la facture validée, et l’endroit où le connecteur constate qu’elle a été payée.

  • SAP S/4HANA - une facture fournisseur, créée via son API de factures fournisseurs ; les paiements à partir des postes soldés de la comptabilité fournisseurs.
  • Microsoft Dynamics 365 Business Central - une facture d’achat, créée et validée via son API ; les paiements à partir des écritures comptables fournisseur.
  • Microsoft Dynamics 365 Finance - une facture fournisseur, via ses entités de données ; les paiements à partir des transactions fournisseur lettrées avec elle.
  • Oracle Fusion Cloud ERP - une facture fournisseur (payables invoice), via son API REST ; les paiements à partir des règlements fournisseurs effectués sur elle.
  • Sage Intacct - une facture fournisseur (AP bill) ; les paiements à partir des règlements fournisseurs qui l’ont payée.
  • Workday - une facture fournisseur, via ses services web ; les paiements à partir du statut de paiement de la facture fournisseur.

Un ERP ou une plateforme de facturation électronique qui accepte déjà des documents standard peut se passer de la correspondance : Pleaxy fournit chaque facture sous forme d’Invoice UBL 2.1, son statut sous forme de StatusUpdateRequest cXML ou d’ApplicationResponse UBL, et chaque paiement sous forme de PaymentRemittanceRequest cXML ou de RemittanceAdvice UBL.

Bon à savoir

  • Les validations restent humaines. Une clé API peut lire des factures et enregistrer des paiements, mais elle ne peut jamais valider ni rejeter une facture : c’est toujours la personne désignée par l’étape de validation, dans Pleaxy.
  • Les paiements portent sur la totalité du montant dû. Pleaxy n’enregistre pas les paiements partiels : envoyez une facture une fois qu’elle est payée en totalité.
  • Votre équipe voit ce que la clé a fait. Chaque appel qui enregistre des paiements déclenche une notification dans l’espace de travail indiquant la clé et les factures, et une clé enregistre au plus 500 paiements par jour.
  • Les problèmes de livraison sont visibles. La liste Livraisons du webhook affiche chaque événement, ses tentatives et la réponse de votre endpoint, et Envoyer un test envoie un ping signé.

Conclusion

Dans cet article, je vous ai montré comment connecter Pleaxy à NetSuite avec trois petits morceaux de code : un récepteur de webhooks, une tâche qui comptabilise les factures validées comme factures fournisseurs, et une tâche qui rapatrie les paiements. Vos approbateurs continuent de décider dans Pleaxy, votre comptabilité fournisseurs continue de payer dans NetSuite, et vos fournisseurs sont informés de chaque paiement sans que personne n’ait à le ressaisir.

Pour commencer, créez une clé API et un endpoint de webhook dans votre espace de travail acheteur, puis consultez les références Webhooks et Synchronisation des documents. Si vous utilisez un ERP qui n’est pas traité ici, ou si vous voulez un coup de main pour votre premier connecteur, contactez-nous.

À propos de l’auteur

Ryan Brown

Développeur principal, plateforme Pleaxy

Ryan Brown est le développeur principal de la plateforme Pleaxy. Il conçoit l’API, les webhooks et les intégrations qui connectent Pleaxy aux systèmes comptables et aux ERP que les équipes finance utilisent déjà.

Nous contacter