Descripción general de la solución
Pleaxy se conecta con QuickBooks Online y Xero sin configuración adicional. Los equipos de finanzas más grandes suelen usar otro sistema: NetSuite, SAP S/4HANA, Microsoft Dynamics 365, Oracle Fusion, Sage Intacct o Workday. No hace falta esperar a un conector específico para usar Pleaxy con ellos. La API y los webhooks de Pleaxy te dan todo lo necesario para construir el circuito tú mismo, con tres piezas pequeñas de código.
En este artículo, te muestro cómo conectar Pleaxy con NetSuite, paso a paso: las facturas aprobadas se convierten en facturas de proveedor y los pagos que tu equipo de cuentas por pagar hace en NetSuite vuelven a Pleaxy. Después te muestro qué cambia en los demás ERP y qué tener en cuenta antes de pasar a producción.
Pleaxy
Una factura se comprueba contra su orden de compra y su contrato, y las personas de su cadena de aprobación la aprueban.
Tu conector
Un webhook avisa de que la factura cambió. Tu conector la lee y crea una factura de proveedor en NetSuite.
NetSuite
Cuentas por pagar paga la factura en NetSuite, como paga todas las demás.
Pleaxy
Tu conector registra el pago en Pleaxy. La factura aparece como pagada y el proveedor recibe el aviso de pago y el aviso de remesa.
Nada se escribe dos veces y cada sistema conserva su función: Pleaxy decide qué se debe pagar y NetSuite lo contabiliza y lo paga.
Requisitos previos
En Pleaxy (un administrador del espacio de trabajo del comprador)
- Tu ID de espacio de trabajo, que encontrarás en Perfil de la empresa, y una clave de API para leer facturas.
- Una segunda clave de API con el alcance
payments:record, creada con el rol de administrador o de finanzas. Solo una clave con este alcance puede registrar pagos, así que mantenla separada de la primera. - Un endpoint de webhook: Perfil del comprador, pestaña Integraciones, Webhooks, Añadir endpoint. Elige el evento
invoice.updatedy copia el secreto de firma; solo se muestra una vez.
En NetSuite
- Los servicios web REST y la autenticación basada en tokens activados, un registro de integración y un token de acceso para un rol que pueda crear facturas de proveedor y ejecutar consultas SuiteQL sobre transacciones.
- Una correspondencia entre cada proveedor de Pleaxy y su proveedor en NetSuite, y las cuentas de gastos e impuestos en las que se contabilizan sus facturas. Para empezar basta con una pequeña tabla en tu conector.
Paso 1: entérate de las facturas aprobadas
Primero, dos pequeñas funciones auxiliares: una llama a la API de Pleaxy con una clave de API y la otra llama a los servicios web REST de 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
}Cuando se aprueba una factura, Pleaxy envía un evento invoice.updated por POST a tu endpoint. El evento identifica la factura, pero no la contiene, así que una solicitud falsificada no puede meter nada en tu contabilidad: tu conector lee la factura por sí mismo, con su propia clave. Aun así, comprueba la cabecera Pleaxy-Signature, responde en menos de 10 segundos y haz el trabajo después. Pleaxy reintenta durante unos dos días cualquier entrega que no reciba un 2xx.
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);¿Prefieres consultar periódicamente, o quieres una red de seguridad para el día en que tu endpoint estuvo caído? GET /sync/events lista los mismos eventos, del más antiguo al más reciente, a partir de un cursor que guardas tú. Consulta Sincronización de documentos.
Paso 2: contabiliza la factura de proveedor
invoice.updated también se dispara cuando una factura se rechaza o cambian sus datos bancarios, así que el conector pregunta primero en qué estado está la factura y solo continúa si está approved. Después lee la factura y crea la factura de proveedor, con el número de factura del proveedor, las fechas, las líneas, los descuentos, los cargos y los impuestos, para que el total coincida con el importe que aprobó Pleaxy.
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;
}
}- Una factura de proveedor por factura. Los eventos pueden llegar dos veces. El ID externo de la factura de proveedor,
pleaxy-más el ID de la factura, es único en NetSuite, así que un evento repetido encuentra la factura de proveedor y se detiene. - Se aprueba una sola vez. La factura de proveedor entra ya aprobada: los aprobadores ya decidieron en Pleaxy, con la factura cotejada con su orden, su contrato y su consumo. Si tu NetSuite envía las facturas de proveedor a aprobación, omite ese campo y también la aprobarán allí.
- El PDF, si lo quieres.
GET /invoices/{id}/documentdevuelve la factura en UBL 2.1, y el PDF de la factura se puede adjuntar a la factura de proveedor como archivo.
Paso 3: trae los pagos de vuelta
Cuentas por pagar paga la factura de proveedor en NetSuite, con cheque, ACH o una remesa de pagos. Después, un proceso programado encuentra con una consulta SuiteQL las facturas de proveedor de Pleaxy que están pagadas por completo y las registra en Pleaxy con RecordPayments, hasta 50 a la vez.
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}`);
}
}
}Cada factura registrada aparece como pagada en Pleaxy. Su proveedor recibe un aviso de pago y un aviso de remesa con tu referencia, y su propio sistema recibe un evento invoice.paid. Registrar un pago no mueve dinero: indica lo que NetSuite ya pagó. Ejecutar el proceso dos veces no supone ningún riesgo, porque un pago registrado antes vuelve como already_recorded.
El mismo circuito para SAP, Dynamics y los demás
La parte de Pleaxy no cambia: el webhook, las dos lecturas y RecordPayments son iguales para todos los ERP. Solo cambia la parte del ERP: en qué registro se convierte la factura aprobada y dónde comprueba el conector que se pagó.
- SAP S/4HANA: una factura de proveedor, creada con su API de facturas de proveedor; los pagos, a partir de las partidas compensadas en cuentas por pagar.
- Microsoft Dynamics 365 Business Central: una factura de compra, creada y registrada con su API; los pagos, a partir de los movimientos de proveedor.
- Microsoft Dynamics 365 Finance: una factura de proveedor, mediante sus entidades de datos; los pagos, a partir de las transacciones de proveedor liquidadas contra ella.
- Oracle Fusion Cloud ERP: una factura de cuentas por pagar, con su API REST; los pagos, a partir de los pagos de cuentas por pagar realizados contra ella.
- Sage Intacct: una factura de cuentas por pagar (AP bill); los pagos, a partir de los pagos de cuentas por pagar que la saldaron.
- Workday: una factura de proveedor, mediante sus servicios web; los pagos, a partir del estado de pago de la factura de proveedor.
Un ERP o una plataforma de facturación electrónica que ya acepte documentos estándar puede saltarse la correspondencia de campos: Pleaxy entrega cada factura como UBL 2.1 Invoice, su estado como cXML StatusUpdateRequest o UBL ApplicationResponse, y cada pago como cXML PaymentRemittanceRequest o UBL RemittanceAdvice.
Conviene saber
- Las aprobaciones son cosa de personas. Una clave de API puede leer facturas y registrar pagos, pero nunca puede aprobar ni rechazar una factura: eso lo hace siempre, en Pleaxy, la persona que indica el paso de aprobación.
- Los pagos son por el importe total adeudado. Pleaxy no registra pagos parciales, así que envía una factura de proveedor cuando esté pagada por completo.
- Tu equipo ve lo que hizo la clave. Cada llamada que registra pagos genera una notificación en el espacio de trabajo con el nombre de la clave y las facturas, y una clave registra como máximo 500 pagos al día.
- Los problemas de entrega están a la vista. La lista Entregas del webhook muestra cada evento, sus intentos y lo que respondió tu endpoint, y Enviar prueba envía un ping firmado.
Conclusión
En este artículo, te mostré cómo conectar Pleaxy con NetSuite con tres piezas pequeñas de código: un receptor de webhooks, un proceso que contabiliza las facturas aprobadas como facturas de proveedor y otro que trae los pagos de vuelta. Tus aprobadores siguen decidiendo en Pleaxy, tu equipo de cuentas por pagar sigue pagando en NetSuite y tus proveedores se enteran de cada pago sin que nadie tenga que volver a escribirlo.
Para empezar, crea una clave de API y un endpoint de webhook en tu espacio de trabajo de comprador, y lee las referencias de Webhooks y Sincronización de documentos. Si usas un ERP que no aparece aquí, o quieres ayuda con tu primer conector, habla con nosotros.
Sobre el autor
Ryan Brown
Desarrollador principal, plataforma Pleaxy
Ryan Brown es el desarrollador principal de la plataforma Pleaxy. Desarrolla la API, los webhooks y las integraciones que conectan Pleaxy con los sistemas contables y ERP que los equipos de finanzas ya usan.