Especificação · Controle Orçamentário

Orçamento

A pergunta de primeira reunião comercial no Brasil. Sem controle orçamentário, o aprovador decide no escuro: ele sabe que pode aprovar R$ 40 mil porque a alçada permite, e não sabe se R$ 40 mil. Esta spec resolve isso sem transformar o Nexio num sistema de planejamento financeiro — a verba entra pronta ou é coletada por centro de custo, e o que o produto faz de verdade é não deixar o dinheiro ser contado duas vezes entre a requisição, o pedido e a nota.

depende de CostCenter · GLAccount · Company consumido por Requisition · PurchaseOrder · Receipt · Invoice · Contract 6 agregados · câmbio em Moeda e Câmbio v1.1 · 01/09/2026

Ponto de partida

Dez decisões

Decisão 1 · Modelo

Verba não é um número, é um livro-razão. Não existe coluna consumedAmount atualizada por UPDATE. Existe BudgetEntry append-only e o saldo é projeção. Estorno é linha nova de sinal contrário; nada é apagado, nada é reescrito.

Decisão 2 · Estágios

Três estágios: CommitmentObligationActual. Requisição aprovada compromete, pedido emitido empenha, recebimento realiza. Cada estágio liquida o anterior na proporção do que avançou — nunca soma em cima.

Decisão 3 · Chave

Dimensões opcionais com rollup. Empresa · centro de custo · conta · período, com centro de custo e conta anuláveis (nulo = qualquer). Verba num nó intermediário cobre a subárvore por prefixo de path. Vence a linha mais específica que casa.

Decisão 4 · Ausência

Regra assimétrica. Fora de escopo controlado, passa livre. Dentro de escopo controlado sem nenhuma linha que cubra, bloqueia — porque a linha coringa (conta nula) existe justamente para cobrir o resto. Fica descoberto por decisão, nunca por acidente.

Decisão 5 · Registro

O movimento é sempre gravado, com ou sem controle, pela tupla de dimensões e não só pelo id da linha. Custo zero, e quando o cliente ligar o controle no ano seguinte terá doze meses de consumo real para dimensionar a verba em vez de chutar.

Decisão 6 · Competência

O balde é escolhido pela data de necessidade, não pela data do documento. A requisição de dezembro para entrega em janeiro consome janeiro. Mata a corrida de fim de ano e faz o problema do compromisso que atravessa a virada quase desaparecer.

Decisão 7 · Carry-forward

É modo de leitura, não movimento de transporte. PeriodOnly lê só o balde; Cumulative lê de janeiro até o balde. Sem fechamento mensal obrigatório, sem tela de reabertura, e a nota atrasada se corrige sozinha. Dentro do exercício apenas — nunca entre exercícios.

Decisão 8 · Virada

O compromisso segue o documento, não o calendário. Na virada, o que está aberto é revertido no exercício que fecha e recriado no novo, sem trazer verba junto por padrão. Aperto vira estouro em janeiro — que é exatamente quando o sócio quer ser consultado.

Decisão 9 · Moeda

Um exercício, uma moeda; a taxa congela no movimento. Documento em moeda estrangeira converte no instante do lançamento e nunca é reavaliado. A variação cambial entre estágios aparece sozinha como consumo real, e é atribuível. O cadastro, a captura e a resolução da taxa são de Moeda e Câmbio.

Decisão 10 · Visibilidade

Ver o semáforo e ver o número são permissões diferentes. read:budget_state devolve cor e razão; read:budget_amounts devolve valores, recortados pelos nós em que a pessoa tem vínculo. O corte é no servidor — o campo não vai na resposta, não é escondido na tela.

Fundamento

Por que razão, e não saldo

O desenho ingênuo quebra em silêncio

A implementação óbvia é BudgetLine(amount, consumedAmount) com UPDATE consumed = consumed + x em cada aprovação. Ela funciona no caminho feliz e quebra em quatro lugares previsíveis: a requisição devolvida (quanto estornar, se o valor mudou na rodada?), o pedido cancelado parcialmente, a nota que chega com preço diferente do pedido, e o reprocessamento de uma mensagem duplicada. Em nenhum desses casos o sistema acusa erro — ele passa a mostrar um saldo errado, e ninguém descobre até a conversa com a controladoria em que os números não batem.

O que o razão dá de graça: resposta linha a linha para "quem comeu minha verba", reversão correta por construção, idempotência por chave natural, e reconstrução completa do saldo a partir dos fatos quando alguém desconfiar do número.

Saldo é projeção, com uma tabela materializada por desempenho

A verdade é BudgetEntry. Para não somar milhões de linhas a cada digitação de requisição, existe BudgetLineBalance — uma linha por budgetLineId × estágio, atualizada por upsert na mesma transação do movimento. Ela é cache, não fonte: um comando administrativo a reconstrói inteira a partir do razão, e a divergência entre as duas é o teste de integridade que roda em ambiente de homologação.

A consulta informa; o servidor decide

budgetStateFor é leitura sem trava, serve para pintar a tela enquanto a pessoa digita e pode estar desatualizada no instante seguinte. O bloqueio real acontece dentro da transação que grava o movimento, sob trava da linha de saldo. Nenhuma decisão de bloqueio confia numa consulta anterior — dois pedidos simultâneos contra a última fatia de verba não podem passar os dois, e essa é a única forma de garantir isso.

O que esta spec deliberadamente não é. Não é planejamento financeiro. Não há driver de cálculo (headcount × valor médio), comparação de cenários, rateio de overhead, projeção ou forecast rolante. Isso é FP&A e é outro produto. Planejamento aqui significa exatamente uma coisa: coletar números por centro de custo e aprová-los, reusando o motor de aprovação que já existe.

Modelo

Mapa de entidades

Budget aggregate root · o exercício Empresa + período + versão. Guarda a moeda, a granularidade do balde e o ciclo de vida Draft → InReview → Approved → Closed → Archived. A versão é do exercício, nunca da linha.
BudgetLine N por exercício · a verba Dimensões (centro de custo? · conta? · período) e valor. As duas dimensões anuláveis significam qualquer, e é isso que permite a linha coringa.
BudgetEntry append-only · o movimento Estágio, sinal, valor, taxa congelada, documento de origem e chave de idempotência. Sem update, sem delete. É a única fonte de verdade do consumo.
BudgetLineBalance projeção · cache Totais por linha e estágio, mantidos na mesma transação do movimento. Reconstruível a partir do razão — existe por desempenho e por trava, não por verdade.
BudgetPolicy por nó de CC · com vigência As quatro políticas: exigir verba, modo de saldo, reação ao estouro e comportamento na virada. Cada campo é resolvido subindo o path, independentemente dos outros.
BudgetChangeRequest documento · passa pelo motor Suplementação, remanejamento ou redução. Aprovado pelo processo ALTERACAO_ORCAMENTARIA, que já é semente. Ao aprovar, emite movimentos de Adjustment.
Currency · ExchangeRate transversal · outro documento Nasceram aqui porque o orçamento precisou primeiro, e saíram em 01/09/2026 para a spec de Moeda e Câmbio — Cotação, Pedido e Nota consomem os mesmos. O orçamento é consumidor, não dono.
CostCenter · GLAccount já especificados As duas árvores que dão as dimensões. O orçamento consome path, allowsPosting e o rollup; nenhuma das duas ganha campo novo.

Convenção de nomes. O documento é escrito em português; todo identificador — entidade, atributo, valor de enum, rota, permissão e código de erro — é em inglês. Dados de negócio brasileiros ficam como são no mundo real: PtaxVenda, cnpj, BRL.

O coração

Os três estágios e a liquidação encadeada

É aqui que essas implementações morrem. Se o pedido emitido a partir de uma requisição aprovada apenas somar ao consumo, o mesmo dinheiro é contado duas vezes e o saldo vira ficção antes do fim do primeiro mês.

Documento que dispara Requisição aprovada motor de aprovação conclui Pedido emitido PurchaseOrder sai do rascunho Recebimento · Nota o que de fato entrou Estágio do razão Commitment comprometido · ainda dá para segurar Obligation empenhado · já foi assinado Actual realizado · virou custo liquida na proporção do que avançou liquida na proporção do que chegou encerramento do documento libera o resíduo — e só o encerramento BudgetEntry · append-only todo movimento acima é uma linha nova, com chave de idempotência; nada é atualizado e nada é apagado
Cada seta para a esquerda é um movimento de sinal negativo no estágio anterior, gravado na mesma transação do movimento positivo do estágio novo. O consumo efetivo de uma fatia é sempre a soma dos três estágios — que é por construção o valor do estágio mais avançado alcançado, mais o resíduo ainda preso nos anteriores.

Requisição aprovada de 100 unidades a R$ 10, um centro de custo, uma conta, tudo na moeda do exercício:

A vida de uma fatia, movimento a movimento
EventoMovimentos gravadosComprometidoEmpenhadoRealizadoConsumido
Requisição aprovadaCommitment +1.0001.0001.000
Pedido de 60 un a R$ 11Obligation +660
Commitment −600
4006601.060
Recebimento de 55 unActual +605
Obligation −605
400556051.060
Pedido encerrado com resíduoObligation −554006051.005
Requisição encerradaCommitment −400605605

A regra, em uma frase

Cada estágio liquida o anterior na proporção do que avançou. O resíduo só é liberado no encerramento explícito do documento de origem. O pedido de 60 das 100 unidades liquida 600 do comprometido e deixa 400 presos — porque as outras 40 unidades ainda são uma intenção viva. Elas só voltam para o saldo quando alguém encerra a requisição, e esse encerramento é ação de domínio com dono, não faxina automática.

Diferença de preço é consumo real, não erro

O pedido saiu a R$ 11 e a requisição tinha estimado R$ 10. O razão mostra 60 unidades liquidando 600 de comprometido e criando 660 de empenhado — os R$ 60 de diferença aparecem como consumo adicional, que é exatamente o que aconteceu no mundo. Nenhuma conta é "corrigida" para trás.

Idempotência é chave natural, não tentativa

Todo movimento carrega idempotencyKey único, derivado de (sourceType, sourceId, sourceLineId, allocationId, stage, sequence). Reprocessar a mesma mensagem — reentrega de fila, duplo clique, retry de integração — colide no índice único e é descartado sem efeito. Não existe "verificar se já lancei"; existe deixar o banco recusar.

Rateio: a fatia é a unidade, sempre

Uma linha de documento com CostAllocation de 70% / 30% gera dois conjuntos de movimentos, contra duas combinações de dimensões que podem cair em verbas diferentes, com políticas diferentes, e uma pode bloquear enquanto a outra passa. O bloqueio é do documento: se qualquer fatia bloqueia, a operação inteira é recusada — não existe gravar meia linha. O resíduo de arredondamento segue a regra já fechada no Centro de Custo: vai para a maior fatia, empate pelo menor order.

Algoritmo

Qual verba responde por esta fatia

Toda fatia chega com quatro coordenadas: companyId, costCenterId, glAccountId e a data de necessidade. A resolução acontece nesta ordem e para no primeiro resultado:

1 · O exercício

O Budget da empresa, com status Approved ou Closed, cujo intervalo contém a data de necessidade. Não pode haver dois — a sobreposição de períodos entre exercícios aprovados da mesma empresa é recusada no banco, por EXCLUDE com btree_gist, mesma mecânica de ApprovalPolicy e CostCenterAssignment. Se não houver exercício, o resultado é NotControlled e acabou.

2 · A política efetiva

BudgetPolicy resolvida para o nó de centro de custo, subindo o path, campo a campo: requiresBudget pode vir do nó, overBudgetPolicy do avô e carryForwardMode do padrão do tenant. Se requiresBudget resolver como falso, o resultado é NotControlled — o movimento ainda é gravado, mas nada é consultado nem bloqueado.

3 · A linha, pela especificidade

Entre as linhas do exercício cujo período contém a data de necessidade, ficam as candidatas em que costCenterId é nulo ou é ancestral-ou-igual do nó da fatia (prefixo de path), e glAccountId é nulo ou é ancestral-ou-igual da conta da fatia. Vence, nesta ordem: maior especificidade (2 = as duas dimensões preenchidas, 1 = uma, 0 = coringa), depois maior depth do centro de custo, depois maior depth da conta. O critério é total e determinístico — não existe empate.

4 · Nenhuma linha cobre

Escopo controlado e nenhuma candidata: resultado Uncovered, e a operação é recusada com 422 nomeando a combinação. A saída para o cliente não é afrouxar a regra — é cadastrar a linha coringa daquele centro de custo, que cobre toda conta que ninguém previu.

Por que a especificidade decide aqui e não decide no motor de aprovação. No motor, especificidade explicitamente não desempata — só prioridade declarada — porque duas políticas de alçada que casam representam intenções concorrentes do cliente e adivinhar qual vale é armadilha. Aqui é o oposto: as linhas não são regras concorrentes, são caixas dentro de caixas. "TI · qualquer conta · março" e "TI · licenças de software · março" não competem, se aninham — e a mais interna é obviamente a que responde. Mesmo raciocínio que já usamos no rollup de dono do centro de custo.

Campos

Budget · o exercício

CampoTipoRegra
iduuid
tenantIduuidDo token. Id de outro tenant responde 404, nunca 403.
companyIduuidObrigatório. Orçamento é da pessoa jurídica — é ela que tem sócio, conselho e prestação de contas. FK composta (tenantId, companyId).
namevarchar(120)Rótulo humano: "Orçamento 2027", "Obra Guarulhos 2027–2029".
periodStart · periodEnddateO intervalo do exercício. Não precisa ser o ano civil — exercício de abril a março é comum em subsidiária de multinacional. Pode ultrapassar doze meses (orçamento plurianual de obra).
periodGranularityenumMonthly | Quarterly | Yearly. Define os baldes gerados entre periodStart e periodEnd. Imutável após Approved — mudar granularidade reparticiona todas as linhas e invalidaria o razão.
currencychar(3)ISO 4217. Copiada de Company.functionalCurrency na criação e congelada. Um exercício tem uma moeda só.
versionintSequencial por (companyId, periodStart). Versão nova nasce como cópia da anterior em Draft.
supersedesBudgetIduuid?A versão que esta substitui. A substituída vai para Archived no instante em que a nova é aprovada.
statusenumDraft | InReview | Approved | Closed | Archived. Só Approved e Closed participam da resolução; Draft e InReview são invisíveis para o consumo.
originenumImported | Planned. Informativo — o ciclo de vida é o mesmo; muda só por onde as linhas entraram.
approvalRequestIduuid?A instância do motor quando origin = Planned. Processo ORCAMENTO.
lateActualsUntildate?Janela de lançamento tardio depois de Closed: aceita Actual que liquida Obligation deste exercício, recusa qualquer compromisso novo. Padrão de fábrica: 60 dias após periodEnd.
externalCodevarchar(60)?Conciliação com o ERP. Único por tenant quando preenchido.
rowVersionbyteaConcorrência otimista. 409 declarado no OpenAPI.
Transições de status
De → paraQuem disparaO que acontece
Draft → InReviewAção do usuárioAbre instância do processo ORCAMENTO. Linhas viram somente-leitura.
InReview → ApprovedMotor de aprovaçãoVerifica que não há outro Approved com período sobreposto. Arquiva a versão substituída. A partir daqui as linhas só mudam por BudgetChangeRequest.
InReview → DraftDevolução no motorDevolver não é rejeitar: a instância segue viva e as linhas voltam a ser editáveis.
Approved → ClosedAção com permissão própriaRoda a virada de exercício (adiante). Recusa Commitment e Obligation novos; aceita Actual até lateActualsUntil.
Closed → ArchivedAutomáticoPassada a janela tardia. Nada mais entra. O razão continua consultável para sempre.

Campos

BudgetLine · a verba

CampoTipoRegra
id · tenantId · budgetIduuid
costCenterIduuid?Nulo = qualquer centro de custo. Quando preenchido, cobre o nó e toda a subárvore. Não exige allowsPosting — orçar num nó intermediário é o caso normal; quem não pode receber lançamento é o documento, não a verba.
glAccountIduuid?Nulo = qualquer conta. Mesma regra de subárvore.
periodKeyvarchar(7)2027-03, 2027-Q1 ou 2027, conforme a granularidade do exercício. Materializado junto com periodStart/periodEnd para consulta por intervalo sem interpretar string.
amountnumeric(18,2)Na moeda do exercício. Pode ser zero (declarar explicitamente "aqui não se gasta" é diferente de não ter linha). Negativo é recusado.
specificitysmallintDerivado e materializado: 2 com as duas dimensões, 1 com uma, 0 coringa. Existe para o índice do desempate não precisar de CASE.
notes · externalCodevarcharJustificativa da verba e código do ERP.

Unicidade com duas colunas anuláveis

A chave lógica é (budgetId, costCenterId, glAccountId, periodKey), mas no Postgres dois nulos não colidem — um índice único simples deixaria criar duas linhas coringa idênticas. Como já foi feito em UserCompanyScope, a unicidade sai em quatro índices parciais: ambos preenchidos, só centro de custo, só conta, e nenhum dos dois. É verboso e é a única forma correta.

Linha só muda por documento, depois de aprovada

Enquanto o exercício está em Draft, editar linha é edição comum. Depois de Approved, amount não é mais editável: a mudança vem de BudgetChangeRequest aprovado, que grava movimento de Adjustment. O valor efetivo de uma linha passa a ser amount + Σ Adjustment — e o histórico de por que a verba cresceu fica no razão, com justificativa e aprovador, em vez de sumir num UPDATE.

Campos

BudgetEntry · o movimento

CampoTipoRegra
id · tenantId · companyIduuid
stageenumCommitment | Obligation | Actual | Adjustment | CarryOut | CarryIn. Os três primeiros consomem; Adjustment altera a verba; os dois últimos são a virada de exercício.
amountnumeric(18,2)Com sinal, na moeda do exercício. Positivo consome (ou, em Adjustment, aumenta a verba); negativo libera. Zero é recusado.
costCenterId · glAccountIduuidObrigatórios — vêm da fatia, que é sempre concreta. É a tupla que permite ler consumo mesmo onde não havia verba.
competenceDate · competencePeriodKeydate · varchar(7)A data de necessidade da linha, e o balde derivado dela. O balde é resolvido na gravação e congelado; mudar a data de entrega depois gera movimentos de remanejamento (negativo no balde velho, positivo no novo), nunca um UPDATE nesta coluna.
budgetId · budgetLineIduuid?Resolvidos na gravação e congelados. Nulos quando resolutionReason = NotControlled. Congelar evita que cadastrar uma linha nova reescreva silenciosamente o passado.
resolutionReasonenumNotControlled | Covered | Uncovered. Uncovered só aparece em movimento de liberação ou estorno — o consumo Uncovered foi bloqueado antes de virar movimento.
currencyDocument · amountDocumentchar(3) · numeric(18,2)O valor como está no documento. Igual a amount e à moeda do exercício no caso comum.
exchangeRate · rateType · rateDatenumeric(18,8) · enum · dateA taxa usada, congelada. 1.0 / None quando não há conversão. Detalhe na seção de multimoeda.
sourceType · sourceId · sourceLineId · allocationIdenum · uuidRequisition | PurchaseOrder | Receipt | Invoice | Contract | BudgetChangeRequest | Rollover | Import | Manual. É o rastro que responde "quem comeu minha verba".
liquidatesEntryIduuid?Preenchido nos movimentos negativos de liquidação encadeada. Permite reconstruir a cadeia requisição → pedido → nota sem consultar os agregados de origem.
reversalOfEntryIduuid?Estorno integral (cancelamento, rejeição). Distinto de liquidação: liquidar é avançar, estornar é desfazer.
reasonCodevarchar(40)?PriceChange, QuantityChange, DateChange, DocumentCancelled, ResidueRelease, Supplement, Transfer, YearEndRollover. Alimenta o relatório de por que o consumo mudou.
idempotencyKeyvarchar(200)Único por tenant. Derivado de (sourceType, sourceId, sourceLineId, allocationId, stage, sequence). É o que torna reprocessamento inofensivo.
occurredAt · postedAt · createdByUserIdtimestamptz · uuidQuando o fato aconteceu no domínio e quando entrou no razão. userId do gryd, sem FK — mesmo padrão de CostCenterAssignment.

BudgetEntry não tem UPDATE nem DELETE. Não é convenção de equipe, é grant: o usuário de aplicação recebe apenas INSERT e SELECT nessa tabela, e uma trigger recusa as outras duas operações com mensagem explícita. Toda correção é movimento novo. Quem precisar apagar razão em produção precisa de um caminho administrativo separado e auditado — e nunca precisou até hoje em nenhum sistema que fez isso direito.

Campos

BudgetPolicy · as quatro políticas

Tabela própria, e não colunas no nó de centro de custo, por dois motivos: as políticas precisam de vigência (ligar controle para 2027 sem reescrever o passado) e o nó já carrega bagagem suficiente. Mesma forma de CostCenterAssignment.

CampoTipoRegra
costCenterIduuid?Nulo = padrão do tenant. Preenchido, vale para o nó e seus descendentes até que um descendente sobrescreva o mesmo campo.
validFrom · validTodate · date?Vigência. Sobreposição de duas políticas do mesmo costCenterId recusada por EXCLUDE + btree_gist. Nunca se edita vigência para o passado — trocar política é encerrar e abrir.
requiresBudgetbool?Liga o controle. Padrão de fábrica falso.
carryForwardModeenum?PeriodOnly | Cumulative. Padrão de fábrica PeriodOnly.
overBudgetPolicyenum?Warn | Block | RequireApproval. Padrão de fábrica Warn.
tolerancePercent · toleranceAmountnumeric(7,4)? · numeric(18,2)?Folga antes de a política disparar. Vale o menor dos dois quando ambos existem — 5% de uma verba de R$ 10 milhões não deve virar meio milhão de folga silenciosa.
yearEndCommitmentPolicyenum?CarryCommitmentOnly | CarryCommitmentAndBudget | CancelOpen. Padrão de fábrica CarryCommitmentOnly.

Herança campo a campo, e a API devolve de onde veio

Cada campo sobe o path por conta própria: requiresBudget pode vir do nó "Obra Guarulhos", overBudgetPolicy de "Operações" e carryForwardMode do padrão do tenant. É mais flexível e é mais difícil de explicar em tela — por isso GET /budget-policies/effective devolve, para cada campo, o valor e o nó de onde ele veio, exatamente como effectiveOwners devolve inheritedFrom. A tela mostra "herdado de Operações" ao lado de cada linha, e ninguém precisa adivinhar.

Campos

BudgetChangeRequest · mexer na verba

Depois de aprovado o exercício, verba só muda por documento — e documento passa pelo motor, no processo ALTERACAO_ORCAMENTARIA que já nasceu como semente.

CampoTipoRegra
numbervarchar(30)Via NumberSequence, escopo Company.
typeenumSupplement (dinheiro novo), Transfer (remanejamento entre linhas), Reduction (devolver verba).
fromLineId · toLineIduuid?Transfer exige os dois; SupplementtoLineId; ReductionfromLineId. Ambos precisam pertencer ao mesmo budgetId.
amountnumeric(18,2)Positivo. O sinal vem do type, não do número — assim ninguém digita menos-menos.
justificationtextObrigatória. É o que a controladoria vai ler em dezembro.
effectiveDatedateData do movimento de Adjustment. Não pode ser anterior ao periodStart do exercício.
status · approvalRequestIdenum · uuid?Draft | PendingApproval | Approved | Rejected | Cancelled. A transição para Approved é do motor, e é ela que grava os movimentos.

Remanejamento não pode deixar a origem negativa

Na aprovação, o servidor revalida o saldo da linha de origem naquele instante, sob trava — não no momento em que o pedido foi feito. Verba pode ter sido consumida durante a aprovação. Se não couber, a aprovação falha com 422 e o documento volta ao solicitante com o número atual, em vez de deixar a origem estourada.

Movimento, não versão

Movimento ajusta o orçado; versão substitui o exercício. Suplementar R$ 80 mil em março é um Adjustment — a linha original continua visível, e a diferença tem autor, data e justificativa. Refazer o plano inteiro no meio do ano é Budget versão 2, com o razão do anterior preservado e reapontado. Confundir os dois é como confundir commit com force push.

Contrato

Consulta de saldo

Um contrato só, consultado por todo mundo: a tela do requisitante enquanto ele digita, o motor de aprovação como critério, e o próprio serviço de lançamento antes de gravar. O documento manda as fatias que quer avaliar; a resposta vem fatia a fatia.

POST /budget-state:evaluate
{
  "companyId": "…",
  "slices": [
    { "ref": "L1-A", "costCenterId": "…", "glAccountId": "…",
      "neededOn": "2027-03-14", "amount": 18400.00, "currency": "BRL" }
  ]
}

200 OK
{
  "results": [{
    "ref": "L1-A",
    "signal": "Warning",
    "reason": "Covered",
    "budgetId": "…", "budgetLineId": "…", "periodKey": "2027-03",
    "carryForwardMode": "Cumulative",
    "overBudgetPolicy": "RequireApproval",
    "policySource": { "requiresBudget": "CC 4.2 Operações",
                      "overBudgetPolicy": "tenant" },
    "amounts": {                       // ausente sem read:budget_amounts
      "currency": "BRL",
      "budgeted": 240000.00,           // amount + Σ Adjustment
      "committed": 51200.00,
      "obligated": 96300.00,
      "actual": 74100.00,
      "consumed": 221600.00,
      "available": 18400.00,
      "toleranceRoom": 2400.00
    },
    "wouldExceedBy": 0.00
  }]
}
BudgetSignal · o que a tela pinta
SinalQuandoO que o requisitante vê
NotControlledSem exercício, ou requiresBudget falsoCinza · "sem controle orçamentário". Nenhum número, nem se a pessoa tiver permissão — não há o que mostrar.
OkCabe no disponívelVerde. Segue o baile.
WarningEstoura, mas dentro da tolerânciaÂmbar · "vai consumir toda a verba de março". Passa, e o gestor recebe notificação.
ExceededEstoura além da tolerânciaVermelho, com o texto vindo da política: avisa e passa, bloqueia, ou "vai exigir aprovação da controladoria".
UncoveredControlado e nenhuma linha cobreVermelho · "não há verba cadastrada para esta conta neste centro de custo". Bloqueia sempre, e o texto diz o que falta cadastrar.

available depende do modo de leitura

Com PeriodOnly, é budgeted(março) − consumed(março). Com Cumulative (acumulado), é Σ budgeted(janeiro..março) − Σ consumed(janeiro..março). Mesmo razão, mesma linha, janela diferente — e é por isso que o modo aparece na resposta: a tela precisa poder dizer "R$ 18.400 disponíveis no acumulado do ano", que é uma frase diferente de "R$ 18.400 disponíveis em março".

O rollup do centro de custo é do lado da verba, não do consumo

Uma verba em "Operações" cobre "Operações › Manutenção › Elétrica". O consumo daquela fatia é gravado com o nó folha e resolvido para a linha de "Operações" — então o extrato da verba mostra o gasto das três folhas somado, e cada movimento continua sabendo de qual folha veio. Não existe verba "consolidada" calculada por cima: existe uma linha, com consumo de vários nós.

O motor de aprovação usa isto como critério, e nada mais

O motor lê signal e wouldExceedBy como critérios de política — "estourou a verba" acrescenta um nível com a controladoria, exatamente como "valor acima de X" acrescenta um diretor. O motor não conhece verba e não grava movimento; quem grava é o agregado do documento, na transição. A dependência é num sentido só, como já é com centro de custo.

Segurança

Quem vê o semáforo e quem vê o número

Em muitas empresas o valor da verba é informação de diretoria, e o requisitante não deveria saber que sobraram R$ 3 mil no mês — mas precisa saber que está prestes a estourar. As duas coisas são permissões separadas.

PermissãoO que libera
read:budget_stateO bloco signal + reason + o texto explicativo. Sem valores. Semeada por padrão no papel de requisitante — sem ela o produto volta a aprovar no escuro, que é o problema que esta spec existe para resolver.
read:budget_amountsO bloco amounts, recortado: só para os nós de centro de custo em que a pessoa tem CostCenterAssignment vigente, com rollup para os descendentes. Dono de "Operações" vê os números de toda a subárvore; dono de "Elétrica" vê os da dele.
read:budget_amounts_allRemove o recorte. Controladoria, financeiro, diretoria.
read:budgetsAs telas do módulo: lista de exercícios, linhas, extrato. Implica ver valores dos exercícios que a pessoa alcança pelas duas regras acima.

O corte é no servidor. Sem read:budget_amounts, o objeto amounts não vai na resposta — não vai zerado, não vai mascarado, não vai escondido por CSS. Vale para a API, para o CSV exportado e para o payload de notificação. Uma permissão que só apaga o campo na tela não é permissão, é decoração: o número continua indo pela rede e aparece no primeiro DevTools aberto.

Isto não toca a plataforma. As permissões são linhas no catálogo que o gryd já mantém por tenant, e chegam achatadas no token como qualquer outra — nenhuma entidade do gryd muda, e a geração do token continua igual. O recorte por nó não vem do token: é resolvido no servidor a cada requisição, a partir de CostCenterAssignment, que já existe e já é a fonte de "quem manda em qual pedaço da árvore".

Tempo

Baldes, competência e carry-forward

A competência é a data de necessidade

O balde de um movimento é escolhido pela data de necessidade da linha do documento — quando a coisa precisa existir — e não pela data em que o documento foi criado ou aprovado. A requisição feita em 20 de dezembro para entrega em 15 de janeiro consome janeiro desde o primeiro movimento.

Por quê: é a leitura econômica correta e mata a corrida de fim de ano, em que todo mundo empurra pedido em dezembro para não perder verba. E resolve de graça o problema mais chato da virada — o compromisso que atravessa o ano já nasce no ano certo.

O que custa: mudar a data de entrega remaneja consumo entre baldes. O tratamento é o mesmo de sempre: dois movimentos com reasonCode = DateChange, negativo no balde velho e positivo no novo, e se o novo balde não couber, vale a política de estouro daquele nó. Nunca um UPDATE em competencePeriodKey.

PeriodOnly × Cumulative — e por que nenhum dos dois transporta nada

O desenho óbvio de carry-forward é transportar: no fechamento de março, movimento negativo em março e positivo em abril. Funciona e arrasta duas coisas pesadas — fechamento mensal vira operação obrigatória (alguém precisa declarar que março acabou) e a nota atrasada quebra o fechamento (reabrir período, recalcular transporte, reescrever abril). Todo ERP que faz assim tem uma tela de reabertura de período e um chamado por mês por causa dela.

Aqui é modo de leitura. O razão é idêntico nos dois casos; muda a janela do somatório. Cumulative é carry-forward, exato, sem linha de transporte, sem fechamento e sem reabertura: a nota de março que chega em 8 de abril entra na soma e o disponível de abril se corrige sozinho — que é o comportamento certo, e no modelo de transporte seria um incidente.

O que se perde: o registro formal "em 31/03 foram transportados R$ 12.400". Isso vira consulta, renderizada na tela de acompanhamento, e não um fato gravado. Se algum cliente de setor regulado exigir o registro imutável, o fechamento formal nasce depois como opt-in — o razão já suporta, bastam os movimentos CarryOut/CarryIn que a virada de exercício já usa.

Cumulative nunca atravessa exercício

A janela do acumulado é periodStart do exercício até o balde consultado. Em 1º de janeiro ela reinicia, sempre, qualquer que seja a política. O que sobrou de 2026 morre em 2026. Atravessar a virada é assunto da seção seguinte, tem regra própria e exige decisão explícita — não pode ser efeito colateral de um modo de leitura.

A pergunta difícil

O que acontece com o que está aberto em 31 de dezembro

Em 31 de dezembro existem requisições aprovadas que ainda não viraram pedido, pedidos emitidos que ainda não chegaram, e entregas feitas cuja nota ainda não veio. Cada uma dessas três situações tem um pedaço de verba de 2026 preso nela. A pergunta é o que fazer com esse dinheiro — e é uma pergunta de dono, não de sistema.

Como o mercado resolve
SistemaRegraComentário
Oracle General LedgerTrês regras à escolha: Encumbrances Only, Encumbrances and Encumbered Budget, Funds Available. Sem processo de virada, todo compromisso vai a zero.O compromisso é levado como saldo inicial do novo ano, não como movimento do período. A terceira regra é o que aqui já é o modo Cumulative, e não precisa existir na virada.
Microsoft Dynamics 365Duas opções: processar sem carregar orçamento, ou processar carregando orçamento. Nas duas, o compromisso é revertido no ano que fecha e restabelecido no novo.Carregar o orçamento gera ajuste que reduz o ano velho e recria no novo. Data de fechamento é o último dia do exercício; a de abertura, o primeiro do seguinte.
PeopleSoft / Oracle FSCMKeep Budget Active (o orçamento atravessa) ou Close and Reestablish (fecha, recria com valores novos, mantém as transações abertas vivas).O objetivo declarado é não obrigar a redigitar documento. A transação sobrevive à virada em qualquer das opções.
Setor público brasileiroRestos a pagar: o empenho não pago continua onerando o exercício em que nasceu, inscrito e carregado adiante.Contraexemplo deliberado. O resultado conhecido é uma fila que se acumula entre exercícios e um orçamento vigente que nunca é o orçamento real. É regra de direito financeiro público, não boa prática de gestão privada.

A convergência

Os três produtos privados fazem a mesma coisa e discordam só num ponto. Iguais: o compromisso aberto atravessa a virada — é revertido no exercício que fecha e recriado no novo, sem que ninguém redigite documento. Diferentes: se a verba vai junto — e nos três isso é opção do cliente, não do produto.

A recomendação · CarryCommitmentOnly como padrão

Ao fechar o exercício, cada saldo aberto de Commitment e Obligation gera um CarryOut negativo no exercício que fecha e um CarryIn positivo no novo, mantendo a mesma cadeia de documento, com competenceDate remapeada para o primeiro balde aberto e a taxa de câmbio preservada. A verba não vai junto.

Na visão do sócio. O número de 2027 foi decidido para 2027. Se o compromisso trouxesse verba automaticamente, o ano começaria comprometido por um valor que ninguém aprovou — e no primeiro mês o controle vira ficção: quanto mais atraso houve em dezembro, mais orçamento aparece do nada em janeiro, o que premia exatamente o comportamento que ele quer evitar. Com o padrão, o que sobrou de 2026 morre em 2026 e qualquer aperto aparece como estouro em janeiro — que é o momento em que ele quer ser consultado, com nome, valor e documento na frente.

Na visão do requisitante. O documento não morre, não precisa ser redigitado e não volta para a fila de aprovação: a cadeia já cumprida é preservada, pela mesma regra do motor de que continuidade e redução não reavaliam. Ele só descobre que houve virada se houver aperto real — e nesse caso o que ele vê é a política de estouro do nó dele, um caminho que ele já conhece, e não uma mensagem de erro sobre exercício encerrado.

As duas outras opções, e quando fazem sentido

CarryCommitmentAndBudget emite, junto com o CarryIn, um Adjustment de mesmo valor na linha do novo exercício, e um Adjustment negativo no exercício que fecha. É o caso de obra e projeto plurianual, em que a verba foi aprovada para o projeto e não para o calendário. Ressalva registrada: para esses casos a resposta melhor é um Budget com periodStart/periodEnd cobrindo o projeto inteiro — a opção existe como saída para quem já tem o orçamento partido por ano e não quer remodelar.

CancelOpen estorna os compromissos abertos e devolve os documentos ao estado anterior, forçando redecisão no ano novo. Existe porque algumas empresas querem exatamente isso na virada, e não é o padrão porque a maioria só quer que a compra de janeiro aconteça.

Nota atrasada de dezembro

Actual com competência num exercício Closed é aceito até lateActualsUntil, desde que liquide um Obligation daquele mesmo exercício. Sem isso, a nota de dezembro que chega em janeiro cairia em 2027 — consumindo verba nova por uma entrega que já tinha sido orçada e empenhada em 2026, o que estoura o ano novo por um fato do ano velho. Passada a janela, o exercício vai a Archived e a nota tardia é lançada em 2027 com reasonCode próprio, aparecendo no relatório de despesa de exercício anterior.

Documento em aprovação na virada

Requisição ainda em aprovação não tem movimento nenhum — o Commitment nasce na conclusão da cadeia. Se ela conclui em janeiro com data de necessidade em dezembro, o balde de destino está num exercício fechado: a competência é reapontada para o primeiro balde aberto, com aviso na tela e no evento. Como valor, centro de custo e rateio não mudaram, a cadeia de aprovação não é reavaliada.

Moeda

Uma moeda por exercício, taxa congelada no movimento

O cadastro de moeda, os pares, a rotina de captura, a regra de resolução da taxa e o serviço de conversão saíram desta spec. Eles são transversais — o Catálogo, o Fornecedor, a Cotação, o Pedido e a Nota consomem os mesmos — e vivem agora na especificação de Moeda e Câmbio. O que fica aqui é só o que é do orçamento: qual moeda o exercício tem, o que ele congela e como a variação cambial aparece no razão.

O exercício tem uma moeda; os documentos têm a que tiverem

Company.functionalCurrency — que já existia na spec de Empresa como metadado informativo e passa a ter consequência a partir daqui — define em que moeda a pessoa jurídica orça e presta contas. Padrão BRL, e BRL na v1, por decisão registrada na spec de Moeda e Câmbio: o boletim do Banco Central publica moeda contra real e mais nada, e conversão cruzada sem fonte produz número plausível e errado. Budget.currency é copiada dali na criação e congelada.

Um exercício não tem duas moedas. Empresa que quer acompanhar o mesmo orçamento em real e em dólar está pedindo relatório com conversão de apresentação, não um segundo orçamento — e dois orçamentos concorrentes para o mesmo período colidiriam com a regra de não-sobreposição, que existe justamente para não haver duas verdades.

A conversão acontece uma vez, no movimento, e nunca é refeita

Cada BudgetEntry guarda amountDocument e currencyDocument como estão no documento, mais exchangeRate, quoteUnit, rateType e rateDate, e o amount já convertido para a moeda do exercício. Como valores, não como chave estrangeira — se a cotação for substituída depois por retificação de boletim, o movimento gravado não muda.

Reavaliação retroativa não existe. Se a taxa de ontem mudasse o consumo de hoje, o saldo mudaria sozinho durante a noite e ninguém conseguiria explicar para o gestor por que a verba encolheu sem ninguém ter comprado nada.

A variação cambial aparece sozinha, e é consumo de verdade

Pedido de US$ 10.000 a 5,20 grava Obligation de R$ 52.000. A nota chega com o dólar a 5,60: a liquidação estorna o empenho pela taxa dele (−52.000) e lança Actual pela taxa nova (+56.000). Os R$ 4.000 de diferença consomem verba, porque consumiram caixa. Nenhum tratamento especial, nenhuma conta de ajuste — e o relatório de variação cambial é a soma dos deltas das cadeias cuja currencyDocument difere da moeda do exercício.

Converte-se a fatia, e a taxa que faltou recusa o documento inteiro

O rateio acontece na moeda do documento e cada CostAllocation converte separadamente — converter o total e depois ratear faz a soma das linhas do extrato não bater com o total do documento. Sem cotação para o par, o tipo e a janela, o lançamento é recusado com EXCHANGE_RATE_MISSING 422 e a atomicidade por documento vale igual: nada é gravado. O sistema nunca estima taxa.

A janela de tolerância para trás, o tipo padrão e o comportamento em fim de semana e feriado são de CurrencySettings — a entidade que rascunhos anteriores desta spec chamavam de BudgetSettings. A rateDate efetivamente usada e a idade em dias voltam na conversão, são gravadas no movimento e aparecem no extrato: "US$ 10.000 × 5,2013 · PTAX venda de 29/12 (2 dias)".

Taxa contratada

Compra com câmbio travado usa a taxa do contrato, não a do dia: o documento carrega exchangeRateOverride com rateType = Contracted, exige a permissão override:exchange_rate e registra quem informou e com que referência. É a única forma de a taxa não vir da resolução, e ela fica visível no extrato da verba.

Política

Estouro e tolerância

PolíticaO que fazPara quem
WarnPassa, grava o movimento, marca o documento como fora do orçamento e notifica os donos do nó por rollup. O documento fica marcado para sempre — não é um aviso que some quando a pessoa clica "ok".Padrão de fábrica. Primeiro ano de uso, quando ninguém confia nos números ainda.
BlockRecusa a operação com 422, dizendo a combinação, o disponível e quanto falta. O documento fica em rascunho.Verba de investimento, obra fechada, centro de custo de terceiro.
RequireApprovalPassa, mas o motor acrescenta nível — porque signal e wouldExceedBy são critérios de política de aprovação. Não é o orçamento que aprova nada; é o motor, com a informação que o orçamento deu.A escolha da maioria depois do primeiro ano: nada trava, tudo passa por quem responde pelo dinheiro.

Tolerância é o menor dos dois

tolerancePercent aplicado sobre o orçado da linha, toleranceAmount em valor absoluto; quando os dois existem, vale o menor. Cinco por cento é razoável numa verba de R$ 200 mil e é meio milhão de folga invisível numa de R$ 10 milhões — e é sempre a verba grande que ninguém revisou. Dentro da tolerância o sinal é Warning e a política não dispara; passou dela, é Exceeded e a política manda.

Não existe furar bloqueio

Não há permissão de override de bloqueio orçamentário, e isso é decisão, não esquecimento. Toda porta de fuga vira o caminho normal em três meses: quem tem a permissão passa a ser chamado para toda compra apertada, e o controle deixa de existir sem que ninguém tenha decidido desligá-lo. O caminho sancionado para gastar mais do que se orçou é pedir mais verbaBudgetChangeRequest, com justificativa e aprovação — ou mudar a política do nó para RequireApproval, o que é uma decisão registrada com vigência e autor.

A avaliação é sobre a fatia, e o bloqueio é do documento

Cada fatia é avaliada contra a sua própria verba, com a sua própria política — 70% em Operações com Block e 30% em Marketing com Warn é uma combinação normal. Mas gravar é atômico: se qualquer fatia bloqueia, nada é gravado. O erro devolve a lista das fatias que falharam, não a primeira.

Origem do número

Importar, planejar e revisar

Importado — o caminho da maioria

O número aprovado já existe, em planilha ou no ERP. Entra por carga em lote, o exercício nasce direto em Approved e ninguém dentro do Nexio decide valor nenhum. É o caminho esperado do primeiro cliente e o único que precisa estar pronto no dia um.

Planejado — coleta e aprovação, sem motor novo

O exercício nasce em Draft. Cada dono de nó — os mesmos CostCenterAssignment que já respondem pela alçada — preenche as linhas da sua subárvore; ninguém enxerga nem edita o que não é seu, pela mesma regra de recorte da visibilidade de valores. Fechada a coleta, o exercício vai a InReview e abre instância do processo ORCAMENTO no motor que já existe. Devolver reabre a edição sem matar a instância, como em qualquer outro documento.

É uma máquina de estados e duas telas. Não é um módulo — e é por isso que dá para oferecer sem virar concorrente de FP&A.

Revisar — movimento ou versão, e a diferença importa

Movimento (BudgetChangeRequest) para o ajuste pontual: suplementar março, remanejar de Marketing para TI, devolver verba. A linha original continua visível e a diferença tem autor, data e justificativa.

Versão quando o plano inteiro é refeito — reorçamento de meio de ano, mudança de estrutura societária, obra replanejada. Nasce como cópia em Draft, passa pelo mesmo processo ORCAMENTO, e ao ser aprovada arquiva a anterior. O razão não é reescrito: os movimentos do exercício antigo continuam apontando para as linhas antigas, e a versão nova recebe movimentos de CarryIn equivalentes ao consumido até ali, para que o disponível não pule. Refazer o plano nunca apaga o que já foi gasto.

Carga

Importação em lote

Mesma mecânica já fechada na importação de centro de custo, com as diferenças que o orçamento impõe:

  • Dry-run obrigatório. A primeira chamada valida e devolve o relatório; a segunda, com o token daquele relatório, aplica. Não existe importar sem ter visto o que vai acontecer.
  • Tudo ou nada. Uma linha inválida recusa o arquivo inteiro, com a lista completa de problemas por número de linha — nunca "importadas 812 de 1.000".
  • Idempotente por chave de negócio(código do centro de custo, código da conta, período), e não por posição no arquivo. Reenviar o mesmo arquivo não duplica nada e permite corrigir uma célula e reenviar.
  • Referência por código, não por id. A planilha traz o código do centro de custo e o da conta, como o financeiro escreve; a resolução para id é do servidor, e código inexistente ou nó inativo é erro nomeado.
  • Nó sem allowsPosting é aceito — verba em nó intermediário é o caso normal.
  • Depois de Approved, importação não altera valor. Ela só cria linhas que não existiam; alterar exige BudgetChangeRequest. Sem isso, a planilha reenviada por engano vira alteração orçamentária sem aprovação — que é exatamente o buraco que o documento existe para fechar.

Fronteiras

Relação com os demais domínios

DomínioO que ele faz com o orçamento
RequisitionConsulta na digitação (semáforo por fatia). Grava Commitment na conclusão da cadeia de aprovação, não na submissão — rascunho e documento em aprovação não seguram verba, senão o saldo fica preso em intenção que talvez nunca exista. Encerrar a requisição libera o resíduo.
PurchaseOrderGrava Obligation na emissão e liquida o Commitment da requisição de origem na proporção. Pedido sem requisição gera Obligation direto, sem liquidação. Encerramento libera o resíduo.
ReceiptGrava Actual pelo que efetivamente entrou e liquida o Obligation. É o recebimento, e não a nota, que marca o realizado — a nota pode não chegar nunca e a mercadoria está lá.
InvoiceNão grava consumo novo no caminho normal: o realizado já veio do recebimento. Grava Adjustment quando o three-way match acusa diferença de preço aceita, com reasonCode = PriceChange.
ContractContrato não empenha na assinatura. O valor total aparece como visibilidade — "há R$ 2,4 milhões contratados para 36 meses" — e o consumo nasce nas liberações parciais, cada uma virando pedido e seguindo o caminho comum. Empenhar três anos de contrato contra a verba de um exercício não descreve nada que aconteça no mundo. Serviço recorrente segue a mesma regra: consome mês a mês, no balde da competência de cada liberação.
Motor de aprovaçãosignal e wouldExceedBy como critérios. Não grava movimento e não conhece verba. Quem grava é o agregado do documento, na transição de estado, na mesma transação.
CostCenterFornece path para o rollup, CostCenterAssignment para o recorte de visibilidade e a regra de resíduo do rateio. Não ganha campo novo — as políticas moram em BudgetPolicy.
GLAccountSegunda dimensão, com o mesmo rollup por path. Verba em "Despesas Operacionais" cobre toda conta abaixo dela.
CompanyFornece functionalCurrency (padrão BRL), que era informativo e passa a ser a moeda do exercício — a spec de Empresa vai para v1.2 por causa disto. Nenhum campo novo. O exercício é sempre de uma empresa.
Currency · ExchangeRateFornece o catálogo de moedas, a resolução da taxa e o serviço de conversão. O orçamento é o primeiro consumidor e não o dono: o mesmo serviço atende Cotação, Pedido e Nota. Ver Moeda e Câmbio.
EstablishmentNenhuma relação. Orçamento é da pessoa jurídica; estabelecimento é dimensão fiscal. Se um dia um cliente quiser orçar por filial, a saída já existe e é a árvore de centro de custo.
Item · CategoryNode · SupplierNenhuma relação, escrita de propósito. Categoria sugere conta contábil, e é a conta que entra na chave da verba. Nunca derivar verba de item, categoria ou fornecedor.

API

Endpoints

RotaPermissãoDevolveErros
GET /budgetsread:budgetsPagedResult<BudgetSummaryDto>400
GET /budgets/{id}read:budgetsBudgetDetailDto404
POST /budgetscreate:budgetsBudgetDetailDto400 · 409 · 422
PUT /budgets/{id}update:budgetsBudgetDetailDto400 · 404 · 409 · 422
POST /budgets/{id}/submitupdate:budgetsBudgetDetailDto404 · 409 · 422
POST /budgets/{id}/closeclose:budgetsBudgetCloseReportDto404 · 409 · 422
POST /budgets/{id}/versionscreate:budgetsBudgetDetailDto404 · 409
GET /budgets/{id}/linesread:budgetsPagedResult<BudgetLineDto>400 · 404
PUT /budgets/{id}/linesupdate:budgets204 · só em Draft404 · 409 · 422
POST /budgets/{id}/lines:importimport:budgetsImportReportDto (dry-run ou aplicado)400 · 409 · 422
POST /budget-state:evaluateread:budget_statearray de BudgetStateDto400 · 422
GET /budget-lines/{id}/entriesread:budgetsPagedResult<BudgetEntryDto> — o extrato403* · 404
GET /budget-entriesread:budgetsRazão filtrável por documento, nó, conta e período400
POST /budget-change-requestscreate:budget_change_requestsBudgetChangeRequestDto400 · 422
POST /budget-change-requests/{id}/submitcreate:budget_change_requestsBudgetChangeRequestDto404 · 409 · 422
GET /budget-policies/effectiveread:budgetsEffectivePolicyDto com inheritedFrom por campo400 · 404
PUT /budget-policiesupdate:budget_policies204400 · 409 · 422

* 403 aqui é a única exceção à regra de responder 404: o extrato de uma linha que a pessoa pode ver como semáforo mas não pode ver em valores é recusa de detalhe, não de existência. Para exercício ou linha de outro tenant, continua 404.

Não há endpoint público de lançamento. BudgetEntry é gravado por contrato de domínio interno, chamado pelos agregados de documento dentro da própria transação. Expor "lançar movimento" como rota seria abrir uma porta lateral para consumir verba sem documento — e o razão perderia a propriedade que o torna útil, que é toda linha ter origem rastreável.

Contrato

Erros

CódigoHTTPQuando
BUDGET_UNCOVERED_ALLOCATION422Escopo controlado e nenhuma linha cobre a combinação. A mensagem nomeia centro de custo, conta e período, e sugere a linha coringa.
BUDGET_EXCEEDED422Política Block e o valor ultrapassa disponível + tolerância. Traz available e wouldExceedBy por fatia.
BUDGET_PERIOD_CLOSED422Compromisso novo com competência em exercício Closed ou Archived.
BUDGET_OVERLAPPING_PERIOD409Aprovar exercício cujo período se sobrepõe a outro já aprovado da mesma empresa.
BUDGET_LINE_DUPLICATE409Mesma combinação de dimensões e período já existe no exercício.
BUDGET_LINE_IMMUTABLE422Tentativa de alterar valor de linha em exercício aprovado sem BudgetChangeRequest.
BUDGET_TRANSFER_INSUFFICIENT422Remanejamento deixaria a linha de origem negativa, revalidado no instante da aprovação.
BUDGET_GRANULARITY_IMMUTABLE422Mudar periodGranularity ou currency depois de aprovado.
EXCHANGE_RATE_MISSING422Sem cotação para o par, tipo e data. Nunca se estima taxa.
BUDGET_ENTRY_IMMUTABLE409Qualquer tentativa de UPDATE ou DELETE no razão. Recusado no banco, não na aplicação.
BUDGET_CONCURRENCY_CONFLICT409rowVersion divergente no exercício ou na linha.

Segurança

Permissões

PermissãoSemeada emObservação
read:budget_stateRequisitante, Comprador, AprovadorSemáforo e razão. Sem ela o produto perde o motivo de existir.
read:budget_amountsGestor de centro de custoValores, recortados pelos nós com vínculo vigente, com rollup.
read:budget_amounts_allControladoria, DiretoriaRemove o recorte.
read:budgetsControladoria, Gestor de CCTelas do módulo e extrato.
create:budgetsControladoriaCriar exercício e versionar.
update:budgetsControladoriaEditar linhas em Draft e submeter para aprovação.
close:budgetsControladoriaFechar exercício e rodar a virada. Separada de update de propósito.
import:budgetsControladoriaCarga em lote.
create:budget_change_requestsGestor de centro de custoPedir suplementação ou remanejamento. Aprovar é do motor.
update:budget_policiesAdministradorLigar controle, definir estouro, tolerância e virada.
read:exchange_ratesControladoria, CompradorConsultar cotações. Definida em Moeda e Câmbio.
manage:exchange_ratesControladoriaCadastrar taxa manual ou contratada. Definida em Moeda e Câmbio.
override:exchange_rateNinguém, por padrãoInformar taxa contratada num documento. Registra autor e referência.

Todas são linhas no catálogo de permissões que o gryd já mantém por tenant, e chegam achatadas no token como qualquer outra. Nenhuma entidade da plataforma muda, e a geração do token continua igual.

Invariantes

Regras de negócio

RN-O01O razão é a verdade

BudgetEntry aceita apenas INSERT e SELECT. UPDATE e DELETE são recusados por grant e por trigger. Toda correção é movimento novo.

RN-O02Saldo é projeção reconstruível

BudgetLineBalance é atualizada na mesma transação do movimento e pode ser reconstruída integralmente a partir do razão. Divergência entre as duas é falha de integridade, verificada por rotina em homologação.

RN-O03Checar e gravar é uma operação só

A decisão de bloqueio acontece dentro da transação que grava, sob SELECT … FOR UPDATE da linha de saldo. Consulta anterior nunca autoriza gravação — sem isso, dois documentos simultâneos contra a última fatia de verba passam os dois.

RN-O04Idempotência por chave natural

idempotencyKey único por tenant. Reprocessamento colide no índice e é descartado sem efeito.

RN-O05Liquidação proporcional

Cada estágio liquida o anterior na proporção do que avançou, com liquidatesEntryId preenchido. O resíduo só é liberado no encerramento explícito do documento de origem.

RN-O06Atomicidade por documento

Todas as fatias de todas as linhas de um documento são avaliadas e gravadas juntas. Se uma bloqueia, nada é gravado, e o erro devolve a lista completa das que falharam.

RN-O07Um exercício aprovado por período e empresa

Sobreposição de intervalos entre Budget aprovados da mesma empresa é recusada por EXCLUDE + btree_gist, no banco.

RN-O08Resolução determinística

Especificidade, depois profundidade do centro de custo, depois profundidade da conta. O critério é total: não existe empate e não existe escolha arbitrária.

RN-O09Ausência assimétrica

Sem controle, passa livre e grava. Com controle e sem linha que cubra, bloqueia. Não há terceiro comportamento.

RN-O10Movimento sempre gravado

Inclusive onde não há controle, com a tupla de dimensões preenchida e budgetLineId nulo.

RN-O11Competência congelada na gravação

competencePeriodKey nunca é atualizado. Mudança de data de necessidade gera par de movimentos com reasonCode = DateChange.

RN-O12O modo cumulativo não atravessa exercício

A janela do modo Cumulative começa em periodStart e termina no balde consultado. Reinicia a cada exercício, sempre.

RN-O13Compromisso segue o documento na virada

Saldos abertos de Commitment e Obligation geram CarryOut/CarryIn com a cadeia preservada, sem reabrir aprovação. A verba só acompanha com CarryCommitmentAndBudget.

RN-O14Realizado tardio liquida o próprio exercício

Actual em exercício Closed é aceito até lateActualsUntil e apenas quando liquida Obligation do mesmo exercício.

RN-O15Taxa congelada, jamais reavaliada

A conversão acontece uma vez, no movimento. Sem cotação disponível, o lançamento é recusado; nunca estimado.

RN-O16Uma moeda por exercício

Budget.currency vem de Company.functionalCurrency e é imutável após a criação.

RN-O17Arredondamento consulta a moeda

Conversão com oito casas, arredondamento único para minorUnits da moeda de destino, HALF_UP. Resíduo de rateio vai para a maior fatia, empate pelo menor order.

RN-O18Verba aprovada só muda por documento

Depois de Approved, amount é imutável. O valor efetivo é amount + Σ Adjustment, e todo ajuste tem autor, data e justificativa.

RN-O19Remanejamento revalidado na aprovação

O saldo da origem é conferido no instante da aprovação, sob trava, e não no momento do pedido.

RN-O20Política herdada campo a campo

Cada campo de BudgetPolicy sobe o path independentemente. A API sempre devolve de qual nó cada valor veio.

RN-O21Vigência não se edita para trás

Mudar política é encerrar a vigente e abrir outra. Sobreposição recusada por EXCLUDE.

RN-O22Valores são cortados no servidor

Sem read:budget_amounts, o bloco de valores não vai na resposta — nem na API, nem no CSV, nem na notificação.

RN-O23Não existe furar bloqueio

Nenhuma permissão ignora Block. O caminho para gastar mais é pedir mais verba ou mudar a política do nó, com registro.

RN-O24Contrato não empenha na assinatura

O consumo nasce nas liberações parciais. O total contratado é visibilidade, não movimento.

RN-O25Escopo de tenant e empresa

Id de outro tenant ou de empresa fora do conjunto resolvido responde 404, nunca 403 — com a única exceção declarada do extrato de valores.

Implementação

Concorrência, índices e volume

A trava é a linha de saldo, e é curta

A transação de gravação segue sempre a mesma ordem: resolver as linhas de todas as fatias, ordenar os budgetLineId por id, travar nessa ordem com FOR UPDATE, reavaliar, inserir os movimentos, atualizar os saldos, confirmar. Ordenar antes de travar é o que evita deadlock entre dois documentos que tocam as mesmas duas verbas em ordem inversa — e é a única forma que sobrevive a produção.

Índices que precisam existir desde o primeiro dia

Em BudgetEntry: (tenantId, budgetLineId, stage) para a projeção; (tenantId, sourceType, sourceId) para o estorno e o extrato por documento; (tenantId, costCenterId, competencePeriodKey) para o consumo fora de verba; único em idempotencyKey. Em BudgetLine: (budgetId, periodKey, specificity DESC), mais os quatro índices parciais de unicidade. Em BudgetPolicy: (tenantId, costCenterId) com EXCLUDE de vigência.

Ordem de grandeza

Um cliente de mil requisições por mês, três linhas cada, duas fatias por linha, gera cerca de seis mil movimentos por estágio e por mês — algumas centenas de milhares de linhas ao ano. É pequeno para o Postgres e não justifica particionamento no lançamento. O particionamento por budgetId fica declarado como gancho para quando o primeiro cliente grande aparecer, e não antes.

A consulta de digitação é barata de propósito

budget-state:evaluateBudgetLineBalance e a política resolvida, sem trava e sem tocar o razão. Ela é chamada a cada mudança de linha na tela do requisitante, então precisa ser dois selects indexados — e é por isso que a projeção materializada existe, mesmo o razão sendo a verdade.

Contexto

Referência de mercado

NexioSAPOracleCoupaERP brasileiro
Controle orçamentárioAvailability Control / Funds ManagementBudgetary ControlBudgets / Spend controlControle Orçamentário
CommitmentObrigo / commitment de requisiçãoCommitmentCommittedPré-empenho
ObligationObrigo de pedidoObligationCommitted (não separa)Empenho
ActualValor realExpenditure / ActualInvoicedRealizado
BudgetChangeRequestSuplementação / transferênciaBudget transferBudget adjustmentAlteração orçamentária
CarryOut / CarryInCommitment carryforwardYear-end carry forwardRestos a pagar (setor público)

Duas notas de posicionamento. A primeira: separar Commitment de Obligation coloca o Nexio acima do Coupa nesse ponto específico — o Coupa mostra um bolo único de comprometido, e quem gere centro de custo quer saber o que ainda dá para segurar. A segunda: a maioria dos ERPs brasileiros de médio porte não tem controle orçamentário de compras que funcione antes da nota, e é por isso que o assunto aparece na primeira reunião comercial.

Limites

Fora de escopo

O quePor quê
Planejamento financeiro (FP&A)Drivers de cálculo, cenários comparados, rateio de overhead, forecast rolante. É outro produto e concorre com o ERP e com a controladoria.
Orçamento de receitaO Nexio controla gasto. Receita orçada não tem consumidor nenhum aqui.
ContabilizaçãoNenhum lançamento contábil é gerado. O razão orçamentário é gerencial e não pretende ser diário contábil.
Fechamento mensal formalSubstituído pelo modo de leitura. Nasce depois, como opt-in, se um cliente de setor regulado exigir o registro imutável de transporte.
Orçamento por estabelecimentoOrçamento é da pessoa jurídica. Quem quiser recorte por filial usa a árvore de centro de custo, que já resolve.
Verba por fornecedor, item ou categoriaDimensão errada. Categoria sugere conta contábil, e é a conta que entra na chave.
Override de bloqueioDecisão explícita. Toda porta de fuga vira o caminho normal em três meses.
Reavaliação cambial periódicaMudaria o saldo sem ninguém ter comprado nada. Variação aparece na liquidação, que é quando ela é real.
Consolidação entre empresasSomar orçamento de várias empresas é relatório de grupo, e depende de moeda e plano de contas comuns. Fica para quando existir demanda declarada.