Blog do Pleaxy

Conecte o Pleaxy ao NetSuite, ao SAP ou a qualquer ERP

Um exemplo prático com o NetSuite: as faturas aprovadas saem como contas a pagar e os pagamentos voltam, pela API e pelos webhooks do Pleaxy.

por Ryan Brownem em Integrações, ERP e API9 min de leitura

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.

  1. Pleaxy

    A fatura é conferida com o pedido de compra e o contrato, e as pessoas da cadeia de aprovação a aprovam.

  2. Seu conector

    Um webhook avisa que a fatura mudou. O seu conector a lê e cria uma conta a pagar no NetSuite.

  3. NetSuite

    O contas a pagar paga a conta no NetSuite, como paga todas as outras.

  4. 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.updated e 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.

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
}

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.

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);

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.

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;
  }
}
  • 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}/document retorna 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.

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

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.

Fale conosco