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 emitido empenha, recebimento realiza. Cada estágio liquida o anterior na proporção do que avançou — nunca soma em cima.
Dimensões opcionais com rollup. Empresa · centro de custo · conta · período, com centro de custo e conta anuláveis (nulo = qualquer). Verba num nó intermediário cobre a subárvore por prefixo de path. Vence a linha mais específica que casa.
Regra assimétrica. Fora de escopo controlado, passa livre. Dentro de escopo controlado sem nenhuma linha que cubra, bloqueia — porque a linha coringa (conta nula) existe justamente para cobrir o resto. Fica descoberto por decisão, nunca por acidente.
O movimento é sempre gravado, com ou sem controle, pela tupla de dimensões e não só pelo id da linha. Custo zero, e quando o cliente ligar o controle no ano seguinte terá doze meses de consumo real para dimensionar a verba em vez de chutar.
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ê de janeiro até o balde. Sem fechamento mensal obrigatório, sem tela de reabertura, e a nota atrasada se corrige sozinha. Dentro do exercício apenas — nunca entre exercícios.
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 congela no movimento. Documento em moeda estrangeira converte no instante do lançamento e nunca é reavaliado. A variação cambial entre estágios aparece sozinha como consumo real, e é atribuível. O cadastro, a captura e a resolução da taxa são de Moeda e Câmbio.
Ver o semáforo e ver o número são permissões diferentes. read:budget_state devolve cor e razão; read:budget_amounts devolve valores, recortados pelos nós em que a pessoa tem vínculo. O corte é no servidor — o campo não vai na resposta, não é escondido na tela.
Fundamento
Por que razão, e não saldo
O desenho ingênuo quebra em silêncio
A implementação óbvia é BudgetLine(amount, consumedAmount) com UPDATE consumed = consumed + x em cada aprovação. Ela funciona no caminho feliz e quebra em quatro lugares previsíveis: a requisição devolvida (quanto estornar, se o valor mudou na rodada?), o pedido cancelado parcialmente, a nota que chega com preço diferente do pedido, e o reprocessamento de uma mensagem duplicada. Em nenhum desses casos o sistema acusa erro — ele passa a mostrar um saldo errado, e ninguém descobre até a conversa com a controladoria em que os números não batem.
O que o razão dá de graça: resposta linha a linha para "quem comeu minha verba", reversão correta por construção, idempotência por chave natural, e reconstrução completa do saldo a partir dos fatos quando alguém desconfiar do número.
Saldo é projeção, com uma tabela materializada por desempenho
A verdade é BudgetEntry. Para não somar milhões de linhas a cada digitação de requisição, existe BudgetLineBalance — uma linha por budgetLineId × estágio, atualizada por upsert na mesma transação do movimento. Ela é cache, não fonte: um comando administrativo a reconstrói inteira a partir do razão, e a divergência entre as duas é o teste de integridade que roda em ambiente de homologação.
A consulta informa; o servidor decide
budgetStateFor é leitura sem trava, serve para pintar a tela enquanto a pessoa digita e pode estar desatualizada no instante seguinte. O bloqueio real acontece dentro da transação que grava o movimento, sob trava da linha de saldo. Nenhuma decisão de bloqueio confia numa consulta anterior — dois pedidos simultâneos contra a última fatia de verba não podem passar os dois, e essa é a única forma de garantir isso.
O que esta spec deliberadamente não é. Não é planejamento financeiro. Não há driver de cálculo (headcount × valor médio), comparação de cenários, rateio de overhead, projeção ou forecast rolante. Isso é FP&A e é outro produto. Planejamento aqui significa exatamente uma coisa: coletar números por centro de custo e aprová-los, reusando o motor de aprovação que já existe.
Modelo
Mapa de entidades
Draft → InReview → Approved → Closed → Archived. A versão é do exercício, nunca da linha.
path, independentemente dos outros.
ALTERACAO_ORCAMENTARIA, que já é semente. 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 emitido a partir de uma requisição aprovada apenas somar ao consumo, o mesmo dinheiro é contado duas vezes e o saldo vira ficção antes do fim do primeiro mês.
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.
Diferença de preço é consumo real, não erro
O pedido saiu a R$ 11 e a requisição tinha estimado R$ 10. O razão mostra 60 unidades liquidando 600 de comprometido e criando 660 de empenhado — os R$ 60 de diferença aparecem como consumo adicional, que é exatamente o que aconteceu no mundo. Nenhuma conta é "corrigida" para trás.
Idempotência é chave natural, não tentativa
Todo movimento carrega idempotencyKey único, derivado de (sourceType, sourceId, sourceLineId, allocationId, stage, sequence). Reprocessar a mesma mensagem — reentrega de fila, duplo clique, retry de integração — colide no índice único e é descartado sem efeito. Não existe "verificar se já lancei"; existe deixar o banco recusar.
Rateio: a fatia é a unidade, sempre
Uma linha de documento com CostAllocation de 70% / 30% gera dois conjuntos de movimentos, contra duas combinações de dimensões que podem cair em verbas diferentes, com políticas diferentes, e uma pode bloquear enquanto a outra passa. O bloqueio é do documento: se qualquer fatia bloqueia, a operação inteira é recusada — não existe gravar meia linha. O resíduo de arredondamento segue a regra já fechada no Centro de Custo: vai para a maior fatia, empate pelo menor order.
Algoritmo
Qual verba responde por esta fatia
Toda fatia chega com quatro coordenadas: companyId, costCenterId, glAccountId e a data de necessidade. A resolução acontece nesta ordem e para no primeiro resultado:
1 · O exercício
O Budget da empresa, com status Approved ou Closed, cujo intervalo contém a data de necessidade. Não pode haver dois — a sobreposição de períodos entre exercícios aprovados da mesma empresa é recusada no banco, por EXCLUDE com btree_gist, mesma mecânica de ApprovalPolicy e CostCenterAssignment. Se não houver exercício, o resultado é NotControlled e acabou.
2 · A política efetiva
BudgetPolicy resolvida para o nó de centro de custo, subindo o path, campo a campo: requiresBudget pode vir do nó, overBudgetPolicy do avô e carryForwardMode do padrão do tenant. Se requiresBudget resolver como falso, o resultado é NotControlled — o movimento ainda é gravado, mas nada é consultado nem bloqueado.
3 · A linha, pela especificidade
Entre as linhas do exercício cujo período contém a data de necessidade, ficam as candidatas em que costCenterId é nulo ou é ancestral-ou-igual do nó da fatia (prefixo de path), e glAccountId é nulo ou é ancestral-ou-igual da conta da fatia. Vence, nesta ordem: maior especificidade (2 = as duas dimensões preenchidas, 1 = uma, 0 = coringa), depois maior depth do centro de custo, depois maior depth da conta. O critério é total e determinístico — não existe empate.
4 · Nenhuma linha cobre
Escopo controlado e nenhuma candidata: resultado Uncovered, e a operação é recusada com 422 nomeando a combinação. A saída para o cliente não é afrouxar a regra — é cadastrar a linha coringa daquele centro de custo, que cobre toda conta que ninguém previu.
Por que a especificidade decide aqui e não decide no motor de aprovação. No motor, especificidade explicitamente não desempata — só prioridade declarada — porque duas políticas de alçada que casam representam intenções concorrentes do cliente e adivinhar qual vale é armadilha. Aqui é o oposto: as linhas não são regras concorrentes, são caixas dentro de caixas. "TI · qualquer conta · março" e "TI · licenças de software · março" não competem, se aninham — e a mais interna é obviamente a que responde. Mesmo raciocínio que já usamos no rollup de dono do centro de custo.
Campos
Budget · o exercício
| 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 ORCAMENTO. |
lateActualsUntil | date? | Janela de lançamento tardio depois de Closed: aceita Actual que liquida Obligation deste exercício, recusa qualquer compromisso novo. Padrão de fábrica: 60 dias após periodEnd. |
externalCode | varchar(60)? | Conciliação com o ERP. Único por tenant quando preenchido. |
rowVersion | bytea | Concorrência otimista. 409 declarado no OpenAPI. |
| De → para | Quem dispara | O que acontece |
|---|---|---|
Draft → InReview | Ação do usuário | Abre instância do processo ORCAMENTO. 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. A partir daqui as linhas só mudam por BudgetChangeRequest. |
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 | Ação com permissão própria | Roda a virada de exercício (adiante). Recusa Commitment e Obligation novos; aceita Actual até lateActualsUntil. |
Closed → Archived | Automático | Passada a janela tardia. Nada mais entra. 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(7) | 2027-03, 2027-Q1 ou 2027, conforme a granularidade do exercício. Materializado junto com periodStart/periodEnd para consulta por intervalo sem interpretar string. |
amount | numeric(18,2) | Na moeda do exercício. Pode ser zero (declarar explicitamente "aqui não se gasta" é diferente de não ter linha). Negativo é recusado. |
specificity | smallint | Derivado e materializado: 2 com as duas dimensões, 1 com uma, 0 coringa. Existe para o índice do desempate não precisar de CASE. |
notes · externalCode | varchar | Justificativa da verba e código do ERP. |
Unicidade com duas colunas anuláveis
A chave lógica é (budgetId, costCenterId, glAccountId, periodKey), mas no Postgres dois nulos não colidem — um índice único simples deixaria criar duas linhas coringa idênticas. Como já foi feito em UserCompanyScope, a unicidade sai em quatro índices parciais: ambos preenchidos, só centro de custo, só conta, e nenhum dos dois. É verboso e é a única forma correta.
Linha só muda por documento, depois de aprovada
Enquanto o exercício está em Draft, editar linha é edição comum. Depois de Approved, amount não é mais editável: a mudança vem de BudgetChangeRequest aprovado, que grava movimento de Adjustment. O valor efetivo de uma linha passa a ser amount + Σ Adjustment — e o histórico de por que a verba cresceu fica no razão, com justificativa e aprovador, em vez de sumir num UPDATE.
Campos
BudgetEntry · o movimento
| Campo | Tipo | Regra |
|---|---|---|
id · tenantId · companyId | uuid | — |
stage | enum | Commitment | Obligation | Actual | Adjustment | CarryOut | CarryIn. Os três primeiros consomem; Adjustment altera a verba; os dois últimos são a virada de exercício. |
amount | numeric(18,2) | Com sinal, na moeda do exercício. Positivo consome (ou, em Adjustment, aumenta a verba); negativo libera. Zero é recusado. |
costCenterId · glAccountId | uuid | Obrigatórios — vêm da fatia, que é sempre concreta. É a tupla que permite ler consumo mesmo onde não havia verba. |
competenceDate · competencePeriodKey | date · varchar(7) | A data de necessidade da linha, e o balde derivado dela. O balde é resolvido na gravação e congelado; mudar a data de entrega depois gera movimentos de remanejamento (negativo no balde velho, positivo no novo), nunca um UPDATE nesta coluna. |
budgetId · budgetLineId | uuid? | Resolvidos na gravação e congelados. Nulos quando resolutionReason = NotControlled. Congelar evita que cadastrar uma linha nova reescreva silenciosamente o passado. |
resolutionReason | enum | NotControlled | Covered | Uncovered. Uncovered só aparece em movimento de liberação ou estorno — o consumo Uncovered foi bloqueado antes de virar movimento. |
currencyDocument · amountDocument | char(3) · numeric(18,2) | O valor como está no documento. Igual a amount e à moeda do exercício no caso comum. |
exchangeRate · rateType · rateDate | numeric(18,8) · enum · date | A taxa usada, congelada. 1.0 / None quando não há conversão. 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". |
liquidatesEntryId | uuid? | Preenchido nos movimentos negativos de liquidação encadeada. Permite reconstruir a cadeia requisição → pedido → nota sem consultar os agregados de origem. |
reversalOfEntryId | uuid? | Estorno integral (cancelamento, rejeição). Distinto de liquidação: liquidar é avançar, estornar é desfazer. |
reasonCode | varchar(40)? | PriceChange, QuantityChange, DateChange, DocumentCancelled, ResidueRelease, Supplement, Transfer, YearEndRollover. Alimenta o relatório de por que o consumo mudou. |
idempotencyKey | varchar(200) | Único por tenant. Derivado de (sourceType, sourceId, sourceLineId, allocationId, stage, sequence). É o que torna reprocessamento inofensivo. |
occurredAt · postedAt · createdByUserId | timestamptz · uuid | Quando o fato aconteceu no domínio e quando entrou no razão. userId do gryd, sem FK — mesmo padrão de CostCenterAssignment. |
BudgetEntry não tem UPDATE nem DELETE. Não é convenção de equipe, é grant: o usuário de aplicação recebe apenas INSERT e SELECT nessa tabela, e uma trigger recusa as outras duas operações com mensagem explícita. Toda correção é movimento novo. Quem precisar apagar razão em produção precisa de um caminho administrativo separado e auditado — e nunca precisou até hoje em nenhum sistema que fez isso direito.
Campos
BudgetPolicy · as quatro políticas
Tabela própria, e não colunas no nó de centro de custo, por dois motivos: as políticas precisam de vigência (ligar controle para 2027 sem reescrever o passado) e o nó já carrega bagagem suficiente. Mesma forma de CostCenterAssignment.
| 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. |
validFrom · validTo | date · date? | Vigência. Sobreposição de duas políticas do mesmo costCenterId recusada por EXCLUDE + btree_gist. Nunca se edita vigência para o passado — trocar política é encerrar e abrir. |
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. |
tolerancePercent · toleranceAmount | numeric(7,4)? · numeric(18,2)? | Folga antes de a política disparar. Vale o menor dos dois quando ambos existem — 5% de uma verba de R$ 10 milhões não deve virar meio milhão de folga silenciosa. |
yearEndCommitmentPolicy | enum? | CarryCommitmentOnly | CarryCommitmentAndBudget | CancelOpen. Padrão de fábrica CarryCommitmentOnly. |
Herança campo a campo, e a API devolve de onde veio
Cada campo sobe o path por conta própria: requiresBudget pode vir do nó "Obra Guarulhos", overBudgetPolicy de "Operações" e carryForwardMode do padrão do tenant. É mais flexível e é mais difícil de explicar em tela — por isso GET /budget-policies/effective devolve, para cada campo, o valor e o nó de onde ele veio, exatamente como effectiveOwners devolve inheritedFrom. A tela mostra "herdado de Operações" ao lado de cada linha, e ninguém precisa adivinhar.
Campos
BudgetChangeRequest · mexer na verba
Depois de aprovado o exercício, verba só muda por documento — e documento passa pelo motor, no processo ALTERACAO_ORCAMENTARIA que já nasceu como semente.
| Campo | Tipo | Regra |
|---|---|---|
number | varchar(30) | Via NumberSequence, escopo Company. |
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(18,2) | Positivo. O sinal vem do type, não do número — assim ninguém digita menos-menos. |
justification | text | Obrigatória. É o que a controladoria vai ler em dezembro. |
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 não pode deixar a origem negativa
Na aprovação, o servidor revalida o saldo da linha de origem naquele instante, sob trava — não no momento em que o pedido foi feito. Verba pode ter sido consumida durante a aprovação. Se não couber, a aprovação falha com 422 e o documento volta ao solicitante com o número atual, em vez de deixar a origem estourada.
Movimento, não versão
Movimento ajusta o orçado; versão substitui o exercício. Suplementar R$ 80 mil em março é um Adjustment — a linha original continua visível, e a diferença tem autor, data e justificativa. Refazer o plano inteiro no meio do ano é Budget versão 2, com o razão do anterior preservado e reapontado. Confundir os dois é como confundir commit com force push.
Contrato
Consulta de saldo
Um contrato só, consultado por todo mundo: a tela do requisitante enquanto ele digita, o motor de aprovação como critério, e o próprio serviço de lançamento antes de gravar. O documento manda as fatias que quer avaliar; a resposta vem fatia a fatia.
POST /budget-state:evaluate
{
"companyId": "…",
"slices": [
{ "ref": "L1-A", "costCenterId": "…", "glAccountId": "…",
"neededOn": "2027-03-14", "amount": 18400.00, "currency": "BRL" }
]
}
200 OK
{
"results": [{
"ref": "L1-A",
"signal": "Warning",
"reason": "Covered",
"budgetId": "…", "budgetLineId": "…", "periodKey": "2027-03",
"carryForwardMode": "Cumulative",
"overBudgetPolicy": "RequireApproval",
"policySource": { "requiresBudget": "CC 4.2 Operações",
"overBudgetPolicy": "tenant" },
"amounts": { // ausente sem read:budget_amounts
"currency": "BRL",
"budgeted": 240000.00, // amount + Σ Adjustment
"committed": 51200.00,
"obligated": 96300.00,
"actual": 74100.00,
"consumed": 221600.00,
"available": 18400.00,
"toleranceRoom": 2400.00
},
"wouldExceedBy": 0.00
}]
}
| 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 sempre, e o texto diz o que falta cadastrar. |
available depende do modo de leitura
Com PeriodOnly, é budgeted(março) − consumed(março). Com Cumulative (acumulado), é Σ budgeted(janeiro..março) − Σ consumed(janeiro..março). Mesmo razão, mesma linha, janela diferente — e é por isso que o modo aparece na resposta: a tela precisa poder dizer "R$ 18.400 disponíveis no acumulado do ano", que é uma frase diferente de "R$ 18.400 disponíveis em março".
O rollup do centro de custo é do lado da verba, não do consumo
Uma verba em "Operações" cobre "Operações › Manutenção › Elétrica". O consumo daquela fatia é gravado com o nó folha e resolvido para a linha de "Operações" — então o extrato da verba mostra o gasto das três folhas somado, e cada movimento continua sabendo de qual folha veio. Não existe verba "consolidada" calculada por cima: existe uma linha, com consumo de vários nós.
O motor de aprovação usa isto como critério, e nada mais
O motor lê signal e wouldExceedBy como critérios de política — "estourou a verba" acrescenta um nível com a controladoria, exatamente como "valor acima de X" acrescenta um diretor. O motor não conhece verba e não grava movimento; quem grava é o agregado do documento, na transição. A dependência é num sentido só, como já é com centro de custo.
Segurança
Quem vê o semáforo e quem vê o número
Em muitas empresas o valor da verba é informação de diretoria, e o requisitante não deveria saber que sobraram R$ 3 mil no mês — mas precisa saber que está prestes a estourar. As duas coisas são permissões separadas.
| Permissão | O que libera |
|---|---|
| read:budget_state | 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. |
| read:budget_amounts_all | Remove o recorte. Controladoria, financeiro, diretoria. |
| read:budgets | As telas do módulo: lista de exercícios, linhas, extrato. Implica ver valores dos exercícios que a pessoa alcança pelas duas regras acima. |
O corte é no servidor. Sem read:budget_amounts, o objeto amounts não vai na resposta — não vai zerado, não vai mascarado, não vai escondido por CSS. Vale para a API, para o CSV exportado e para o payload de notificação. Uma permissão que só apaga o campo na tela não é permissão, é decoração: o número continua indo pela rede e aparece no primeiro DevTools aberto.
Isto não toca a plataforma. As permissões são linhas no catálogo que o gryd já mantém por tenant, e chegam achatadas no token como qualquer outra — nenhuma entidade do gryd muda, e a geração do token continua igual. O recorte por nó não vem do token: é resolvido no servidor a cada requisição, a partir de CostCenterAssignment, que já existe e já é a fonte de "quem manda em qual pedaço da árvore".
Tempo
Baldes, competência e carry-forward
A competência é a data de necessidade
O balde de um movimento é escolhido pela data de necessidade da linha do documento — quando a coisa precisa existir — e não pela data em que o documento foi criado ou aprovado. A requisição feita em 20 de dezembro para entrega em 15 de janeiro consome janeiro desde o primeiro movimento.
Por quê: é a leitura econômica correta e mata a corrida de fim de ano, em que todo mundo empurra pedido em dezembro para não perder verba. E resolve de graça o problema mais chato da virada — o compromisso que atravessa o ano já nasce no ano certo.
O que custa: mudar a data de entrega remaneja consumo entre baldes. O tratamento é o mesmo de sempre: dois movimentos com reasonCode = DateChange, negativo no balde velho e positivo no novo, e se o novo balde não couber, vale a política de estouro daquele nó. Nunca um UPDATE em competencePeriodKey.
PeriodOnly × Cumulative — e por que nenhum dos dois transporta nada
O desenho óbvio de carry-forward é transportar: no fechamento de março, movimento negativo em março e positivo em abril. Funciona e arrasta duas coisas pesadas — fechamento mensal vira operação obrigatória (alguém precisa declarar que março acabou) e a nota atrasada quebra o fechamento (reabrir período, recalcular transporte, reescrever abril). Todo ERP que faz assim tem uma tela de reabertura de período e um chamado por mês por causa dela.
Aqui é modo de leitura. O razão é idêntico nos dois casos; muda a janela do somatório. Cumulative é carry-forward, exato, sem linha de transporte, sem fechamento e sem reabertura: a nota de março que chega em 8 de abril entra na soma e o disponível de abril se corrige sozinho — que é o comportamento certo, e no modelo de transporte seria um incidente.
O que se perde: o registro formal "em 31/03 foram transportados R$ 12.400". Isso vira consulta, renderizada na tela de acompanhamento, e não um fato gravado. Se algum cliente de setor regulado exigir o registro imutável, o fechamento formal nasce depois como opt-in — o razão já suporta, bastam os movimentos CarryOut/CarryIn que a virada de exercício já usa.
Cumulative nunca atravessa exercício
A janela do acumulado é periodStart do exercício até o balde consultado. Em 1º de janeiro ela reinicia, sempre, qualquer que seja a política. O que sobrou de 2026 morre em 2026. Atravessar a virada é assunto da seção seguinte, tem regra própria e exige decisão explícita — não pode ser efeito colateral de um modo de leitura.
A pergunta difícil
O que acontece com o que está aberto em 31 de dezembro
Em 31 de dezembro existem requisições aprovadas que ainda não viraram pedido, pedidos emitidos que ainda não chegaram, e entregas feitas cuja nota ainda não veio. Cada uma dessas três situações tem um pedaço de verba de 2026 preso nela. A pergunta é o que fazer com esse dinheiro — e é uma pergunta de dono, não de sistema.
| 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
Ao fechar o exercício, cada saldo aberto de Commitment e Obligation gera um CarryOut negativo no exercício que fecha e um CarryIn positivo no novo, mantendo a mesma cadeia de documento, com competenceDate remapeada para o primeiro balde aberto e a taxa de câmbio preservada. A verba não vai junto.
Na visão do sócio. O número de 2027 foi decidido para 2027. Se o compromisso trouxesse verba automaticamente, o ano começaria comprometido por um valor que ninguém aprovou — e no primeiro mês o controle vira ficção: quanto mais atraso houve em dezembro, mais orçamento aparece do nada em janeiro, o que premia exatamente o comportamento que ele quer evitar. Com o padrão, o que sobrou de 2026 morre em 2026 e qualquer aperto aparece como estouro em janeiro — que é o momento em que ele quer ser consultado, com nome, valor e documento na frente.
Na visão do requisitante. O documento não morre, não precisa ser redigitado e não volta para a fila de aprovação: a cadeia já cumprida é preservada, pela mesma regra do motor de que continuidade e redução não reavaliam. Ele só descobre que houve virada se houver aperto real — e nesse caso o que ele vê é a política de estouro do nó dele, um caminho que ele já conhece, e não uma mensagem de erro sobre exercício encerrado.
As duas outras opções, e quando fazem sentido
CarryCommitmentAndBudget emite, junto com o CarryIn, um Adjustment de mesmo valor na linha do novo exercício, e um Adjustment negativo no exercício que fecha. É o caso de obra e projeto plurianual, em que a verba foi aprovada para o projeto e não para o calendário. Ressalva registrada: para esses casos a resposta melhor é um Budget com periodStart/periodEnd cobrindo o projeto inteiro — a opção existe como saída para quem já tem o orçamento partido por ano e não quer remodelar.
CancelOpen estorna os compromissos abertos e devolve os documentos ao estado anterior, forçando redecisão no ano novo. Existe porque algumas empresas querem exatamente isso na virada, e não é o padrão porque a maioria só quer que a compra de janeiro aconteça.
Nota atrasada de dezembro
Actual com competência num exercício Closed é aceito até lateActualsUntil, desde que liquide um Obligation daquele mesmo exercício. Sem isso, a nota de dezembro que chega em janeiro cairia em 2027 — consumindo verba nova por uma entrega que já tinha sido orçada e empenhada em 2026, o que estoura o ano novo por um fato do ano velho. Passada a janela, o exercício vai a Archived e a nota tardia é lançada em 2027 com reasonCode próprio, aparecendo no relatório de despesa de exercício anterior.
Documento em aprovação na virada
Requisição ainda em aprovação não tem movimento nenhum — o Commitment nasce na conclusão da cadeia. Se ela conclui em janeiro com data de necessidade em dezembro, o balde de destino está num exercício fechado: a competência é reapontada para o primeiro balde aberto, com aviso na tela e no evento. Como valor, centro de custo e rateio não mudaram, a cadeia de aprovação não é reavaliada.
Moeda
Uma moeda por exercício, taxa congelada no movimento
O cadastro de moeda, os pares, a rotina de captura, a regra de resolução da taxa e o serviço de conversão saíram desta spec. Eles são transversais — o Catálogo, o Fornecedor, a Cotação, o Pedido e a Nota consomem os mesmos — e vivem agora na especificação de Moeda e Câmbio. O que fica aqui é só o que é do orçamento: qual moeda o exercício tem, o que ele congela e como a variação cambial aparece no razão.
O exercício tem uma moeda; os documentos têm a que tiverem
Company.functionalCurrency — que já existia na spec de Empresa como metadado informativo e passa a ter consequência a partir daqui — define em que moeda a pessoa jurídica orça e presta contas. Padrão BRL, e só BRL na v1, por decisão registrada na spec de Moeda e Câmbio: o boletim do Banco Central publica moeda contra real e mais nada, e conversão cruzada sem fonte produz número plausível e errado. Budget.currency é copiada dali na criação e congelada.
Um exercício não tem duas moedas. Empresa que quer acompanhar o mesmo orçamento em real e em dólar está pedindo relatório com conversão de apresentação, não um segundo orçamento — e dois orçamentos concorrentes para o mesmo período colidiriam com a regra de não-sobreposição, que existe justamente para não haver duas verdades.
A conversão acontece uma vez, no movimento, e nunca é refeita
Cada BudgetEntry guarda amountDocument e currencyDocument como estão no documento, mais exchangeRate, quoteUnit, rateType e rateDate, e o amount já convertido para a moeda do exercício. Como valores, não como chave estrangeira — se a cotação for substituída depois por retificação de boletim, o movimento gravado não muda.
Reavaliação retroativa não existe. Se a taxa de ontem mudasse o consumo de hoje, o saldo mudaria sozinho durante a noite e ninguém conseguiria explicar para o gestor por que a verba encolheu sem ninguém ter comprado nada.
A variação cambial aparece sozinha, e é consumo de verdade
Pedido de US$ 10.000 a 5,20 grava Obligation de R$ 52.000. A nota chega com o dólar a 5,60: a liquidação estorna o empenho pela taxa dele (−52.000) e lança Actual pela taxa nova (+56.000). Os R$ 4.000 de diferença consomem verba, porque consumiram caixa. Nenhum tratamento especial, nenhuma conta de ajuste — e o relatório de variação cambial é a soma dos deltas das cadeias cuja currencyDocument difere da moeda do exercício.
Converte-se a fatia, e a taxa que faltou recusa o documento inteiro
O rateio acontece na moeda do documento e cada CostAllocation converte separadamente — converter o total e depois ratear faz a soma das linhas do extrato não bater com o total do documento. Sem cotação para o par, o tipo e a janela, o lançamento é recusado com EXCHANGE_RATE_MISSING 422 e a atomicidade por documento vale igual: nada é gravado. O sistema nunca estima taxa.
A janela de tolerância para trás, o tipo padrão e o comportamento em fim de semana e feriado são de CurrencySettings — a entidade que rascunhos anteriores desta spec chamavam de BudgetSettings. A rateDate efetivamente usada e a idade em dias voltam na conversão, são gravadas no movimento e aparecem no extrato: "US$ 10.000 × 5,2013 · PTAX venda de 29/12 (2 dias)".
Taxa contratada
Compra com câmbio travado usa a taxa do contrato, não a do dia: o documento carrega exchangeRateOverride com rateType = Contracted, exige a permissão override:exchange_rate e registra quem informou e com que referência. É a única forma de a taxa não vir da resolução, e ela fica visível no extrato da verba.
Política
Estouro e tolerância
| Polí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. | Verba de investimento, obra fechada, centro de custo de terceiro. |
RequireApproval | Passa, mas o motor acrescenta nível — porque signal e wouldExceedBy são critérios de política de aprovação. Não é o orçamento que aprova nada; é o motor, com a informação que o orçamento deu. | A escolha da maioria depois do primeiro ano: nada trava, tudo passa por quem responde pelo dinheiro. |
Tolerância é o menor dos dois
tolerancePercent aplicado sobre o orçado da linha, toleranceAmount em valor absoluto; quando os dois existem, vale o menor. Cinco por cento é razoável numa verba de R$ 200 mil e é meio milhão de folga invisível numa de R$ 10 milhões — e é sempre a verba grande que ninguém revisou. Dentro da tolerância o sinal é Warning e a política não dispara; passou dela, é Exceeded e a política manda.
Não existe furar bloqueio
Não há permissão de override de bloqueio orçamentário, e isso é decisão, não esquecimento. Toda porta de fuga vira o caminho normal em três meses: quem tem a permissão passa a ser chamado para toda compra apertada, e o controle deixa de existir sem que ninguém tenha decidido desligá-lo. O caminho sancionado para gastar mais do que se orçou é pedir mais 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.
Planejado — coleta e aprovação, sem motor novo
O exercício nasce em Draft. Cada dono de nó — os mesmos CostCenterAssignment que já respondem pela alçada — preenche as linhas da sua subárvore; ninguém enxerga nem edita o que não é seu, pela mesma regra de recorte da visibilidade de valores. Fechada a coleta, o exercício vai a InReview e abre instância do processo ORCAMENTO no motor que já existe. Devolver reabre a edição sem matar a instância, como em qualquer outro documento.
É uma máquina de estados e duas telas. Não é um módulo — e é por isso que dá para oferecer sem virar concorrente de FP&A.
Revisar — movimento ou versão, e a diferença importa
Movimento (BudgetChangeRequest) para o ajuste pontual: suplementar março, remanejar de Marketing para TI, devolver verba. A linha original continua visível e a diferença tem autor, data e justificativa.
Versão quando o plano inteiro é refeito — reorçamento de meio de ano, mudança de estrutura societária, obra replanejada. Nasce como cópia em Draft, passa pelo mesmo processo ORCAMENTO, e ao ser aprovada arquiva a anterior. O razão não é reescrito: os movimentos do exercício antigo continuam apontando para as linhas antigas, e a versão nova recebe movimentos de CarryIn equivalentes ao consumido até ali, para que o disponível não pule. Refazer o plano nunca apaga o que já foi gasto.
Carga
Importação em lote
Mesma mecânica já fechada na importação de centro de custo, com as diferenças que o orçamento impõe:
- Dry-run obrigatório. A primeira chamada valida e devolve o relatório; a segunda, com o token daquele relatório, aplica. Não existe importar sem ter visto o que vai acontecer.
- Tudo ou nada. Uma linha inválida recusa o arquivo inteiro, com a lista completa de problemas por número de linha — nunca "importadas 812 de 1.000".
- Idempotente por chave de negócio —
(código do centro de custo, código da conta, período), e não por posição no arquivo. Reenviar o mesmo arquivo não duplica nada e permite corrigir uma célula e reenviar. - Referência por código, não por id. A planilha traz o código do centro de custo e o da conta, como o financeiro escreve; a resolução para id é do servidor, e código inexistente ou nó inativo é erro nomeado.
- Nó sem
allowsPostingé aceito — verba em nó intermediário é o caso normal. - Depois de
Approved, importação não altera valor. Ela só cria linhas que não existiam; alterar exigeBudgetChangeRequest. Sem isso, a planilha reenviada por engano vira alteração orçamentária sem aprovação — que é exatamente o buraco que o documento existe para fechar.
Fronteiras
Relação com os demais domínios
| Domí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, 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. |
PurchaseOrder | Grava Obligation na emissão e liquida o Commitment da requisição de origem na proporção. Pedido sem requisição gera Obligation direto, sem liquidação. Encerramento libera o resíduo. |
Receipt | Grava Actual pelo que efetivamente entrou e liquida o Obligation. É o recebimento, e não a nota, que marca o realizado — a nota pode não chegar nunca e a mercadoria está lá. |
Invoice | Não grava consumo novo no caminho normal: o realizado já veio do recebimento. Grava Adjustment quando o three-way match acusa diferença de preço aceita, com reasonCode = PriceChange. |
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. |
CostCenter | Fornece path para o rollup, CostCenterAssignment para o recorte de visibilidade e a regra de resíduo do rateio. Não ganha campo novo — as políticas moram em BudgetPolicy. |
GLAccount | Segunda dimensão, com o mesmo rollup por path. Verba em "Despesas Operacionais" cobre toda conta abaixo dela. |
Company | Fornece functionalCurrency (padrão BRL), que era informativo e passa a ser a moeda do exercício — a spec de Empresa vai para v1.2 por causa disto. Nenhum campo novo. O exercício é sempre de uma empresa. |
Currency · 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. |
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 | close: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 | import:budgets | ImportReportDto (dry-run ou aplicado) | 400 · 409 · 422 |
| POST /budget-state:evaluate | read:budget_state | array de 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 |
| 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 | 422 | Escopo controlado e nenhuma linha cobre a combinação. A mensagem nomeia centro de custo, conta e período, e sugere a linha coringa. |
| BUDGET_EXCEEDED | 422 | Política Block e o valor ultrapassa disponível + tolerância. Traz available e wouldExceedBy por fatia. |
| BUDGET_PERIOD_CLOSED | 422 | Compromisso novo com competência em exercício Closed ou Archived. |
| BUDGET_OVERLAPPING_PERIOD | 409 | Aprovar exercício cujo período se sobrepõe a outro já aprovado da mesma empresa. |
| BUDGET_LINE_DUPLICATE | 409 | Mesma combinação de dimensões e período já existe no exercício. |
| BUDGET_LINE_IMMUTABLE | 422 | Tentativa de alterar valor de linha em exercício aprovado sem BudgetChangeRequest. |
| BUDGET_TRANSFER_INSUFFICIENT | 422 | Remanejamento deixaria a linha de origem negativa, revalidado no instante da aprovação. |
| BUDGET_GRANULARITY_IMMUTABLE | 422 | Mudar periodGranularity ou currency depois de aprovado. |
| EXCHANGE_RATE_MISSING | 422 | Sem cotação para o par, tipo e data. Nunca se estima taxa. |
| BUDGET_ENTRY_IMMUTABLE | 409 | Qualquer tentativa de UPDATE ou DELETE no razão. Recusado no banco, não na aplicação. |
| BUDGET_CONCURRENCY_CONFLICT | 409 | rowVersion divergente no exercício ou na linha. |
Segurança
Permissões
| Permissão | Semeada em | Observação |
|---|---|---|
| read:budget_state | 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 e versionar. |
| update:budgets | Controladoria | Editar linhas em Draft e submeter para aprovação. |
| close:budgets | Controladoria | Fechar exercício e rodar a virada. Separada de update de propósito. |
| import:budgets | Controladoria | Carga em lote. |
| 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. |
| manage:exchange_rates | Controladoria | Cadastrar taxa manual ou contratada. Definida em Moeda e Câmbio. |
| override:exchange_rate | Ninguém, por padrão | Informar taxa contratada num documento. Registra autor e referência. |
Todas são linhas no catálogo de permissões que o gryd já mantém por tenant, e chegam achatadas no token como qualquer outra. Nenhuma entidade da plataforma muda, e a geração do token continua igual.
Invariantes
Regras de negócio
BudgetEntry aceita apenas INSERT e SELECT. UPDATE e DELETE são recusados por grant e por trigger. Toda correção é movimento novo.
BudgetLineBalance é atualizada na mesma transação do movimento e pode ser reconstruída integralmente a partir do razão. Divergência entre as duas é falha de integridade, verificada por rotina em homologação.
A decisão de bloqueio acontece dentro da transação que grava, sob SELECT … FOR UPDATE da linha de saldo. Consulta anterior nunca autoriza gravação — sem isso, dois documentos simultâneos contra a última fatia de verba passam os dois.
idempotencyKey único por tenant. Reprocessamento colide no índice e é descartado sem efeito.
Cada estágio liquida o anterior na proporção do que avançou, com liquidatesEntryId preenchido. O resíduo só é liberado no encerramento explícito do documento de origem.
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 aprovados da mesma empresa é recusada por EXCLUDE + btree_gist, no banco.
Especificidade, depois profundidade do centro de custo, depois profundidade da conta. O critério é total: não existe empate e não existe escolha arbitrária.
Sem controle, passa livre e grava. Com controle e sem linha que cubra, bloqueia. Não há terceiro comportamento.
Inclusive onde não há controle, com a tupla de dimensões preenchida e budgetLineId nulo.
competencePeriodKey nunca é atualizado. Mudança de data de necessidade gera par de movimentos com reasonCode = DateChange.
A janela do modo Cumulative começa em periodStart e termina no balde consultado. Reinicia a cada exercício, sempre.
Saldos abertos de Commitment e Obligation geram CarryOut/CarryIn com a cadeia preservada, sem reabrir aprovação. A verba só acompanha com CarryCommitmentAndBudget.
Actual em exercício Closed é aceito até lateActualsUntil e apenas quando liquida Obligation do mesmo exercício.
A conversão acontece uma vez, no movimento. Sem cotação disponível, o lançamento é recusado; nunca estimado.
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. Resíduo de rateio vai para a maior fatia, empate pelo menor order.
Depois de Approved, amount é imutável. O valor efetivo é amount + Σ Adjustment, e todo ajuste tem autor, data e justificativa.
O saldo da origem é conferido no instante da aprovação, sob trava, e não no momento do pedido.
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 recusada por EXCLUDE.
Sem read:budget_amounts, o bloco de valores não vai na resposta — nem na API, nem no CSV, nem na notificação.
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.
Implementação
Concorrência, índices e volume
A trava é a linha de saldo, e é curta
A transação de gravação segue sempre a mesma ordem: resolver as linhas de todas as fatias, ordenar os budgetLineId por id, travar nessa ordem com FOR UPDATE, reavaliar, inserir os movimentos, atualizar os saldos, confirmar. Ordenar antes de travar é o que evita deadlock entre dois documentos que tocam as mesmas duas verbas em ordem inversa — e é a única forma que sobrevive a produção.
Índices que precisam existir desde o primeiro dia
Em BudgetEntry: (tenantId, budgetLineId, stage) para a projeção; (tenantId, sourceType, sourceId) para o estorno e o extrato por documento; (tenantId, costCenterId, competencePeriodKey) para o consumo fora de verba; único em idempotencyKey. Em BudgetLine: (budgetId, periodKey, specificity DESC), mais os quatro índices parciais de unicidade. Em BudgetPolicy: (tenantId, costCenterId) com EXCLUDE de vigência.
Ordem de grandeza
Um cliente de mil requisições por mês, três linhas cada, duas fatias por linha, gera cerca de seis mil movimentos por estágio e por mês — algumas centenas de milhares de linhas ao ano. É pequeno para o Postgres e não justifica particionamento no lançamento. O particionamento por budgetId fica declarado como gancho para quando o primeiro cliente grande aparecer, e não antes.
A consulta de digitação é barata de propósito
budget-state:evaluate lê BudgetLineBalance e a política resolvida, sem trava e sem tocar o razão. Ela é chamada a cada mudança de linha na tela do requisitante, então precisa ser dois selects indexados — e é por isso que a projeção materializada existe, mesmo o razão sendo a verdade.
Contexto
Referência de mercado
| 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 | Valor real | Expenditure / Actual | Invoiced | Realizado |
BudgetChangeRequest | Suplementação / transferência | Budget transfer | Budget adjustment | Alteração orçamentária |
CarryOut / CarryIn | 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. Variação aparece na liquidação, 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. |