Ponto de partida
Dez decisões
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.
Três estágios: Commitment → Obligation → Actual. Requisição aprovada compromete, pedido aprovado empenha, recebimento ou aceite realiza. Cada estágio liquida o anterior na proporção do que avançou — nunca soma em cima.
Dimensões opcionais, consumo pela tupla concreta. A verba é 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. O movimento grava a tupla concreta — empresa, centro de custo e conta da fatia, balde e estágio — e nenhum id de linha. O consumo de uma linha é a soma das entradas cuja tupla cai sob ela nas duas dimensões; toda linha que cobre a fatia é avaliada com a própria política e a própria tolerância, e vence o pior sinal (RN-O31).
Regra assimétrica. Fora de escopo controlado, passa livre. Dentro de escopo controlado sem nenhuma linha que cubra, bloqueia Commitment e Obligation — o Actual passa marcado — porque a linha coringa (conta nula) existe justamente para cobrir o resto. Fica descoberto por decisão, nunca por acidente.
O movimento é sempre gravado, com ou sem controle, pela tupla de dimensões, sem id de 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.
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.
É modo de leitura, não movimento de transporte. PeriodOnly lê só o balde; Cumulative lê do primeiro balde do exercício (periodStart) 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.
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.
Um exercício, uma moeda; a taxa vem congelada da submissão do documento. Documento em moeda estrangeira converte uma única vez na vida, na submissão, e o razão herda esse número — nunca reavalia e nunca resolve de novo. A variação cambial entra como consumo real e atribuível: no pedido, pela diferença contra a taxa da requisição que ele liquida; na nota, com nome próprio (ExchangeVariance), porque o recebimento grava pela taxa do pedido (RN-O51). O cadastro, a captura e a resolução da taxa são de Moeda e Câmbio.
Ver o semáforo e ver o número são permissões diferentes. read:budget-states 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 BudgetBalance — uma linha por tupla concreta (empresa, centro de custo, conta, balde, estágio), atualizada na mesma transação do movimento e só quando o movimento de fato entrou (RN-O34). O saldo de uma BudgetLine não é gravado em lugar nenhum: é a soma, lida na hora, das tuplas que caem sob ela. A projeção é 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
POST /evaluate:budget-state é 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 a trava das linhas de orçamento que podem recusar ou exigir aprovação (RN-O47). 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
Draft → InReview → Approved → Closed → Archived. A versão é do exercício, nunca da linha.
totalAmount) por tupla concreta — empresa, centro de custo, conta, balde e estágio —, mantido na mesma transação do movimento. Reconstruível a partir do razão: existe por desempenho, não por verdade. A trava é na BudgetLine, que sempre existe.
path, independentemente dos outros.
BUDGET_CHANGE da semente do motor. Ao aprovar, emite movimentos de Adjustment.
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 aprovado 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.
Requisição aprovada de 100 unidades a R$ 10, um centro de custo, uma conta, tudo na moeda do exercício:
| Evento | Movimentos gravados | Comprometido | Empenhado | Realizado | Consumido |
|---|---|---|---|---|---|
| Requisição aprovada | Commitment +1.000 | 1.000 | — | — | 1.000 |
| Pedido de 60 un a R$ 11 | Obligation +660 Commitment −600 | 400 | 660 | — | 1.060 |
| Recebimento de 55 un | Actual +605 Obligation −605 | 400 | 55 | 605 | 1.060 |
| Pedido encerrado com resíduo | Obligation −55 | 400 | — | 605 | 1.005 |
| Requisição encerrada | Commitment −400 | — | — | 605 | 605 |
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.
O grupo do ato e o saldo aberto
Todo movimento que nasce de um mesmo ato carrega o mesmo postingGroupId. No pedido do exemplo, o grupo tem duas entradas: E2 · Obligation +660 e E3 · Commitment −600, esta com liquidatesEntryId = E1 (a requisição, Commitment +1.000). O saldo aberto de uma entrada positiva é o valor dela, mais todo negativo que a aponta — por liquidatesEntryId ou reversalOfEntryId —, mais os estornos desses negativos que não carregam pairedEntryId, mais os estornos que a apontam em pairedEntryId — a restituição desviada para a entrada mais recente da cadeia quando o alvo do negativo estornado foi levado pela virada ou relançado pela correção de taxa (RN-O43). É a definição da RN-O42, a única do módulo. Depois do pedido, o aberto de E1 é 1.000 − 600 = 400, e o de E2 é 660.
Nenhum negativo passa do aberto do alvo. Liquidação, liberação de resíduo, remanejamento e virada consomem no máximo o saldo aberto da entrada que apontam; o que o ato tiver além disso é consumo novo, não liquidação. Quando a linha de origem tem mais de uma entrada aberta, consome-se a mais antiga primeiro — a ordem é determinística —, e a entrada levada pela virada ou relançada pela correção de taxa nunca é escolhida: responde por ela a mais recente da cadeia (RN-O42).
Estorno é sobre o grupo — e é isso que restitui
Estornar um ato é estornar o grupo inteiro: cada entrada ganha uma de sinal contrário, na mesma tupla e no mesmo balde — salvo o que alcança entrada de exercício com a janela tardia vencida, levada ou não, ou entrada relançada pela correção de taxa, que segue a RN-O43 —, com reversalOfEntryId. O positivo sai; o negativo de liquidação vira positivo e restitui o que o ato havia liquidado. Procurar só quem aponta para a entrada estornada não basta: E3 aponta para E1, não para E2, e cancelar o pedido deixaria a requisição com 400 comprometidos em vez de 1.000 — o saldo pareceria maior do que é.
entryReason é DocumentCancelled, Return ou ReceiptReversal no estorno das entradas positivas do grupo — o fato que aconteceu no mundo — e Reverse no estorno dos negativos, que restituem por consequência. Estorno parcial é proporcional: desfazer a fração f do ato (quantidade cancelada ÷ quantidade do ato; em serviço, valor ÷ valor) estorna f de cada entrada do grupo, arredondado à menor unidade da moeda (minorUnits) por HALF_UP e limitado ao aberto — o estorno que zera o ato leva exatamente o que sobrou, sem centavo órfão (RN-O43).
| Ato | Movimentos do grupo de estorno | Comprometido | Empenhado | Realizado |
|---|---|---|---|---|
| Pedido inteiro, sem a opção | Obligation −660 (DocumentCancelled, alvo E2) Commitment +600 (Reverse, alvo E3) | 1.000 | — | — |
| Pedido inteiro, com “cancelar também a requisição” | Obligation −660 (DocumentCancelled, alvo E2) Commitment −400 (DocumentCancelled, alvo E1, o aberto) | — | — | — |
| 20 das 60 un, sem a opção (f = 1/3) | Obligation −220 Commitment +200 | 600 | 440 | — |
| Recebidas 55 un, as 5 restantes, sem a opção (f = 5/60) | Obligation −55 (o aberto de E2) Commitment +50 | 450 | — | 605 |
Cancelar pedido: a requisição volta, ou vai junto
Cancelar pedido tem uma opção explícita, “cancelar também a requisição”. Sem ela, o grupo do pedido é estornado e a requisição volta a ficar disponível para outro pedido, com o comprometido restituído. Com ela, nada é restituído: os negativos do grupo não são estornados, e as linhas de requisição atendidas pelo pedido são canceladas no mesmo ato, estornando o saldo aberto delas. Nos dois casos o empenho do pedido sai.
A restituição é consumo que volta, e passa pela avaliação como qualquer delta positivo (RN-O46). Pedido para maio sobre requisição de março: cancelar sem a opção põe R$ 600 de volta em março, e março com Block e sem folga recusa o cancelamento com BUDGET_EXCEEDED_UNPROCESSABLE. A mensagem oferece a opção, que não restitui nada, só tem deltas negativos e por isso passa sempre (RN-O44).
Requisição já encerrada. Requisição de 1.000, pedido de 660 que liquidou 600, requisição encerrada com resíduo (Commitment −400, ResidueRelease). Cancelar o pedido sem a opção grava só Obligation −660: o Commitment −600 não é estornado, porque a requisição não tem mais intenção viva. Saldo final: tudo zero (RN-O43). Vale igual quando o encerramento não grava nada. Requisição de 1.000 atendida por dois pedidos — PO1 de 660 que liquidou 600 e PO2 de 440 que liquidou 400 — tem aberto zero e é encerrada sem lançamento; cancelar PO1 sem a opção grava só Obligation −660, e o comprometido fica em zero com PO2 em 440 empenhados. O que decide é o estado da linha de requisição, lido do módulo dono sob a trava, não a existência de um ResidueRelease. Se o cancelamento e o encerramento chegam juntos, os dois travam as entradas da origem antes de ler — o encerramento trava mesmo sem aberto a liberar (RN-O47) — e o segundo vê o que o primeiro gravou: encerrada antes, o cancelamento não restitui; cancelado antes, o encerramento libera os 600 que o cancelamento restituiu.
Pedido que consolida duas requisições
Requisição A: 50 un × R$ 10 em /TI/INFRA/ (EA · Commitment +500). Requisição B: 30 un × R$ 10 em /TI/DEV/ (EB · Commitment +300), mesma conta, mesmo mês. Um pedido de 80 un × R$ 11 atende as duas, com as fatias herdadas na proporção 50/80 e 30/80 — o grupo tem quatro pernas:
| Perna | Movimento | Alvo · par |
|---|---|---|
| 1 | Obligation +550 · /TI/INFRA/ | par EA |
| 2 | Obligation +330 · /TI/DEV/ | par EB |
| 3 | Commitment −500 · /TI/INFRA/ | alvo EA |
| 4 | Commitment −300 · /TI/DEV/ | alvo EB |
O saldo de /TI/ vai de 800 comprometidos a 880 empenhados: o delta líquido é +80, a diferença de preço. Cancelar 20 das 80 un (f = 1/4) estorna Obligation −137,50 e −82,50 e restitui Commitment +125 a A e +75 a B — cada requisição recupera um quarto do que o pedido liquidou dela. As pernas 3 e 4 só não colidem na chave de idempotência por causa do alvo; as pernas 1 e 2 levam o par — pairedEntryId = EA e EB, o alvo do negativo de cada par —, e com as duas requisições na mesma fatia é só ele que as separa (RN-O34).
Devolução e recebimento acima do pedido
Os dois primeiros atos partem do recebimento de 55 un do exemplo — grupo Actual +605 e Obligation −605 com alvo E2; aberto de E2 = 55. Os dois últimos partem do recebimento de mais 8 un, cada um no lugar do outro.
| Ato | Movimentos | Comprometido | Empenhado | Realizado |
|---|---|---|---|---|
| Devolução de 10 un ao fornecedor (f = 10/55 do grupo do recebimento) | Actual −110 (Return) Obligation +110 (Reverse) | 400 | 165 | 495 |
| Recebimento de mais 8 un (63 contra 60 pedidas) | Actual +55 (par: E2) Actual +33 (sem par: o excedente) Obligation −55 (limitado ao aberto de E2) | 400 | — | 693 |
| Depois dele, devolução de 3 das 8 un | Actual −33 (Return, alvo o excedente) | 400 | — | 660 |
| Ou, depois dele, devolução de 5 das 8 un | Actual −33 (Return, alvo o excedente) Actual −22 (Return, f = 2/5 da perna com par) Obligation +22 (Reverse) | 400 | 22 | 638 |
A devolução estorna a fração do grupo do recebimento: o realizado sai e o empenho volta ao pedido, que fica com 15 un em aberto — o fornecedor repõe, ou o pedido é encerrado com resíduo. O recebimento acima grava um Actual por par — R$ 55 com par em E2, que liquida só o que o pedido tinha aberto — e um sem par, os R$ 33 além disso, consumo real que nunca é recusado (RN-O33, RN-O48). A devolução desfaz primeiro o excedente: 3 das 8 un saem só do Actual sem par, e o pedido, que continua todo recebido, não recupera empenho; só o que passa do excedente restitui, na proporção da perna com par — 5 das 8 un devolvem 2 un, R$ 22, ao pedido (RN-O45). O excedente é achado pelo vínculo que todo Actual de recebimento ou aceite grava — sourceLineId e allocationId da linha de pedido e da fatia recebidas —, entre os Actual sem pairedEntryId; a variação da nota (InvoicePriceVariance, ExchangeVariance) também não tem par e fica fora da busca. Com o pedido já encerrado, a devolução tira o realizado e não restitui: 5 das 8 un gravam Actual −33 e Actual −22, e nada volta ao empenho. Aceitar ou não a quantidade a mais é regra do recebimento; o razão só registra o que entrou (RN-O45).
Reabrir requisição com pedido vivo
Editar uma requisição aprovada grava só a diferença da parte ainda não pedida. Se a edição é ajuste para o motor — valor que não subiu, nenhum critério mudou, cadeia igual —, a diferença grava na hora, sem reabrir (Fluxo de Aprovação, RN-AP-71); se reabre, a reabertura não grava nada — o comprometido aberto continua preso enquanto ela está em edição — e a diferença grava na reaprovação, que é o caso da tabela abaixo. A parte já pedida conserva a data e o rateio do ato que a pediu, e a edição não reduz a quantidade abaixo do que já foi pedido (RN-O45). Exemplo: requisição de 100 un × R$ 10 para março, pedido vivo de 60 un; a pessoa reabre, muda para 80 un e para abril, e reaprova — o aberto passa de 40 para 20 un.
| Ato | Movimentos | Março | Abril |
|---|---|---|---|
| Reabrir | nada | C 400 · O 660 | — |
| Reaprovar a versão 2 | Commitment −200 março (QuantityChange, alvo E1) Commitment −200 março (DateChange, alvo E1) Commitment +200 abril (DateChange, par E1) | C — · O 660 | C 200 |
| Depois, cancelar o pedido sem a opção | Obligation −660 março Commitment +600 março (Reverse, alvo E3) | C 600 · O — | C 200 |
Na reaprovação só abril é avaliado: março tem delta −400 e nunca recusa (RN-O46). Na última linha as 60 un voltam a março, a data do ato que as pediu; para levá-las a abril, a requisição é editada de novo e grava o próprio DateChange, avaliado como qualquer delta positivo.
O resíduo tem dono, idade e aviso
O resíduo só sai por ação humana, e a ação humana precisa de ferramenta: sem consulta, envelhecimento, aviso e critério de encerramento, resíduo de intenção morta come o disponível ao longo do ano — o problema que o produto existe para resolver, com o sinal invertido. Sem entidade nova, quatro peças fecham isso:
- Consulta.
GET /budget-entries/residuesdevolve, por documento de origem e por fatia, o saldo aberto deCommitmenteObligationcom a idade em dias desde o último movimento da cadeia, filtrável por nó, período e faixa de idade. É uma leitura sobre o razão e a projeção — nenhuma tabela nova. - Dono nomeado. O responsável pelo resíduo é o dono do nó de centro de custo da fatia, resolvido por
effectiveOwnerscom rollup. Não se inventa papel: é quem já responde pelo dinheiro daquele ramo. - Aviso periódico. Uma vez por semana, uma rotina de sistema lê a consulta acima e avisa o dono de cada nó que tem resíduo mais velho que
residueAlertAfterDays— campo daBudgetPolicy, resolvido para o nó da fatia como os demais, com padrão de fábrica de 60 dias. Um aviso por dono e por nó, com a lista dos documentos, a idade de cada um e o link para a consulta. Sem valor: o saldo preso fica do outro lado do link (RN-O22). O slug énexio.budget-residue.aged, na Notificação. - Critério de encerramento por completude. Gancho declarado, não regra da v1: quando
RequisitionePurchaseOrderforem especificados, o documento cuja quantidade recebida iguala a pedida pode ser proposto para encerramento automático — a decisão continua sendo do dono, e a proposta só existe para que ele não precise procurar. Enquanto os dois documentos não existem, não há o que automatizar e a consulta acima já é a ferramenta que faltava.
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.
Vale igual no three-way match, e é por isso que Adjustment não entra aqui. Nota R$ 500 acima do recebido, com a diferença aceita por uma pessoa, grava Actual adicional de R$ 500, sem alvo, com entryReason = InvoicePriceVariance e o reasonCode do uso PriceVarianceAccept; nota abaixo grava o negativo. Adjustment aumenta a verba: uma nota mais cara criaria orçamento sem BudgetChangeRequest, pelo caminho de maior volume do sistema (RN-O28, RN-O51).
Idempotência é chave natural, não tentativa
Todo movimento carrega idempotencyKey único: o hash de (sourceType, sourceId, sourceLineId, allocationId, documentVersion, stage, entryReason, alvo, perna). Reprocessar o mesmo ato — reentrega de fila, duplo clique, retry de integração — produz as mesmas chaves e é descartado sem efeito. Não existe "verificar se já lancei"; existe deixar o índice único recusar.
Cada peça da chave existe para não descartar o que precisa entrar. documentVersion separa dois atos do mesmo documento — cancelar 20 un hoje e mais 20 amanhã são versões diferentes do pedido. entryReason separa fatos que compartilham fatia, estágio e alvo — na reaprovação de requisição reaberta, QuantityChange −200 e DateChange −200 em março, com o mesmo alvo, só não colidem por ele; a operation, mais grossa, os confundiria. allocationId é a identidade estável da fatia, não o order, que é reusado quando o rateio vai de 70/30 para 50/50. O alvo separa as duas liquidações do pedido que consolida duas requisições. O positivo não tem alvo, e no lugar dele entra a entrada do par (pairedEntryId; none quando não há par): sem ela colidiriam os dois positivos do pedido que liquida duas entradas da mesma fatia, os dois positivos da virada de uma linha com duas entradas abertas, os dois relançamentos da RateCorrection e os dois positivos do DateChange sobre duas entradas abertas da mesma fatia e, no recebimento acima do pedido, o Actual com par e o excedente. Por isso todo positivo que nasce em par com um negativo aponta em pairedEntryId o alvo do negativo do par. E o alvo entra na chave com o nome da coluna, não só com o id: o relançamento da RateCorrection aponta por pairedEntryId a mesma entrada que o estorno dele aponta por reversalOfEntryId, no mesmo estágio, motivo e balde. A perna separa as duas metades de um par: o negativo no balde velho e o positivo no novo do DateChange, as duas linhas do Transfer, as duas pontas da virada.
O grupo entra inteiro ou não entra. A repetição é detectada primeiro pelo postingGroupId, que é determinístico por ato, antes de recalcular alvos — refazer o cálculo pela ordem das entradas abertas daria outros alvos se o estado mudou entre as tentativas. Se o grupo já existe, o ato já foi gravado: nada é inserido, nenhum saldo é tocado, e a resposta é a do registro existente. Chave presente para parte do grupo e ausente para outra é falha de integridade — erro 500 e rollback —, porque o grupo sempre é gravado numa transação só. BudgetBalance só é atualizada pelas entradas que de fato entraram (RN-O34).
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 o resultado é um conjunto de linhas candidatas, não uma linha:
1 · O exercício
O Budget da empresa, com status Approved ou Closed, cujo intervalo contém o balde aplicado — o mês da data de necessidade, salvo as regras de virada (RN-O48). Não pode haver dois — a sobreposição de períodos entre exercícios Approved ou Closed da mesma empresa é recusada como exclusão de faixas, excluída só a versão que o exercício substitui (RN-O54). EXCLUDE com btree_gist é um exemplo de implementação, com verificação sob trava na aplicação onde o banco não oferecer equivalente — a mesma mecânica de ApprovalPolicy e CostCenterAssignment. Se não houver exercício, o resultado é NotControlled e acabou.
2 · A política efetiva
requiresBudget é resolvido para o nó da fatia, subindo o path. Se resolver como falso, o resultado é NotControlled — o movimento ainda é gravado, e nada é consultado nem bloqueado. Os demais campos — overBudgetPolicy, tolerancePercent, toleranceAmount e carryForwardMode — são resolvidos para o nó de cada linha candidata, campo a campo: overBudgetPolicy pode vir do nó, a tolerância do avô e carryForwardMode do padrão. Linha de centro de custo nulo usa a política de costCenterId nulo (a da empresa, se houver; senão a do tenant).
A política é da linha; a decisão é do pior sinal. Cada candidata calcula o próprio sinal com a própria verba, a própria tolerância e a própria política, e a fatia fica com o pior desfecho entre elas: Exceeded sob Block > Exceeded sob RequireApproval > Exceeded sob Warn > Warning > Ok. Verba de R$ 300 mil em /TI/INFRA/ com Warn, dentro de R$ 1 milhão em /TI/ com Block, não abre uma porta de R$ 1,3 milhão: estourar TI recusa o documento mesmo que INFRA ainda tenha folga (RN-O31).
3 · As linhas candidatas
São candidatas as linhas do exercício cujo período contém o balde e em que costCenterId é nulo ou é ancestral-ou-igual do nó da fatia, e glAccountId é nulo ou é ancestral-ou-igual da conta da fatia — as duas comparações pelo path da árvore vigente (RN-O50). Não existe linha vencedora: toda candidata é avaliada, e o conjunto é determinístico porque depende só das duas árvores e das linhas do exercício.
Nenhuma candidata recebe o movimento. O razão grava uma entrada só, com a tupla concreta; cada candidata enxerga esse consumo porque a tupla cai sob ela nas duas dimensões (RN-O31). Gravar N entradas, uma por linha, daria o mesmo saldo e multiplicaria por N o custo do estorno (RN-O43); gravar a entrada numa linha escolhida faria a linha nova, a versão nova do exercício e o orçamento aprovado com o ano já começado não enxergarem o que foi gasto antes deles (RN-O32).
4 · Nenhuma linha cobre
Escopo controlado e nenhuma candidata: sinal Uncovered. Commitment e Obligation são recusados com BUDGET_UNCOVERED_ALLOCATION_UNPROCESSABLE, nomeando a combinação; Actual nunca é recusado — grava, marca o documento como fora do orçamento e notifica os donos do nó (RN-O33). O positivo da virada da rotina e o da virada sob demanda disparada por liquidação também não são recusados: eles gravam marcados e notificam, como o Actual (RN-O13, RN-O46, RN-O48). 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 não existe linha vencedora: com duas dimensões anuláveis, as linhas não se aninham — se cruzam. (/TI/INFRA/, qualquer conta) e (qualquer centro de custo, Software) cobrem a mesma fatia sem que uma contenha a outra. Escolher uma deixaria a outra sem consumo para sempre; por isso todas são avaliadas, cada uma com a sua política.
Linhas cruzadas, com números. Empresa Alfa, março de 2027. Linha A = (/TI/INFRA/, qualquer conta), R$ 300.000, política do nó Warn, consumo R$ 200.000. Linha B = (qualquer centro de custo, 3.1.02 Software), R$ 100.000, política padrão da empresa Block com tolerancePercent = 2 (folga R$ 2.000), consumo R$ 95.000 — o software de todos os centros de custo. Fatia de R$ 8.000 em (/TI/INFRA/, Software): as duas são candidatas. A: disponível 100.000, delta 8.000 → Ok. B: disponível 5.000 + folga 2.000 = 7.000 < 8.000 → Exceeded sob Block. Recusada, com signalFrom = B e wouldExceedBy = 1.000. A mesma fatia em (/TI/INFRA/, 3.1.05 Hardware) só tem A como candidata e passa. Se a entrada fosse gravada numa linha só — A, pela profundidade do centro de custo —, B continuaria em R$ 95.000 e todo software da empresa passaria pelo teto indefinidamente.
Campos
Budget · o exercício
| Campo | Tipo | Regra |
|---|---|---|
id | uuid | — |
tenantId | uuid | Do token. Id de outro tenant responde 404, nunca 403. |
companyId | uuid | Obrigatório. Orçamento é da pessoa jurídica — é ela que tem sócio, conselho e prestação de contas. FK composta (tenantId, companyId). |
name | varchar(120) | Rótulo humano: "Orçamento 2027", "Obra Guarulhos 2027–2029". |
periodStart · periodEnd | date | O 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). |
periodGranularity | enum | Monthly | 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. |
currency | char(3) | ISO 4217. Copiada de Company.functionalCurrency na criação e congelada. Um exercício tem uma moeda só. |
version | int | Sequencial por (companyId, periodStart). Versão nova nasce como cópia da anterior em Draft. |
supersedesBudgetId | uuid? | A versão que esta substitui. A substituída vai para Archived no instante em que a nova é aprovada. |
status | enum | Draft | InReview | Approved | Closed | Archived. Só Approved e Closed participam da resolução; Draft e InReview são invisíveis para o consumo. |
origin | enum | Imported | Planned. Informativo — o ciclo de vida é o mesmo; muda só por onde as linhas entraram. |
approvalRequestId | uuid? | A instância do motor quando origin = Planned. Processo BUDGET — valor avaliado: total orçado do exercício; critérios: valor e empresa. |
lateActualsUntil | date? | Fim da janela de lançamento tardio. Entre periodEnd e esta data entram no exercício só os movimentos que liquidam entrada dele, o positivo do mesmo par — o Actual com par vai ao balde da entrada que liquida — e o Actual sem par com competência nele; positivo novo sem entrada a liquidar vai ao exercício seguinte. A virada só executa depois desta data (RN-O48). Padrão de fábrica: 60 dias após periodEnd. |
externalCode | varchar(60)? | Conciliação com o ERP. Único por tenant quando preenchido. |
rowVersion | int | Concorrência otimista: nasce 1 e incrementa a cada escrita; o cliente envia expectedVersion e a divergência é 409, declarado no OpenAPI. É o mesmo mecanismo de Item, Fornecedor e Centro de Custo — o nome aqui é rowVersion porque version já é a revisão do exercício. Vale também para BudgetLine. Coluna inteira comum, sem tipo específico de banco. |
| De → para | Quem dispara | O que acontece |
|---|---|---|
Draft → InReview | Ação do usuário | Abre instância do processo BUDGET. Linhas viram somente-leitura. |
InReview → Approved | Motor de aprovação | Verifica que não há outro Approved com período sobreposto. Arquiva a versão substituída, sob a trava das linhas dela (RN-O47). A partir daqui as linhas só mudam por BudgetChangeRequest. Nenhum movimento é gravado: o que já estava no razão, inclusive com o período começado, passa a ser lido contra as linhas deste exercício na hora (RN-O32). |
InReview → Draft | Devolução no motor | Devolver não é rejeitar: a instância segue viva e as linhas voltam a ser editáveis. |
Approved → Closed | Automático | Rotina de sistema, no dia seguinte a periodEnd — ninguém precisa lembrar de fechar. Abre a janela de lançamento tardio; as regras de balde da virada valem pela data, não por este status (RN-O48). Nenhum movimento de consumo é recusado por período: o positivo sem entrada a liquidar é gravado no exercício seguinte. |
Closed → Archived | Automático | Passado lateActualsUntil — ou antecipado por POST /budgets/{id}/close, com permissão própria, que traz a data para hoje —, a rotina de sistema executa a virada pela yearEndCommitmentPolicy e arquiva o exercício. Depois dela, o par que liquida entrada levada — o negativo e o positivo — vai ao balde da entrada recriada no exercício seguinte, positivo sem entrada a liquidar vai ao exercício seguinte, e o estorno ou a restituição que alcança entrada dele, levada ou não, também (RN-O43, RN-O48). O razão continua consultável para sempre. |
Campos
BudgetLine · a verba
| Campo | Tipo | Regra |
|---|---|---|
id · tenantId · budgetId | uuid | — |
costCenterId | uuid? | 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. |
glAccountId | uuid? | Nulo = qualquer conta. Mesma regra de subárvore. |
periodKey | varchar(10) | Rótulo do período, sem ambiguidade em exercício fora do ano civil: 2027-03 no mensal (o mês civil); FY2027-Q1 e FY2027 no trimestral e no anual, com o ano de periodEnd do exercício e o trimestre contado a partir de periodStart — no exercício de abril de 2026 a março de 2027, FY2027-Q1 é abril a junho de 2026. Materializado junto com periodStart/periodEnd, que é o que decide quais baldes mensais a linha cobre: nenhuma regra interpreta a string. |
amount | numeric(19,4) | Na moeda do exercício. Pode ser zero (declarar explicitamente "aqui não se gasta" é diferente de não ter linha). Negativo é recusado. |
notes · externalCode | varchar | Justificativa da verba e código do ERP. |
Unicidade com duas colunas anuláveis
A chave lógica é (budgetId, costCenterId, glAccountId, periodKey), e nela nulo colide com nulo: duas linhas coringa com as mesmas dimensões nulas no mesmo período são a mesma linha, e a segunda é recusada com BUDGET_LINE_ALREADY_EXISTS. É requisito, não recurso de banco: índice único simples em banco que trata nulos como distintos não o cumpre, e a implementação escolhida precisa passar no teste das duas coringas idênticas (RN-O54).
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
| Campo | Tipo | Regra |
|---|---|---|
id · tenantId · companyId | uuid | companyId é a empresa do documento: não há rateio entre empresas na v1. É ela que escolhe o Budget consumido (RN-O40). |
stage | enum | Commitment | Obligation | Actual | Adjustment — quatro valores. Os três primeiros consomem; Adjustment altera a verba e só nasce de BudgetChangeRequest, de Import ou de Rollover (RN-O28) — os mesmos três de que fala a regra das dimensões, logo abaixo. A virada grava nos próprios estágios, com entryReason = YearEndRollover (RN-O13). |
amount | numeric(19,4) | Com sinal, na moeda do exercício. Positivo consome (ou, em Adjustment, aumenta a verba); negativo libera. Zero é recusado. |
costCenterId · glAccountId | uuid? | Obrigatórios nos três estágios de consumo (Commitment, Obligation, Actual) — vêm da fatia, que é sempre concreta: o Centro de Custo (RN-CC-29) recusa a submissão de fatia sem conta, então nenhum documento de consumo chega ao razão com glAccountId vazio. É a tupla que decide o saldo: o consumo de toda linha é lido dela, nas duas dimensões (RN-O31). Em Adjustment não existe fatia — o movimento nasce de BudgetChangeRequest, de Import ou da virada, e copia as dimensões da BudgetLine que ele ajusta, inclusive quando elas são nulas. É isso que torna possível suplementar a linha coringa, que é a linha que a Decisão 4 recomenda cadastrar (RN-O36). |
competenceDate · competencePeriodKey | date · varchar(7) | competenceDate é a data de necessidade da linha do documento. competencePeriodKey é o balde aplicado, sempre mensal (2027-03), qualquer que seja a granularidade do exercício: a linha trimestral ou anual soma os meses do seu período, e o movimento gravado antes de o exercício existir já nasce com balde. Normalmente é o mês de competenceDate; é outro só pelas regras de virada (RN-O48), e o negativo que aponta um alvo copia o balde do alvo (RN-O30). Congelado: mudar a data de entrega depois gera movimentos de remanejamento (negativo no balde velho, positivo no novo), nunca um UPDATE nesta coluna. |
budgetLineId | uuid? | Obrigatório em Adjustment, nulo nos três estágios de consumo. Adjustment muda a verba de uma linha e aponta para ela. Movimento de consumo não guarda linha nem exercício: o saldo é lido pela tupla (RN-O31), e por isso cadastrar linha, aprovar versão nova ou aprovar o exercício com o período já começado vale para o que já foi gravado, sem movimento novo nenhum (RN-O32). O negativo que aponta um alvo copia dele a tupla, o balde e a taxa (RN-O30). |
currencyDocument · amountDocument | char(3) · numeric(19,4) | O valor como está no documento. Igual a amount e à moeda do exercício no caso comum. |
exchangeRate · quoteUnit · rateType · rateDate | numeric(18,8) · int · enum · date | A taxa usada e o fator de cotação da taxa efetivamente usada, ambos congelados. Qual fator é esse em cada ramo é regra da Moeda e Câmbio (RN-CUR-11 e RN-CUR-27), que esta página cita e não redefine: com taxa informada por exchangeRateOverride não há linha ExchangeRate, e o fator é o do CurrencyPair, conferido e congelado com o override; sem rate — override só de tipo —, a taxa vem da resolução e o fator é o da própria linha, congelado nela na gravação. 1.0 / 1 / None quando não há conversão. Taxa sem fator não reconstitui a conta: em moeda cotada por 100 o erro seria de duas ordens de grandeza. Movimento originado por documento não resolve taxa: herda a que o documento congelou na submissão (RN-O41). Resolução própria existe só para sourceType = Manual e Import. Detalhe na seção de multimoeda. |
sourceType · sourceId · sourceLineId · allocationId | enum · uuid | Requisition | PurchaseOrder | Receipt | Invoice | Contract | BudgetChangeRequest | Rollover | Import | Manual. É o rastro que responde "quem comeu minha verba". allocationId é a identidade estável da fatia — gerada na criação da CostAllocation e preservada na edição do documento (RN-CC-33), não o order, que é reusado quando o rateio muda de 70/30 para 50/50. No Actual de recebimento ou aceite, sourceId é o recebimento e sourceLineId e allocationId são os da linha de pedido e da fatia recebidas — é o vínculo do excedente, que não tem par, com o pedido (RN-O45). Na virada, os movimentos de estágio conservam as quatro colunas da entrada levada: a entrada recriada tem a origem do documento, e Rollover só aparece na chave de idempotência e no grupo (RN-O34); os Adjustment do CarryCommitmentAndBudget gravam sourceType = Rollover (RN-O28). |
postingGroupId | uuid | Obrigatório. Todo movimento que nasce do mesmo ato — o pedido aprovado, o recebimento, o cancelamento, a virada de uma entrada — carrega o mesmo grupo: o positivo do estágio novo e os negativos que ele liquida. Determinístico por ato: derivado de (sourceType, sourceId, documentVersion) no ato de documento e de (Rollover, entrada levada) na rotina da virada — por exemplo, UUID versão 5 —, de modo que repetir o ato produz o mesmo grupo. A virada sob demanda não tem grupo próprio: os movimentos dela — o negativo no exercício de origem e o positivo no exercício novo — carregam o postingGroupId do ato que a provoca, e é por isso que entram no delta líquido dele — na parcela isenta quando a virada foi disparada por liquidação, na avaliada quando foi disparada por restituição (RN-O46, RN-O48). A repetição é detectada por ele antes de qualquer cálculo de alvo. É a unidade do estorno (RN-O43) e da avaliação pelo delta líquido (RN-O46). Ver RN-O42. |
liquidatesEntryId | uuid? | A entrada positiva cujo saldo aberto este negativo consome — na liquidação encadeada (Liquidate), na liberação de resíduo (ResidueRelease), no remanejamento e na revisão (DateChange, QuantityChange, PriceRevision) e na virada (YearEndRollover). O valor é limitado ao saldo aberto do alvo (RN-O42). Permite reconstruir a cadeia requisição → pedido → recebimento sem consultar os agregados de origem. |
reversalOfEntryId | uuid? | A entrada que este movimento desfaz, com sinal contrário, na mesma tupla e no mesmo balde. O estorno é sobre o grupo (RN-O43): cada entrada do grupo ganha o seu, os positivos voltam e os negativos restituem o alvo. Estorno de positivo é limitado ao saldo aberto dele; estorno de negativo, ao valor ainda não estornado. Não se restitui a entrada encerrada: se a linha de documento de origem do alvo está encerrada ou cancelada — estado lido do módulo dono, sob a trava das entradas da origem (RN-O43) —, o negativo do grupo que o aponta não é estornado. Entrada levada pela virada está encerrada no exercício que fechou, e entrada relançada pela correção de taxa está substituída: o estorno que as alcança vai à entrada mais recente da cadeia, pela taxa e no balde dela. Entrada de exercício com a janela tardia vencida que não foi levada: a restituição chega ao exercício seguinte pela virada sob demanda — primeiro mês depois do periodEnd do exercício mais recente da empresa da entrada cuja virada já executou, ignorando as versões substituídas —, e o estorno de Actual com par acompanha o par; o estorno de Actual sem par fica no balde da entrada que desfaz, no exercício de origem. Fora esses casos, o estorno fica sempre no balde da entrada que desfaz (RN-O43). Estorno não se estorna: entrada com reversalOfEntryId nunca é alvo de outra. Distinto de liquidação: liquidar é avançar, estornar é desfazer. |
pairedEntryId | uuid? | A entrada do par, no movimento que não tem alvo. Em todo positivo que nasce em par com um negativo, é o alvo do negativo do par — liquidatesEntryId ou reversalOfEntryId: o pedido que liquida E1 grava Obligation +660 com pairedEntryId = E1; o positivo do DateChange aponta a entrada que o negativo remaneja; o relançamento da RateCorrection, a entrada que o negativo estorna. Na virada, é a entrada levada, em toda perna do grupo que não a liquida — o positivo do exercício novo e os dois Adjustment do CarryCommitmentAndBudget. Nulo no positivo sem par — o que o ato tem além do aberto, como o excedente do recebimento acima do pedido — e nos movimentos com liquidatesEntryId ou reversalOfEntryId, salvo a restituição desviada pela virada ou pela correção de taxa, em que é a entrada mais recente da cadeia que o estorno reabre (RN-O43). Entra na chave de idempotência (RN-O34), decide o balde do positivo na virada (RN-O48) e separa, na devolução, o Actual que liquidou empenho do excedente (RN-O45). |
entryReason | enum | Obrigatório em todo movimento. BudgetEntryReason, enum fechado: Post, Liquidate, Reverse, DocumentCancelled, Return, ReceiptReversal, QuantityChange, PriceRevision, DateChange, ResidueRelease, YearEndRollover, InvoicePriceVariance, ExchangeVariance, RateCorrection, Reconversion. Responde "por que este movimento existe", e quem escolhe é a máquina de estados — nunca uma pessoa. A lista fechada é declarada na spec de Motivo, que é a dona dela. |
reasonCodeId · reasonCode · reasonLabel | uuid? · varchar(40)? · varchar(120)? | Anuláveis, e os três juntos. FK para o catálogo de Motivo mais o snapshot do código e do rótulo, na quádrupla que todo ato grava. Preenchidos só quando uma pessoa deu o motivo: respondem "por que a pessoa fez isso". O caso canônico é o Adjustment — pela RN-O28 ele só nasce de BudgetChangeRequest ou Import, e mudança de verba tem justificativa humana. O reasonComment fica no documento de origem; o razão guarda o código e o rótulo, não o texto. |
idempotencyKey | char(64) | Único por tenant. Hash SHA-256, em hexadecimal, de (sourceType, sourceId, sourceLineId, allocationId, documentVersion, stage, entryReason, alvo, perna). Alvo = o nome da coluna e o id: liquidatesEntryId ou reversalOfEntryId; no movimento que não tem nenhum dos dois, pairedEntryId — a entrada do par —, e o marcador fixo none quando também não há par; a coluna entra porque o estorno e o relançamento da RateCorrection apontam a mesma entrada no mesmo balde. Na virada, Rollover e o exercício que fecha entram no lugar de sourceType e sourceId; perna = companyId, costCenterId, glAccountId, budgetLineId e competencePeriodKey. Guardado como hash para o índice ter largura fixa. É o que torna reprocessamento inofensivo sem descartar a segunda perna de um par (RN-O34). |
occurredAt · postedAt · createdByUserId | timestamptz · uuid | Quando o fato aconteceu no domínio — a data real do recebimento ou do aceite, que o balde do Actual com par não segue na janela tardia (RN-O48) — e quando entrou no razão. userId do gryd, sem FK — mesmo padrão de CostCenterAssignment. |
Dois campos de motivo, e não é redundância — é que o razão responde a duas perguntas diferentes. entryReason diz por que este movimento existe, e quem escolhe é a máquina de estados: nenhum movimento nasce sem ele. reasonCode diz por que a pessoa fez isso, e só existe quando houve pessoa. Um Adjustment de suplementação tem entryReason = Post e o reasonCode que o solicitante escolheu no BudgetChangeRequest; o estorno que restitui por consequência tem entryReason = Reverse e reasonCode nulo, porque ninguém escolheu nada. Colapsar os dois num campo só perderia uma das duas informações no extrato.
Por que os valores de máquina não podem morar no catálogo. ReasonCode é sobreponível por tenant: o cliente renomeia, desativa e cria linhas. Um valor de que a máquina de estados depende, dentro de uma tabela que o cliente edita, é a porta que a regra "motivo é dado, nunca regra" existe para fechar — o tenant que desativasse YearEndRollover quebraria a virada de exercício. Por isso BudgetEntryReason é enum, e por isso a lista fechada dele está escrita na spec de Motivo, que é onde o acervo inventaria vocabulário de motivo, sem virar linha de tabela.
E operation é derivado de entryReason. Os três primeiros valores do enum têm o mesmo nome de três valores de BudgetEntryOperation, e a coincidência é de propósito: a razão é a coluna fina, a operação é a grossa, e a segunda sai da primeira por uma tabela fixa — DocumentCancelled, Return, ReceiptReversal e Reverse dão Reverse; QuantityChange, PriceRevision e DateChange dão Rebucket; ResidueRelease dá Release; InvoicePriceVariance, ExchangeVariance, YearEndRollover e Post dão Post; RateCorrection e Reconversion dão Rebucket; Liquidate dá Liquidate. A chave de idempotência (RN-O34) usa entryReason, a coluna fina, porque dois fatos da mesma operação podem ter a mesma fatia e o mesmo alvo: não são dois campos independentes que podem divergir — é um campo e a sua projeção. Sem esta frase, a primeira leitura da página é que o razão tem três colunas dizendo a mesma coisa.
BudgetEntryOperation não tem Supplement nem Transfer. Os dois seriam cópia do type do BudgetChangeRequest, que o movimento já alcança por sourceType e sourceId. Duas fontes da verdade para o mesmo fato divergem na primeira correção; a que fica é a do documento.
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.
| Campo | Tipo | Regra |
|---|---|---|
costCenterId | uuid? | Nulo = padrão do tenant. Preenchido, vale para o nó e seus descendentes até que um descendente sobrescreva o mesmo campo. |
companyId | uuid? | Nulo = todas as empresas do tenant. Preenchido, a política só vale para documentos daquela empresa. Sem esta coluna, um grupo — que é o público-alvo declarado — não conseguia ligar requiresBudget na empresa madura e deixá-lo desligado na recém-adquirida sem duplicar nós de centro de custo por empresa, que é exatamente o que o nó corporativo existe para evitar. Mesmo espírito de GLAccountCompany: a árvore é compartilhada, a decisão é da pessoa jurídica. Na resolução, política com empresa vence a de empresa nula para o mesmo campo e o mesmo nó (RN-O37). |
validFrom · validTo | date · date? | Vigência. Sobreposição de duas políticas do mesmo (costCenterId, companyId) é recusada — a exclusão de faixas com igualdade nas duas dimensões, nulo colidindo com nulo, e sobreposição no período, com BUDGET_POLICY_OVERLAPPING_PERIOD_CONFLICT; EXCLUDE + btree_gist como exemplo de implementação, com verificação na aplicação onde o banco não oferecer equivalente. Política de empresa nula e política de empresa preenchida convivem no mesmo nó e no mesmo período, e é essa convivência que dá o padrão do grupo com exceção por empresa. Nunca se edita vigência para o passado — trocar política é encerrar e abrir. |
requiresBudget | bool? | Liga o controle. Padrão de fábrica falso. |
carryForwardMode | enum? | PeriodOnly | Cumulative. Padrão de fábrica PeriodOnly. |
overBudgetPolicy | enum? | Warn | Block | RequireApproval. Padrão de fábrica Warn. É a única fonte da decisão de bloqueio no produto: o motor de aprovação lê esta política já resolvida e obedece, e não tem mais parâmetro próprio. |
tolerancePercent · toleranceAmount | numeric(9,6)? · numeric(19,4)? | 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. |
yearEndCommitmentPolicy | enum? | CarryCommitmentOnly | CarryCommitmentAndBudget | CancelOpen. Padrão de fábrica CarryCommitmentOnly. |
residueAlertAfterDays | int? | Idade, em dias desde o último movimento da cadeia, a partir da qual o resíduo aberto entra no aviso semanal ao dono do nó (RN-O35). Inteiro de 7 a 365. Padrão de fábrica 60. Resolvido para o nó da fatia do resíduo, com a mesma herança campo a campo: a obra pode esperar 180 dias enquanto a TI avisa com 30. |
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 BUDGET_CHANGE da semente, que avalia o delta da verba com os critérios valor, centro de custo e conta contábil.
| Campo | Tipo | Regra |
|---|---|---|
number | varchar(60) | Via NumberSequence, escopo Company. A largura é a do número composto da Empresa, com prefixo, período e código do estabelecimento quando houver. |
type | enum | Supplement (dinheiro novo), Transfer (remanejamento entre linhas), Reduction (devolver verba). |
fromLineId · toLineId | uuid? | Transfer exige os dois; Supplement só toLineId; Reduction só fromLineId. Ambos precisam pertencer ao mesmo budgetId. |
amount | numeric(19,4) | Positivo. O sinal vem do type, não do número — assim ninguém digita menos-menos. |
reasonCodeId · reasonCode · reasonLabel · reasonComment | uuid · varchar(40) · varchar(120) · text | Obrigatórios, os quatro. A quádrupla do Motivo, no uso BudgetChangeJustify: o código classifica para o relatório, o comentário explica o caso. É o que a controladoria vai ler em dezembro — e, agora, é também o que ela consegue contar. É esta quádrupla que o BudgetEntry do Adjustment copia. |
effectiveDate | date | Data do movimento de Adjustment. Não pode ser anterior ao periodStart do exercício. |
status · approvalRequestId | enum · uuid? | Draft | PendingApproval | Approved | Rejected | Cancelled. A transição para Approved é do motor, e é ela que grava os movimentos. |
Remanejamento e redução não podem deixar a origem negativa
Na decisão final de Transfer e de Reduction, o contrato de desfecho trava a linha de origem — qualquer que seja a política dela — e confere naquele instante, não no pedido, que o disponível da origem cobre o valor: orçado mais ajustes menos o consumo lido pela tupla, no período da linha; no Cumulative, o menor acumulado desse período ao fim do exercício. Verba pode ter sido consumida durante a aprovação. Se não cobrir, Refused(BUDGET_CHANGE_INSUFFICIENT_UNPROCESSABLE): nada é gravado, a tarefa continua ativa e o aprovador vê o número atual (RN-O49). Linha de março com R$ 10.000 e R$ 9.200 consumidos: redução de R$ 1.000 é recusada (disponível 800); a de R$ 800 passa e deixa disponível zero (RN-O19).
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: só os valores das linhas mudam, e o razão — que guarda a tupla, não a linha — passa a ser lido contra as linhas novas sem movimento nenhum. 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 /evaluate:budget-state
{
"companyId": "…",
"slices": [
{ "ref": "L1-A", "costCenterId": "…", "glAccountId": "…",
"neededOn": "2027-03-14", "amount": 18400.00, "currency": "BRL",
"stage": "Obligation",
"liquidates": [ { "entryId": "…", "amount": 16000.00 } ] } // entradas que o ato vai liquidar
]
}
200 OK
{
"results": [{
"ref": "L1-A",
"signal": "Warning",
"reason": "Covered",
"periodKey": "2027-03",
"netDelta": 2400.00, // 18.400 − 16.000 liquidados
"pendingOverrun": false, // informativo, nunca critério (RN-O56)
"lines": [{ // toda linha candidata, pior sinal primeiro
"budgetLineId": "…", "costCenterPath": "/TI/INFRA/", "glAccountPath": null,
"overBudgetPolicy": "RequireApproval", "carryForwardMode": "PeriodOnly",
"policySource": { "overBudgetPolicy": "CC 4.2 Operações",
"carryForwardMode": "tenant" },
"signal": "Warning", "netDelta": 2400.00, "wouldExceedBy": 0.00,
"amounts": { // ausente sem read:budget-amounts
"currency": "BRL",
"budgeted": 240000.00, // amount + Σ Adjustment, na janela
"committed": 51200.00,
"obligated": 96300.00,
"actual": 91500.00,
"consumed": 239000.00, // tuplas sob a linha, nas duas dimensões
"available": 1000.00,
"toleranceRoom": 2400.00, // o menor entre 1% e R$ 5.000
"pendingApproval": 0.00 // documentos em aprovação sob a linha (RN-O56)
}
}],
"signalFrom": { "budgetLineId": "…", "periodKey": "2027-03",
"overBudgetPolicy": "RequireApproval" }, // a linha e o balde do pior sinal
"wouldExceedBy": 0.00
}]
}
| Sinal | Quando | O que o requisitante vê |
|---|---|---|
NotControlled | Sem exercício, ou requiresBudget falso | Cinza · "sem controle orçamentário". Nenhum número, nem se a pessoa tiver permissão — não há o que mostrar. |
Ok | Cabe no disponível | Verde. Segue o baile. |
Warning | Estoura, mas dentro da tolerância | Âmbar · "vai consumir toda a verba de março". Passa, e o gestor recebe notificação. |
Exceeded | Estoura além da tolerância | Vermelho, com o texto vindo da política: avisa e passa, bloqueia, ou "vai exigir aprovação da controladoria". |
Uncovered | Controlado e nenhuma linha cobre | Vermelho · "não há verba cadastrada para esta conta neste centro de custo". Bloqueia Commitment e Obligation, e o texto diz o que falta cadastrar. Actual passa marcado. |
O contrato dos sinais, e o que cada um obriga
BudgetSignal tem cinco valores. NotControlled é a ausência de controle e não é critério de nada: nem o motor nem o serviço de lançamento fazem qualquer coisa com ele. Os outros quatro são o contrato que POST /evaluate:budget-state publica, e cada um obriga a exatamente uma coisa:
| Sinal | O serviço de lançamento | O motor de aprovação |
|---|---|---|
| Ok | Grava. | Nada. Não é critério. |
| Warning | Grava e marca o documento como fora do orçamento. | Critério disponível para política Supplemental; sem política que case, segue a cadeia normal. |
| Exceeded | Grava, salvo linha candidata em Exceeded sob Block, que recusa Commitment e Obligation com BUDGET_EXCEEDED_UNPROCESSABLE — e, na decisão final, também sob RequireApproval quando o sinal piorou desde a rodada (RN-O49). Actual grava sempre. | Critério de política. RequireApproval é sinal, não comando (RN-O27). |
| Uncovered | Recusa Commitment e Obligation, com BUDGET_UNCOVERED_ALLOCATION_UNPROCESSABLE, independentemente de overBudgetPolicy — salvo o positivo da virada da rotina e o da virada sob demanda disparada por liquidação, que gravam marcados e notificam, como o Actual (RN-O13, RN-O46, RN-O48). Actual grava, marca e notifica (RN-O33). | Recusa a submissão repassando o mesmo erro. Não é um grau de estouro e não tem política que o afrouxe. |
O que Uncovered significa, escrito de uma vez: o escopo é controlado (requiresBudget verdadeiro) e nenhuma linha do exercício cobre a combinação — não é "estourou", é "a dimensão não existe no plano". Por isso ele não passa por tolerância nem por política de estouro: não há verba contra a qual medir. A saída é cadastrar a linha coringa daquele centro de custo, e é isso que a mensagem diz. Ver RN-O39.
available depende do modo de leitura
Com PeriodOnly, é budgeted(março) − consumed(março). Com Cumulative (acumulado), é Σ budgeted(periodStart..março) − Σ consumed(periodStart..março) — de janeiro a março num exercício que começa em janeiro, de abril a março num que começa em abril. 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".
No Cumulative, o lançamento num balde é avaliado contra todos os acumulados que ele altera — o do próprio balde e os dos baldes seguintes do exercício —, e decide o menor disponível. Janeiro com R$ 100 orçados e nada consumido, fevereiro com R$ 100 orçados e R$ 190 consumidos: lançar R$ 90 em janeiro cabe no acumulado de janeiro (disponível 100) e estoura o de fevereiro (200 − 190 = 10). Sob Block e sem folga é recusado, e signalFrom aponta a linha de fevereiro (RN-O46).
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 movimento daquela fatia grava o nó folha e a conta folha, e a linha de "Operações" lê o consumo de toda tupla que cai sob ela — 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, lendo o consumo de vários nós.
Linha no pai e linha no filho enxergam o mesmo gasto, cada uma contra a sua verba. R$ 1 milhão em /TI/ e R$ 300 mil em /TI/INFRA/: R$ 250 mil gastos em INFRA são consumo das duas linhas, e o disponível de TI desconta esses R$ 250 mil mais o resto de TI. As verbas se contêm em vez de somar poder de gasto — como Oracle Budgetary Control e SAP FM, que aplicam o controle em vários níveis ao mesmo tempo (RN-O31). A leitura não pergunta se o escopo que gravou era controlado: fatia de nó com requiresBudget falso não é avaliada, mas o gasto dela conta em toda linha que a cobre.
Documento em aprovação aparece, e não conta
Documento em aprovação não grava movimento, então três requisições submetidas contra a mesma verba veriam Ok cada uma. A consulta devolve, por linha, pendingApproval — a soma das fatias de documentos submetidos e ainda não concluídos que caem sob a linha, excluído o próprio documento — dentro de amounts, e o aviso pendingOverrun fora dele: verdadeiro quando consumo + em aprovação + delta do ato passa do orçado + folga. É informação, nunca critério: não muda o sinal, não trava, não recusa, e o motor não o lê. Quem soma é o documento: cada módulo de documento com aprovação publica, no seu .Contracts, a leitura das fatias em aprovação por tupla e balde, e o Orçamento só agrega por linha; enquanto Requisição e Pedido não existem, os dois campos voltam nulos (RN-O56).
Com números. Verba de março R$ 50.000, consumo 40.000, R$ 7.000 em requisições em aprovação. Requisição nova de R$ 5.000: sinal Ok (5.000 ≤ 10.000), pendingApproval = 7.000, pendingOverrun = verdadeiro (40.000 + 7.000 + 5.000 = 52.000 > 50.000). A tela diz: cabe hoje; com o que está em aprovação, não cabe.
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 — chamado pelo contrato de desfecho do processo, na mesma transação da decisão (RN-AP-38). 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ão | O que libera |
|---|---|
| read:budget-states | O 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-amounts | O 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. A linha coringa segue o mesmo recorte pelo nó que ela tem: a coringa comum — costCenterId preenchido, glAccountId nulo, que é a linha que a Decisão 4 manda cadastrar — é vista por quem responde por aquele nó, como qualquer outra. A linha sem centro de custo nenhum (as duas dimensões nulas, verba da empresa inteira) não tem nó por onde recortar: ela só aparece com read:budget-amounts-all, e o sinal continua indo para todo mundo por read:budget-states. Não é falha do recorte — é o que "verba de ninguém em particular" significa. |
| read:budget-amounts-all | Remove o recorte. Controladoria, financeiro, diretoria. |
| read:budgets | As telas do módulo: lista de exercícios, linhas, extrato. Não implica ver valores: os números das telas seguem read:budget-amounts e o recorte acima; sem eles, a pessoa vê estrutura e semáforo. |
O corte é no servidor, e vale para todo número derivado da linha — amounts, wouldExceedBy, pendingApproval, o available do erro de estouro — e para o caminho da linha que está fora do recorte, que só aparece por budgetLineId, sinal e política (RN-O22). Sem a permissão, o número 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 entryReason = DateChange, negativo no balde velho e positivo no novo, e se o novo balde não couber, vale a política das linhas dele. Nunca um UPDATE em competencePeriodKey.
Com números. Requisição de R$ 1.000 para março (E1), data mudada para abril: um grupo com Commitment −1.000 em março, alvo E1, e Commitment +1.000 em abril, os dois DateChange. Março tem delta −1.000 e nunca recusa. Linha de abril com R$ 5.000 orçados, R$ 4.300 consumidos, Block e tolerância de 5% limitada a R$ 200: disponível 700 + folga 200 = 900 < 1.000 → a mudança de data é recusada com BUDGET_EXCEEDED_UNPROCESSABLE (wouldExceedBy = 100) e a requisição continua em março, intacta (RN-O46).
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 de virada — negativo e positivo nos próprios estágios, com entryReason = YearEndRollover, que é a mesma mecânica da virada de exercício (RN-O13).
Cumulative nunca atravessa exercício
A janela do acumulado é periodStart do exercício até o balde consultado. No periodStart do exercício seguinte ela reinicia, sempre, qualquer que seja a política — 1º de abril, num exercício de abril a março. 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 aprovados 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.
| Sistema | Regra | Comentário |
|---|---|---|
| Oracle General Ledger | Trê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 365 | Duas 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 FSCM | Keep 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 brasileiro | Restos 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
Passada a janela de lançamento tardio, cada saldo aberto de Commitment e Obligation do exercício gera, num grupo por entrada, um negativo no próprio estágio, no balde dela e com ela como alvo, e um positivo no mesmo estágio no primeiro mês depois de periodEnd — os dois com entryReason = YearEndRollover, a origem do documento, a tupla e a taxa de câmbio preservadas — Rollover identifica só a chave de idempotência e o grupo da virada (RN-O34). A verba não vai junto. São movimentos de sistema, isentos de Block e de Uncovered: o compromisso já foi aprovado e o documento não volta à fila, e o positivo da virada da rotina grava marcado como fora do orçamento e notifica os donos do nó, como o Actual (RN-O33), mesmo que nenhuma linha cubra a tupla no balde de destino — o exercício novo pode estar aprovado e mesmo assim não ter linha que cubra aquela combinação, e a rotina não pode ficar travada num balde que só a linha coringa destrava (RN-O13, RN-O39). A isenção é da rotina, e a virada sob demanda que um ato provoca depois dela segue o gatilho: disparada por liquidação, entra na parcela isenta e é isenta dos dois sinais, como a da rotina; disparada por restituição, é avaliada com o ato e continua podendo ser recusada com BUDGET_UNCOVERED_ALLOCATION_UNPROCESSABLE (RN-O46, RN-O48).
É o que faz a Decisão 8 ser verdade. Gravado nos próprios estágios, o compromisso levado consome desde o primeiro dia do exercício novo, sem enum novo e sem campo novo na consulta de saldo: o aperto de dezembro aparece como estouro em janeiro (RN-O13).
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 movimento positivo do estágio no exercício novo, um Adjustment de mesmo valor na linha desse exercício, e um Adjustment negativo no exercício que fecha — os dois com origem Rollover, que é a terceira e última porta de entrada de Adjustment junto com BudgetChangeRequest e Import (RN-O28). É 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 força redecisão no ano novo. O orçamento não conhece o documento, então não o devolve nem o cancela: a virada chama, por documento de origem com saldo aberto, o contrato de virada que o módulo dono registra para aquele sourceType no seu .Contracts. O documento encerra ou devolve a linha dele e grava o próprio estorno de grupo (RN-O43), na mesma transação; o que o contrato devolver como Failed fica aberto e visível no relatório da virada, que não arquiva o exercício enquanto houver pendência. Publicar BudgetPolicy com CancelOpen é recusado com BUDGET_POLICY_YEAR_END_UNSUPPORTED_UNPROCESSABLE enquanto algum tipo de documento que grava no razão não tiver o contrato (RN-O57). 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.
A janela tardia e a virada, com números
A janela existe para que o fato de dezembro que chega em janeiro caia em 2026 — sem ela, o recebimento de uma entrega orçada e empenhada em 2026 consumiria verba de 2027 e estouraria o ano novo por um fato do ano velho. E a virada espera a janela acabar para que as duas coisas não se anulem: levar o empenho para 2027 enquanto 2026 ainda aceita o recebimento dele deixaria o mesmo dinheiro nos dois anos. Exercício 2026 de janeiro a dezembro, lateActualsUntil = 01/03/2027, CarryCommitmentOnly, requisição de 100 un × R$ 10 com necessidade em 20/12/2026 (E1 · Commitment +1.000, dezembro de 2026):
| Data | Ato | Movimentos e balde | 2026 · C · O · A | 2027 · C · O · A |
|---|---|---|---|---|
| 10/01/2027 | Pedido de 60 un × R$ 11 | Obligation +660 dez/26 · Commitment −600 dez/26 (alvo E1) — o par liquida entrada de 2026 e fica em 2026 | 400 · 660 · — | — |
| 12/01/2027 | Requisição nova com necessidade em 28/12/2026 | Commitment +300 jan/27 — positivo sem entrada a liquidar vai ao exercício seguinte | 400 · 660 · — | 300 · — · — |
| 14/01/2027 | Pedido das 30 un × R$ 10 da requisição de 12/01 | Obligation +300 jan/27 · Commitment −300 jan/27 — o par liquida entrada de 2027, e o positivo vai ao primeiro mês depois de periodEnd | 400 · 660 · — | — · 300 · — |
| 20/01/2027 | Recebimento das 30 un — entrega antecipada: o fornecedor entregou em 30/12/2026, antes do pedido | Actual +300 jan/27 · Obligation −300 jan/27 — Actual com par vai ao balde da entrada que liquida, não a dezembro; 30/12/2026 fica em occurredAt | 400 · 660 · — | — · — · 300 |
| 05/02/2027 | Recebimento de 55 un | Actual +605 dez/26 · Obligation −605 dez/26 — Actual com par, no balde da entrada que liquida (E2) | 400 · 55 · 605 | — · — · 300 |
| 02/03/2027 | Virada, sistema, isenta de Block | Commitment −400 dez/26 e +400 jan/27 (E1′) · Obligation −55 dez/26 e +55 jan/27 (E2′) — as recriadas conservam a origem do documento | — · — · 605 | 400 · 55 · 300 |
| 10/03/2027 | Recebimento das 5 un restantes | Actual +55 jan/27 · Obligation −55 jan/27 — a trava por origem encontra E2 e E2′, só E2′ está aberta: o par a liquida e vai ao balde dela | — · — · 605 | 400 · — · 355 |
| 15/03/2027 | Pedido das 40 un restantes × R$ 11 | Obligation +440 jan/27 · Commitment −400 jan/27 | — · — · 605 | — · 440 · 355 |
| 20/03/2027 | Devolução de 10 un do recebimento de 05/02 (f = 10/55) | Actual −110 jan/27 (Return) · Obligation +110 jan/27 (Reverse, pairedEntryId = E2′) — E2 foi levada: as duas pernas vão ao balde da recriada, que volta a ter 10 un em aberto | — · — · 605 | — · 550 · 245 |
| Variante | No lugar do recebimento de 10/03, cancelar as 5 un restantes sem a opção (f = 5/60) | Obligation −55 jan/27 (DocumentCancelled, alvo E2′, o aberto dela) · Commitment +50 jan/27 (Reverse, pairedEntryId = E1′) — o empenho sai da recriada do pedido e o comprometido volta à recriada da requisição | — · — · 605 | 450 · — · 300 |
| Variante 2 | No lugar do pedido de 15/03: pedido das 40 un em 20/02, na janela (Obligation +440 (E4) e Commitment −400 (E5), dez/26), e a virada de 02/03 leva E2 e E4 — E1, com aberto zero, fica. Em 20/03, cancelar esse pedido sem a opção | Obligation −440 jan/27 (DocumentCancelled, alvo E4′) · Commitment +400 dez/26 (Reverse, alvo E5) e, na mesma transação, Commitment −400 dez/26 e +400 jan/27 (virada sob demanda, E1′) — a restituição chega a 2027 e é avaliada com o ato: delta −40 | — · — · 605 | 400 · — · 355 |
| Variante 3 | No recebimento de 05/02 vieram 63 un: 60 com par, que é todo o aberto de E2 (Actual +660 · Obligation −660, dez/26), e 3 un excedentes — Actual +33 sem par, dez/26 —, e 2026 fecha com 693 realizados. E2 fica com aberto zero: não há recebimento em 10/03, e a virada de 02/03 leva só E1. Em 20/03/2027, no lugar da devolução das 10 un, devolver as 3 un excedentes | Actual −33 dez/26 (Return, sem par) — estorno de Actual sem par de exercício com a janela tardia vencida baixa o realizado no balde da entrada que desfaz, no exercício de origem; nada é gravado em 2027 | — · — · 660 | — · 440 · 300 |
2026 fecha com exatamente o que foi realizado nele, R$ 605, e nenhum ato foi recusado por exercício fechado. 2027 carrega o que ainda estava vivo e enxerga o aperto desde janeiro. A entrega de 30/12/2026 não parte a cadeia: o realizado fica em 2027, junto do empenho que liquidou, e a data real continua no movimento para relatório. O pedido de 15/03 herda a necessidade de 20/12/2026 e mesmo assim não cai num exercício arquivado: o par liquida a entrada recriada pela virada e vai ao balde dela (RN-O48). Nada disso depende de alguém fechar o exercício: 2026 passa a Closed em 01/01/2027 e a virada roda em 02/03/2027 sozinhos. E o que anda para trás depois da virada também fica em 2027: a devolução de 20/03 e o cancelamento da variante alcançam entradas levadas e gravam as duas pernas no balde das recriadas — 2026, arquivado, continua em 605, e o pedido volta a ter 10 un, ou nenhuma, por receber. Na variante 2 a requisição não tinha aberto a levar, e a restituição vai a 2027 assim mesmo: gravada só em dezembro de 2026, onde nada é controlado, o cancelamento liberaria os R$ 440 do empenho em 2027 sem trazer os R$ 400 da requisição, que o pedido seguinte traria depois (RN-O43). E o que anda para trás sem par não devolve verba nenhuma ao ano novo: na variante 3 a devolução do excedente baixa os R$ 33 do realizado de 2026, onde ele foi consumido, e o disponível de 2027 fica igual — com o negativo em janeiro de 2027, 2027 ganharia R$ 33 de folga que ninguém orçou. O excedente dessa variante só existe acima do aberto da entrada: 63 un × R$ 11 = R$ 693 contra os R$ 660 de E2 deixam R$ 33 sem par, enquanto 58 un × R$ 11 = R$ 638 ainda caberiam nos R$ 660 e liquidariam sem sobra nenhuma (RN-O45, RN-O51). E, com E2 zerada, a virada de 02/03 não a leva e não há mais nada a receber do pedido de 60 un — o que resta em 2027 é o empenho das 40 un de 15/03 e o realizado de 20/01. Em nenhum dos dois casos, com par ou sem par, o disponível do exercício novo muda. Nada disso depende do estado Archived: o que aciona as regras é a janela tardia ter vencido, e entre o fim dela e o arquivamento a rotina já pulou a entrada de aberto zero e já levou os empenhos (RN-O43, RN-O48).
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 necessidade em dezembro, o positivo não tem entrada a liquidar e vai ao primeiro mês do exercício seguinte, com aviso na tela e no evento — é a linha de 12/01 da tabela acima. A cadeia não é reavaliada por isso; se as linhas de janeiro recusarem sob a trava, vale o desfecho da decisão final (RN-O49).
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 não são desta spec. Eles são transversais — o Catálogo, o Fornecedor, a Cotação, o Pedido e a Nota consomem os mesmos — e vivem 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, da spec de Empresa, define em que moeda a pessoa jurídica orça e presta contas. Na v1 é sempre BRL: o campo existe e fica imutável depois do primeiro Budget da empresa. Documentos, preços, limites de alçada e cotações continuam em qualquer ISO 4217 — o que a v1 fixa é só a moeda em que a pessoa jurídica orça, que é a do CNPJ e do SPED. Com funcional em real, toda conversão é moeda estrangeira contra real, que é o que o boletim do Banco Central publica; par sem cotação vigente recusa, e não se estima taxa cruzada. 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 na submissão do documento — e de novo só em rodada nova —, e o razão herda
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.
Quem escolhe o número é a submissão, não o lançamento. O Commitment nasce dias depois da submissão, na conclusão da cadeia, e converter de novo ali daria outro número para o mesmo dinheiro. Vale a submissão: todo movimento originado por documento copia exchangeRate, quoteUnit, rateType e rateDate do documento; o negativo de liquidação copia da entrada que liquida (RN-O30). A resolução própria de taxa desta spec vale só para movimento sem documento de origem — Manual e Import (RN-O41). Requisição, pedido e nota convertem cada um na sua submissão; recebimento e aceite não convertem — gravam pela taxa do pedido. A variação entre a requisição e o pedido aparece no próprio grupo do pedido, como a diferença de preço; a variação entre o pedido e a nota entra pela nota, como ExchangeVariance (RN-O51, abaixo).
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.
O realizado é o custo incorrido; a nota traz só a diferença, com nome próprio
O Actual é gravado no recebimento da mercadoria ou no aceite do serviço, pela taxa que o pedido congelou; a nota não liquida empenho nem regrava o realizado. O que a nota traz de novo entra como Actual sem alvo, com entryReason próprio: a diferença de preço aceita (InvoicePriceVariance), valorizada pela taxa do pedido, e a variação cambial (ExchangeVariance), que é o resto. Pedido de 100 un × US$ 100, taxa congelada 5,20:
| Ato | Movimentos | O | A |
|---|---|---|---|
| Aprovação do pedido | Obligation +52.000 | 52.000 | — |
| Recebimento das 100 un | Actual +52.000 · Obligation −52.000 | — | 52.000 |
| Nota 1: 60 un × US$ 100 a 5,60 = R$ 33.600 | Actual +2.400 (ExchangeVariance = 33.600 − 60 × 100 × 5,20) | — | 54.400 |
| Nota 2: 40 un × US$ 102, preço aceito, a 5,50 = R$ 22.440 | Actual +416 (InvoicePriceVariance = 40 × US$ 2 × 5,20) · Actual +1.224 (ExchangeVariance = 22.440 − 40 × 102 × 5,20) | — | 56.040 |
O realizado termina igual ao faturado convertido — R$ 56.040 — e o extrato separa quanto foi preço e quanto foi câmbio. Nota abaixo do recebido, ou dólar em queda, grava os mesmos movimentos com sinal negativo. Serviço segue igual, com o aceite no lugar do recebimento. Nota sem pedido — conta de consumo, serviço contratado sem pedido — é o próprio documento que incorre o custo: grava Actual sem alvo, pela taxa da submissão dela e sem liquidação, no ato que a spec da Nota definir — aprovação no motor ou ato do próprio documento. Faturado e liberado para pagamento são leitura fornecida pela Nota por fatia, fora do razão — não consomem verba, porque o custo já foi incorrido; enquanto a Nota não existir, a consulta os devolve nulos (RN-O51).
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_UNPROCESSABLE 422 e a atomicidade por documento vale igual: nada é gravado. O sistema nunca estima taxa.
A janela de tolerância para trás e o comportamento em fim de semana e feriado são de CurrencySettings, da spec de Moeda e Câmbio. O tipo de taxa do documento vem de processRateTypes, por processo, e quem pede, prepara ou submete não escolhe; defaultRateType vale só para movimento sem documento de origem — o Manual e o Import deste razão. A Moeda é a dona da regra; aqui ela só é citada. 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 de informar taxa no documento 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. Taxa informada aciona a controladoria: o documento recebe no motor o passo complementar da controladoria, porque a taxa decide a alçada; e a taxa informada nunca vira cotação do tenant. E o autor da taxa não decide: o capturedBy de toda taxa do tenant usada na conversão entra em excludedUsers com a razão RateOverrider — com ou sem a flag, e qualquer que seja o papel dele no documento —, e quem registrou o override entra com a mesma razão: o overriddenBy, com taxa informada ou só com o tipo. A regra é do Fluxo de Aprovação e de Moeda e Câmbio, que nomeia os dois campos; aqui ela só é citada.
Corrigir uma taxa não mexe na verba
RateCorrection tem um gatilho só: a cotação do tenant que o documento usou foi substituída — a linha nova aponta supersededById na antiga, e o motivo é o supersedeReasonCode dela, sem código novo. A regra é de Moeda e Câmbio, que é a dona; aqui ela só é citada. Nesse caso o razão se corrige com um grupo que estorna o saldo aberto de cada entrada afetada e relança o mesmo saldo pela taxa certa, no próprio estágio, as duas pernas com entryReason = RateCorrection. Nunca Adjustment, que muda a verba (RN-O28). A parte já liquidada não se corrige para trás: ela virou realizado, e a diferença aparece na nota como ExchangeVariance. A correção é isenta de Block, passa marcada e notifica os donos do nó: recusá-la manteria o número errado — no sentido perigoso, quando a taxa certa é maior.
Taxa informada errada não se corrige no razão. O exchangeRateOverride é do documento, e documento se corrige por versão nova: outro exchangeRateOverride, com a permissão de informar taxa no documento, que deriva de novo exchangeRateOverridden e traz o passo complementar da controladoria, e com os dois autores que Moeda e Câmbio nomeia em excludedUsers como RateOverrider: o capturedBy, autor da linha de taxa efetivamente usada, e o overriddenBy, quem registrou o override — que é o único dos dois preenchido quando a taxa vem informada. E informar ou trocar a taxa depois de aprovado abre rodada nova, para cima ou para baixo: qualquer mudança de taxa recomeça a rodada do zero, com as decisões anteriores Obsolete — a flag exchangeRateOverridden, que informar ou trocar a taxa deriva de novo, está no retrato dos atributos de critério (RN-AP-21), e a regra é do Fluxo de Aprovação (RN-AP-70), que é a dona; aqui ela só é citada. Sem isso, quem tem a permissão de informar taxa no documento baixava a taxa depois de aprovado e liberava R$ 1.080 de empenho (US$ 6.000 de 5,20 para 5,02) sem rota, sem autor declarado, sem segunda pessoa e sem redecisão — a regra que só olhava o valor subir deixava passar exatamente a taxa que desce, que é o sentido em que a verba se libera —, ou a subia e punha o valor acima da alçada sem rodada nova.
Com números. Empenho de US$ 10.000 convertido pela cotação de 5,20, substituída depois por 5,02, com 40 un já recebidas (R$ 20.800 realizados). O grupo grava Obligation −31.200, alvo o empenho, e Obligation +30.120 (US$ 6.000 × 5,02): empenhado aberto de 31.200 para 30.120, realizado intacto. O recebimento das 60 un restantes liquida a entrada nova, a 5,02 (RN-O53).
Devolução depois da correção. Devolver 10 das 40 un grava Actual −5.200 (Return), pela taxa com que o realizado foi gravado, e Obligation +5.020 (Reverse, pairedEntryId = a entrada nova), US$ 1.000 pela taxa dela: o empenho aberto fica em 35.140, as 70 un a 5,02. Restituir à entrada original, a 5,20, deixaria 35.320, e a entrega seguinte a liquidaria pela taxa errada. No sentido inverso — gravada a 5,02, corrigida para 5,20 — a restituição é Obligation +5.200 contra Actual −5.020: os R$ 180 de diferença são correção de taxa, isentos de Block — parcela isenta R$ 180, parcela avaliada zero (RN-O46) —, e sem a regra o empenho ficaria R$ 180 abaixo (RN-O43).
Política
Estouro e tolerância
| Política | O que faz | Para quem |
|---|---|---|
Warn | Passa, 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. |
Block | Recusa a operação com 422, dizendo a combinação, o disponível e quanto falta. O documento fica em rascunho. Vale só para Commitment e Obligation — os estágios em que ainda dá para não gastar. Nunca recusa Actual (RN-O33). | Verba de investimento, obra fechada, centro de custo de terceiro. |
RequireApproval | Passa, e declara que aquele nó quer um passo a mais no estouro — mas quem acrescenta o passo é uma política Supplemental do cliente, com o critério estado do orçamento, não o motor por conta própria (RN-O27). Não é o orçamento que aprova nada, e o motor não inventa nível: o orçamento dá o sinal, e a política escrita no Fluxo decide o que fazer com ele. | 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.
Block não recusa mercadoria na doca
Bloquear é dizer "não compre". Aplicado a Actual, viraria "não registre o que já chegou": a mercadoria está na doca, o caminhão foi embora, e recusar o recebimento não devolve o dinheiro — só apaga o fato do razão, que é a única coisa que o produto não pode deixar acontecer. O caso mais frequente é o da própria spec: o pedido empenhou US$ 10.000 a 5,20 e a nota chega a 5,60, e os R$ 4.000 de variação "consumiram caixa" por definição. Por isso Block avalia Commitment e Obligation e deixa passar Actual, marcando o documento como fora do orçamento e notificando os donos do nó, exatamente como Warn (RN-O33).
E quando o empenho estoura na decisão final
Entre a submissão e a conclusão da cadeia passam dias, e outro documento pode ter comido a verba no intervalo. O Commitment é gravado pelo contrato de desfecho do processo, na transação da decisão e sob a trava (RN-O47), e o desfecho tem três resultados (RN-O49):
Applied— decisão, transição eCommitmentgravados na mesma transação.Refused(BUDGET_EXCEEDED_UNPROCESSABLE)— sob a trava, uma linha candidata está emExceededsobBlock, ou sobRequireApprovalcom sinal pior que o da rodada em que a cadeia foi montada. Nada é gravado pela transação da decisão — nem a decisão, nem a transição, nem o movimento — e a tarefa continua ativa. O motor registra a tentativa em transação própria; o aprovador vê a linha, o disponível e quanto faltou, e escolhe devolver com motivo (a rodada nova nasce com o sinal novo) ou aguardar remanejamento e decidir de novo. O requisitante e os donos do nó da linha que recusou são avisados. O mesmo vale paraRefused(BUDGET_UNCOVERED_ALLOCATION_UNPROCESSABLE)— fatia sem linha — e paraRefused(BUDGET_CHANGE_INSUFFICIENT_UNPROCESSABLE), na decisão final doBUDGET_CHANGEque deixaria a origem negativa.Failed— falha técnica: rollback total e nada gravado; repetir é seguro peloexpectedVersione pela chave de idempotência.
O efeito que precisa ser atômico com o ato acontece na mesma transação — pelo próprio caso de uso, por chamada síncrona declarada entre contextos (.Contracts) ou pelo contrato de desfecho do motor; o evento de domínio é publicado depois do commit e serve para notificar, medir e integrar — nunca para produzir efeito de que o ato dependa. Por isso o Commitment é gravado pelo contrato de desfecho, e não por quem escuta o evento de aprovação.
Com números. /TI/ com Block, sem folga, R$ 50.000 orçados em março. A requisição X, de R$ 8.000, foi submetida com R$ 12.000 disponíveis e subiu pela cadeia; enquanto aguardava a última assinatura, outra requisição consumiu R$ 6.000. Na decisão final o disponível é 6.000 < 8.000: Refused, a assinatura não é gravada e a tarefa segue com o diretor. Aprovado um BudgetChangeRequest de R$ 2.000 em /TI/, a nova decisão encontra 8.000 disponíveis e dá Applied.
Materialidade é roteamento; o orçamento vê a decomposição integral
allocationMaterialityPercent afeta apenas a montagem da cadeia de aprovação. O orçamento sempre vê a decomposição integral do rateio, e Block numa fatia absorvida recusa a submissão do mesmo jeito. Um pedido de R$ 400 mil com 3% em /MARKETING/ não gera aprovador de Marketing — e consome a verba de Marketing pelos R$ 12 mil, com a política de Marketing valendo inteira.
Por quê: controle de verba não é roteamento de alçada. A alternativa — deixar passar porque o motor ignorou a fatia — cria a marca que dispara no funcionamento normal, e controladoria que vê marca todo dia aprende a ignorá-la. Esta é a mesma frase que o Fluxo de Aprovação escreve do lado dele, de propósito: "a mesma fatia que a alçada avalia é a que o orçamento consome" continua verdadeira para o consumo; o que a materialidade dispensa é o aprovador daquela fatia, nunca o lançamento (RN-O38, e RN-CC-24 do lado do rateio).
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 verba — BudgetChangeRequest, 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. Controla desde o primeiro dia do período: o que foi gravado antes da carga é lido contra as linhas importadas no instante em que o exercício existe (RN-O32).
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 BUDGET no motor. Devolver reabre a edição sem matar a instância, como em qualquer outro documento.
Aprovar em março o orçamento que começou em janeiro — o caso normal, não a exceção. Nenhum movimento novo é gravado na aprovação. Os movimentos de janeiro e fevereiro foram gravados com a tupla concreta e sem linha; no instante em que o exercício vai a Approved, as linhas dele passam a ler esse consumo, e a tela de março mostra a verba já descontada do que foi gasto — em vez da verba intacta, que é o "a verba acabou em setembro" que esta spec existe para eliminar. Linha que janeiro e fevereiro já estouraram nasce Exceeded: nada é desfeito, e o próximo compromisso sob Block nela é recusado (RN-O32).
É 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 BUDGET, e ao ser aprovada arquiva a anterior. O razão não é tocado: a versão nova só troca os valores das linhas. Os movimentos guardam a tupla e não a linha, e no instante da aprovação passam a ser lidos contra as linhas novas — o consumido até ali continua descontado, o disponível não pula, e refazer o plano nunca apaga o que já foi gasto. Adjustment aponta para a linha da versão em que foi aprovado; a cópia em Draft parte de amount + Σ Adjustment de cada linha (RN-O32).
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 cria verba. Linha existente não muda de valor; linha nova entra com valor zero — cobre a combinação e passa a controlar —, e dar valor a ela éBudgetChangeRequest. Arquivo com valor diferente do atual numa linha existente, ou diferente de zero numa linha nova, é recusado inteiro comBUDGET_IMPORT_AMOUNT_AFTER_APPROVAL_UNPROCESSABLE, listando as linhas; reenviar o mesmo arquivo com os mesmos valores passa. Linha nova de valor zero pode passar a recusar documentos sobBlock: é aperto de controle, não verba, e o dry-run lista cada linha nova com a política dela. Sem isso, a planilha reenviada vira alteração orçamentária sem aprovação (RN-O55).
Fronteiras
Relação com os demais domínios
| Domínio | O que ele faz com o orçamento |
|---|---|
Requisition | Consulta na digitação (semáforo por fatia). Grava Commitment na conclusão da cadeia de aprovação — pela última decisão humana ou, na aprovação pela política, na transação do envio (RN-AP-71 do Fluxo) —, 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. Editar a requisição aprovada grava só a diferença da parte ainda não pedida: na hora, se a edição é ajuste; na reaprovação, se reabre — e reabrir não grava nada (RN-O45). |
PurchaseOrder | Grava Obligation na conclusão da aprovação do pedido (RN-O52) 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. Cancelar o pedido estorna o grupo dele, na fração cancelada: sem a opção “cancelar também a requisição”, o Commitment liquidado é restituído e a requisição volta a ficar disponível; com ela, nada é restituído e a requisição é cancelada junto (RN-O43, RN-O44). |
Receipt | Grava Actual pelo que efetivamente entrou — ou, em serviço, pelo que foi aceito — e liquida o Obligation, pela taxa do pedido. É o recebimento ou o aceite, e não a nota, que marca o realizado (RN-O51) — a nota pode não chegar nunca e a mercadoria está lá. Liquida só até o aberto do empenho: o recebido acima do pedido é Actual sem liquidação. Devolução estorna a fração do grupo do recebimento e devolve o empenho ao pedido (RN-O45). |
Invoice | Não grava consumo novo no caminho normal: o realizado já veio do recebimento ou do aceite. Grava só o que a nota traz de diferente, como Actual sem alvo: a diferença de preço aceita no three-way match, com entryReason = InvoicePriceVariance e o reasonCode que a pessoa escolheu ao aceitar, do uso PriceVarianceAccept do catálogo de Motivo; e a variação cambial, com entryReason = ExchangeVariance, que é conta e não escolha. Nota sem pedido grava Actual sem alvo no ato que a spec da Nota definir — aprovação no motor ou ato do documento. Faturado e liberado são leitura que a Nota fornece, fora do razão (RN-O51). |
Contract | Contrato 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ção | Lê signal 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 da decisão que a provocou — o motor invoca o contrato de desfecho do processo e o módulo dono empenha ali (RN-AP-38). O desfecho devolve Applied, Refused(code) ou Failed; em Refused nada é gravado e a tarefa continua ativa (RN-O49). O evento de domínio do motor sai depois do commit e é aviso, nunca a via do empenho. |
CostCenter | Fornece path para a leitura do consumo pela árvore vigente (RN-O50), 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. |
GLAccount | Segunda dimensão, lida pelo mesmo prefixo de path, na árvore vigente. Verba em "Despesas Operacionais" cobre toda conta abaixo dela, e a linha que tem as duas dimensões só enxerga o que cai sob as duas (RN-O31). |
Company | Fornece functionalCurrency (BRL na v1, imutável depois do primeiro Budget), que é a moeda do exercício. Nenhum campo novo. O exercício é sempre de uma empresa. Qual empresa, escrito de uma vez: o Budget consumido é o da empresa do documento. Não há rateio entre empresas na v1; compra centralizada opera no estabelecimento de cada empresa, por escopo, e cada documento consome a verba da própria empresa (RN-O40). |
Currency · ExchangeRate | Fornece 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. |
Attachment | O Budget é dono de anexo (ownerType = Budget): a planilha de premissas e a memória de cálculo do exercício, com APPROVAL_EVIDENCE entre os tipos aceitos, para que apareçam classificadas como prova quando a auditoria as pedir. A guarda é a do Anexo — todo tipo semeado, GENERAL inclusive, guarda cinco anos depois do fechamento do dono. A ata e o ofício que justificam suplementação, remanejamento ou redução não são do Budget: são do BudgetChangeRequest. StateOf devolve Closed no exercício arquivado, nunca Missing. CanRead(user, ownerId), exigido pelo Anexo na listagem e no download: read:budget-amounts-all ou tarefa na instância BUDGET do exercício. O BudgetChangeRequest também é dono (ownerType = BudgetChangeRequest), para a ata, o ofício e a planilha que justificam a suplementação, o remanejamento ou a redução: CanRead = read:budget-amounts-all, read:budget-amounts com recorte que cubra todas as linhas do pedido (fromLineId e toLineId), quem o criou, ou tarefa na instância BUDGET_CHANGE; StateOf = Draft enquanto nunca submetido, InProgress em PendingApproval e devolvido, Closed em Approved, Rejected e Cancelled. read:budgets não dá leitura de anexo em nenhum dos dois: ver a estrutura da verba não é ver os valores que a sustentam (RN-O22). As onze respostas do descritor estão declaradas na página do Anexo, em O dono polimórfico — este módulo implementa, não redecide. |
Establishment | Nenhuma 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 · Supplier | Nenhuma 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
| Rota | Permissão | Devolve | Erros |
|---|---|---|---|
| GET /budgets | read:budgets | PagedResult<BudgetSummaryDto> | 400 |
| GET /budgets/{id} | read:budgets | BudgetDetailDto | 404 |
| POST /budgets | create:budgets | BudgetDetailDto | 400 · 409 · 422 |
| PUT /budgets/{id} | update:budgets | BudgetDetailDto | 400 · 404 · 409 · 422 |
| POST /budgets/{id}/submit | update:budgets | BudgetDetailDto | 404 · 409 · 422 |
| POST /budgets/{id}/close | delete:budgets | BudgetCloseReportDto | 404 · 409 · 422 |
| POST /budgets/{id}/versions | create:budgets | BudgetDetailDto | 404 · 409 |
| GET /budgets/{id}/lines | read:budgets | PagedResult<BudgetLineDto> | 400 · 404 |
| PUT /budgets/{id}/lines | update:budgets | 204 · só em Draft | 404 · 409 · 422 |
| POST /budgets/{id}/lines:import | create:budgets | ?dryRun=true devolve o ImportReportDto sem gravar; sem o parâmetro, aplica. Tudo ou nada, nunca parcial — a mecânica do centro de custo, que é a canônica | 400 · 409 · 422 |
| POST /evaluate:budget-state | read:budget-states | BudgetStateResultDto — { results: BudgetStateDto[] } | 400 · 422 |
| GET /budget-lines/{id}/entries | read:budgets | PagedResult<BudgetEntryDto> — o extrato | 403* · 404 |
| GET /budget-entries | read:budgets | Razão filtrável por documento, nó, conta e período | 400 |
| GET /budget-entries/residues | read:budgets | Resíduo aberto de Commitment e Obligation por documento de origem e fatia, com a idade em dias desde o último movimento da cadeia. Filtra por nó, período, faixa de idade e dono. É leitura sobre o razão e a projeção — sem tabela nova (RN-O35) | 400 |
| GET /budget-lines/move-impact | read:budget-states | Para ?costCenterId=…&newParentId=…, as linhas de orçamento que deixam de enxergar e que passam a enxergar o consumo da subárvore se o nó for movido, com o sinal de cada uma antes e depois; valores só com read:budget-amounts. Leitura, nada é gravado (RN-O50) | 400 · 404 |
| POST /budget-change-requests | create:budget-change-requests | BudgetChangeRequestDto | 400 · 422 |
| POST /budget-change-requests/{id}/submit | create:budget-change-requests | BudgetChangeRequestDto | 404 · 409 · 422 |
| GET /budget-policies/effective | read:budgets | EffectivePolicyDto com inheritedFrom por campo | 400 · 404 |
| PUT /budget-policies | update:budget-policies | 204 | 400 · 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ódigo | HTTP | Quando |
|---|---|---|
| BUDGET_UNCOVERED_ALLOCATION_UNPROCESSABLE | 422 | Escopo controlado e nenhuma linha cobre a combinação, em Commitment ou Obligation; Actual nunca recebe este erro. A mensagem nomeia centro de custo, conta e período, e sugere a linha coringa. |
| BUDGET_EXCEEDED_UNPROCESSABLE | 422 | O delta líquido de Commitment ou Obligation ultrapassa disponível + tolerância de uma linha candidata sob Block — ou, na decisão final, sob RequireApproval com sinal pior que o da rodada (RN-O49). Traz, por fatia, a linha e o balde de signalFrom, available e wouldExceedBy — os números e o caminho da linha só para quem os veria na consulta (RN-O22). É o erro que o motor de aprovação repassa ao recusar a submissão, e o código do Refused na decisão final — ele não reembrulha nem cria um código próprio. Nunca é devolvido para Actual. |
| BUDGET_PERIOD_CLOSED_UNPROCESSABLE | 422 | Adjustment — de BudgetChangeRequest ou de Import — com data em exercício Closed ou Archived. Movimento de consumo nunca recebe este erro: vai ao balde que as regras de virada determinam (RN-O48). |
| BUDGET_OVERLAPPING_PERIOD_CONFLICT | 409 | Aprovar exercício cujo período se sobrepõe a outro Approved ou Closed da mesma empresa, que não seja a versão substituída. |
| BUDGET_POLICY_OVERLAPPING_PERIOD_CONFLICT | 409 | Política cuja vigência se sobrepõe a outra do mesmo (costCenterId, companyId), nulo colidindo com nulo. |
| BUDGET_POLICY_YEAR_END_UNSUPPORTED_UNPROCESSABLE | 422 | Política com yearEndCommitmentPolicy = CancelOpen quando algum tipo de documento que grava no razão não registrou o contrato de virada. |
| BUDGET_LINE_ALREADY_EXISTS | 409 | Mesma combinação de dimensões e período já existe no exercício. |
| BUDGET_LINE_IMMUTABLE_CONFLICT | 409 | Tentativa de alterar valor de linha em exercício aprovado sem BudgetChangeRequest. |
| BUDGET_CHANGE_INSUFFICIENT_UNPROCESSABLE | 422 | Transfer ou Reduction deixaria a linha de origem com disponível negativo, conferido sob trava na decisão final. |
| BUDGET_IMPORT_AMOUNT_AFTER_APPROVAL_UNPROCESSABLE | 422 | Importação em exercício Approved com valor diferente do atual em linha existente, ou diferente de zero em linha nova. Lista as linhas do arquivo. |
| BUDGET_GRANULARITY_IMMUTABLE_CONFLICT | 409 | Mudar periodGranularity ou currency depois de aprovado. |
| EXCHANGE_RATE_MISSING_UNPROCESSABLE | 422 | Sem cotação para o par, tipo e data. Nunca se estima taxa. |
| BUDGET_ENTRY_IMMUTABLE_CONFLICT | 409 | Qualquer tentativa de UPDATE ou DELETE no razão. Recusado no banco, não na aplicação. |
| BUDGET_CONCURRENCY_CONFLICT | 409 | expectedVersion divergente do rowVersion atual do exercício ou da linha. |
Segurança
Permissões
| Permissão | Semeada em | Observação |
|---|---|---|
| read:budget-states | Requisitante, Comprador, Aprovador | Semáforo e razão. Sem ela o produto perde o motivo de existir. |
| read:budget-amounts | Gestor de centro de custo | Valores, recortados pelos nós com vínculo vigente, com rollup. |
| read:budget-amounts-all | Controladoria, Diretoria | Remove o recorte. |
| read:budgets | Controladoria, Gestor de CC | Telas do módulo e extrato. |
| create:budgets | Controladoria | Criar exercício, versionar e carregar linhas em lote (importação). |
| update:budgets | Controladoria | Editar linhas em Draft e submeter para aprovação. |
| delete:budgets | Controladoria | Antecipar o fim da janela de lançamento tardio e rodar a virada. Separada de update de propósito. |
| create:budget-change-requests | Gestor de centro de custo | Pedir suplementação ou remanejamento. Aprovar é do motor. |
| update:budget-policies | Administrador | Ligar controle, definir estouro, tolerância e virada. |
| read:exchange-rates | Controladoria, Comprador | Consultar cotações. Definida em Moeda e Câmbio. |
| create:exchange-rates | Controladoria | Cadastrar taxa manual ou contratada. Definida em Moeda e Câmbio. |
| (permissão de informar taxa no documento) | Ninguém, por padrão | Informar taxa contratada num documento. Registra autor e referência, e aciona o passo complementar da controladoria no motor. [PERGUNTA AO ROGER: o override de taxa por documento não cabe em nenhum dos cinco verbos sem se confundir com o cadastro de taxa (create:exchange-rates); qual permissão o exige?] |
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
BudgetEntry aceita apenas INSERT e SELECT. UPDATE e DELETE são recusados por grant e por trigger. Toda correção é movimento novo.
BudgetBalance guarda uma linha por tupla concreta — empresa, centro de custo, conta, balde, estágio —, com o total em totalAmount numeric(19,4) (RN-O58), é atualizada na mesma transação do movimento, só pelas entradas que de fato entraram, e pode ser reconstruída integralmente a partir do razão. O saldo de uma BudgetLine não é materializado: é a soma das tuplas sob ela, lida na hora. Divergência entre projeção e razão é falha de integridade, verificada por rotina em homologação.
A decisão de bloqueio acontece dentro da transação que grava, sob a trava das linhas candidatas que podem recusar ou exigir aprovação, com as somas lidas depois da trava (RN-O47). Consulta anterior nunca autoriza gravação — sem isso, dois documentos simultâneos contra a última fatia de verba passam os dois.
idempotencyKey único por tenant, com alvo e perna na chave (RN-O34). Reprocessamento colide no índice e é descartado sem efeito e sem tocar saldo.
Cada estágio liquida o anterior na proporção do que avançou, com liquidatesEntryId preenchido e limitado ao saldo aberto do alvo (RN-O42). O resíduo só é liberado no encerramento explícito do documento de origem.
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.
Sobreposição de intervalos entre Budget Approved ou Closed da mesma empresa é recusada, excluída a versão substituída (RN-O54).
As candidatas de uma fatia são todas as linhas do exercício cujo período contém o balde e que cobrem a tupla nas duas dimensões — centro de custo nulo ou ancestral-ou-igual, conta nula ou ancestral-ou-igual. Não existe linha vencedora nem escolha arbitrária: o conjunto depende só das duas árvores e das linhas, e o pior sinal decide (RN-O31).
Sem controle, passa livre e grava. Com controle e sem linha que cubra, bloqueia Commitment e Obligation; Actual grava e marca. Não há terceiro comportamento.
Inclusive onde não há controle, com a tupla concreta preenchida. Movimento de consumo nunca guarda linha: o que foi gravado sem controle passa a contar no instante em que um exercício aprovado cobre a tupla (RN-O32).
competencePeriodKey nunca é atualizado. Mudança de data de necessidade gera par de movimentos com entryReason = DateChange.
A janela do modo Cumulative começa em periodStart e termina no balde consultado. Reinicia a cada exercício, sempre.
Depois de lateActualsUntil, saldos abertos de Commitment e Obligation geram, num grupo por entrada, um negativo com a entrada como alvo e um positivo no mesmo estágio no primeiro mês depois de periodEnd, com entryReason = YearEndRollover, a origem do documento conservada — Rollover entra só na chave de idempotência e no grupo (RN-O34) — e a cadeia preservada — sem reabrir aprovação e isentos de Block e de Uncovered: o positivo da virada da rotina grava marcado como fora do orçamento e notifica os donos do nó, como o Actual (RN-O33), mesmo que nenhuma linha cubra a tupla no balde de destino; a virada sob demanda disparada por restituição continua avaliada e continua podendo ser recusada com BUDGET_UNCOVERED_ALLOCATION_UNPROCESSABLE. A verba só acompanha com CarryCommitmentAndBudget. Balde e janela: RN-O48.
Até lateActualsUntil, o exercício Closed recebe os movimentos que liquidam entrada dele, o positivo do mesmo par e o Actual sem par com competência nele; o Actual com par vai ao balde da entrada que liquida. O que não tem entrada dele a liquidar vai ao exercício seguinte, e nada é recusado por período (RN-O48).
A conversão acontece na submissão do documento e se renova só no ato que abre rodada nova de aprovação (Moeda e Câmbio, RN-CUR-22); o movimento herda o número (RN-O41), e a reconversão entra como estorno e relançamento (RN-O59). Sem cotação disponível para o par, o tipo e a janela, a submissão é recusada; nunca estimada.
Budget.currency vem de Company.functionalCurrency e é imutável após a criação.
Conversão com oito casas, arredondamento único para minorUnits da moeda de destino, HALF_UP. O resíduo do rateio segue a RN-CC-23, sem reescrita: maior fatia, empate pelo menor order. E o total do documento na moeda do exercício é a soma das fatias convertidas — o razão guarda fatias, não totais.
Depois de Approved, amount é imutável. O valor efetivo é amount + Σ Adjustment, e todo ajuste tem autor, data e justificativa.
Em Transfer e em Reduction, o disponível da origem é conferido sob trava na decisão final, e não no momento do pedido; faltando, Refused(BUDGET_CHANGE_INSUFFICIENT_UNPROCESSABLE).
Cada campo de BudgetPolicy sobe o path independentemente. A API sempre devolve de qual nó cada valor veio.
Mudar política é encerrar a vigente e abrir outra. Sobreposição do mesmo (costCenterId, companyId) é recusada, nulo colidindo com nulo (RN-O54).
Sem read:budget-amounts sobre a linha (ou read:budget-amounts-all), nenhum número derivado dela vai na resposta — nem na API, nem no CSV, nem na notificação, nem no corpo de erro: o bloco amounts, wouldExceedBy, pendingApproval, o available do erro BUDGET_EXCEEDED_UNPROCESSABLE e o caminho da linha (costCenterPath, glAccountPath) em lines[] e signalFrom. Quem não vê a linha recebe budgetLineId, signal e overBudgetPolicy. read:budgets não implica valor nenhum.
Nenhuma permissão ignora Block. O caminho para gastar mais é pedir mais verba ou mudar a política do nó, com registro.
O consumo nasce nas liberações parciais. O total contratado é visibilidade, não movimento.
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.
A pergunta "este documento passa?" é respondida por POST /evaluate:budget-state, com a política resolvida no nó, a tolerância aplicada e o sinal por fatia. O Fluxo de Aprovação não tem parâmetro de bloqueio — e não pode ter: dois donos da mesma decisão divergem no primeiro documento com fatias em centros de custo diferentes.
RequireApproval não inventa nível de aprovaçãoEle é sinal, não comando: quem acrescenta o passo da controladoria é uma política Supplemental escrita pelo cliente com o critério estado do orçamento = Exceeded, que já é um dos critérios do motor. Em execução, sem política que case, Exceeded se comporta como Warn — passa marcado, e o documento fica marcado para sempre. Nunca bloqueia por omissão nem aprova em silêncio.
As duas peças moram em módulos diferentes, e há guarda nos dois sentidos. A tela de política de orçamento avisa ao salvar quando não existe política complementar casando; e, no outro sentido, aposentar ou deixar expirar a Supplemental deixaria o nó marcado RequireApproval rebaixando-se a Warn sem ninguém ter decidido desligar o controle — por isso o GET /approval-policies/{id}/validate do Fluxo acusa os nós que ficariam órfãos, e a tela o consulta tanto ao publicar quanto ao aposentar (RN-AP-41). Avisa, não trava — travar a configuração obrigaria a mexer em todos os nós antes de trocar de política.
E quando a deriva já aconteceu, o documento não passa mais calado. A cadeia resolvida sem nenhum passo vindo de política com critério de orçamento, havendo fatia em RequireApproval, grava ApprovalRequest.budgetRequirementUnmet e dispara nexio.budget.approval_requirement_unmet à controladoria. É a mesma forma do authorityExceeded e do resolvedOutOfScope — os três são "a regra não saiu como o desenho previa". Por que não bastava o overrun_recorded: aquele aviso diz "estourou" e vai para os donos do nó, que são justamente quem se beneficiou de o controle não ter rodado; "o controle configurado não rodou" é outro fato, e tem outro destinatário.
Adjustment só nasce de mudança de verbaUm movimento de estágio Adjustment exige sourceType ∈ {BudgetChangeRequest, Import, Rollover}. É invariante do estágio, verificada na gravação, e não há caminho de exceção. Nenhum evento de execução — recebimento, nota, match — grava Adjustment.
Por quê: Adjustment aumenta a verba, e a RN-O18 diz que verba aprovada só muda por documento. Deixar a nota mais cara gravar Adjustment criava orçamento pelo caminho de maior volume do sistema, sem justificativa e sem aprovação. Diferença de preço aceita é Actual adicional, ou o par estorno + relançamento de Actual.
Estornar um ato estorna, na mesma transação, as entradas do grupo dele (RN-O43): cada uma ganha entrada nova de sinal contrário, na mesma tupla e no mesmo balde — salvo o que alcança entrada de exercício com a janela tardia vencida, levada ou não, ou entrada relançada pela correção de taxa, que segue a RN-O43 —, com reversalOfEntryId, e os negativos de liquidação viram positivos que restituem o alvo. entryReason é DocumentCancelled, Return ou ReceiptReversal no estorno das entradas positivas do grupo, e Reverse no estorno dos negativos — o extrato distingue o que aconteceu no mundo do que aconteceu por consequência. Nada é apagado.
Por quê: o negativo de liquidação do pedido aponta para a requisição, não para o empenho do pedido; procurar só "quem aponta para a entrada estornada" não o encontra, e cancelar o pedido deixaria o comprometido da requisição liquidado para sempre — o saldo passa a parecer maior do que é, no caso mais rotineiro que existe.
Todo negativo que aponta um alvo — liquidação, liberação, remanejamento, virada — copia companyId, costCenterId, glAccountId, competencePeriodKey, exchangeRate, quoteUnit e rateDate da entrada que consome. Só o positivo tem balde próprio, pelas regras da RN-O48.
Por quê: requisição para 14/03 liquidada por pedido para 08/05 deixaria março com 1.000 comprometidos para sempre e maio com −600, porque os dois movimentos do pedido usariam a data do pedido. A RN-O11 só cobre mudança de data dentro do mesmo documento; este é o buraco entre documentos, e o mesmo valia para a taxa de câmbio.
O consumo grava uma entrada, com a tupla concreta e sem linha. O consumo de uma BudgetLine é a soma das entradas de consumo da mesma empresa cujo balde está na janela da linha e cuja tupla cai sob ela nas duas dimensões — centro de custo nulo ou ancestral-ou-igual, conta nula ou ancestral-ou-igual. requiresBudget é resolvido para o nó da fatia; toda candidata é avaliada com a própria política, a própria tolerância e o próprio carryForwardMode, resolvidos para o nó dela. A fatia fica com o pior desfecho — Exceeded sob Block > Exceeded sob RequireApproval > Exceeded sob Warn > Warning > Ok, empate pelo menor id de linha —, e a resposta declara em signalFrom a linha e o balde que o produziram.
Por quê: com as duas dimensões anuláveis as linhas se cruzam em vez de se aninhar. Gravar a entrada numa linha escolhida deixaria a outra sem consumo: a linha de R$ 100 mil para software em qualquer centro de custo nunca encheria, porque a compra de software de INFRA iria para a linha de INFRA. Gravar N entradas, uma por linha, daria o mesmo saldo e multiplicaria por N o custo do estorno. E verba de R$ 1 milhão no pai com R$ 300 mil no filho continua sendo R$ 1 milhão de poder de gasto, não R$ 1,3 milhão.
A transição para Approved — de exercício planejado, de versão nova ou de carga importada — não grava nem reescreve movimento nenhum. O que já estava no razão, inclusive com o período começado, passa a contar contra as linhas do exercício no instante da transição, porque o saldo é lido pela tupla (RN-O31). Versão nova só troca valores de linha. Linha que nasce estourada nasce Exceeded, e nada é desfeito.
Por quê: o orçamento quase nunca está aprovado em 1º de janeiro. Congelar a linha no movimento obrigaria a imputar de novo janeiro e fevereiro na aprovação — e o movimento de imputação, sem contrapartida, contaria o consumo em dobro; a liquidação posterior apontaria para a entrada original e deixaria a linha nova com o comprometido para sempre; e o exercício importado direto em Approved, que nunca passa pela transição, ficaria sem controle nos meses anteriores.
Block e Uncovered valem só para Commitment e ObligationBlock e Uncovered recusam a gravação só nos dois estágios de compromisso. Actual nunca é recusado — nem por estouro, nem por falta de linha: grava, marca o documento como fora do orçamento e notifica os donos do nó, como em Warn. O aviso é o nexio.budget.overrun-recorded, o mesmo que cobre o positivo isento da virada (RN-O13, RN-O46, RN-O48).
Por quê: recusar Actual é recusar o registro de mercadoria que já está na doca — não devolve o dinheiro, só apaga o fato do razão. Inclui o caso que a própria spec descreve: a variação cambial entre o empenho e a nota, que "consumiu caixa" por definição.
idempotencyKey é único por tenant: hash SHA-256 de (sourceType, sourceId, sourceLineId, allocationId, documentVersion, stage, entryReason, alvo, perna), alvo = o nome da coluna e o id — liquidatesEntryId ou reversalOfEntryId; no movimento sem nenhum dos dois, pairedEntryId, que todo positivo nascido em par com um negativo preenche com o alvo desse negativo; e o marcador fixo none quando também não há par — e perna = companyId, costCenterId, glAccountId, budgetLineId, competencePeriodKey. allocationId é a identidade estável da CostAllocation (RN-CC-33), nunca o order; dois atos do mesmo documento nunca têm o mesmo documentVersion. Na virada, os movimentos de estágio gravam a origem da entrada levada — sourceType, sourceId, sourceLineId, allocationId —, e a chave é calculada com Rollover no lugar de sourceType, o exercício que fecha no lugar de sourceId e documentVersion = 1; o alvo é a entrada levada, no negativo por liquidatesEntryId e no positivo por pairedEntryId. A chave da virada não colide com a do documento, que nunca tem Rollover nem YearEndRollover, e duas entradas levadas da mesma linha de documento e da mesma fatia não colidem entre si, porque o alvo difere. A repetição é detectada pelo postingGroupId antes de recalcular alvos; o grupo entra inteiro ou não entra: repetido, nada é inserido e nenhum saldo é tocado; chave presente só para parte do grupo é falha de integridade (500, rollback).
Por quê: sem alvo e perna, as duas metades de todo par colidem — o negativo e o positivo do DateChange, as duas linhas do Transfer, as duas pontas da virada, as duas liquidações do pedido que consolida duas requisições — e a segunda é descartada em silêncio; sem a entrada do par colidem os positivos, que não têm alvo — os dois positivos da virada de uma linha com duas entradas abertas, os do pedido sobre duas entradas da mesma fatia, os dois relançamentos da RateCorrection e os dois positivos do DateChange numa linha com duas entradas abertas, o Actual com par e o excedente do recebimento acima do pedido —, e a colisão parcial dá 500: o ato daquela entrada nunca concluiria; sem o nome da coluna no alvo, o relançamento da RateCorrection colidiria com o próprio estorno; e somar o saldo quando a inserção foi descartada faria a projeção divergir do razão.
O resíduo continua saindo só pelo encerramento explícito do documento (RN-O05), e ganha sustentação: GET /budget-entries/residues lista o saldo aberto por documento e fatia com a idade em dias; o dono é o dono do nó de centro de custo da fatia, por effectiveOwners com rollup; o resíduo mais velho que residueAlertAfterDays (BudgetPolicy, padrão de 60 dias) entra no aviso semanal a esse dono, nexio.budget-residue.aged, com a lista dos documentos e sem valor (RN-O22). O encerramento por completude fica declarado como gancho — a proposta automática nasce quando Requisition e PurchaseOrder existirem, e a decisão continua do dono.
Por quê: "ação de domínio com dono" sem consulta, sem envelhecimento e sem aviso é uma frase, não um controle — e resíduo de intenção morta come o disponível ao longo do ano.
costCenterId e glAccountId são obrigatórios em Commitment, Obligation e Actual, onde vêm da fatia. Em Adjustment o movimento copia as dimensões da BudgetLine que ajusta, inclusive nulas.
Por quê: Adjustment, Import e a virada não nascem de fatia. Com a obrigatoriedade geral, suplementar a linha coringa — a linha que a Decisão 4 manda cadastrar — não tinha valor para gravar.
BudgetPolicy pode ter empresacompanyId anulável; nulo vale para todas as empresas do tenant. Política com empresa vence a de empresa nula para o mesmo campo e o mesmo nó, e as duas convivem no mesmo período. A não-sobreposição é apurada por (costCenterId, companyId).
Por quê: Budget é por empresa e a árvore de centro de custo é compartilhada. Sem a coluna, ligar o controle na empresa madura e deixá-lo desligado na recém-adquirida exigia duplicar nós por empresa — o que o nó corporativo existe para evitar.
allocationMaterialityPercent afeta apenas a montagem da cadeia de aprovação. O orçamento sempre avalia e grava a decomposição integral do rateio, e Block numa fatia absorvida recusa a submissão do mesmo jeito.
Por quê: controle de verba não é roteamento de alçada. A alternativa cria uma marca que dispara no funcionamento normal, e marca que dispara todo dia é marca que ninguém lê.
POST /evaluate:budget-state devolve NotControlled — ausência de controle, não é critério de nada — e os quatro sinais do contrato: Ok, Warning, Exceeded e Uncovered. Uncovered significa que a dimensão não existe no plano, não que estourou: recusa Commitment e Obligation com BUDGET_UNCOVERED_ALLOCATION_UNPROCESSABLE, sem passar por tolerância nem por overBudgetPolicy; Actual grava e marca (RN-O33). O motor repassa esse erro; não o reembrulha e não o afrouxa.
Por quê: o critério do motor listava três sinais e o quarto chegava sem tratamento — ou passava fatia contra dimensão inexistente, ou devolvia erro não declarado.
BudgetO Budget consumido é o da empresa do documento, gravada em BudgetEntry.companyId. Não há rateio entre empresas na v1: toda fatia é da empresa do documento, e a fatia não carrega empresa própria (RN-CC-34).
Por quê: matriz e filial são a mesma pessoa jurídica, então uma empresa na fatia só importaria entre empresas diferentes — o caso que a v1 não tem. Uma coluna que só pode receber um valor é campo que ninguém testa. O razão já guarda companyId; criar rateio entre empresas depois é coluna na fatia e preenchimento a partir do documento, sem migração de chave no razão.
Movimento com documento de origem copia exchangeRate, quoteUnit, rateType e rateDate do documento, congelados na submissão dele; o negativo de liquidação copia da entrada que liquida (RN-O30). Resolução própria de taxa vale só para sourceType ∈ {Manual, Import}.
Por quê: o Commitment nasce dias depois da submissão. Com resolução própria, a alçada comparava um número e o razão gravava outro para o mesmo dinheiro.
Todo movimento carrega postingGroupId, determinístico por ato: o positivo do estágio novo e os negativos que ele produz saem no mesmo grupo, na mesma transação. Todo negativo que consome saldo de uma entrada — liquidação, liberação de resíduo, remanejamento, virada — aponta essa entrada em liquidatesEntryId e é limitado ao saldo aberto dela, que tem esta definição e nenhuma outra: o valor da entrada, mais todo negativo que a aponta — por liquidatesEntryId ou reversalOfEntryId —, mais os estornos desses negativos que não carregam pairedEntryId, mais os estornos que a apontam em pairedEntryId — a restituição desviada para a entrada mais recente da cadeia (RN-O43). O limite serializa pela origem: antes de ler qualquer aberto, a transação trava, ordenadas por id, todas as entradas positivas de cada origem que o ato liquida — (sourceType, sourceId, sourceLineId, allocationId, stage) —, abertas ou não e qualquer que seja a política da linha; só sob a trava escolhe a entrada aberta mais antiga — nunca a entrada levada pela virada nem a relançada pela correção de taxa, pelas quais responde a mais recente da cadeia — e limita o negativo — dois recebimentos de R$ 605 contra o empenho de R$ 660 liquidam 605 e 55, nunca 605 e 605. A trava é lógica, por id da entrada (RN-O47). A entrada recriada pela virada conserva a origem do documento e por isso está no conjunto: o recebimento de um pedido levado trava a entrada levada e a recriada, e liquida a recriada, a única aberta. E se a mais antiga aberta é de exercício com a janela tardia vencida que a rotina ainda não levou, o ato a leva primeiro, na mesma transação, pela virada sob demanda — cujos movimentos entram no grupo do ato, na parcela isenta —, e liquida a recriada (RN-O46, RN-O48). O que o ato tiver além do aberto é consumo novo, nunca liquidação.
Por quê: sem o grupo não há como ligar o positivo do estágio novo aos negativos que ele gerou, e o estorno não restitui; sem o limite, recebimento acima do pedido liquidaria empenho que não existe e deixaria o aberto negativo. E com a origem trocada por Rollover na recriada, o recebimento das 5 un restantes depois da virada não a veria: gravaria Actual +55 sem par e deixaria os R$ 55 empenhados — o mesmo dinheiro contado duas vezes. E com a restituição contada também no alvo original, a devolução de 20/03 da tabela da virada deixaria E2 com 660 − 605 − 55 + 110 = 110 abertos ao lado dos 110 da recriada: a reposição das 10 un escolheria E2, a mais antiga, gravaria Actual +110 em dezembro de 2026, e os R$ 110 continuariam empenhados em 2027.
Desfazer a fração f de um ato estorna f de cada entrada do grupo dele, com reversalOfEntryId, na mesma tupla e no mesmo balde: o positivo até o seu aberto, o negativo até o valor ainda não estornado. Negativo cujo alvo está encerrado não é estornado, salvo o alvo levado pela virada ou relançado pela correção de taxa, que segue a regra do fim deste parágrafo. A entrada está encerrada quando a linha de documento de origem dela está encerrada ou cancelada — resposta do módulo dono, pelo contrato de estado que ele registra no .Contracts para aquele sourceType, ao lado do contrato de virada (RN-O57) —, e não pela existência de ResidueRelease ou DocumentCancelled, que não existem quando o aberto já era zero. O estorno trava as entradas positivas da origem de cada entrada estornada e das que ela aponta como alvo ou par (RN-O47), e só sob a trava confere o estado: encerrar ou cancelar linha de origem trava as mesmas entradas, mesmo sem aberto a liberar, e um dos dois atos espera o outro. Entrada levada pela virada está encerrada no exercício que fechou, entrada relançada pela correção de taxa está substituída, e o estorno que as alcança vai à entrada mais recente da cadeia, pela taxa e no balde dela — a cadeia segue pairedEntryId pelos positivos de YearEndRollover e de RateCorrection, quantas viradas e correções houver: o estorno da própria entrada estorna a mais recente (reversalOfEntryId = ela, até o aberto dela); o estorno de negativo cujo alvo foi levado ou relançado restitui a mais recente, com reversalOfEntryId = o negativo estornado, pairedEntryId = a mais recente e o valor na moeda do documento convertido pela taxa dela; o estorno do positivo cujo par foi levado — o Actual que a devolução tira — grava no mesmo balde, pela taxa com que foi gravado. A diferença entre as duas taxas é a correção que a RN-O53 fez no aberto, e é isenta de Block como ela. Por entrada levada, nada é gravado no exercício com a janela tardia vencida; recriada encerrada segue a regra da entrada encerrada. Entrada de exercício com a janela tardia vencida que não foi levada — aberto zero na virada — vai na mesma direção: a restituição que a alcança é gravada no balde dela e, na mesma transação, levada pela virada sob demanda do valor restituído, que carrega o postingGroupId do ato, entra no delta líquido dele e é avaliada (RN-O44, RN-O46, RN-O48); os totais do exercício de origem não mudam. O balde de destino é o primeiro mês depois do periodEnd do exercício mais recente da empresa da entrada — o companyId do movimento — cuja virada já executou, ignorando as versões substituídas: Archived marca também a versão que outra substituiu (supersedesBudgetId), que nunca teve virada, e duas empresas com exercícios diferentes — janeiro a dezembro e julho a junho — têm baldes de destino diferentes. Se o balde de destino cai em mês que nenhum exercício aprovado daquela empresa cobre, ou se nenhuma linha dele cobre a tupla, a restituição é recusada com BUDGET_UNCOVERED_ALLOCATION_UNPROCESSABLE, e a saída é a mesma da recusa por estouro: a opção “cancelar também a requisição”, que não restitui nada e só tem deltas negativos (RN-O44), ou cadastrar a linha coringa daquele centro de custo (RN-O39). Actual não é alcançado por essa recusa: ele grava, marca e notifica (RN-O33). A isenção do gatilho por liquidação vale também para Uncovered: o positivo da virada sob demanda disparada por liquidação grava marcado como fora do orçamento e notifica os donos do nó, como o Actual (RN-O33), mesmo que nenhuma linha cubra a tupla no balde de destino; a virada sob demanda disparada por restituição continua avaliada e continua podendo ser recusada com BUDGET_UNCOVERED_ALLOCATION_UNPROCESSABLE. O gatilho é a data: tudo isto vale desde que lateActualsUntil do exercício tenha passado, mesmo que a rotina ainda não o tenha marcado Archived (RN-O48). O estorno de Actual com par acompanha o par — as duas pernas no balde da entrada recriada, líquido zero no exercício novo. O estorno de Actual sem par — o excedente recebido na janela tardia — é exceção declarada a “o exercício fechado não muda”: fica no balde da entrada que desfaz, no exercício de origem, como baixa do realizado dele. Nos dois casos o disponível do exercício novo não muda: devolução e estorno em exercício diferente nunca devolvem verba ao exercício novo. Cadeia que termina em exercício também fechado: achada a entrada mais recente da cadeia, as regras deste parágrafo se aplicam a ela — se ela é de exercício com a janela vencida e não foi levada, a restituição é gravada no balde dela e levada pela virada sob demanda do mesmo jeito, com o balde de destino recalculado pela empresa dela. Estorno não se estorna: entrada com reversalOfEntryId nunca é alvo de outro estorno — a fórmula do aberto não o cobre —; desfazer uma devolução é recebimento novo, desfazer um cancelamento é pedido novo, e o contrato que receber estorno como alvo falha por integridade (500, rollback). Encerrada é definitivo: o contrato de estado nunca devolve aberta uma linha que já devolveu encerrada, e reabrir linha encerrada no documento não volta a restituir nada. f = quantidade desfeita ÷ quantidade do ato (em serviço, valor ÷ valor); arredondamento à menor unidade da moeda por HALF_UP, e o estorno que zera o ato leva exatamente o que sobrou. Cancelar 20 de 60 un de um pedido de R$ 660 que liquidou R$ 600 grava Obligation −220 e Commitment +200.
Por quê: cancelamento parcial de pedido e devolução parcial são rotina; estorno só integral obrigaria o documento a calcular à mão o que restituir, e cada cálculo à mão é um saldo que diverge. E conferir o encerramento sem travar o alvo deixa passar a corrida: o cancelamento do pedido lê a requisição aberta, o encerramento dela libera os 400 do aberto, os dois confirmam, e ficam R$ 600 comprometidos numa requisição encerrada, que nenhum ato futuro libera. Conferir o encerramento pelo razão também falha quando ele não grava nada: a requisição de 1.000 atendida por dois pedidos é encerrada com aberto zero, e cancelar o primeiro pedido restituiria R$ 600 a ela. E restituir a entrada levada gravaria no exercício com a janela tardia vencida, onde a resolução dá NotControlled e nenhuma virada volta a passar, e deixaria a recriada com o empenho de um pedido que voltou a esperar entrega ou foi cancelado. Deixar no exercício com a janela tardia vencida a restituição de entrada que não foi levada abre o mesmo buraco por outro lado: requisição de 1.000 atendida por PO1 (660, liquidou 600) e PO2 (440, liquidou 400) chega à virada com aberto zero, e só os empenhos são levados; com 2027 todo consumido sob Block de 2.000, cancelar PO1 libera 660 em 2027 e devolve 600 a dezembro de 2026, outro documento consome os 660, e o PO3 de 600 leva a requisição sob demanda — 2027 fecharia em 2.600. Pela regra, o cancelamento grava −660 e +600 em 2027, delta −60, e só 60 ficam livres. E restituir pela taxa do alvo relançado erra o empenho pela diferença de taxa: gravado a 5,02 e corrigido para 5,20 com 40 de 100 un recebidas, devolver 10 un restituiria R$ 5.020 à entrada original, a entrega seguinte a liquidaria, e o pedido ficaria R$ 180 abaixo do empenho certo. Ler o exercício fechado pelo estado Archived erra de duas maneiras: Archived marca também a versão de orçamento que outra substituiu, e com uma v1 de 2027 substituída o “último arquivado” passa a ser 2027 — a restituição cairia em janeiro de 2028 e 2027 fecharia em 2.600 contra um Block de 2.000; e entre empresas com exercícios diferentes a restituição de uma iria para o balde da outra. E devolver ao exercício novo o que foi consumido no velho abre folga de mesmo valor no Block do ano novo: o excedente de R$ 33 recebido na janela tardia de 2026 e devolvido em 2027 liberaria R$ 33 de verba de 2027, que ninguém orçou.
Cancelar pedido, inteiro ou em parte, exige a escolha explícita “cancelar também a requisição”. Sem ela, estorna-se o grupo do pedido na fração cancelada (RN-O43): o comprometido liquidado é restituído e a requisição volta a ficar disponível. Com ela, estornam-se só as entradas positivas do grupo, nada é restituído, e a quantidade cancelada sai também das linhas de requisição de origem; cancelado o pedido inteiro, a requisição é cancelada e o saldo aberto dela é estornado com DocumentCancelled. A restituição é delta positivo e é avaliada (RN-O46), inclusive a que chega ao exercício novo pela virada sob demanda (RN-O43); recusada, a mensagem oferece a opção, que só tem deltas negativos e passa sempre.
Por quê: “cancelar o pedido” tem dois significados no mundo — “compre de outro jeito” e “não compre mais” — e só o dono do documento sabe qual é. Adivinhar deixa verba presa, ou libera verba de uma intenção viva.
Devolução estorna primeiro o Actual sem par da mesma linha de pedido e fatia — o excedente recebido, do mais recente para o mais antigo —, com Return e sem restituição; só o que passar dele é estorno proporcional das pernas com par do grupo do recebimento devolvido: o Actual sai com Return e o empenho volta ao pedido com Reverse — à entrada mais recente da cadeia, pela taxa e no balde dela, se o empenho foi levado pela virada ou relançado pela correção de taxa; ao exercício novo, pela virada sob demanda, se é de exercício com a janela tardia vencida e não foi levado; e a nenhuma se a linha de pedido está encerrada (RN-O43). O excedente é achado pelo vínculo que todo Actual de recebimento ou aceite grava — sourceLineId e allocationId da linha de pedido e da fatia recebidas —, entre os Actual sem pairedEntryId, excluídos InvoicePriceVariance e ExchangeVariance; antes de ler quanto dele resta, a devolução trava, por id, as entradas positivas da origem do pedido e os excedentes candidatos (RN-O47). Recebimento acima do pedido grava Actual pelo recebido e liquida o empenho só até o aberto; o excedente é Actual sem liquidação, e nunca é recusado. Editar requisição aprovada grava, no grupo da versão nova, só a diferença da parte ainda não pedida (QuantityChange, DateChange), e o momento depende do motor (Fluxo de Aprovação, RN-AP-71): a edição que é ajuste — valor que não subiu, retrato de critérios e cadeia iguais — não reabre, e grava a diferença na transação da própria edição, pelo desfecho de ajuste, com o delta avaliado como sempre (RN-O46) e o Refused recusando a edição; a que reabre não grava nada na reabertura — o comprometido aberto segue preso — e grava a diferença na reaprovação; a parte já pedida conserva a data e o rateio do ato que a pediu, e a edição não reduz a quantidade abaixo do já pedido. Cancelar a requisição reaberta estorna o aberto. A revisão do pedido aprovado está na RN-O60.
Por quê: são os três casos em que a cadeia anda para trás ou além do previsto, e sem regra escrita cada documento inventa a sua. Reabrir sem gravar é o lado conservador: o saldo nunca parece maior do que é, e o requisitante não é bloqueado pelo próprio dinheiro enquanto edita. E devolver pela proporção do grupo inteiro restituiria empenho a pedido já todo recebido: devolver 3 das 8 un recebidas além das 55 deixaria R$ 20,63 empenhados num pedido sem nada a receber. Sem o vínculo, o excedente não tem ligação com a linha de pedido, e a variação de uma nota — também Actual sem par — poderia ser desfeita no lugar dele; sem a trava, duas devoluções simultâneas desfariam o mesmo excedente duas vezes.
A avaliação é do ato, não do movimento. Para cada linha candidata de cada perna, o delta líquido é a soma dos movimentos do grupo — positivos e negativos — cuja tupla e balde caem sob a linha, e o grupo do ato inclui os movimentos da virada sob demanda que ele provoca, nos dois exercícios (RN-O48): grupo próprio de virada existe só na rotina. O delta se parte em duas parcelas. A isenta é a diferença de taxa que o ato carrega quando duas pernas dele desfazem a mesma quantidade em taxas diferentes — a restituição pela taxa da entrada mais recente da cadeia contra o Actual estornado pela taxa com que foi gravado (RN-O43, RN-O53) —, mais os movimentos da virada, pela regra do gatilho: a virada sob demanda disparada por liquidação — o ato precisa liquidar entrada que a rotina ainda não levou — entra na parcela isenta, como os movimentos da rotina; a disparada por restituição a entrada não levada entra na parcela avaliada (RN-O48). A avaliada é todo o resto. Só a parcela avaliada é comparada com o disponível; a isenta passa marcada e notifica os donos do nó, como a RN-O53. A isenção do gatilho por liquidação vale também para Uncovered: o positivo da virada sob demanda disparada por liquidação grava marcado como fora do orçamento e notifica os donos do nó, como o Actual (RN-O33), mesmo que nenhuma linha cubra a tupla no balde de destino; a virada sob demanda disparada por restituição continua avaliada e continua podendo ser recusada com BUDGET_UNCOVERED_ALLOCATION_UNPROCESSABLE. Parcela avaliada ≤ 0 nunca recusa, mesmo em linha já estourada. Parcela avaliada > 0: Ok se ela ≤ disponível (a igualdade cabe); Warning se ≤ disponível + tolerância; Exceeded além disso, com wouldExceedBy = parcela avaliada − disponível − tolerância. No Cumulative, o disponível é o menor entre os acumulados do balde da perna e dos baldes seguintes do exercício. POST /evaluate:budget-state recebe, por fatia, as entradas que o ato vai liquidar e responde com o delta líquido de cada linha.
Por quê: avaliado pelo bruto, o pedido de uma requisição que já reservou toda a verba seria recusado — Obligation +1.000 contra disponível zero — embora o efeito líquido seja zero: o caminho saudável é o que travaria. Com a verba de março em R$ 1.000 consumida pela requisição, Block e tolerância de 10% limitada a R$ 150 (folga 100), o pedido a R$ 1.100 tem delta +100 e passa como Warning; a R$ 1.200, delta +200, é recusado. E avaliar só o grupo do ato, deixando a virada sob demanda num grupo próprio, deixava passar o sentido contrário: com 2027 consumido em R$ 2.000 sob Block de R$ 2.000, cancelar um pedido cujo empenho aberto era R$ 540 grava −540 no grupo do ato — delta negativo, passa — e +600 na virada que traz a requisição, sem avaliação nenhuma, e 2027 fecharia em R$ 2.060. No mesmo grupo o delta é +60 contra disponível zero, e a recusa oferece a opção que só tem deltas negativos (RN-O44). E avaliar também a virada que o ato provoca para liquidar recusava o caminho saudável: com 2027 consumido em R$ 2.000 sob Block de R$ 2.000 e a janela tardia vencida, o recebimento integral de uma entrada de R$ 660 que a rotina não levou grava Obligation +660 (virada), Actual +660 e Obligation −660 — delta bruto +660, Exceeded sob Block, e nada seria gravado, contra “Actual nunca é recusado” (RN-O33) e “nenhum movimento de consumo é recusado por período” (RN-O48). Com o positivo da virada na parcela isenta a avaliada fica em zero e o ato passa — e o dinheiro não é novo: o positivo da virada é limitado ao aberto da entrada levada, que já foi aprovado no exercício que fechou, e o consumo próprio do ato continua na parcela avaliada. O estado final é o mesmo que a rotina produziria se tivesse levado a entrada antes, e nenhuma regra pode deixá-lo depender de a rotina já ter rodado. A restituição é outra coisa: ela devolve ao exercício novo verba que nenhum ato dele consumiu, e por isso segue avaliada.
Antes de ler as somas, a transação trava, ordenadas por id, primeiro as entradas — as positivas de cada origem que o ato liquida (RN-O42); no estorno, as positivas da origem de cada entrada estornada e das que ela aponta como alvo ou par, o que inclui a recriada pela virada (RN-O43); na devolução, também os Actual sem par candidatos a excedente (RN-O45) —, por trava lógica por id da entrada, porque BudgetEntry não aceita UPDATE; depois as BudgetLine candidatas de todas as pernas do ato cuja política pode recusar ou exigir aprovação (Block, RequireApproval); no Cumulative, as linhas das mesmas dimensões em todos os períodos do exercício, porque o acumulado lê os anteriores e o lançamento altera os seguintes. Linha Warn não trava. A BudgetLine sempre existe, então a primeira gravação de uma tupla trava tanto quanto a milésima. Aprovar versão nova e fechar exercício travam as linhas do exercício na mesma ordem antes de mudar o status; o lançamento que, depois da trava, encontra o exercício fora de Approved/Closed refaz a resolução. Todo aberto e toda soma são lidos depois de obter a trava e enxergam o que foi confirmado durante a espera, e a repetição do ato é conferida de novo pelo postingGroupId sob ela; o incremento de BudgetBalance é atômico (somar no banco, nunca ler-somar-gravar); a rotina de virada trava, na mesma ordem, as entradas que leva e as linhas que toca; encerrar ou cancelar linha de documento trava as positivas da origem dela, mesmo sem aberto a liberar (RN-O43).
Por quê: travando só a linha da fatia, dois documentos de R$ 8.000 em /TI/INFRA/ e /TI/DEV/ travam linhas diferentes, leem o total de /TI/ sem trava, passam os dois e estouram em R$ 6.000 a verba de /TI/, que tinha R$ 10.000 sob Block. Travar a projeção não serviria: a linha de saldo da primeira gravação ainda não existe. RequireApproval entra porque, sem trava, dois documentos concorrentes escapariam da aprovação exigida.
Negativo que aponta alvo vai ao balde do alvo (RN-O30). Positivo — decidido por perna, e o ato grava um positivo por par (fatia, entrada de origem que ele liquida), mais um positivo para o que não liquida nada — vai ao mês da data de necessidade d, salvo quando d cai num exercício da empresa cujo periodEnd já passou na data do ato: (a) até lateActualsUntil, o Actual com par vai ao balde da entrada que o par liquida, e a data real do recebimento ou do aceite fica em occurredAt; o Actual sem par e o positivo de outro estágio cujo par liquida entrada daquele exercício ficam no mês de d; (b) depois de lateActualsUntil — pela data, não pelo estado Archived, que a rotina só grava ao terminar —, o positivo com par vai ao balde da entrada que o par liquida — a entrada recriada pela virada no exercício seguinte, que conserva a origem do documento e é a que a trava por origem encontra aberta, nunca a original, que a virada encerrou; a restituição e o estorno que alcançam entrada de exercício com a janela vencida, levada ou não, vão ao exercício seguinte (RN-O43), salvo o estorno de Actual sem par, que baixa o realizado no balde da entrada que desfaz, no exercício de origem (RN-O43); (c) nos demais casos, ao primeiro mês depois de periodEnd. Vale a data, não o status: o exercício passa a Closed sozinho no dia seguinte a periodEnd, e a virada roda sozinha depois de lateActualsUntil, por rotina de sistema com tenantId no payload, um grupo por entrada levada, isenta de Block e de Uncovered, arquivando o exercício ao terminar; ato que precisa liquidar entrada ainda não levada leva-a primeiro, na mesma transação — a virada sob demanda, que também leva o valor restituído a entrada não levada (RN-O43) —, e ela não tem grupo próprio: os movimentos dela carregam o postingGroupId do ato que a provoca e entram no delta líquido dele, na parcela que o gatilho decide — a virada sob demanda disparada por liquidação — o ato precisa liquidar entrada que a rotina ainda não levou — entra na parcela isenta, como os movimentos da rotina; a disparada por restituição a entrada não levada entra na parcela avaliada (RN-O46). A isenção da rotina vale também para Uncovered: o positivo da virada da rotina grava marcado como fora do orçamento e notifica os donos do nó, como o Actual (RN-O33), mesmo que nenhuma linha cubra a tupla no balde de destino; a virada sob demanda disparada por restituição continua avaliada e continua podendo ser recusada com BUDGET_UNCOVERED_ALLOCATION_UNPROCESSABLE. A isenção do gatilho por liquidação vale também para Uncovered: o positivo da virada sob demanda disparada por liquidação grava marcado como fora do orçamento e notifica os donos do nó, como o Actual (RN-O33), mesmo que nenhuma linha cubra a tupla no balde de destino; a virada sob demanda disparada por restituição continua avaliada e continua podendo ser recusada com BUDGET_UNCOVERED_ALLOCATION_UNPROCESSABLE. Sob as outras duas políticas de virada ela produz o mesmo estado final que a rotina produziria: com CarryCommitmentAndBudget emite também, no grupo do ato, o par de Adjustment de mesmo valor — negativo na linha do exercício que fechou e positivo na do exercício novo, os dois com origem Rollover (RN-O28) —, e esse Adjustment não recebe o BUDGET_PERIOD_CLOSED_UNPROCESSABLE, que é do Adjustment de BudgetChangeRequest e de Import; com CancelOpen nada é levado — a restituição é gravada no balde da entrada e, na mesma transação, o contrato de virada do sourceType dela é chamado, o documento encerra ou devolve a linha e grava o próprio estorno de grupo, e nada chega ao exercício novo (RN-O57) —, e contrato que devolve Failed faz o ato inteiro falhar, com rollback total: no ato não há relatório de virada onde a pendência espere. E o gatilho é a data também aqui: o que aciona as regras do exercício fechado é lateActualsUntil ter passado, nunca o estado Archived. Entre o fim da janela e o arquivamento a rotina já pulou a entrada de aberto zero e já levou os empenhos; decidir pelo estado deixaria a restituição no exercício velho enquanto o empenho já está no novo, e o ato liberaria verba no exercício novo sem trazer nada para ele. POST /budgets/{id}/close só antecipa o fim da janela. A data do ato é o dia civil em America/Sao_Paulo na v1. Nenhum movimento de consumo é recusado por período.
Por quê: a competência pela data de necessidade, sozinha, recusava o pedido de janeiro de uma requisição de dezembro (o empenho caía no exercício fechado) e o recebimento de pedido levado (resolvia para o ano velho, com o empenho no novo); e levar o empenho enquanto a janela tardia ainda aceitava o recebimento dele contaria o mesmo dinheiro nos dois anos. E o Actual pela data dele partiria a cadeia entre dois exercícios: a entrega feita em 30/12/2026 de um pedido empenhado em janeiro de 2027 poria o realizado em 2026 e liberaria o empenho em 2027. O razão é gerencial — a competência contábil é do ERP —, e consumo e aprovação ficam no mesmo exercício. Decidir por status deixaria o balde nas mãos de quem esquece de fechar; decidir por ato levaria a 2026 o pedido que consolida requisições de 2026 e de 2027. Com a regra por perna: pedido de 10/01/2027 com necessidade em 28/12/2026 sobre requisição A de dezembro (500) e B de janeiro (300) grava Obligation +550 e Commitment −500 em dez/26 e Obligation +330 e Commitment −300 em jan/27. E a parcela da virada sob demanda depende do gatilho porque os dois gatilhos movem dinheiro em sentidos opostos: levar uma entrada para liquidá-la não cria consumo — o positivo é limitado ao aberto já aprovado no exercício que fechou, e o ato o liquida no mesmo grupo —, e avaliá-lo recusava o recebimento de mercadoria já na doca, contra Actual nunca ser recusado (RN-O46); levar o valor restituído a entrada não levada devolve ao exercício novo verba que ele não consumiu, e sem avaliação 2027 fecharia em R$ 2.060 contra um Block de R$ 2.000 (RN-O43, RN-O44).
Refused, e nada é gravadoO contrato de desfecho que grava Commitment ou Obligation na decisão final devolve Applied, Refused(code) ou Failed. Sob a trava (RN-O47), é Refused(BUDGET_EXCEEDED_UNPROCESSABLE) quando uma linha candidata está em Exceeded sob Block, ou sob RequireApproval e a fatia foi avaliada na rodada com sinal melhor que Exceeded — o motor passa esse sinal por fatia no contrato; e Refused(BUDGET_UNCOVERED_ALLOCATION_UNPROCESSABLE) quando a fatia ficou sem linha; e Refused(BUDGET_CHANGE_INSUFFICIENT_UNPROCESSABLE) na decisão final do processo BUDGET_CHANGE, quando o remanejamento ou a redução deixaria a origem negativa. São estes os três códigos que o desfecho do Orçamento produz, e são os que a RN-AP-38 do Fluxo de Aprovação lista como recusas conhecidas. Em Refused a transação da decisão não grava nada — nem decisão, nem transição, nem movimento —, a tarefa continua ativa, e o requisitante e os donos do nó da linha que recusou são avisados. Em Failed, rollback total.
Por quê: gravar a decisão e devolver o documento com a cadeia preservada fazia o efeito falhar e a decisão ficar, contra a regra de que efeito e decisão são atômicos; e sem a trava e a comparação com a rodada, dois documentos concorrentes em linha RequireApproval escapariam, os dois, da aprovação que a linha exige.
A comparação de prefixo usa o path atual das duas árvores. Mover um nó leva junto o consumo já gravado dele: as linhas do pai antigo deixam de enxergá-lo e as do pai novo passam a enxergá-lo no instante do move, sem movimento no razão. O move não é recusado por isso — mover não gasta dinheiro, e o próximo compromisso sob Block já enfrenta o saldo novo —, e a tela do move consulta GET /budget-lines/move-impact, que lista as linhas de orçamento afetadas com o sinal de cada uma antes e depois (RN-CC-10).
Por quê: congelar o caminho no movimento reintroduziria, por outro nome, o congelamento de linha que a leitura pela tupla existe para eliminar; e reorganizar a estrutura é, no mundo, decidir que aquele gasto passa a ser daquele ramo.
Actual é custo incorrido; a nota grava só a diferençaActual nasce no recebimento da mercadoria ou no aceite do serviço, pela taxa congelada no pedido, e liquida o empenho. A nota não liquida nem regrava: grava Actual sem alvo só pela diferença de preço aceita (InvoicePriceVariance, valorizada pela taxa do pedido, com o reasonCode de quem aceitou) e pela variação cambial (ExchangeVariance, o resto até o valor da nota convertido). Nota sem pedido grava Actual sem alvo no ato que a spec da Nota definir — aprovação no motor ou ato do documento; o movimento é o mesmo nos dois casos. O recebimento e o aceite somam antes de gravar: um Actual por (linha de pedido, fatia, par) e um sem par por (linha de pedido, fatia) — lotes, volumes e linhas do mesmo recebimento que atendem a mesma linha de pedido viram uma perna só, e o negativo de liquidação segue a mesma soma. Faturado e liberado são leitura da Nota, fora do razão.
Por quê: com três respostas para "quem grava o realizado", a variação cambial não entrava em lugar nenhum: o recebimento gravava à taxa do pedido, a nota não gravava nada, e o único caminho da nota — aceite de diferença de preço — não servia ao câmbio. E dois lotes da mesma linha de pedido gravados um a um dariam dois Actual com a mesma chave — mesmo recebimento, linha de pedido, fatia, estágio e alvo pairedEntryId —, e a colisão parcial é 500: o recebimento nunca concluiria.
Obligation nasce na conclusão da aprovação do pedidoO empenho é gravado pelo contrato de desfecho do processo do pedido, na transação da decisão final (RN-O49) — nem no rascunho, nem na submissão, nem numa "emissão" separada.
Por quê: empenhar no rascunho ou na submissão prende verba de pedido que pode ser rejeitado, e empenhar numa emissão separada, depois da aprovação, deixa pedido aprovado sem empenho.
O gatilho é um só: a cotação do tenant que o documento usou foi substituída — a spec de Moeda e Câmbio é a dona, com supersededById e supersedeReasonCode. Taxa informada errada é do documento, e se corrige por versão nova dele, com o complementar da controladoria, o autor em excludedUsers como RateOverrider e rodada nova se o valor sobe — nunca pelo razão. Substituída a cotação, a taxa se corrige com um grupo que estorna o saldo aberto de cada entrada afetada e relança o mesmo saldo pela taxa certa, no mesmo estágio, as duas pernas com entryReason = RateCorrection — o estorno aponta a entrada em reversalOfEntryId, e o relançamento, em pairedEntryId (RN-O34); a entrada relançada fica substituída, e o estorno que a alcançar depois vai ao relançamento, pela taxa dele (RN-O43). A parte já liquidada não é corrigida — a diferença aparece na nota como ExchangeVariance. Isenta de Block, como a diferença de taxa que uma restituição posterior carrega; passa marcada e notifica os donos do nó. Nunca Adjustment.
Por quê: Adjustment muda a verba, e corrigir taxa com ele criaria orçamento; recusar a correção manteria o número errado, e no sentido perigoso quando a taxa certa é maior. E deixar o gatilho aberto a “taxa contratada digitada errado” dava a quem tem a permissão de informar taxa no documento um caminho sem rota, sem autor declarado e sem segunda pessoa: corrigir para baixo depois de aprovado liberava R$ 1.080 de empenho, e para cima punha o valor acima da alçada sem rodada nova.
Nas três chaves com coluna anulável do módulo, nulo é igual a nulo: BudgetLine (budgetId, costCenterId, glAccountId, periodKey) → BUDGET_LINE_ALREADY_EXISTS; vigência de BudgetPolicy por (costCenterId, companyId) → BUDGET_POLICY_OVERLAPPING_PERIOD_CONFLICT; período de Budget por empresa entre Approved e Closed, excluída a versão substituída → BUDGET_OVERLAPPING_PERIOD_CONFLICT. Qualquer implementação serve se passar nos três testes: duas coringas idênticas, duas políticas padrão sobrepostas, versão nova sobre exercício Closed.
Por quê: em quase todo banco relacional dois nulos não colidem num índice único, e a restrição escrita "por coluna" deixaria passar exatamente as linhas e políticas coringa — as mais amplas, e por isso as que mais decidem.
Approved não cria verbaEm exercício Approved, a importação não muda valor de linha existente e só cria linha nova com valor zero; arquivo com valor diferente é recusado inteiro com BUDGET_IMPORT_AMOUNT_AFTER_APPROVAL_UNPROCESSABLE. Dar valor à linha nova é BudgetChangeRequest.
Por quê: verba aprovada só muda por documento (RN-O18); uma planilha reenviada que criasse linha com valor seria suplementação sem aprovação.
A consulta devolve pendingApproval por linha (dentro de amounts) e pendingOverrun (fora), somados das fatias de documentos submetidos e não concluídos, excluído o próprio documento, a partir da leitura que cada módulo de documento publica. Nunca muda sinal, nunca trava, nunca recusa, e o motor não o lê. Sem módulo que publique, os campos voltam nulos.
Por quê: várias requisições em aprovação na mesma verba veem Ok cada uma; reservar na submissão prenderia verba de intenção não aprovada, e mostrar é o que a tela do requisitante precisa para não descobrir o estouro na decisão final.
CancelOpen é executado pelo documentoNa virada com CancelOpen, o Orçamento chama o contrato de virada registrado pelo módulo dono de cada sourceType com saldo aberto; o documento encerra ou devolve a linha e grava o próprio estorno de grupo, na mesma transação. Failed fica aberto no relatório e impede o arquivamento. Publicar política CancelOpen sem o contrato de todos os tipos de documento que gravam no razão é recusado com BUDGET_POLICY_YEAR_END_UNSUPPORTED_UNPROCESSABLE.
Por quê: "devolver os documentos ao estado anterior" é efeito no documento, que o Orçamento não conhece; estornar só o razão deixaria documento vivo sem verba.
Toda coluna de valor do módulo é numeric(19,4) e guarda o valor já arredondado para Currency.minorUnits da sua moeda, HALF_UP (RN-O17): duas casas em BRL e USD, zero em JPY, três em KWD. Nunca se assume duas casas.
Por quê: o documento e a cotação admitem qualquer ISO 4217, e minorUnits vai de 0 a 4; uma coluna de duas casas truncaria moeda de três.
Quando a rodada nova reconverteu o documento — reenvio de devolvido ou reabertura com rodada do zero (Moeda e Câmbio, RN-CUR-22) —, o desfecho que conclui a rodada grava, no mesmo grupo da diferença da versão nova (RN-O45, RN-O52), o estorno do saldo aberto de cada entrada do documento pela taxa antiga e o relançamento do mesmo saldo pela taxa nova, no mesmo estágio e balde, as duas pernas com entryReason = Reconversion. O estorno aponta a entrada em reversalOfEntryId e o relançamento, em pairedEntryId, e vale para elas o que as RN-O34 e RN-O43 dizem das pernas da RateCorrection: a entrada relançada fica substituída, e o estorno que a alcançar depois vai ao relançamento, pela taxa dele. A diferença de quantidade, data ou rateio da versão nova é gravada já pela taxa nova. A parte já liquidada não é reconvertida: fica na taxa com que foi liquidada, e a diferença aparece na nota como ExchangeVariance. Diferente da RateCorrection, não é isenta de Block: o delta líquido do grupo — câmbio e edição juntos — é avaliado como o de qualquer ato (RN-O46). Documento que ainda não tem entrada no razão — devolvido na primeira rodada — não grava nada.
Por quê: a rodada nova aprova o valor de hoje, e a verba tem de consumir o número aprovado; relançar só a diferença da edição deixaria o aberto na taxa antiga, e o razão mostraria um número que ninguém aprovou. Isentar do bloqueio faria da reconversão um caminho para consumir acima da verba sem ninguém ver: a RateCorrection é isenta porque corrige uma cotação errada, e aqui não há erro — há um valor novo, decidido de novo.
A revisão de pedido aprovado grava, no grupo da versão nova, só a diferença de Obligation por fatia, e só sobre o que ainda não foi recebido nem aceito. Quantidade e preço são movimentos separados, cada um com o seu entryReason. Quantidade que sobe grava o positivo de Obligation com QuantityChange e liquida o Commitment ainda aberto da requisição de origem, como a emissão do pedido (Liquidate, RN-O42); o que passa do aberto dela é consumo adicional, sem par. Quantidade que desce grava o negativo de Obligation com QuantityChange, com alvo na entrada do pedido, e devolve à requisição de origem, com Reverse, o Commitment que aquela parte tinha liquidado, na proporção, como o cancelamento do pedido (RN-O43). Nada volta se a linha da requisição está encerrada. Preço que muda grava PriceRevision, sem par e sem liquidar nada: positivo quando sobe, negativo com alvo na entrada do pedido quando desce. Nada volta à requisição, porque a diferença de preço nunca foi dela. A parte recebida não é revista: ela já é Actual, o preço novo dela chega pela nota como InvoicePriceVariance, e o negativo nunca passa do aberto do pedido (RN-O42). O momento é o do motor (Fluxo de Aprovação, RN-AP-38 e RN-AP-71): a revisão que é ajuste — valor que não subiu, retrato de critérios e cadeia iguais — grava na transação da própria edição; a que reabre não grava nada na reabertura e grava a diferença na reaprovação, pelo desfecho, com o delta avaliado como sempre (RN-O46). O Refused recusa a revisão, e o pedido fica na versão anterior.
Exemplo: requisição de 100 un × R$ 10 com 40 un ainda abertas; pedido de 60 un × R$ 11 (Obligation 660) com 55 un recebidas. A versão 2 passa a 70 un × R$ 12 e grava Obligation +110 (QuantityChange, 10 un × R$ 11) com Commitment −100 na requisição (Liquidate), e Obligation +15 (PriceRevision, R$ 1 × as 15 un abertas). As 55 un recebidas continuam a R$ 11 no razão, e a diferença delas chega pela nota.
Por quê: sem esta regra, a spec do Pedido teria de decidir como o razão grava, e o razão é deste módulo. Preço e quantidade ficam separados porque são fatos diferentes: comprar mais consome a intenção da requisição, e pagar mais é consumo novo. Com um motivo só, a revisão que baixa preço e quantidade na mesma versão geraria dois negativos com o mesmo alvo e a mesma chave (RN-O34), e o segundo seria descartado como repetido, em silêncio.
Implementação
Concorrência, índices e volume
A trava é a entrada e a linha de orçamento, e é curta
A transação de gravação segue sempre a mesma ordem: (1) derivar o postingGroupId do ato e parar se o grupo já existe (RN-O34); (2) travar, ordenadas por id, as entradas de que o ato depende — todas as entradas positivas de cada origem que ele vai liquidar, (sourceType, sourceId, sourceLineId, allocationId, stage), abertas ou não — a recriada pela virada tem a mesma origem da levada —, porque só sob a trava se sabe o aberto (RN-O42); no estorno, as positivas da origem de cada entrada estornada e das que ela aponta como alvo ou par (RN-O43); na devolução, também os Actual sem par da mesma linha de pedido e fatia, candidatos a excedente (RN-O45); (3) conferir de novo o postingGroupId, agora sob a trava; (4) ler o aberto das entradas travadas, escolher a mais antiga e limitar cada negativo — e, se a escolhida é de exercício com a janela tardia vencida que a rotina ainda não levou, montar antes a virada sob demanda dela, no grupo do ato e com os movimentos dela na parcela isenta, e liquidar a recriada (RN-O42, RN-O46, RN-O48); no estorno, ler do módulo dono o estado da linha de origem e achar a entrada mais recente da cadeia do alvo levado ou relançado — ou, se o alvo é de exercício com a janela tardia vencida e não foi levado, montar a virada sob demanda do valor restituído, no grupo do ato e na parcela avaliada (RN-O43, RN-O46); (5) montar as pernas, somadas pelas colunas da chave — dois lotes da mesma linha de pedido, fatia e par são uma perna só (RN-O51) —, e resolver as linhas candidatas; (6) travar por id as que têm Block ou RequireApproval (RN-O47); (7) ler as somas das tuplas sob cada uma e avaliar pelo delta líquido (RN-O46); (8) inserir os movimentos, atualizar BudgetBalance com as entradas que entraram e confirmar. Nada que decide é lido antes de a trava que o protege estar obtida. Toda transação trava entradas antes de linhas e, dentro de cada classe, por id — é o que evita deadlock entre dois atos que tocam as mesmas entradas ou as mesmas verbas em ordem inversa.
As duas travas são requisito, não recurso de banco. A da linha é exclusiva sobre a BudgetLine e dura até o fim da transação — SELECT … FOR UPDATE é um exemplo, e qualquer equivalente serve. A da entrada é lógica, por id da entrada: BudgetEntry não aceita UPDATE (é grant), e a trava de linha de alguns bancos exige esse privilégio — no PostgreSQL, por exemplo. O requisito é exclusão mútua por id, com escopo de transação e liberada no commit ou no rollback; trava consultiva de transação com chave derivada do id (pg_advisory_xact_lock no PostgreSQL, sp_getapplock no SQL Server) ou uma linha de trava por entrada em tabela própria servem igual. Colisão da chave derivada só serializa a mais, nunca a menos.
| Passo | X · R$ 8.000 em /TI/INFRA/ | Y · R$ 8.000 em /TI/DEV/ |
|---|---|---|
| 1 | Trava /TI/; INFRA é Warn e não trava | Tenta travar /TI/ e espera X |
| 2 | /TI/: disponível 10.000 ≥ 8.000 → Ok · INFRA: 8.000 ≥ 8.000 → Ok | — |
| 3 | Grava Commitment +8.000, confirma e solta a trava | Obtém a trava |
| 4 | — | /TI/: disponível 2.000 < 8.000 → Exceeded sob Block · DEV: 2.000 < 8.000 → Exceeded sob Warn |
| 5 | — | Pior sinal: /TI/. Recusado com BUDGET_EXCEEDED_UNPROCESSABLE, wouldExceedBy = 6.000; nada gravado |
Sem /TI/ no conjunto de trava, os dois leriam 10.000 disponíveis e /TI/ terminaria com 56.000 consumidos — R$ 6.000 acima de uma verba sob Block.
Índices que precisam existir desde o primeiro dia
Em BudgetEntry: (tenantId, postingGroupId) para o estorno do grupo; (tenantId, liquidatesEntryId) e (tenantId, reversalOfEntryId) para o saldo aberto; (tenantId, sourceType, sourceId) para o extrato por documento; (tenantId, budgetLineId) para o Adjustment; único em (tenantId, idempotencyKey). Em BudgetBalance: único na tupla (tenantId, companyId, competencePeriodKey, stage, costCenterId, glAccountId), que atende a leitura por empresa e mês antes do filtro de prefixo das duas árvores. Em BudgetLine: (budgetId, periodKey), mais a unicidade da chave lógica com nulo colidindo com nulo. Em BudgetPolicy: exclusão de vigência por (tenantId, costCenterId, companyId), nulo colidindo com nulo. Em Budget: exclusão de período por (tenantId, companyId) entre Approved e Closed.
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 qualquer banco relacional e não justifica particionamento no lançamento. O particionamento por empresa e ano de competência fica declarado como gancho para quando o primeiro cliente grande aparecer, e não antes.
A consulta de digitação é barata de propósito
POST /evaluate:budget-state lê BudgetBalance, as linhas candidatas 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 leitura indexada — as tuplas da empresa no mês, filtradas pelo prefixo das duas árvores, agregadas por linha candidata — — e é por isso que a projeção materializada existe, mesmo o razão sendo a verdade.
Contexto
Referência de mercado
| Nexio | SAP | Oracle | Coupa | ERP brasileiro |
|---|---|---|---|---|
| Controle orçamentário | Availability Control / Funds Management | Budgetary Control | Budgets / Spend control | Controle Orçamentário |
Commitment | Obrigo / commitment de requisição | Commitment | Committed | Pré-empenho |
Obligation | Obrigo de pedido | Obligation | Committed (não separa) | Empenho |
Actual (custo incorrido: recebimento ou aceite) | Valor real | Expenditure / Actual | Invoiced — momento diferente: consome na nota; aqui o faturado é leitura | Realizado |
BudgetChangeRequest | Suplementação / transferência | Budget transfer | Budget adjustment | Alteração orçamentária |
| Virada de exercício (entryReason = YearEndRollover) | Commitment carryforward | Year-end carry forward | — | Restos 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 que | Por 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 receita | O Nexio controla gasto. Receita orçada não tem consumidor nenhum aqui. |
| Contabilização | Nenhum lançamento contábil é gerado. O razão orçamentário é gerencial e não pretende ser diário contábil. |
| Fechamento mensal formal | Substituí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 estabelecimento | Orç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 categoria | Dimensão errada. Categoria sugere conta contábil, e é a conta que entra na chave. |
| Override de bloqueio | Decisão explícita. Toda porta de fuga vira o caminho normal em três meses. |
| Reavaliação cambial periódica | Mudaria o saldo sem ninguém ter comprado nada. A variação entra quando um documento da cadeia converte de novo — no pedido e, com nome próprio, na nota (RN-O51) —, que é quando ela é real. |
| Consolidação entre empresas | Somar 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. |
Registro
Histórico de revisões
| Versão | Data | O que mudou |
|---|---|---|
| v1.25 | 30/10/2026 | Permissões padronizadas em read/lookup/create/update/delete (decisão do Roger de 30/10/2026). De → para: import:budgets → create:budgets (carga em lote de linhas, POST /budgets/{id}/lines:import); manage:exchange-rates → create:exchange-rates (cadastrar taxa; a permissão é da Moeda e Câmbio); override:exchange-rates sem permissão definida (pergunta ao Roger). |
| v1.24 | 21/09/2026 | Validação de 18/09, achado 16 (decisão do Roger). RN-O60 nova — pedido aprovado que muda de valor: grava só a diferença de Obligation, só sobre a parte não recebida. Quantidade com QuantityChange (sobe liquidando a requisição de origem, desce devolvendo a ela), preço com o valor novo PriceRevision, sem par. O momento é o do motor (RN-AP-38, RN-AP-71). BudgetEntryReason: catorze → quinze valores; o campo entryReason passa a listar também Reconversion, que tinha entrado em 19/09 e faltava aqui. PriceRevision dá Rebucket e entra na lista de alvo do liquidatesEntryId. A RN-O45 aponta para a RN-O60. |
| v1.23 | 21/09/2026 | Validação de 18/09, achado 22 (decisão do Roger: o aviso de resíduo fica na v1). A idade tem dono: campo novo BudgetPolicy.residueAlertAfterDays, de 7 a 365 dias, com padrão de fábrica de 60 e herança campo a campo. O aviso é semanal, um por dono e por nó, com a lista dos documentos e a idade de cada um, sem o valor preso (RN-O22; a Notificação proíbe quantia no aviso). O slug é nexio.budget-residue.aged. O Actual sob Block ou Uncovered e o positivo isento da virada passam a apontar para o nexio.budget.overrun-recorded (RN-O33, RN-O35). |
| v1.22 | 20/09/2026 | Recuo da validação de 18/09 (achado 19). A nota sem pedido deixa de gravar “na própria aprovação”, que pressupunha um processo de nota inexistente no motor: grava Actual sem alvo no ato que a spec da Nota definir — aprovação no motor ou ato do documento (RN-O51, § Relação com os demais domínios e o exemplo do câmbio). O que grava não muda. |
| v1.21 | 19/09/2026 | Decisão D4, do Fluxo de Aprovação (RN-AP-71): a edição de requisição aprovada que o motor trata como ajuste não reabre, e grava a diferença na transação da própria edição, com o Refused recusando a edição; a que reabre continua sem gravar nada na reabertura e grava a diferença na reaprovação. A aprovação pela política grava o Commitment na transação do envio (RN-O45, § Reabertura, tabela de documentos). Decisão D3, no mesmo dia (Moeda e Câmbio, RN-CUR-22): a rodada nova reconverte o documento, e o razão estorna o aberto pela taxa antiga e relança pela nova com entryReason = Reconversion, sujeito a Block (RN-O59 nova; RN-O15 e a regra da conversão ajustadas). |
| v1.20 | 19/09/2026 | A coluna do total de BudgetBalance ganha nome: totalAmount numeric(19,4), na escala da RN-O58 (RN-O02 e nó do diagrama). A página dizia só “totais por tupla concreta”, e o Modelo de Classes desenhava o campo como “sem nome na dona”. Nenhuma regra muda. |
| v1.19 | 14/09/2026 | Validação da onda 1, rodada 8. A isenção alcança também a virada da rotina: o positivo dela grava marcado como fora do orçamento e notifica os donos do nó, como o Actual, mesmo que nenhuma linha cubra a tupla no balde de destino — a rotina era isenta só de Block, e com o exercício novo aprovado sem linha que cobrisse a combinação o positivo caía em Uncovered, o grupo não entrava, o exercício velho não arquivava e nada destravava a rotina sem a linha coringa; a justificativa da isenção sob demanda, que dizia entrar na parcela isenta “como a da rotina”, era circular enquanto a da rotina não existia (RN-O13, RN-O48, § A recomendação, § Nenhuma linha cobre, contrato dos sinais). A virada disparada por restituição continua avaliada e recusável nos dois gatilhos. Fator de cotação por ramo do override, alinhado ao que a Moeda e Câmbio escreve e esta página cita sem redefinir (RN-CUR-11, RN-CUR-27): com taxa informada, o fator é o do CurrencyPair, congelado com o override; sem rate, o da própria linha — a descrição anterior, “o fator com que a taxa foi publicada”, não cobria o ramo da taxa informada, onde não há linha publicada (campo exchangeRate · quoteUnit). |
| v1.18 | 13/09/2026 | Validação da onda 1, rodada 7. A isenção do gatilho por liquidação vale também para Uncovered: o positivo da virada sob demanda disparada por liquidação grava marcado e notifica, como o Actual, mesmo que nenhuma linha cubra a tupla no balde de destino — sem isso, o recebimento integral de uma entrada não levada em exercício sem linha que cobrisse a tupla era recusado por Uncovered e derrubava o Actual junto, na mesma transação, contra “Actual nunca é recusado”; a virada por restituição continua avaliada e recusável (mesma frase na RN-O43, na RN-O46 e na RN-O48, no contrato dos sinais e na § Nenhuma linha cobre). Justificativa da rodada nova alinhada ao que o Fluxo de Aprovação escreve: a flag exchangeRateOverridden está no retrato dos atributos de critério (RN-AP-21), não “a taxa”. Nomenclatura igualada no “Por quê” da RN-O43 (“arquivado” → “com a janela tardia vencida”) e a linha coringa do #historico volta a citar a RN-O39, como o corpo. |
| v1.17 | 12/09/2026 | Validação da onda 1, rodada 6. Qualquer mudança de taxa depois de aprovado abre rodada nova, para cima ou para baixo — a regra que só olhava o valor subir deixava passar a taxa que desce, que é o sentido em que a verba se libera (US$ 6.000 de 5,20 para 5,02 liberavam R$ 1.080 de empenho); a regra é do Fluxo de Aprovação (RN-AP-70), aqui citada (§ Corrigir uma taxa). A virada sob demanda entra na parcela que o gatilho decide: disparada por liquidação — o ato precisa liquidar entrada que a rotina ainda não levou —, os movimentos dela vão à parcela isenta, como os da rotina; disparada por restituição, à parcela avaliada — sem isso, o recebimento integral de uma entrada não levada dava delta bruto +660 e era recusado sob Block, contra “Actual nunca é recusado” e “nenhum movimento de consumo é recusado por período” (RN-O42, RN-O46, RN-O48, campo postingGroupId, § A recomendação, roteiro de concorrência). A virada sob demanda sob as outras duas políticas: CarryCommitmentAndBudget emite também o par de Adjustment de origem Rollover, que não recebe o BUDGET_PERIOD_CLOSED_UNPROCESSABLE; CancelOpen não leva nada e chama o contrato de virada do documento, e Failed faz o ato falhar inteiro (RN-O48, RN-O57). Variante 3 da tabela da virada refeita: 63 un em 05/02 (60 com par, 3 excedentes) e sem o recebimento de 10/03 — com 58 un não havia excedente nenhum, porque R$ 638 cabiam nos R$ 660 de aberto; 2026 fecha em — · — · 660 e 2027 em — · 440 · 300. Saída declarada quando o balde de destino da restituição cai em mês sem exercício aprovado ou sem linha que cubra a tupla: recusa por BUDGET_UNCOVERED_ALLOCATION_UNPROCESSABLE, com a opção da RN-O44 ou a linha coringa (RN-O39). Citação da Moeda com os dois campos — capturedBy e overriddenBy — em excludedUsers como RateOverrider (§ Taxa contratada, § Corrigir uma taxa). Nomenclatura igualada na RN-O43: “exercício arquivado” → “exercício com a janela tardia vencida”. |
| v1.16 | 11/09/2026 | Validação da onda 1, rodada 5. A virada sob demanda não tem grupo próprio: os movimentos dela carregam o postingGroupId do ato que a provoca e entram no delta líquido avaliado — grupo (Rollover, entrada levada) fica só para a rotina (campo postingGroupId, RN-O46, RN-O48). Balde do exercício fechado: primeiro mês depois do periodEnd do exercício mais recente da empresa da entrada cuja virada já executou, ignorando as versões substituídas, que também estão em Archived (RN-O43). O gatilho é a data — lateActualsUntil passado —, nunca o estado Archived (RN-O43, RN-O48, campo reversalOfEntryId). Devolução e estorno em exercício diferente nunca devolvem verba ao exercício novo: com par, as duas pernas na entrada recriada e líquido zero; sem par, o negativo fica no balde da entrada que desfaz, no exercício de origem — exceção declarada a “o exercício fechado não muda” (RN-O43, RN-O48, variante 3 da tabela da virada). Delta em duas parcelas: a isenta (diferença de taxa e rotina da virada) separada da avaliada (RN-O46). RateCorrection tem um gatilho só — cotação do tenant substituída, com supersededById/supersedeReasonCode —; taxa informada errada se corrige por versão nova do documento, com o complementar da controladoria, o autor em excludedUsers como RateOverrider e rodada nova se o valor sobe (§ Corrigir uma taxa, RN-O53). Tipo de taxa do documento vem de processRateTypes; defaultRateType só para movimento sem documento (§ Converte-se a fatia). RN-O49 e a lista do estouro passam a trazer também BUDGET_CHANGE_INSUFFICIENT_UNPROCESSABLE. |
| v1.15 | 11/09/2026 | Validação da onda 1, rodada 4. Saldo aberto com uma definição só: a RN-O42 passa a trazer a fórmula do § O grupo do ato, que a cita, e a entrada levada ou relançada nunca é escolhida para liquidação. Exercício arquivado: restituição e estorno depois da virada que alcançam entrada dele, levada ou não, vão ao exercício seguinte; a não levada chega pela virada sob demanda do valor restituído, avaliada com o ato, e a virada sob demanda deixa de ser isenta de Block (RN-O29, RN-O43, RN-O44, RN-O45, RN-O48, campo reversalOfEntryId, transição Closed → Archived, tabela da virada com a variante 2). Cadeia do relançamento: o que alcança entrada relançada pela RateCorrection vai ao relançamento, pela taxa dele, com a diferença isenta de Block (RN-O43, RN-O53, § Corrigir uma taxa). O recebimento soma por (linha de pedido, fatia, par) antes de gravar (RN-O51, roteiro). Estorno não se estorna; encerrada é definitivo (RN-O43). Exemplo dos dois pedidos: o encerramento libera 600, não 1.000. "Passa a valer só" sai do § Moeda. |
| v1.14 | 11/09/2026 | Validação da onda 1, rodada 3. Estorno de entrada levada (devolução, estorno ou cancelamento depois da virada): as duas pernas vão ao balde da entrada recriada, no exercício novo; a restituição carrega pairedEntryId = a recriada e conta no aberto dela; o exercício arquivado não muda (RN-O43, RN-O45, RN-O48, tabela da virada com a devolução de 20/03 e a variante do cancelamento). Origem conservada: os movimentos de estágio da virada gravam a origem do documento, e Rollover fica só na chave e no grupo — a trava e a escolha por origem enxergam a recriada (RN-O13, RN-O34, RN-O42, roteiro). Chave: todo positivo de par aponta em pairedEntryId o alvo do negativo do par (RateCorrection, DateChange, pedido consolidado), e o alvo entra na chave com o nome da coluna, porque o estorno e o relançamento da RateCorrection apontam a mesma entrada no mesmo balde (RN-O34, RN-O53). Encerramento: conferido pelo estado da linha de origem, lido do módulo dono sob a trava — a requisição encerrada com aberto zero não recebia marca (RN-O43, RN-O47). Excedente: vínculo com a linha de pedido e a fatia em sourceLineId/allocationId, variação da nota fora da busca, excedentes candidatos travados (RN-O45, RN-O47). O Budget é dono só da planilha de premissas e da memória de cálculo; ata e ofício são do BudgetChangeRequest. BudgetEntryReason citado sem contagem (a lista é da spec de Motivo). Notas de revisão fora do corpo (RN-O52, resíduo, operation, Moeda, Relação com Empresa) |
| v1.13 | 11/09/2026 | Validação da onda 1, rodada 2. Chave do positivo: coluna nova pairedEntryId (a entrada do par; na virada, a entrada levada), que entra na chave de idempotência no lugar do alvo, com none para o positivo sem par — os positivos da virada de duas entradas, do pedido sobre duas entradas e do recebimento acima do pedido colidiam. Travas: roteiro de gravação em oito passos; as entradas de cada origem liquidada são travadas por id antes de ler o aberto, por trava lógica (o razão não aceita UPDATE); o estorno trava o alvo do negativo estornado e confere o encerramento sob a trava (RN-O42, RN-O43, RN-O47). Janela tardia: Actual com par vai ao balde da entrada que liquida, e a entrada levada é a recriada (RN-O48, RN-O14, tabela da virada com a entrega antecipada). Devolução desfaz primeiro o excedente (RN-O45, exemplos de 3 e 5 das 8 un). BudgetEntryReason com treze valores no campo. Obligation na aprovação do pedido também na Decisão 2; Cumulative a partir de periodStart; variação cambial no pedido e na nota; frase canônica de efeito × evento; BudgetChangeRequest.number varchar(60); CanRead de Budget e BudgetChangeRequest; taxa informada aciona a controladoria |
| v1.12 | 11/09/2026 | Validação de 10/09. Consumo pela tupla concreta: o movimento de consumo não guarda linha nem exercício (saem budgetId e resolutionReason; budgetLineId só em Adjustment); toda linha que cobre a fatia nas duas dimensões é avaliada com a própria política e vence o pior sinal; acaba o backfill e sai BudgetBackfill; BudgetLineBalance vira BudgetBalance por tupla; sai BudgetLine.specificity. Mecânica do razão: postingGroupId, estorno sobre o grupo e parcial proporcional, cancelar pedido com a opção de cancelar a requisição, devolução, recebimento acima do pedido, reabertura de requisição, delta líquido, conjunto de trava em Block e RequireApproval, balde mensal decidido por perna e pela data de fim do exercício, fechamento e virada automáticos (close só antecipa a janela), Refused na decisão final, leitura pela árvore vigente e rota GET /budget-lines/move-impact; chave de idempotência em hash com entryReason, alvo e perna, repetição detectada pelo grupo; limite de liquidação serializado pelo alvo; estorno não restitui entrada encerrada. Realizado: Actual = custo incorrido; a nota grava só InvoicePriceVariance e ExchangeVariance; Obligation na conclusão da aprovação do pedido; correção de taxa com RateCorrection. Controles: nulo colide com nulo nas três chaves; importação depois de Approved não cria verba; recorte de valores em todo número derivado; documento em aprovação como leitura (pendingApproval); CancelOpen pelo contrato do documento; Reduction revalidada (BUDGET_CHANGE_INSUFFICIENT_UNPROCESSABLE substitui o de transfer); periodKey fiscal; valores em numeric(19,4) pela moeda; moeda funcional BRL na v1; empresa do documento decide o Budget. Regras RN-O42 a RN-O58; notas de revisão saíram do corpo |
| v1.8 – v1.11 | 09/09/2026 | Correções da validação de 08/09: estágios CarryOut/CarryIn saem e a virada grava nos próprios estágios; Adjustment só por BudgetChangeRequest, Import ou Rollover; liquidação herda balde e taxa; Block nunca recusa Actual; contrato fechado dos sinais; resíduo com consulta, dono e aviso; BudgetPolicy.companyId; quoteUnit como coluna; os dois campos de motivo do razão (entryReason × reasonCode) e BudgetChangeRequest com a quádrupla de motivo; taxa congelada na submissão e herdada pelo razão |
| até v1.7 | até 08/09/2026 | Processo BUDGET na semente do motor; critério de conta contábil no BUDGET_CHANGE; bloqueio decidido só por esta spec (sai budgetBlockBehavior do motor); RequireApproval como sinal, com guarda nos dois sentidos; rowVersion inteiro no lugar de bytea; moeda e câmbio saem para spec própria |
| v1.0 | 01/09/2026 | Primeira versão: razão append-only em três estágios, dimensões opcionais, ausência assimétrica, competência pela data de necessidade, carry-forward como modo de leitura, virada com CarryCommitmentOnly, semáforo separado de valor |