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), porquecore.secretsV1 é 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-pagarcom 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
readonlydo 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. uniqueno descritor. As factories defieldsaceitam apenasrequired,defaultelabel; opções Mongoose comouniquevão no descritor. O Core cria o índice ao subir (OON_SYNC_INDEXES, padrãocreate).
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
- Segredos (core.secrets) — Contrato, criptografia, provisionamento e recadastro.
- Rotas privadas — Permissões, auditoria, sensitiveBody e integração externa.
- Central de compras — Outro tutorial completo com esteira de aprovação.
- Do commit à Produção — Publique o App em Dev, Homologação e Produção.