Ponto de partida
Oito decisões
Currency é catálogo do produto, não do tenant. ISO 4217 é uma lista fechada e mantida por terceiro. Deixar o cliente criar moeda produz dois USD com nomes diferentes e mata qualquer relatório que cruze tenants. O que o tenant faz é ativar — e é isso que TenantCurrency resolve, dando dono ao conceito de "moedas ativas do tenant" que o Catálogo já cita sem definir.
A rotina não busca moeda, busca par — e a lista de pares se mantém sozinha. Ativar EUR no tenant garante o par EUR→BRL com autoFetch ligado. Sem esse cadastro, a rotina diária ou varre as 150 moedas do boletim todo dia ou não sabe o que buscar. É a lacuna que faltava para a captura automática existir de fato.
Cascata determinística, sem empate e sem troca de tipo. Taxa do documento > taxa do tenant > taxa oficial, para o mesmo rateType e a mesma data; se não achar, anda para trás até maxRateAgeDays repetindo a cascata em cada dia. Nunca troca de rateType como plano B — cair de PtaxVenda para PtaxCompra muda o número em ~0,3% e ninguém consegue explicar de onde veio.
Janela, não silêncio nem paralisia. Fim de semana, carnaval e Natal emendado não podem travar requisição, e rotina quebrada há três semanas não pode converter em silêncio. A saída é a janela de maxRateAgeDays (padrão 5 dias corridos) com a rateDate efetivamente usada gravada no movimento e exibida no extrato. Fora da janela, recusa.
Não se inverte taxa. O par é armazenado no sentido em que é usado — USD→BRL, porque a compra é em dólar e a empresa fecha em real. 1 ÷ cotação de venda não é a cotação de compra, e o relatório que faz isso não fecha por alguns décimos que ninguém rastreia. Conversão no domínio é sempre moeda do documento → moeda funcional, um sentido só.
Existe quoteUnit. A cotação é "tantos reais por N unidades", com N = 1 no caso comum e 100 ou 1.000 para iene, guarani e peso chileno. É o TCURF do SAP e o fator de conversão do Oracle — os dois têm porque quem não tem acaba escondendo o fator dentro do número e perdendo casas.
Taxa não é editada; é substituída. Retificação de boletim e erro de digitação criam linha nova que marca a anterior como superseded. A resolução só enxerga a vigente. E movimento já gravado não se mexe — a taxa congelada no BudgetEntry é um fato do passado, não uma referência à tabela.
Provedor plugável com um adaptador implementado. A interface IRateProvider nasce com BcbPtaxProvider e mais nada. O custo hoje é uma interface de dois métodos; o custo de não ter é reescrever a rotina no primeiro cliente que exigir fonte própria ou moeda fora do boletim do Banco Central.
Fundamento
Por que taxa é cadastro, e não uma chamada de API
O desenho ingênuo é buscar a cotação na hora de converter
A implementação óbvia é uma função ConvertAsync que chama o serviço do Banco Central no momento em que precisa do número. Ela funciona na demonstração e falha em produção por quatro motivos ao mesmo tempo: a aprovação de uma requisição passa a depender de um host externo estar de pé; o mesmo documento reprocessado converte com número diferente; ninguém consegue responder "que taxa você usou naquele dia" seis meses depois; e a latência de uma chamada externa entra no caminho de uma tela de digitação que precisa responder em milissegundos.
SAP, Oracle e Dynamics chegaram os três ao mesmo desenho: a taxa é uma linha de tabela com data e tipo, alimentada por rotina, e o documento guarda a taxa que usou. A chamada externa fica na rotina, longe do caminho crítico — e quando ela falha, o efeito é um alerta, não uma aprovação travada.
Converter é um ato datado, e o resultado carrega a prova
Nenhum consumidor recebe só o valor convertido. O serviço devolve um ConversionResult com o valor, a taxa, o rateType, a rateDate realmente usada, a idade em dias e o id da linha de cotação. O consumidor congela o conjunto inteiro, e é isso que permite o extrato dizer "US$ 10.000 × 5,2013 (PTAX venda de 29/12, 2 dias) = R$ 52.013,00" em vez de mostrar um número que ninguém sabe defender.
Reavaliação retroativa não existe
Se a taxa de ontem mudasse o consumo de hoje, o saldo orçamentário mudaria sozinho durante a madrugada e ninguém conseguiria explicar ao gestor por que a verba encolheu sem ninguém ter comprado nada. A variação cambial entre estágios não é corrigida: ela aparece, como consumo real, no momento da liquidação — e é atribuível por relatório.
O que esta spec deliberadamente não é. Não é tesouraria. Não há posição cambial, hedge, contrato de câmbio como entidade, conta de variação cambial, moeda de reporte para consolidação nem reavaliação de saldo em aberto. O Nexio compra; converter para saber quanto custou em real é necessário, e tudo além disso é do sistema financeiro.
Modelo
Mapa de entidades
minorUnits. Semeada e curada pelo produto; o tenant não cria nem edita. É a lista de onde tudo o mais escolhe.
BRL nasce ativa e não é desativável na v1.
quoteUnit, provedor, tipo padrão e autoFetch. Mantido automaticamente pela ativação de moeda. Guarda também a saúde da captura: último sucesso, última falha, falhas consecutivas.
tenantId nulo, compartilhada) ou própria do cliente. Corrigir é substituir, nunca editar.
defaultRateType que a spec de Orçamento citava como BudgetSettings sem nunca definir.
BRL.
Convenção de nomes. O documento é escrito em português e todo identificador — entidade, atributo, valor de enum, rota, permissão e código de erro — é em inglês. As exceções são dados de negócio: os códigos ISO 4217 (BRL, USD, JPY) e os nomes dos boletins do Banco Central (Fechamento, Abertura), que são conteúdo e não código.
Campos
Currency · o catálogo do produto
Sem tenantId. Uma tabela de referência, semeada na migração e atualizada por versão do produto — do mesmo jeito que a tabela de NCM e a de natureza jurídica. O cliente que precisar de uma moeda que não está na lista abre chamado; o cliente que puder criar moeda vai criar US$, USD e DOLAR na mesma semana.
| Campo | Tipo | Regra |
|---|---|---|
code | char(3) | Chave primária. Código alfabético ISO 4217, maiúsculo. Imutável. |
numericCode | char(3) | Código numérico ISO 4217 (986 para BRL, 840 para USD). Existe porque a NF-e e a declaração de importação usam o numérico, não o alfabético. |
name · symbol | varchar(60) · varchar(6) | Exibição. O símbolo é opcional e nunca participa de cálculo nem de arquivo. |
minorUnits | smallint | Casas decimais. Não é sempre 2. Iene, guarani e peso chileno têm zero; o dinar tunisiano tem três. Todo arredondamento consulta este campo em vez de assumir centavos. Faixa 0–4. |
bcbSymbol | char(3)? | Símbolo da moeda no boletim do Banco Central, quando existe. Coincide com o ISO na esmagadora maioria dos casos, e o campo existe para que a divergência seja dado e não exceção no código. |
isActive | bool | Curadoria do produto. Falso esconde a moeda de toda tela de ativação, sem afetar dado histórico. |
Campos
TenantCurrency · a ativação
A spec de Catálogo já valida referencePriceCurrency contra "as moedas ativas do tenant" e a de Fornecedor grava currency no estabelecimento. Nenhuma das duas define onde essa lista mora. É aqui. Sem essa entidade o combo de moeda oferece 180 opções e o comprador escolhe XOF por engano num pedido de R$ 800.
| Campo | Tipo | Regra |
|---|---|---|
tenantId · currencyCode | uuid · char(3) | Chave composta. Único por tenant. |
isEnabled | bool | Aparece nos combos e é aceita nos campos de moeda. Desativar nunca apaga nem invalida o passado — impede uso novo e mantém tudo o que já existe legível. |
enabledAt · enabledBy | timestamptz · uuid | Rastro. Ativar moeda é ato administrativo com consequência: cria par, dispara backfill e liga a rotina diária. |
Ativar tem três efeitos, e é isso que faz a captura automática funcionar
Ativar EUR num tenant, na mesma transação: garante TenantCurrency; garante o CurrencyPair EUR→BRL global, com autoFetch ligado e provedor BCB_PTAX; e enfileira um backfill dos últimos 30 dias. O cadastro de pares não é uma tela que alguém precisa lembrar de preencher — ele é consequência de um ato que a pessoa já ia praticar de qualquer jeito. A tela de pares existe para curadoria e diagnóstico, não para o caminho comum.
Desativar é recusado enquanto houver documento em aberto
CURRENCY_IN_USE 409, com a contagem por tipo de documento na mensagem — "3 pedidos e 1 requisição em aberto em EUR". Documento fechado e histórico não impedem: eles continuam existindo, continuam legíveis e continuam com a taxa congelada. Impedir a desativação por causa de um pedido de 2024 seria transformar uma decisão de curadoria em impossibilidade permanente.
BRL é ativa por construção
Nasce ativa em todo tenant e a desativação é recusada na v1, porque é a moeda funcional obrigatória de toda empresa. Quando a moeda funcional deixar de ser só BRL, a regra vira "a moeda funcional de qualquer empresa do tenant não é desativável" — que é a mesma coisa, escrita de forma mais geral.
Campos
CurrencyPair · o que a rotina busca
Global, sem tenantId: a cotação oficial do Banco Central é a mesma para todo mundo e buscá-la uma vez por tenant seria pagar N vezes pelo mesmo número. A lista de pares é a união do que os tenants ativaram, e é pequena por natureza — dez a quinze linhas cobrem toda a base.
| Campo | Tipo | Regra |
|---|---|---|
fromCurrency · toCurrency | char(3) · char(3) | O sentido em que a taxa é usada. USD→BRL significa "quantos reais vale quoteUnit dólares". Único por par. O par inverso é outro par, não uma leitura invertida deste. |
quoteUnit | int | Quantas unidades da moeda de origem a taxa cota. Padrão 1; 100 para JPY e CLP, 1000 para PYG. Sem ele, ou a taxa perde casas significativas ou o fator vira convenção oral. |
providerCode | varchar(30) | BCB_PTAX na v1. Identifica o adaptador que sabe buscar este par. |
defaultRateType | enum | Tipo que a rotina captura para este par. Padrão PtaxVenda — é a taxa a que a empresa compra moeda estrangeira, e é o que o custo da compra representa. |
alsoFetchRateTypes | enum[] | Tipos adicionais capturados na mesma passada, sem custo de requisição extra — o boletim traz compra e venda juntos. Padrão [PtaxCompra], para o relatório que precise da outra ponta. |
autoFetch · isActive | bool · bool | Ligados pela ativação de moeda. Desligar autoFetch mantém o par válido para taxa manual — é como se cadastra um par que nenhum provedor cobre. |
lastSuccessAt · lastFailureAt | timestamptz? | Saúde da captura. É o que a tela de diagnóstico mostra e o que responde "desde quando isto está quebrado". |
consecutiveFailures | int | Zerado a cada sucesso. Ao cruzar CurrencySettings.notifyOnFailureAfter, dispara notificação. Dia sem boletim não conta como falha — sábado não é erro. |
Por que não se inverte taxa. A tentação é cadastrar USD→BRL e resolver BRL→USD com 1 ÷ rate. Não funciona: a cotação de compra e a de venda existem justamente porque as duas pontas não são simétricas, e 1 ÷ 5,2013 não é a cotação de compra do real em dólar — é um número sem contraparte no mundo. Some-se o arredondamento em oito casas e o relatório passa a fechar com diferença de alguns décimos que ninguém consegue rastrear. Pedir conversão num sentido sem par cadastrado devolve CURRENCY_PAIR_NOT_FOUND 422. Na prática o Nexio só precisa de um sentido: a empresa compra em moeda estrangeira e fecha em real.
Campos
ExchangeRate · a cotação
Uma linha por par, tipo e data. Duas origens convivem na mesma tabela: a oficial, capturada pela rotina e compartilhada por todos os tenants, e a própria do cliente — contratada, gerencial ou digitada. A do tenant vence a oficial, e a distinção é uma coluna, não um schema.
| Campo | Tipo | Regra |
|---|---|---|
tenantId | uuid? | Nulo = cotação oficial compartilhada, a que a rotina captura. Preenchido = taxa própria do cliente. É a única tabela do Nexio com linha sem tenant, e a exceção é deliberada: replicar o PTAX por tenant seria multiplicar o mesmo fato por cliente. |
fromCurrency · toCurrency | char(3) · char(3) | Sempre o sentido de uso. Referencia um CurrencyPair ativo. |
rateType | enum | PtaxVenda | PtaxCompra | Commercial | Contracted | Manual. Faz parte da chave: duas taxas do mesmo dia com tipos diferentes são dois fatos, não um conflito. |
rateDate | date | A data a que a cotação se refere — a data do boletim, não a da captura. |
rate | numeric(18,8) | Quantas unidades de toCurrency valem quoteUnit unidades de fromCurrency. Oito casas porque o boletim publica quatro e a conta de conversão multiplica antes de arredondar. |
quoteUnit | int | Copiado do par no instante da gravação e congelado aqui. A cotação precisa ser autossuficiente: mudar o fator no par não pode reinterpretar as taxas já gravadas. |
source · sourceReference | varchar(30) · varchar(120) | BCB_PTAX / MANUAL / CONTRACT, e a referência dentro da origem — o tipoBoletim e o dataHoraCotacao do Banco Central, ou o número do contrato de câmbio. |
capturedAt · capturedBy | timestamptz · uuid? | Quando entrou e por quem. Nulo em capturedBy quando a origem é a rotina. |
supersededById · supersededAt · supersedeReason | uuid? · timestamptz? · varchar(200)? | Retificação. A linha antiga permanece e passa a ser invisível para a resolução. Motivo é obrigatório — substituir cotação é evento raro e sempre tem história. |
Não se edita cotação, substitui-se
UPDATE e DELETE recusados por grant e por trigger, mesma disciplina do razão orçamentário. Corrigir uma taxa é gravar a nova e apontar supersededById na antiga, na mesma transação. O motivo aparece na tela de cotações com a taxa que foi substituída ao lado.
E o que já foi convertido não muda. O BudgetEntry de ontem guarda exchangeRate, rateType e rateDate como valores, não como chave estrangeira para esta tabela. Substituir a cotação corrige o futuro; o passado é um fato registrado. Se a diferença for material, o caminho é um movimento de Adjustment com reasonCode próprio — visível, aprovado e rastreável — nunca uma reescrita silenciosa.
Taxa contratada é do documento, não da tabela
Compra com câmbio travado usa a taxa do contrato. 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, marcada. Opcionalmente a mesma taxa é persistida como ExchangeRate do tenant, para que outros documentos do mesmo contrato a encontrem sozinhos.
Campos
CurrencySettings · um por tenant
A spec de Orçamento cita BudgetSettings.defaultRateType e nunca define a entidade. O ajuste não é do orçamento — é do câmbio, e vale igual para o pedido e para a nota. Vive aqui.
| Campo | Tipo | Padrão | Regra |
|---|---|---|---|
defaultRateType | enum | PtaxVenda | Tipo usado quando o chamador não pede um. Mudar não reinterpreta nada do passado. |
maxRateAgeDays | smallint | 5 | Janela de tolerância para trás, em dias corridos. Faixa 0–15. Zero significa "exige a data exata". |
warnRateAgeDays | smallint | 1 | A partir de quantos dias de idade a interface destaca a taxa como antiga. Não bloqueia nada; muda a cor e o texto ao lado do número. |
fetchTimesLocal | time[] | 14:00, 18:00, 22:00 | Horários da rotina em America/Sao_Paulo. O primeiro é a passada normal, os outros são reexecuções — a rotina é idempotente, rodar três vezes grava uma linha. |
notifyOnFailureAfter | smallint | 1 | Falhas consecutivas de um par antes de notificar. Um, porque câmbio quebrado em silêncio é o modo de falha que esta spec inteira existe para evitar. |
alertRecipients | uuid[] | — | Quem recebe. Vazio cai no papel de administrador do tenant. |
allowContractedOverride | bool | false | Liga a possibilidade de informar taxa no documento. Mesmo ligada, ainda exige a permissão. |
O algoritmo
Como a taxa é escolhida
Dada a tupla (fromCurrency, toCurrency, rateDate, rateType?, tenantId), a resolução é uma cascata de cinco passos que termina sempre — com uma taxa ou com uma recusa nomeada. Em cada passo existe no máximo uma linha candidata, garantido pelo índice único. Não há critério de desempate porque não há empate.
| Passo | O que faz | Por quê |
|---|---|---|
| 0 · Override | Se o documento traz exchangeRateOverride, usa e para. Exige override:exchange_rate e allowContractedOverride ligado. | Câmbio travado por contrato é um fato do documento e nenhuma tabela sabe dele. |
| 1 · Tipo | rateType = o pedido pelo chamador, ou CurrencySettings.defaultRateType. Fixado aqui e não muda mais. | Trocar de tipo como plano B produz um número plausível e errado — o defeito mais caro de detectar. |
| 2 · Tenant | Busca (tenantId = X, par, tipo, data) não substituída. | A taxa própria do cliente vence a oficial: se ele cadastrou, é porque a dele é a que vale. |
| 3 · Oficial | Busca (tenantId IS NULL, par, tipo, data) não substituída. | O boletim do Banco Central, o caminho comum. |
| 4 · Janela | Recua um dia e repete 2 e 3, até maxRateAgeDays. O primeiro achado vence. | Fim de semana e feriado não podem travar aprovação, e a data efetivamente usada volta no resultado. |
| 5 · Recusa | EXCHANGE_RATE_MISSING 422, com par, tipo e o intervalo varrido na mensagem. | O sistema nunca estima taxa, nunca troca de tipo e nunca reaproveita cotação de três semanas atrás sem dizer. |
Dias corridos, não dias úteis — e o motivo é prático
Recuar por dia útil exigiria um calendário de feriado bancário mantido por alguém. Ninguém mantém, e feriado municipal não existe para o Banco Central. Cinco dias corridos cobrem o fim de semana comum, a segunda e a terça de carnaval e o Natal emendado ao fim de semana, que são os três casos reais. O preço é que uma rotina quebrada na sexta só é percebida na quarta — e é justamente por isso que a falha notifica na primeira ocorrência, em vez de esperar a janela estourar.
A idade da taxa é dado, não detalhe de implementação
O ConversionResult devolve rateDate e rateAgeDays, e ambos são gravados pelo consumidor. Acima de warnRateAgeDays a interface mostra "PTAX venda de 29/12 · 2 dias" ao lado do valor convertido. Esconder a idade é como esconder a taxa: o número fica indefensável na conversa com a controladoria.
Contraste deliberado com a resolução da verba orçamentária
Na BudgetLine, várias linhas podem cobrir a mesma fatia e vence a mais específica — dimensões aninhadas, empate impossível por construção de depth. Aqui a chave é exata e a cascata é por origem e por data. São problemas diferentes e é bom que as regras não se pareçam: quem lê as duas specs não deve tentar aplicar a intuição de uma na outra.
Integração
A rotina diária e o provedor
Uma interface de dois métodos, um adaptador implementado. IRateProvider expõe FetchDayAsync(pair, date) e FetchRangeAsync(pair, from, to), devolvendo RateQuote — tipo, taxa, data e referência de origem. O par escolhe o adaptador por providerCode. Na v1 existe BcbPtaxProvider e mais nada, e essa é a decisão: a abstração custa uma interface hoje e evita reescrever a rotina no primeiro cliente que exigir fonte própria.
O adaptador do Banco Central
Dados abertos, sem chave, sem autenticação, formato OData. O recurso é CotacaoMoedaDia e o boletim que interessa é o de Fechamento.
GET https://olinda.bcb.gov.br/olinda/servico/PTAX/versao/v1/odata/
CotacaoMoedaDia(moeda=@moeda,dataCotacao=@dataCotacao)
?@moeda='USD'
&@dataCotacao='12-29-2026'
&$format=json
&$select=cotacaoCompra,cotacaoVenda,dataHoraCotacao,tipoBoletim
A armadilha número um: a data vai em MM-DD-YYYY. Formato americano, numa API brasileira, sem validação de intervalo. O erro não estoura — em todo dia menor ou igual a 12 a chamada devolve alegremente o dia errado, e 03-04-2027 traz 4 de março quando você queria 3 de abril. Metade do ano o defeito é invisível. A formatação da data é ponto de teste unitário obrigatório do adaptador, com caso para dia ≤ 12.
Resposta vazia é ausência, não falha
Sábado, domingo e feriado bancário devolvem 200 com value: []. Isso não incrementa consecutiveFailures e não notifica ninguém: é a resposta correta para uma pergunta sobre um dia sem boletim. Falha é timeout, erro de rede, 5xx e resposta que não desserializa — essas contam, e são elas que alertam.
Um par por requisição, sequencial, com backoff
O serviço é público e não documenta limite de uso. Com dez a quinze pares, o custo total da passada é de segundos, e paralelizar contra um serviço gratuito de terceiro para economizar dois segundos é a forma mais rápida de ser bloqueado. Falha de par não interrompe a passada: os outros seguem e o relatório da execução lista o que deu certo e o que não deu.
Idempotente por construção
A gravação colide no índice único (tenantId, par, tipo, data) e é descartada sem efeito. Por isso as três execuções diárias são seguras, o botão "buscar agora" é seguro e reprocessar uma janela inteira é seguro. Uma taxa capturada só muda por substituição explícita, nunca por uma segunda passada da rotina.
Por que PtaxVenda de D−1 como padrão
O boletim de fechamento do próprio dia só sai no fim da tarde, e requisição aprovada às 10h não pode esperar. Venda porque é a taxa a que a empresa compra moeda estrangeira — que é o que uma ordem de compra representa. O boletim traz compra e venda na mesma resposta, então PtaxCompra é gravada junto de graça, para o relatório que precise da outra ponta.
Backfill na ativação, e nada de "primeiro dia sem histórico"
Ativar uma moeda enfileira FetchRangeAsync dos últimos 30 dias via CotacaoMoedaPeriodo — uma requisição só. Sem isso, o cliente ativa EUR na terça e a primeira nota com data de necessidade na semana anterior é recusada por falta de cotação, o que parece defeito e é só falta de histórico.
O que não usamos do boletim, e por quê
A resposta traz também paridadeCompra e paridadeVenda, que é a moeda contra o dólar, com a convenção invertida entre moedas tipo A (USD/moeda) e tipo B (moeda/USD) do recurso Moedas. A paridade é a matéria-prima da conversão cruzada — EUR→USD sem passar pelo real — e a v1 não faz conversão cruzada. Fica registrada como a fonte natural quando fizer, com a ressalva de que a decisão difícil não é obter a paridade, e sim qual ponta usar em cada perna da triangulação.
Contrato
O serviço de conversão
Um contrato de domínio interno, chamado pelos agregados dentro da própria transação. É o único ponto do sistema que converte dinheiro, e devolve sempre a prova junto com o número.
ConversionResult Convert(
amount, fromCurrency, toCurrency, conversionDate,
rateType?, tenantId, exchangeRateOverride?)
ConversionResult {
amount // convertido e arredondado
rate, quoteUnit, rateType
rateDate, rateAgeDays
exchangeRateId? // nulo quando veio de override
isOverride
}
Um único arredondamento, nas casas da moeda de destino
amount × rate ÷ quoteUnit em precisão cheia, e um arredondamento no fim, para Currency.minorUnits do destino, HALF_UP. Arredondar no meio da conta — depois da multiplicação e antes da divisão pelo fator — é o defeito clássico de centavo, e ele só aparece nas moedas com quoteUnit diferente de 1, ou seja, tarde e em produção.
Converte-se a fatia, nunca o total
Quando a linha do documento é rateada entre centros de custo, o rateio acontece na moeda do documento e cada fatia converte separadamente. Converter o total e depois ratear dá uma soma diferente por um ou dois centavos, e a diferença aparece exatamente onde dói: a soma das linhas do extrato não bate com o total do documento.
O resíduo de arredondamento da conversão segue a mesma regra de resíduo do rateio já definida na spec de Centro de Custo — sobra para a fatia de maior percentual, com desempate por menor costCenterId. Mesma regra em dois lugares é uma regra; duas regras parecidas é uma armadilha.
Moeda igual é passagem direta, não conversão com taxa 1
from == to devolve o valor intacto, rate = 1, exchangeRateId nulo e rateAgeDays = 0, sem tocar na tabela. O caminho comum — documento em real, empresa em real — nunca depende de existir cotação de BRL→BRL, e nenhuma falha de câmbio pode alcançar o cliente que só compra no Brasil.
O consumidor congela o resultado inteiro
Não basta guardar o valor convertido, e não basta guardar o id da cotação. O documento grava valor de origem, moeda de origem, taxa, quoteUnit, tipo e data — como valores. É o que permite reconstruir a conta seis meses depois mesmo que a cotação tenha sido substituída, e é o que torna o extrato defensável.
Limite declarado
Por que só BRL como moeda funcional na v1
Company.functionalCurrency aceita apenas BRL na v1; qualquer outro valor é recusado com CURRENCY_FUNCTIONAL_NOT_SUPPORTED 422 e mensagem explícita — não é um campo que aparenta funcionar e produz número errado.
O motivo é a fonte, não a modelagem
O modelo já suporta qualquer par: CurrencyPair não sabe o que é real. O que não existe é fonte. O boletim do Banco Central publica moeda contra real, e mais nada. Uma subsidiária que orçasse em dólar e comprasse em euro precisaria de EUR→USD, que só sai de conversão cruzada — por dupla conversão via real, ou pela paridade do próprio boletim. As duas envolvem escolher qual ponta usar em cada perna, e as duas produzem números plausíveis quando a escolha está errada. Conversão cruzada errada é o defeito mais difícil de detectar que existe nesta área: ninguém desconfia de 5,43 quando o certo era 5,41.
O gancho, escrito
Quando houver demanda declarada: CurrencyPair ganha derivationMode (Direct | ViaAnchor | ViaParity) e anchorCurrency; a resolução ganha um passo 3.5 que compõe duas taxas e devolve isDerived com as duas pernas no ConversionResult; e a regra de qual ponta usar vira decisão documentada, não convenção de código. É o mesmo desenho de moeda de referência do SAP e de moeda âncora do Dynamics — a diferença é que eles precisam com dezenas de moedas funcionais, e nós ainda não temos a primeira.
Fronteiras
Quem consome e o que congela
| Domínio | O que faz com moeda e câmbio |
|---|---|
Company | Fornece functionalCurrency, o destino de toda conversão. Só BRL na v1. |
Budget | Budget.currency copia e congela a moeda funcional na criação do exercício. Cada BudgetEntry guarda amountDocument, currencyDocument, exchangeRate, rateType e rateDate, além do valor convertido. Um exercício não tem duas moedas. |
Item | referencePriceCurrency valida contra TenantCurrency ativa. Não converte — preço de referência é benchmark, e converter benchmark com taxa de hoje esconde que o preço é de dois anos atrás. |
ItemSupplier · SupplierEstablishment | Guardam moeda como dado comercial negociado, validada contra as ativas. A moeda do estabelecimento é o padrão sugerido ao documento; a do ItemSupplier acompanha o preço. |
Quotation · RFQ | O caso mais delicado. Propostas em moedas diferentes só são comparáveis convertidas. A taxa usada no mapa comparativo é a do dia da abertura do mapa, gravada no próprio mapa, e cada valor convertido aparece com a taxa ao lado. A decisão é humana e a conversão é declarada — nunca uma coluna "valor em R$" cuja origem ninguém consegue reconstituir. Reabrir o mapa depois não reconverte. |
PurchaseOrder | Converte na emissão, congela no documento e no movimento de Obligation. O pedido inteiro tem uma moeda; o preço unitário e o total vivem nela. |
Receipt | Não converte nada de novo: reusa a taxa congelada no pedido para a parcela recebida. Recebimento é quantidade, não preço. |
Invoice · three-way match | O confronto é sempre na moeda do documento. Comparar pedido e nota depois de converter faz a variação cambial virar divergência de preço e trava a nota por um fato que não é do fornecedor. A nota converte com a taxa da sua própria data, e a diferença entre a conversão do pedido e a da nota é variação cambial — consumo real, atribuível, e não erro de match. |
Contract | Contrato em moeda estrangeira guarda o valor na moeda dele. As liberações parciais convertem cada uma na sua data. Congelar a taxa da assinatura por 36 meses descreveria um mundo que não existe. |
| Motor de aprovação | Alçada é comparada na moeda funcional, com a taxa já congelada no documento. Um documento não pode mudar de faixa de alçada porque o dólar mexeu entre a submissão e a aprovação. |
A regra que resume a tabela: converte-se uma vez, no evento que cria o compromisso, e o resultado vira dado do documento. Toda comparação entre dois documentos acontece na moeda de origem; toda comparação com verba e com alçada acontece na moeda funcional, com o número que já estava congelado. Nenhuma tela reconverte para exibir.
Interface
Telas
| Tela | O que tem |
|---|---|
| Administração › Moedas | Lista do catálogo com um interruptor de ativação por moeda, minorUnits e símbolo visíveis. Ativar mostra em uma frase o que vai acontecer — par criado, captura ligada, 30 dias de histórico buscados. Desativar com uso aberto mostra a contagem por tipo de documento e recusa. |
| Câmbio › Cotações | Grade por par e período, com origem, tipo, idade e a marca de substituída. Filtro por par, tipo e intervalo. Exportação em CSV com as mesmas colunas — inclusive a taxa e a data, nunca só o valor. |
| Câmbio › Pares e captura | Uma linha por par com quoteUnit, provedor, último sucesso, última falha e falhas consecutivas. Semáforo por par. Botão buscar agora, que roda a passada do par e mostra o resultado na hora. |
| Câmbio › Nova cotação | Cadastro manual e contratada. Pede par, tipo, data, taxa e referência de origem. Colisão com linha existente oferece substituir, exigindo motivo — nunca sobrescreve calado. |
| Administração › Câmbio (configurações) | Os campos de CurrencySettings, com o efeito de cada um escrito ao lado. A janela de idade mostra um exemplo calculado com a data de hoje. |
| Em todo campo de valor em moeda estrangeira | O convertido aparece abaixo, em cinza, com a taxa e a data: "≈ R$ 52.013,00 · PTAX venda de 29/12 (2 dias)". É a diferença entre um número que o comprador defende e um número que ele repassa. |
API
Endpoints
| Rota | Permissão | Devolve | Erros |
|---|---|---|---|
| GET /currencies | read:currencies | PagedResult<CurrencyDto> — o catálogo | 400 |
| GET /tenant-currencies | read:currencies | array de TenantCurrencyDto | 400 |
| PUT /tenant-currencies/{code} | manage:currencies | TenantCurrencyDto — ativa ou desativa | 404 · 409 · 422 |
| GET /currency-pairs | read:exchange_rates | array de CurrencyPairDto com a saúde da captura | 400 |
| PUT /currency-pairs/{id} | manage:currencies | CurrencyPairDto — quoteUnit, provedor, autoFetch | 404 · 422 |
| GET /exchange-rates | read:exchange_rates | PagedResult<ExchangeRateDto>, filtrável por par, tipo e período | 400 |
| POST /exchange-rates | manage:exchange_rates | ExchangeRateDto — manual ou contratada | 400 · 409 · 422 |
| POST /exchange-rates/{id}:supersede | manage:exchange_rates | ExchangeRateDto — a nova, com a antiga marcada. Motivo obrigatório | 404 · 409 · 422 |
| POST /exchange-rates:fetch | manage:exchange_rates | FetchReportDto — por par: capturadas, sem boletim, falhas | 400 · 422 · 503 |
| GET /exchange-rates:resolve | read:exchange_rates | ResolvedRateDto — a taxa que seria usada, com o passo que a achou | 400 · 422 |
| POST /currency:convert | read:exchange_rates | ConversionResultDto — pré-visualização para a tela | 400 · 422 |
| GET /currency-settings | read:currencies | CurrencySettingsDto | 404 |
| PUT /currency-settings | manage:currency_settings | CurrencySettingsDto | 400 · 409 · 422 |
POST /currency:convert é pré-visualização e nunca autoriza gravação. Quem grava chama o serviço de domínio dentro da própria transação, resolvendo a taxa de novo — o mesmo princípio da consulta de saldo orçamentário, pela mesma razão: entre a tela e o submit passa tempo, e nesse intervalo a rotina pode ter gravado o boletim do dia.
Contrato
Erros
| Código | HTTP | Quando |
|---|---|---|
| EXCHANGE_RATE_MISSING | 422 | Nenhuma cotação para o par e o tipo dentro da janela. A mensagem traz par, tipo, data pedida e o intervalo varrido. Substitui o BUDGET_EXCHANGE_RATE_MISSING da spec de Orçamento v1.0 — o fato é do câmbio e vai ter os mesmos consumidores no pedido e na nota; três códigos para o mesmo fato é ruído. |
| CURRENCY_NOT_ENABLED | 422 | Moeda válida no catálogo, não ativa neste tenant. |
| CURRENCY_IN_USE | 409 | Desativar moeda com documento em aberto. A mensagem traz a contagem por tipo. |
| CURRENCY_FUNCTIONAL_NOT_SUPPORTED | 422 | Moeda funcional diferente de BRL na v1. |
| CURRENCY_PAIR_NOT_FOUND | 422 | Conversão pedida num sentido sem par cadastrado. Não se inverte taxa. |
| CURRENCY_PAIR_INACTIVE | 422 | Par existe e está desativado. |
| EXCHANGE_RATE_DUPLICATE | 409 | Já existe cotação vigente para par, tipo e data. O caminho é substituir, com motivo. |
| EXCHANGE_RATE_IMMUTABLE | 409 | Tentativa de UPDATE ou DELETE. Recusado no banco, não na aplicação. |
| EXCHANGE_RATE_OVERRIDE_FORBIDDEN | 403 | Taxa informada no documento sem override:exchange_rate, ou com allowContractedOverride desligado. |
| EXCHANGE_RATE_INVALID | 400 | Taxa não positiva, quoteUnit não positivo, data futura, ou moeda fora do catálogo. |
| CURRENCY_PROVIDER_UNAVAILABLE | 503 | Só na busca manual. A rotina agendada nunca devolve erro HTTP — ela registra, conta a falha e notifica. |
Segurança
Permissões
| Permissão | Semeada em | Observação |
|---|---|---|
| read:currencies | Todos os papéis operacionais | Ler catálogo, ativas e configurações. Sem ela nenhum combo de moeda carrega. |
| manage:currencies | Administrador | Ativar e desativar moeda, editar par. Ato com efeito colateral — cria par e dispara captura. |
| read:exchange_rates | Controladoria, Comprador | Consultar cotações, pares e pré-visualizar conversão. |
| manage:exchange_rates | Controladoria | Cadastrar taxa manual ou contratada, substituir cotação, disparar busca. |
| override:exchange_rate | Ninguém, por padrão | Informar taxa dentro de um documento. Registra autor e referência, e a marca fica visível no extrato. |
| manage:currency_settings | Administrador | Tipo padrão, janela de idade, horários e destinatários do alerta. |
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. As duas que já apareciam na spec de Orçamento (read:exchange_rates e manage:exchange_rates) são as mesmas linhas, e passam a ser documentadas aqui.
Invariantes
Regras de negócio
Currency não tem tenantId e não é editável por API de cliente. Moeda nova entra por migração, com code, numericCode e minorUnits conferidos contra a ISO 4217.
referencePriceCurrency, ItemSupplier.currency, SupplierEstablishment.currency, moeda de documento e Budget.currency recusam código que não seja TenantCurrency com isEnabled. Validação no servidor, não no combo.
BRL nasce ativa e não é desativávelEm todo tenant. Enquanto a moeda funcional for obrigatoriamente BRL, desativá-la deixaria o tenant sem destino de conversão.
Recusada com CURRENCY_IN_USE enquanto houver documento em aberto na moeda. Histórico e documentos fechados permanecem legíveis, com a taxa que congelaram.
TenantCurrency + CurrencyPair (moeda→BRL, autoFetch ligado, provedor BCB_PTAX) + backfill de 30 dias enfileirado. O cadastro de pares não depende de ninguém lembrar de preenchê-lo.
A conversão exige par cadastrado no sentido pedido. 1 ÷ rate não é a cotação inversa, e derivá-la produz divergência que ninguém rastreia.
rateTypeO tipo é fixado no passo 1 e vale até a recusa. Não existe plano B por tipo — só por data, dentro da janela.
Para o mesmo par, tipo e data. Existe uma linha candidata em cada passo, garantida pelo índice único parcial — a cascata é determinística e não tem desempate.
Até maxRateAgeDays. A rateDate efetivamente usada e a rateAgeDays voltam no resultado, são gravadas pelo consumidor e aparecem no extrato. Nunca se anda para a frente: cotação de amanhã não existe.
UPDATE e DELETE recusados por grant e trigger. supersedeReason obrigatório. A resolução só enxerga linha com supersededById nulo.
O documento guarda taxa, tipo, data e quoteUnit como valores, não como referência. Diferença material se resolve por movimento de ajuste, visível e aprovado.
amount × rate ÷ quoteUnit em precisão cheia, arredondado uma vez para Currency.minorUnits, HALF_UP. Nunca se assume duas casas.
O rateio ocorre na moeda do documento e cada fatia converte separadamente. O resíduo segue a regra de resíduo do rateio do Centro de Custo — maior percentual, desempate por menor id.
from == to é passagem direta com rate = 1. Falha de câmbio nunca alcança o cliente que só compra no Brasil.
Colisão no índice único descarta sem efeito. value: [] registra "sem boletim" e não incrementa consecutiveFailures.
A partir de notifyOnFailureAfter falhas consecutivas de um par, notificação para alertRecipients. A tela de pares mostra desde quando está quebrado.
Three-way match compara pedido, recebimento e nota na moeda do documento. Variação cambial não é divergência de preço e não trava nota.
POST /currency:convert é pré-visualização. Quem grava resolve a taxa de novo dentro da transação.
Toda gravação de ExchangeRate, substituição, ativação e desativação de moeda e alteração de CurrencySettings gera registro com autor, antes e depois.
Contexto
Referência de mercado
| Nexio | SAP | Oracle | Dynamics 365 |
|---|---|---|---|
Currency | TCURC + TCURX (casas decimais) | Currencies | Currencies |
ExchangeRate | TCURR, por tipo e data | GL Daily Rates | Exchange rates, por par e data inicial |
rateType | TCURV — M média, B/G compra e venda, P planejamento | Corporate | Spot | User | Exchange rate types |
quoteUnit | TCURF, o fator de conversão | Conversion factor | Unidade de cotação no par |
| Não implementado · gancho | Moeda de referência e triangulação | Cross rates | Moeda âncora |
| Fora de escopo | Segunda e terceira moeda, reavaliação (FAGL_FC_VAL) | Revaluation, reporting currency | Revaluation, moeda de reporte |
Três notas. A primeira: o fator de unidade existe nos três, e existe porque é inevitável — quem não o tem acaba embutindo o fator no número e perdendo casas em iene e guarani. A segunda: o tipo faz parte da chave nos três, e nenhum deles tem fallback de tipo — a razão é a mesma que a nossa, um número plausível e errado é pior que uma recusa. A terceira, sobre o que não se copia: a TCURX do SAP existe porque o campo de valor é fixo em duas casas e as exceções precisam de uma tabela à parte; aqui minorUnits está no catálogo desde a primeira linha, e o problema não chega a nascer.
Fonte da integração: serviço de dados abertos do Banco Central do Brasil — olinda.bcb.gov.br, serviço OData PTAX v1, recursos Moedas, CotacaoMoedaDia e CotacaoMoedaPeriodo. Licença Open Data Commons ODbL. Sem chave, sem autenticação e sem limite de uso documentado — o que é motivo para a rotina ser educada, não para confiar que sempre responderá.
Limites
Fora de escopo
| O que | Por quê |
|---|---|
Moeda funcional diferente de BRL | Não há fonte: o boletim do Banco Central publica moeda contra real e nada mais. O modelo suporta; a v1 recusa com erro nomeado em vez de produzir número plausível e errado. |
| Conversão cruzada e triangulação | Consequência da anterior. O gancho está escrito — derivationMode, anchorCurrency e um passo a mais na resolução. |
| Inversão de taxa | Compra e venda não são recíprocas. Quem precisar do sentido inverso cadastra o par com fonte própria. |
| Reavaliação cambial periódica | Mudaria saldo orçamentário sem ninguém ter comprado nada. A variação aparece na liquidação, que é quando ela é real. |
| Conta de variação cambial e contabilização | O Nexio não gera lançamento contábil. A variação é consumo de verba e sai por relatório, não por partida dobrada. |
| Contrato de câmbio e hedge como entidade | Só a taxa contratada entra, como dado do documento. Posição cambial e operação de proteção são do sistema financeiro. |
| Moeda de reporte e consolidação | A segunda e a terceira moeda do SAP existem para consolidação de grupo. Somar orçamento de várias empresas já está fora de escopo na spec de Orçamento pela mesma razão. |
| Conversão de apresentação em relatório | Ver o orçamento em dólar é relatório com conversão declarada, não um segundo orçamento. Nasce quando existir o módulo de relatórios. |
| Calendário de feriado bancário | Substituído pela janela em dias corridos. Cadastro que ninguém mantém é pior que uma heurística explícita. |
| Criptomoeda | Fora da ISO 4217, sem boletim oficial, e nenhuma empresa compradora paga fornecedor assim. Se um dia acontecer, é provedor novo — a interface já prevê. |