Visão geral da solução
O Pleaxy se conecta ao QuickBooks Online e ao Xero sem configuração extra. Equipes financeiras maiores costumam usar outro sistema: NetSuite, SAP S/4HANA, Microsoft Dynamics 365, Oracle Fusion, Sage Intacct ou Workday. Você não precisa esperar por um conector dedicado para usar o Pleaxy com eles. A API e os webhooks do Pleaxy oferecem tudo o que você precisa para montar o ciclo de ida e volta por conta própria, em três pequenos trechos de código.
Neste post, mostro como conectar o Pleaxy ao NetSuite, passo a passo: as faturas aprovadas viram contas a pagar, e os pagamentos que a sua equipe de contas a pagar faz no NetSuite voltam para o Pleaxy. Depois, mostro o que muda nos outros ERPs e o que levar em conta antes de entrar em produção.
Pleaxy
A fatura é conferida com o pedido de compra e o contrato, e as pessoas da cadeia de aprovação a aprovam.
Seu conector
Um webhook avisa que a fatura mudou. O seu conector a lê e cria uma conta a pagar no NetSuite.
NetSuite
O contas a pagar paga a conta no NetSuite, como paga todas as outras.
Pleaxy
O seu conector registra o pagamento no Pleaxy. A fatura aparece como paga, e o fornecedor recebe a notificação de pagamento e o aviso de pagamento.
Nada é digitado duas vezes, e cada sistema faz o seu trabalho: o Pleaxy decide o que deve ser pago, e o NetSuite lança e paga.
Pré-requisitos
No Pleaxy (um administrador do espaço de trabalho do comprador)
- O seu ID do espaço de trabalho, em Perfil da empresa, e uma chave de API para ler faturas.
- Uma segunda chave de API com o escopo
payments:record, criada com a função de administrador ou de finanças. Só uma chave com esse escopo pode registrar pagamentos, então mantenha-a separada da primeira. - Um endpoint de webhook: Perfil de comprador, aba Integrações, Webhooks, Adicionar endpoint. Escolha o evento
invoice.updatede copie o segredo de assinatura; ele é mostrado uma única vez.
No NetSuite
- Os serviços web REST e a autenticação baseada em token ativados, um registro de integração e um token de acesso para uma função que possa criar contas a pagar e executar consultas SuiteQL em transações.
- Um mapeamento de cada fornecedor do Pleaxy para o fornecedor correspondente no NetSuite, e as contas de despesa e de impostos em que as contas a pagar dele são lançadas. Uma pequena tabela no seu conector basta para começar.
Passo 1: saber quando uma fatura é aprovada
Primeiro, duas pequenas funções auxiliares: uma chama a API do Pleaxy com uma chave de API, a outra chama os serviços web REST do 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
}Quando uma fatura é aprovada, o Pleaxy envia por POST um evento invoice.updated ao seu endpoint. O evento identifica a fatura, mas não a contém, então uma requisição forjada não consegue colocar nada nos seus livros: o seu conector lê a fatura por conta própria, com a própria chave. Mesmo assim, confira o cabeçalho Pleaxy-Signature, responda em até 10 segundos e faça o trabalho depois. O Pleaxy tenta de novo qualquer entrega que não receba um 2xx, por cerca de dois dias.
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);Prefere consultar periodicamente, ou quer uma rede de segurança para um dia em que o seu endpoint ficou fora do ar? GET /sync/events lista os mesmos eventos, do mais antigo para o mais novo, a partir de um cursor que você guarda. Veja Sincronização de documentos.
Passo 2: lançar a conta a pagar
O invoice.updated também é disparado quando uma fatura é rejeitada ou quando os dados bancários dela mudam, então o conector primeiro verifica a situação da fatura e só segue em frente quando ela está approved. Depois, lê a fatura e cria a conta a pagar, com o número da fatura do fornecedor, as datas, as linhas, os descontos, as cobranças e os impostos, para que a conta some exatamente o valor que o Pleaxy aprovou.
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;
}
}- Uma conta por fatura. Os eventos podem chegar duas vezes. O ID externo da conta,
pleaxy-mais o ID da fatura, é único no NetSuite, então um evento repetido encontra a conta e para. - Aprovada uma só vez. A conta entra já aprovada: os aprovadores já decidiram no Pleaxy, com a fatura conferida com o pedido, o contrato e o consumo. Se o seu NetSuite encaminha contas para aprovação, deixe isso de fora e eles também vão aprovar por lá.
- O PDF, se você quiser.
GET /invoices/{id}/documentretorna a fatura em UBL 2.1, e o PDF da fatura pode ser anexado à conta como arquivo.
Passo 3: trazer os pagamentos de volta
O contas a pagar paga a conta no NetSuite, por cheque, ACH ou em um lote de pagamentos. Em seguida, uma tarefa agendada encontra, com uma consulta SuiteQL, as contas do Pleaxy que foram pagas integralmente, e as registra no Pleaxy com RecordPayments, até 50 por 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 fatura registrada aparece como paga no Pleaxy. O fornecedor recebe uma notificação de pagamento e um aviso de pagamento com a sua referência, e o sistema dele recebe um evento invoice.paid. Registrar um pagamento não movimenta dinheiro: só informa o que o NetSuite já pagou. Executar a tarefa duas vezes é seguro, porque um pagamento já registrado volta como already_recorded.
O mesmo ciclo para SAP, Dynamics e os demais
A metade do Pleaxy não muda: o webhook, as duas leituras e o RecordPayments são os mesmos para qualquer ERP. Só a metade do ERP muda: em que registro a fatura aprovada se transforma e onde o conector descobre que ela foi paga.
- SAP S/4HANA — uma fatura de fornecedor, criada pela API de faturas de fornecedor; os pagamentos vêm das partidas compensadas em contas a pagar.
- Microsoft Dynamics 365 Business Central — uma fatura de compra, criada e lançada pela API; os pagamentos vêm dos lançamentos do razão de fornecedores.
- Microsoft Dynamics 365 Finance — uma fatura de fornecedor, pelas entidades de dados; os pagamentos vêm das transações de fornecedor liquidadas contra ela.
- Oracle Fusion Cloud ERP — uma fatura de contas a pagar, pela API REST; os pagamentos vêm dos pagamentos de contas a pagar feitos contra ela.
- Sage Intacct — uma conta a pagar (AP bill); os pagamentos vêm dos pagamentos de AP que a quitaram.
- Workday — uma fatura de fornecedor, pelos serviços web; os pagamentos vêm do status de pagamento da fatura de fornecedor.
Um ERP ou hub de faturamento eletrônico que já aceita documentos padrão pode dispensar o mapeamento: o Pleaxy entrega cada fatura como um UBL 2.1 Invoice, o status dela como um cXML StatusUpdateRequest ou um UBL ApplicationResponse, e cada pagamento como um cXML PaymentRemittanceRequest ou um UBL RemittanceAdvice.
Bom saber
- As aprovações ficam com as pessoas. Uma chave de API pode ler faturas e registrar pagamentos, mas nunca pode aprovar nem rejeitar uma fatura: isso é sempre feito, no Pleaxy, pela pessoa que a etapa de aprovação indica.
- Os pagamentos são do valor total devido. O Pleaxy não registra pagamentos parciais, então envie uma conta só quando ela estiver paga integralmente.
- A sua equipe vê o que a chave fez. Cada chamada que registra pagamentos gera uma notificação no espaço de trabalho com o nome da chave e as faturas, e uma chave registra no máximo 500 pagamentos por dia.
- Os problemas de entrega ficam visíveis. A lista Entregas do webhook mostra cada evento, as tentativas e o que o seu endpoint respondeu, e Enviar teste envia um ping assinado.
Conclusão
Neste post, mostrei como conectar o Pleaxy ao NetSuite com três pequenos trechos de código: um receptor de webhooks, uma tarefa que lança as faturas aprovadas como contas a pagar e uma tarefa que traz os pagamentos de volta. Quem aprova continua decidindo no Pleaxy, a sua equipe de contas a pagar continua pagando no NetSuite, e os seus fornecedores ficam sabendo de cada pagamento sem que ninguém precise digitá-lo de novo.
Para começar, crie uma chave de API e um endpoint de webhook no espaço de trabalho do comprador e leia as referências de Webhooks e de Sincronização de documentos. Se você usa um ERP que não aparece aqui, ou quer uma mão com o seu primeiro conector, fale conosco.
Sobre o autor
Ryan Brown
Desenvolvedor líder da plataforma Pleaxy
Ryan Brown é o desenvolvedor líder da plataforma Pleaxy. Ele desenvolve a API, os webhooks e as integrações que conectam o Pleaxy aos sistemas contábeis e ERPs que as equipes financeiras já usam.