Tutorial: um App ao lado do ERP (clean core)

Mantenha o ERP no padrão e leve o processo específico para um App Oon — despesas com aprovação, credencial cifrada com core.secrets e lançamento idempotente no ERP por uma rota privada.

Este tutorial mostra como construir um App que trabalha ao lado do ERP, sem customizá-lo. O ERP continua sendo o sistema de registro de contas a pagar; o App assume o processo que vem antes: a despesa nasce, passa por aprovação e só então é lançada no ERP, uma única vez, com a credencial guardada cifrada.

Despesa (App Oon)                                      ERP
Rascunho → Em aprovação ─[Aprovar]→ Aprovada ──[Lançar no ERP]──▶ Conta a pagar
                        ─[Recusar]→ Recusada → Rascunho

Ao final, você terá:

  • uma esteira de despesas com transições validadas no backend e aprovação exclusiva de quem tem permissão para aprovar;
  • a credencial do ERP cifrada com core.secrets, nunca devolvida ao navegador;
  • uma rota privada que lança a despesa aprovada no ERP, com permissão própria, auditoria e proteção contra lançamento duplicado;
  • o histórico de cada tentativa (sucesso, erro ou resultado a conferir) consultável no App.

Pré-requisitos

  • Uma Central gerada e rodando localmente, como no Quickstart.
  • O App precisa ser single_tenant (implantação dedicada), porque core.secrets V1 é exclusivo desse modelo — é o padrão do scaffold. Veja Tipos de App e tenancy.
  • A API do seu ERP. Aqui usamos uma API genérica POST /contas-pagar com token Bearer; troque pelo contrato do seu ERP no passo 4.

1. Declare capability, permissões e perfis

{
  "capabilities": ["core.secrets"],
  "capabilitySettings": { "core.secrets": { "enabled": true } },
  "rbac": {
    "permissions": ["despesas.read", "despesas.write", "despesas.approve", "despesas.lancar", "erp.credenciais", "erp.integracao"],
    "roles": [
      { "code": "admin", "name": "Administrador", "admin": true, "permissions": ["*"] },
      { "code": "solicitante", "name": "Solicitante", "description": "Registra despesas e envia para aprovação.",
        "permissions": ["despesas.read", "despesas.write"] },
      { "code": "financeiro", "name": "Financeiro", "description": "Aprova despesas e lança no ERP.",
        "permissions": ["despesas.read", "despesas.write", "despesas.approve", "despesas.lancar"] }
    ]
  }
}

Aprovar (despesas.approve), lançar no ERP (despesas.lancar) e cadastrar a credencial (erp.credenciais) são permissões separadas de editar a despesa (despesas.write). erp.integracao protege a escrita do histórico de lançamentos e não é concedida a nenhum perfil de negócio: só a rota do passo 5 grava esse histórico.

2. Declare a despesa e a esteira

{
  "schemaVersion": 1,
  "name": "Despesas ao lado do ERP",
  "models": [
    {
      "name": "Despesa",
      "singular": "despesa",
      "basePath": "/despesas",
      "crud": { "enabled": true, "permissions": { "read": "despesas.read", "write": "despesas.write" } },
      "fields": {
        "descricao": { "kind": "string", "required": true, "maxLength": 120 },
        "fornecedor": { "kind": "string", "required": true, "maxLength": 120 },
        "cnpjFornecedor": { "kind": "string", "required": true, "maxLength": 18 },
        "valor": { "kind": "currency", "required": true, "min": 0.01 },
        "vencimento": { "kind": "date", "required": true },
        "etapa": {
          "kind": "enum",
          "values": ["Rascunho", "Em aprovação", "Aprovada", "Recusada", "Lançada no ERP"],
          "default": "Rascunho"
        }
      }
    }
  ]
}
{
  "schemaVersion": 1,
  "models": {
    "Despesa": {
      "workflow": {
        "stageField": "etapa",
        "initialStages": [
          "Rascunho"
        ],
        "defaultStage": "Rascunho",
        "transitions": [
          {
            "from": "Rascunho",
            "to": "Em aprovação"
          },
          {
            "from": "Recusada",
            "to": "Rascunho"
          }
        ],
        "lockedFieldsByStage": {
          "Em aprovação": [
            "descricao",
            "fornecedor",
            "cnpjFornecedor",
            "valor",
            "vencimento"
          ],
          "Aprovada": [
            "descricao",
            "fornecedor",
            "cnpjFornecedor",
            "valor",
            "vencimento"
          ],
          "Lançada no ERP": [
            "descricao",
            "fornecedor",
            "cnpjFornecedor",
            "valor",
            "vencimento"
          ]
        },
        "lockedMessage": "Despesa enviada para aprovação não pode ser alterada."
      }
    }
  }
}

A esteira declara só as transições que qualquer editor pode fazer: enviar para aprovação e reabrir uma recusada. Aprovada, Recusada e Lançada no ERP existem como etapas, mas nenhuma transição declarada leva até elas. Por isso um PATCH no CRUD — que exige apenas despesas.write — não aprova, não recusa e não marca como lançada: o backend responde OON_WORKFLOW_TRANSITION_INVALID. Essas mudanças ficam com as rotas do passo 5, cada uma com a sua permissão.

3. Guarde o estado da integração em uma model própria

Cada despesa tem no máximo um lançamento. O índice único em despesaId é a trava que impede lançar a mesma despesa duas vezes, inclusive com dois cliques ao mesmo tempo.

const { defineModel, fields } = require("@oondemand/oon-core-back");

// Estado da integração com o ERP: um registro por despesa (despesaId único).
// A escrita é exclusiva da rota de lançamento; o CRUD fica para consulta.
module.exports = defineModel({
  name: "LancamentoErp",
  singular: "lançamento no ERP",
  basePath: "/lancamentos-erp",
  schema: {
    // `unique` vai no descritor: é o índice que impede dois lançamentos da mesma despesa.
    despesaId: { ...fields.ref("Despesa", { required: true, label: "Despesa" }), unique: true },
    status: fields.enum(["Enviando", "Lançada", "Erro", "Verificar no ERP"], { required: true, label: "Status" }),
    codigoErp: fields.string({ label: "Código no ERP" }),
    erro: fields.string({ label: "Erro" }),
    lancadoEm: fields.date({ label: "Lançado em" }),
  },
  crud: { enabled: true, permissions: { read: "despesas.read", write: "erp.integracao" } },
});

Duas decisões importantes:

  • Por que não guardar o código do ERP na própria despesa? Campos readonly do manifesto de domínio também são imutáveis no schema: uma atualização direta pelo Mongoose ignora esses campos sem erro. Uma model própria para a integração deixa claro quem escreve o quê e guarda o histórico de falhas.
  • unique no descritor. As factories de fields aceitam apenas required, default e label; opções Mongoose como unique vão no descritor. O Core cria o índice ao subir (OON_SYNC_INDEXES, padrão create).

4. Isole a API do ERP em um serviço

O serviço é o único lugar do App que conhece o contrato do ERP. Ele não loga credenciais nem repassa a resposta remota crua.

/**
 * Cliente do ERP: o único lugar do App que conhece a API externa.
 * Troque URL, payload e leitura da resposta pelos do seu ERP.
 */
/**
 * `incerto: true` quando o ERP pode ter registrado a conta (a requisição saiu e a
 * resposta não confirmou nada). Nesses casos não se reenvia: confere-se no ERP.
 */
class ErpError extends Error {
  constructor(code, { details, incerto = false } = {}) {
    super(code);
    this.code = code;
    this.details = details;
    this.incerto = incerto;
  }
}

function erpBaseUrl() {
  let url;
  try { url = new URL(process.env.ERP_API_URL || ""); } catch { throw new ErpError("ERP_NAO_CONFIGURADO"); }
  const local = ["localhost", "127.0.0.1"].includes(url.hostname);
  if ((url.protocol !== "https:" && !local) || url.username || url.password) throw new ErpError("ERP_URL_INVALIDA");
  return url.toString().replace(/\/$/, "");
}

async function criarContaPagar(credencial, despesa) {
  let res;
  try {
    res = await fetch(`${erpBaseUrl()}/contas-pagar`, {
      method: "POST",
      headers: { "content-type": "application/json", authorization: `Bearer ${credencial.token}` },
      body: JSON.stringify({
        codigoIntegracao: String(despesa._id), // permite ao ERP recusar duplicidade
        fornecedorCnpj: despesa.cnpjFornecedor,
        descricao: despesa.descricao,
        valor: despesa.valor,
        vencimento: new Date(despesa.vencimento).toISOString().slice(0, 10),
      }),
      signal: AbortSignal.timeout(10000),
      redirect: "error",
    });
  } catch (error) {
    if (error instanceof ErpError) throw error;
    throw new ErpError("ERP_SEM_RESPOSTA", { incerto: true }); // timeout ou conexão perdida
  }
  if (!res.ok) {
    await res.body?.cancel();
    const details = { remoteStatus: res.status };
    // 4xx: o ERP recusou e nada foi criado. 5xx: o ERP pode ter criado antes de falhar.
    if (res.status < 500) throw new ErpError("ERP_RECUSOU", { details });
    throw new ErpError("ERP_ERRO_REMOTO", { details, incerto: true });
  }
  const corpo = await res.json().catch(() => null);
  if (typeof corpo?.codigo !== "string" || !corpo.codigo) throw new ErpError("ERP_RESPOSTA_INVALIDA", { incerto: true });
  return corpo.codigo.slice(0, 80);
}

module.exports = { criarContaPagar, ErpError };

ERP_API_URL é configuração do ambiente (não é segredo). HTTPS é obrigatório fora de localhost, com timeout, redirect: "error" e erros com código — as mesmas práticas do exemplo de Rotas privadas.

O serviço separa dois tipos de falha:

Falha Código O ERP pode ter criado a conta?
ERP recusou (4xx) ERP_RECUSOU Não — corrija e tente de novo
Timeout ou conexão perdida ERP_SEM_RESPOSTA Talvez
ERP falhou (5xx) ERP_ERRO_REMOTO Talvez
Resposta sem código ERP_RESPOSTA_INVALIDA Talvez

Nos casos "talvez" (incerto: true), reenviar pode criar uma segunda conta a pagar. Se o seu ERP aceita um código de integração e recusa duplicidade, envie o ID da despesa: é a barreira do lado do ERP que permite reconciliar com segurança.

5. Crie as rotas de credencial e de lançamento

const crypto = require("node:crypto");
const { capabilities, defineRoutes, GenericError, mergeScopedFilter, registry } = require("@oondemand/oon-core-back");
const LancamentoErp = require("../models/LancamentoErp");
const { criarContaPagar, ErpError } = require("../services/erpClient");

const OBJECT_ID = /^[a-f\d]{24}$/i;

/** Reserva o lançamento: cria o registro ou retoma um que falhou. Só uma requisição vence. */
async function reservarLancamento(despesaId) {
  const Lancamento = LancamentoErp.mongooseModel;
  try {
    return await Lancamento.create({ despesaId, status: "Enviando" });
  } catch (error) {
    if (error?.code !== 11000) throw error; // 11000 = já existe lançamento para a despesa
  }
  return Lancamento.findOneAndUpdate(
    { despesaId, status: "Erro" },
    { $set: { status: "Enviando" }, $unset: { erro: 1 } },
    { new: true },
  );
}

defineRoutes("/erp", (router) => {
  // Credencial do ERP: cifrada pelo Core e nunca devolvida ao navegador.
  router.private.put("/credenciais", { permission: "erp.credenciais", sensitiveBody: true }, async (req, res) => {
    const token = typeof req.body?.token === "string" ? req.body.token.trim() : "";
    if (!token) throw new GenericError("Informe o token do ERP.", { statusCode: 422, code: "ERP_TOKEN_OBRIGATORIO" });
    res.json(await capabilities.secrets.put({
      name: "erp", context: req.accessContext, correlationId: crypto.randomUUID(), value: { token }, replace: true,
    }));
  });

  router.private.get("/credenciais", { permission: "erp.credenciais" }, async (req, res) => {
    res.json(await capabilities.secrets.status({ name: "erp", context: req.accessContext }));
  });

  // Aprovar e recusar exigem permissão própria: a esteira não declara essas transições,
  // então o CRUD (despesas.write) não consegue aprovar nem recusar.
  for (const [acao, etapa] of [["aprovar", "Aprovada"], ["recusar", "Recusada"]]) {
    router.private.post(`/despesas/:id/${acao}`, {
      permission: "despesas.approve",
      audit: { entidade: "Despesa", acao: etapa.toLowerCase() },
    }, async (req, res) => {
      const despesas = registry.getModel("Despesa");
      const filtro = OBJECT_ID.test(req.params.id)
        && mergeScopedFilter(despesas, { _id: req.params.id, etapa: "Em aprovação" }, req.accessContext);
      const despesa = filtro && await despesas.mongooseModel
        .findOneAndUpdate(filtro, { $set: { etapa } }, { new: true }).lean();
      if (!despesa) {
        throw new GenericError("Só despesas em aprovação podem ser aprovadas ou recusadas.", {
          statusCode: 409, code: "DESPESA_FORA_DE_APROVACAO",
        });
      }
      res.json(despesa);
    });
  }

  // Lança no ERP uma despesa aprovada.
  router.private.post("/despesas/:id/lancar", {
    permission: "despesas.lancar",
    audit: { entidade: "Despesa", acao: "lancada-no-erp" },
  }, async (req, res) => {
    const despesas = registry.getModel("Despesa");
    const naoLancavel = () => new GenericError("Só despesas aprovadas e ainda não lançadas podem ir para o ERP.", {
      statusCode: 409, code: "DESPESA_NAO_LANCAVEL",
    });
    if (!OBJECT_ID.test(req.params.id)) throw naoLancavel();

    // 1. A despesa precisa estar aprovada, dentro do escopo do usuário.
    const despesa = await despesas.mongooseModel
      .findOne(mergeScopedFilter(despesas, { _id: req.params.id, etapa: "Aprovada" }, req.accessContext))
      .lean();
    if (!despesa) throw naoLancavel();

    // 2. Reserva: evita lançar duas vezes a mesma despesa.
    const lancamento = await reservarLancamento(despesa._id);
    if (!lancamento) throw naoLancavel();

    // 3. Chama o ERP com a credencial cifrada. O retorno do callback é descartado e erros
    //    dentro dele viram SECRETS_USE_FAILED; por isso o resultado é guardado fora.
    let resultado;
    try {
      await capabilities.secrets.use({ name: "erp", context: req.accessContext }, async (credencial) => {
        try {
          resultado = { codigo: await criarContaPagar(credencial, despesa) };
        } catch (error) {
          resultado = { erro: error instanceof ErpError ? error : new ErpError("ERP_FALHA_INESPERADA") };
        }
      });
    } catch {
      resultado = { erro: new ErpError("ERP_CREDENCIAL_INDISPONIVEL") };
    }

    // 4. Registra o desfecho. Só falhas em que o ERP certamente não criou a conta
    //    voltam para "Erro" (pode repetir); as incertas exigem conferência no ERP.
    if (resultado?.erro) {
      const { code, details, incerto } = resultado.erro;
      await LancamentoErp.mongooseModel.updateOne(
        { _id: lancamento._id },
        { $set: { status: incerto ? "Verificar no ERP" : "Erro", erro: code } },
      );
      throw new GenericError(incerto
        ? "Não foi possível confirmar o lançamento. Confira no ERP antes de qualquer nova tentativa."
        : "O ERP não registrou a despesa. Corrija e tente novamente.", { statusCode: 502, code, details });
    }
    await LancamentoErp.mongooseModel.updateOne(
      { _id: lancamento._id },
      { $set: { status: "Lançada", codigoErp: resultado.codigo, lancadoEm: new Date() } },
    );
    // "Lançada no ERP" não é transição declarada: só esta rota leva a despesa até lá.
    await despesas.mongooseModel.updateOne({ _id: despesa._id, etapa: "Aprovada" }, { $set: { etapa: "Lançada no ERP" } });
    res.json({ despesaId: despesa._id, etapa: "Lançada no ERP", codigoErp: resultado.codigo });
  });
});

O que garante cada parte:

Proteção Como
Só quem pode aprova Rotas aprovar/recusar com permission: "despesas.approve"; o PATCH do CRUD não faz essas transições
Só quem pode lança permission: "despesas.lancar"; o perfil Solicitante recebe 403
Só despesa aprovada A busca exige etapa: "Aprovada" dentro do escopo de req.accessContext
Uma vez só Índice único em despesaId + retomada apenas de lançamentos em Erro, nunca dos incertos
Credencial protegida core.secrets cifra em repouso; a rota de leitura devolve só o estado (absent/available)
Falha visível Erro (pode repetir) ou Verificar no ERP (conferir antes), com o código da falha
Rastreabilidade audit registra o lançamento; sensitiveBody tira o token dos logs

6. Crie as páginas

import { CorePipeline } from "@oondemand/oon-core-front";

const etapa = (valor: string) => ({ field: "etapa", notEquals: valor });

export default function DespesasPage() {
  return (
    <CorePipeline
      model="Despesa"
      label="Despesas"
      endpoint="/despesas"
      titleField="descricao"
      stageField="etapa"
      create={{ enabled: true, label: "Nova despesa", defaultStage: "Rascunho" }}
      defaultActions={false}
      ticketActions={[
        { id: "enviar", label: "Enviar para aprovação", type: "transition", field: "etapa", value: "Em aprovação",
          hiddenWhen: etapa("Rascunho"), permissions: ["despesas.write"] },
        { id: "aprovar", label: "Aprovar", type: "apiAction", method: "POST", endpoint: "/erp/despesas/:id/aprovar",
          hiddenWhen: etapa("Em aprovação"), permissions: ["despesas.approve"], refresh: ["all"] },
        { id: "recusar", label: "Recusar", type: "apiAction", method: "POST", endpoint: "/erp/despesas/:id/recusar",
          hiddenWhen: etapa("Em aprovação"), permissions: ["despesas.approve"], refresh: ["all"],
          confirm: { title: "Recusar a despesa?", description: "Ela volta a poder ser corrigida em Rascunho." } },
        { id: "reabrir", label: "Voltar para rascunho", type: "transition", field: "etapa", value: "Rascunho",
          hiddenWhen: etapa("Recusada"), permissions: ["despesas.write"] },
        { id: "lancar", label: "Lançar no ERP", type: "apiAction", method: "POST", endpoint: "/erp/despesas/:id/lancar",
          hiddenWhen: etapa("Aprovada"), permissions: ["despesas.lancar"], refresh: ["all"],
          confirm: { title: "Lançar no ERP?", description: "A despesa vira uma conta a pagar no ERP." } },
      ]}
      viewModes={["board", "list"]}
      defaultView="board"
      list={{ columns: ["descricao", "fornecedor", "valor", "vencimento", "etapa"] }}
      stages={[
        { id: "Rascunho", label: "Rascunho" },
        { id: "Em aprovação", label: "Em aprovação" },
        { id: "Aprovada", label: "Aprovada" },
        { id: "Recusada", label: "Recusada" },
        { id: "Lançada no ERP", label: "Lançada no ERP" },
      ]}
    />
  );
}
import { CoreCollection } from "@oondemand/oon-core-front";

export default function LancamentosErpPage() {
  return <CoreCollection model="LancamentoErp" label="Lançamentos no ERP" endpoint="/lancamentos-erp" />;
}
import { useEffect, useState } from "react";
import { useOonApi } from "@oondemand/oon-core-front";

export default function CredencialErpPage() {
  const { http } = useOonApi();
  const [estado, setEstado] = useState<string>("carregando");
  const [token, setToken] = useState("");

  useEffect(() => {
    http.get("/erp/credenciais").then(({ data }) => setEstado(data.state)).catch(() => setEstado("indisponível"));
  }, [http]);

  async function salvar(event: React.FormEvent) {
    event.preventDefault();
    const { data } = await http.put("/erp/credenciais", { token });
    setToken("");
    setEstado(data.state);
  }

  return (
    <form onSubmit={salvar} style={{ maxWidth: 420, display: "grid", gap: 12 }}>
      <h1>Credencial do ERP</h1>
      <p>Situação: {estado === "available" ? "cadastrada" : estado === "absent" ? "não cadastrada" : estado}</p>
      <input type="password" autoComplete="off" value={token} onChange={(e) => setToken(e.target.value)} placeholder="Token da API do ERP" required />
      <button type="submit">Salvar</button>
    </form>
  );
}

Registre rotas e menu:

import { defineOonRoutes } from "@oondemand/oon-core-front/routing";
import { HomePage } from "../pages/HomePage";

export const routes = defineOonRoutes([
  { path: "/", component: HomePage },
  { path: "/despesas", lazy: () => import("../pages/DespesasPage"), permissions: ["despesas.read"] },
  { path: "/lancamentos-erp", lazy: () => import("../pages/LancamentosErpPage"), permissions: ["despesas.read"] },
  { path: "/erp/credencial", lazy: () => import("../pages/CredencialErpPage"), permissions: ["erp.credenciais"] },
]);
import { defineOonNavigation } from "@oondemand/oon-core-front/routing";

export const navigation = defineOonNavigation([
  { label: "Início", href: "/", order: 10 },
  { label: "Despesas", href: "/despesas", section: "Financeiro", order: 20, permissions: ["despesas.read"] },
  { label: "Lançamentos no ERP", href: "/lancamentos-erp", section: "Financeiro", order: 30, permissions: ["despesas.read"] },
  { label: "Credencial do ERP", href: "/erp/credencial", section: "Configurações", order: 90, permissions: ["erp.credenciais"] },
]);

Aprovar, Recusar e Lançar no ERP são apiActions que chamam as rotas do passo 5; aparecem só na etapa certa e para quem tem a permissão. Como em toda ação, esconder o botão é experiência: quem decide é o backend.

7. Rode localmente

Em backend/.env, além do que o scaffold já traz:

ERP_API_URL=http://127.0.0.1:4599
OON_SECRETS_KEY=<32 bytes em base64>
OON_SECRETS_INSTANCE=local:sample
OON_SECRETS_TENANT=local:tenant
APP_ENVIRONMENT=desenvolvimento

Use um ERP de teste ou um dublê local que responda POST /contas-pagar com { "codigo": "..." }. Nunca use a chave local nem credenciais reais de Produção no .env. Na Plataforma, a chave de segredos é provisionada por ambiente na publicação e ERP_API_URL aponta para o ERP do ambiente.

8. Prove a jornada

Cenário Resultado esperado
Lançar uma despesa em Rascunho 409 DESPESA_NAO_LANCAVEL
Solicitante envia PATCH de Em aprovação para Aprovada 409 OON_WORKFLOW_TRANSITION_INVALID
Solicitante chama a rota de aprovação 403
Financeiro aprova uma despesa em aprovação 200; a despesa vai para Aprovada
PATCH de Rascunho direto para Lançada no ERP 409 OON_WORKFLOW_TRANSITION_INVALID
Lançar uma despesa aprovada sem credencial cadastrada 502 ERP_CREDENCIAL_INDISPONIVEL; o lançamento fica em Erro
ERP recusa os dados (4xx) 502 ERP_RECUSOU; o lançamento fica em Erro e pode ser repetido
ERP grava a conta e responde 5xx (ou não responde) 502 ERP_ERRO_REMOTO / ERP_SEM_RESPOSTA; o lançamento fica em Verificar no ERP
Nova tentativa de um lançamento em Verificar no ERP 409 DESPESA_NAO_LANCAVEL; nada é reenviado
Lançar com credencial e ERP no ar 200 com o código do ERP; a despesa vai para Lançada no ERP
Dois cliques simultâneos em Lançar no ERP Um lançamento no ERP; a outra requisição recebe 409
Lançar de novo uma despesa já lançada 409 DESPESA_NAO_LANCAVEL
Alterar o valor de uma despesa lançada Recusado com a mensagem de campo bloqueado
Perfil Solicitante tenta lançar ou gravar em /lancamentos-erp 403

Depois rode npm run check e npm run build --prefix frontend antes de publicar.

Limites desta abordagem

  • Execução síncrona. Não há runtime genérico de integração nem fila durável nesta release: o lançamento acontece na própria requisição, com timeout de 10 segundos. Para lotes grandes, lance por despesa ou avalie um processo assíncrono específico do App.
  • Reconciliação. Lançamentos em Verificar no ERP — e em Enviando, se o processo cair no meio do envio — precisam ser conferidos no ERP pelo código de integração (o ID da despesa). Se a conta existe, registre o código; se não existe, volte o lançamento para Erro para permitir a nova tentativa. Faça isso com um procedimento autorizado (por exemplo, uma rota própria com permissão de administração); nunca reenvie às cegas.
  • Um ERP por vez. O mesmo desenho serve para outros processos: cada novo processo vira uma nova model, uma nova esteira e uma nova rota ao lado do ERP, até que o que antes era customização viva nos Apps.

Próximos passos