# 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. ```txt 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](/docs/capabilities/segredos), 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. > [!NOTE] > **Por que ao lado e não dentro do ERP?** Manter o núcleo do ERP no padrão e construir as extensões fora dele é o que a SAP chama de *clean core*; trocar um sistema aos poucos, processo por processo, é o padrão *Strangler Fig*. Veja a visão de negócio em [Modernização gradual: o padrão de estrangulamento](https://oonapps.com/solucoes/modernizacao-gradual-padrao-estrangulamento) e em [Nem monolito, nem microsserviços demais](https://oonapps.com/solucoes/monolito-ou-microsservicos). ## Pré-requisitos - Uma Central gerada e rodando localmente, como no [Quickstart](/docs/introducao/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](/docs/fundamentos/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 ```json title="central.app.json" { "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 ```json title="backend/central.domain.json" { "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" } } } ] } ``` ```json title="backend/central.process.json" { "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. > [!IMPORTANT] > O manifesto de processo valida **quais** transições existem, não **quem** pode fazê-las: toda transição declarada fica disponível a quem tem a permissão de escrita da model. Quando uma etapa exige outra permissão (aprovar, por exemplo), deixe a transição fora do manifesto e faça a mudança em uma [rota privada](/docs/backend/rotas-privadas) com `permission`. ## 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. ```javascript title="backend/src/models/LancamentoErp.js" 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`](/docs/backend/campos) 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. ```javascript title="backend/src/services/erpClient.js" /** * 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](/docs/backend/rotas-privadas#exemplo-integracao-externa). 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 ```javascript title="backend/src/routes/erp.js" 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 | > [!WARNING] > Atualizações diretas pelo Mongoose **não** passam pelo pipeline do CRUD (validações, processo e auditoria). Aqui isso é intencional e limitado: a rota mexe só no `LancamentoErp` e na etapa final da despesa, com filtro pela etapa esperada. Para qualquer outra mudança de negócio, use o CRUD. Veja [Escopo e accessContext](/docs/backend/escopo-e-contexto#boas-praticas). ## 6. Crie as páginas ```tsx title="frontend/src/pages/DespesasPage.tsx" import { CorePipeline } from "@oondemand/oon-core-front"; const etapa = (valor: string) => ({ field: "etapa", notEquals: valor }); export default function DespesasPage() { return ( ); } ``` ```tsx title="frontend/src/pages/LancamentosErpPage.tsx" import { CoreCollection } from "@oondemand/oon-core-front"; export default function LancamentosErpPage() { return ; } ``` ```tsx title="frontend/src/pages/CredencialErpPage.tsx" import { useEffect, useState } from "react"; import { useOonApi } from "@oondemand/oon-core-front"; export default function CredencialErpPage() { const { http } = useOonApi(); const [estado, setEstado] = useState("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 (

Credencial do ERP

Situação: {estado === "available" ? "cadastrada" : estado === "absent" ? "não cadastrada" : estado}

setToken(e.target.value)} placeholder="Token da API do ERP" required />
); } ``` Registre rotas e menu: ```tsx title="frontend/src/app/routes.tsx" 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"] }, ]); ``` ```ts title="frontend/src/app/navigation.ts" 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 `apiAction`s 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: ```bash title="backend/.env" 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 ```cards [ { "title": "Segredos (core.secrets)", "href": "/docs/capabilities/segredos", "description": "Contrato, criptografia, provisionamento e recadastro." }, { "title": "Rotas privadas", "href": "/docs/backend/rotas-privadas", "description": "Permissões, auditoria, sensitiveBody e integração externa." }, { "title": "Central de compras", "href": "/docs/tutoriais/central-de-compras", "description": "Outro tutorial completo com esteira de aprovação." }, { "title": "Do commit à Produção", "href": "/docs/tutoriais/do-commit-a-producao", "description": "Publique o App em Dev, Homologação e Produção." } ] ``` Fonte: https://sdk.oonapps.com/docs/tutoriais/app-ao-lado-do-erp