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 — e autoFetch é exatamente isso: a união das ativações, derivada, não um interruptor. 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". N é dado da fonte, devolvido pelo provedor junto com a taxa e conferido contra o par — e no PTAX N é 1 para todas as moedas. Os fatores 100 e 1.000 que se vê em iene, guarani e peso chileno são de outras fontes. É 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 derivado. Mantido automaticamente pela ativação de moeda; editável só com permissão de plataforma. 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.
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 — e a curadoria é da plataforma, porque o par é de todos.
Desativar é recusado enquanto houver documento em aberto
CURRENCY_IN_USE_CONFLICT 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. A regra geral, desde 09/09/2026: a moeda funcional de qualquer empresa do tenant não é desativável, e BRL é o caso comum dela, não a exceção — antes a redação era "porque é a moeda funcional obrigatória de toda empresa", e a obrigatoriedade acabou. BRL continua nascendo ativa porque é o destino de conversão do tenant típico brasileiro, e desativá-la enquanto houver empresa que feche nela deixaria o tenant sem destino.
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. Por ser global, o par tem dois donos e nenhum deles é o administrador do tenant: autoFetch é derivado dessa união — liga com a primeira ativação da moeda de origem em qualquer tenant, desliga com a última desativação, e nunca é editado —, e o que resta editável (quoteUnit, provedor, tipos capturados, isActive) exige permissão de plataforma, porque a edição alcança todos os tenants. É o mesmo conceito que o GrydFiles usa para expurgo e legal hold, e que o Anexo exige para desligar legalHold. Fechado em 04/09/2026 pelo achado A6 da contra-análise.
| 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. É propriedade da fonte, não da moeda (09/09/2026): 100 e 1000 existem para provedor que publique assim, e o PTAX não publica — ele cota reais por uma unidade em todas as moedas, então BCB_PTAX implica quoteUnit = 1, iene e guarani incluídos. Até 08/09 esta linha prescrevia 100 para JPY e CLP e 1000 para PYG contra um boletim que cota por 1 — o iene entraria valendo cem vezes menos. A rotina agora confere contra o que o provedor devolve e recusa a divergência. Sem o campo, o fator viraria convenção oral; com ele errado, vira número errado gravado. |
providerCode | varchar(30)? | BCB_PTAX na v1. Identifica o adaptador que sabe buscar este par. Nulo é par sem provedor: fica fora da rotina e aceita só taxa manual — é como se cadastra um par que nenhum provedor cobre. |
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 | autoFetch é derivado, nunca editado: verdadeiro enquanto o par tiver provedor e ao menos um tenant com a moeda de origem ativa — a união das ativações —, recalculado na ativação e na desativação de moeda. isActive é curadoria de plataforma: falso tira o par da rotina e da resolução para todos os tenants, e por isso só se desliga com permissão de plataforma. |
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_CONFIGURED_UNPROCESSABLE 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 · supersedeReasonCodeId | uuid? · timestamptz? · uuid? | 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. Desde 03/09/2026 é motivo codificado, uso ExchangeRateSupersede, com supersedeReasonCode, supersedeReasonLabel e o complemento em texto no lugar do antigo varchar(200) livre. Ver Motivo. |
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-rates 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-rates 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_UNPROCESSABLE 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.
O fluxo de aprovação ganhou uma lista de dias não úteis em 08/09/2026, e ela continua não servindo aqui. Aquela lista é o calendário do escritório do tenant — feriado municipal, recesso, ponte —, e quem decide se houve boletim é o Banco Central, que publica normalmente no aniversário da cidade. Guiar a busca de taxa por ela faria a conversão pular um dia em que existia cotação, e faria a taxa de uma compra depender do RH. São dois calendários com dois donos, e nenhuma rotina desta página lê o do tenant. Anotado em v1.4
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, referência de origem e, desde 09/09/2026, quoteUnit: quantas unidades da moeda de origem aquela taxa cota, dito pela fonte. 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.
O fator de cotação vem do provedor, não do cadastro
Novo em 09/09/2026. quoteUnit é propriedade da fonte, não da moeda. O CotacaoMoedaDia publica reais por uma unidade — para todas as moedas, iene incluído; é a mesma constatação que faz a paridade ser descartada. Logo todo par com providerCode = BCB_PTAX tem quoteUnit = 1, e o 100 do iene e o 1000 do guarani só existem para fonte que publique assim.
Para que isso não dependa de alguém lembrar, a rotina compara o quoteUnit que o RateQuote traz com o do par: divergindo, a gravação é recusada com EXCHANGE_RATE_QUOTE_UNIT_MISMATCH_UNPROCESSABLE e o par entra no relatório da passada como quebrado. Não se ajusta a taxa para caber no fator cadastrado — dividir por um fator que a fonte não usou é justamente o defeito.
Por que a recusa e não o silêncio: com quoteUnit = 100 num par alimentado pelo PTAX, o iene entra a 0,0357 e sai valendo 0,000357 — cem vezes menos, num número que ninguém acha implausível. E como a taxa é congelada no movimento (RN-CUR-11), o erro fica gravado e não se corrige por substituição de cotação: teria de virar movimento de ajuste, um por documento. É o tipo de defeito que só se conserta antes de acontecer. Caso de teste obrigatório do adaptador, ao lado do MM-DD-YYYY: uma moeda de quoteUnit histórico 100 capturada do PTAX tem de gravar 1, e um par cadastrado com 100 tem de recusar.
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 é resolvido uma vez no rateio da moeda original: maior fatia, desempate por menor order, conforme RN-CC-23. Cada fatia converte e arredonda separadamente; o total funcional é a soma das fatias convertidas, sem segundo ajuste para coincidir com a conversão isolada do total.
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.
Escopo
Multimoeda e a moeda da alçada
Revisto em 09/09/2026. Company.functionalCurrency aceita qualquer código ISO 4217 já na v1, como a spec de Empresa sempre disse. A restrição a BRL que esta página impunha saiu, e com ela o CURRENCY_FUNCTIONAL_NOT_SUPPORTED_UNPROCESSABLE. O que continua fora é a conversão cruzada — e é dela, não da moeda funcional, que a falta de fonte fala.
O que a restrição escondia era outro problema
Quatro páginas discordavam: Empresa aceitava qualquer ISO 4217, esta aqui recusava tudo que não fosse BRL, o Centro de Custo declarava BRL como única e o Fluxo escrevia a regra como se a multimoeda já valesse. Discordância dessa ordem não se resolve escolhendo o lado mais restritivo — ela existia porque o problema real nunca tinha sido escrito: com uma empresa em BRL e outra em USD, o limite do nó corporativo era comparado com um valor em outra moeda sem conversão declarada. Comparação silenciosa entre moedas, no ponto exato em que a alçada decide quem aprova. Fechar a moeda funcional adiava o defeito; escrever a conversão o elimina.
O limite de alçada tem moeda própria
CostCenterAssignment.approvalLimit ganha approvalLimitCurrency, obrigatória sempre que o limite estiver preenchido — inclusive no nó corporativo, que não tem empresa e por isso não tinha de onde herdar moeda. Deixou de ser gancho: sem a coluna, o número no nó corporativo é um valor sem unidade. A coluna é escrita pelo Centro de Custo; o que esta página define é o que a comparação faz com ela.
A comparação converte para a moeda do limite, e guarda a prova
O motor converte o valor da fatia para a moeda do limite — nunca o contrário, nunca "as duas para uma terceira" — usando a taxa congelada na submissão do documento, e grava no ApprovalEvent o valor comparado com a taxa e a data usadas. Quem lê a trilha seis meses depois refaz a conta; é a mesma exigência que o BudgetEntry já cumpre.
Sem par vigente, a submissão é recusada — nunca comparada em silêncio
Submeter documento cuja moeda não tenha par de conversão vigente contra a moeda do limite é recusa declarada, com APPROVAL_LIMIT_CURRENCY_NOT_CONVERTIBLE_UNPROCESSABLE 422, e a mensagem nomeia o par que falta. É a mesma decisão da RN-CUR-06 (não se inverte taxa) e da janela de maxRateAgeDays: onde não há número defensável, o produto recusa em vez de produzir um plausível.
O que continua fora é a conversão cruzada
O modelo já suporta qualquer par: CurrencyPair não sabe o que é real. O que não existe é fonte para o par que não passa pelo real. O boletim do Banco Central publica moeda contra real, e mais nada. Uma subsidiária que orce em dólar e compre em euro precisa 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
Enquanto não houver, a empresa em moeda estrangeira funciona com os pares que existem — cada par que ela precisar entra por cadastro, com fonte própria quando não vier do PTAX, e a submissão sem par é recusada em vez de adivinhada. Quando a triangulação for pedida: 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. Qualquer ISO 4217 desde 09/09/2026; o que limita é a existência do par, não o valor do campo. |
Budget | Budget.currency copia e congela a moeda funcional na criação do exercício. Cada BudgetEntry guarda amountDocument, currencyDocument, exchangeRate, quoteUnit, rateType e rateDate, além do valor convertido. quoteUnit entrou nesta lista em 09/09/2026: a regra "o consumidor congela o resultado inteiro" já o exigia, e o razão o omitia — taxa sem fator não reconstitui a conta em moeda cotada por 100. O mesmo vale para o ApprovalEvent, que guarda a prova da comparação de alçada. 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 submissão — não na emissão, não no empenho —, congela no documento e o movimento de Obligation herda a taxa do documento. 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 do limite (approvalLimitCurrency), com a taxa já congelada no documento, e o ApprovalEvent guarda a taxa e a data usadas. Um documento não pode mudar de faixa de alçada porque o dólar mexeu entre a submissão e a aprovação — nem ser comparado com um limite em outra moeda sem que a conversão apareça. |
A regra que resume a tabela, e que é a única do produto desde 09/09/2026: converte-se uma vez, na submissão do documento — que é o evento em que ele passa a valer um número —, e o resultado vira dado do documento. Não se reconverte na aprovação nem no empenho: o Commitment consome a taxa que a submissão congelou, senão a verba consumiria um valor diferente do que foi aprovado. O razão orçamentário herda, não resolve: todo movimento originado por documento copia exchangeRate e rateDate do documento — e o movimento de liquidação copia da entrada que liquida —, de modo que a resolução própria de taxa do Orçamento vale só para movimento sem documento de origem, Manual e Import. Até 08/09 três páginas davam três respostas — "na submissão", "no evento do compromisso" e "no fim da cadeia" —, e o Commitment é gravado dias depois da submissão: eram três taxas diferentes para o mesmo documento. Cada documento seguinte da cadeia converte na sua própria data de submissão. 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. Para o tenant a tela é leitura; editar o par é ato de plataforma. |
| 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 |
| PATCH /tenant-currencies/{code}/activate | manage:currencies | TenantCurrencyDto | 404 · 409 · 422 |
| PATCH /tenant-currencies/{code}/deactivate | manage:currencies | TenantCurrencyDto — recusa com moeda em uso | 404 · 409 · 422 |
| GET /currency-pairs | read:exchange-rates | array de CurrencyPairDto com a saúde da captura | 400 |
| PUT /currency-pairs/{id} | manage:currency-pairs | CurrencyPairDto — quoteUnit, provedor, defaultRateType, alsoFetchRateTypes, isActive. autoFetch não vai no corpo — é derivado. Permissão de plataforma: o par é global e a edição vale para todos os tenants | 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 codificado obrigatório (ExchangeRateSupersede) | 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 /convert:currency | 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 /convert:currency é 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_UNPROCESSABLE | 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_UNPROCESSABLE da spec de Orçamento — 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_UNPROCESSABLE | 422 | Moeda válida no catálogo, não ativa neste tenant. |
| CURRENCY_IN_USE_CONFLICT | 409 | Desativar moeda com documento em aberto. A mensagem traz a contagem por tipo. |
| APPROVAL_LIMIT_CURRENCY_NOT_CONVERTIBLE_UNPROCESSABLE | 422 | Novo em 09/09/2026. Submissão de documento cuja moeda não tem par de conversão vigente contra a moeda do limite de alçada. A mensagem nomeia o par que falta. Entra no lugar de CURRENCY_FUNCTIONAL_NOT_SUPPORTED_UNPROCESSABLE, que recusava moeda funcional diferente de BRL — restrição que saiu na mesma data. |
| CURRENCY_PAIR_NOT_CONFIGURED_UNPROCESSABLE | 422 | Conversão pedida num sentido sem par cadastrado. Não se inverte taxa. |
| CURRENCY_PAIR_INACTIVE_UNPROCESSABLE | 422 | Par existe e está desativado. |
| EXCHANGE_RATE_ALREADY_EXISTS | 409 | Já existe cotação vigente para par, tipo e data. O caminho é substituir, com motivo. |
| EXCHANGE_RATE_IMMUTABLE_CONFLICT | 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-rates, ou com allowContractedOverride desligado. |
| EXCHANGE_RATE_INVALID | 400 | Taxa não positiva, quoteUnit não positivo, data futura, ou moeda fora do catálogo. |
| EXCHANGE_RATE_QUOTE_UNIT_MISMATCH_UNPROCESSABLE | 422 | Novo em 09/09/2026. O quoteUnit devolvido pelo provedor difere do cadastrado no par. A gravação é recusada e o par entra no relatório da passada — a taxa não é ajustada para caber no fator cadastrado. |
| CURRENCY_PROVIDER_UNAVAILABLE | 503 | Só na busca manual. A rotina agendada nunca devolve erro HTTP — ela registra, conta a falha e notifica. O 503 depende do décimo sufixo — _UNAVAILABLE é extensão pedida ao Core e ainda não vigente, então até ela entrar este código cai no default e responde 400. A precedência que a extensão precisa carregar está escrita na página do GrydFiles. |
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. Ato com efeito colateral — garante o par e dispara captura; o par em si não é editado por aqui. |
| manage:currency-pairs | Plataforma — nenhum papel de tenant | Editar par: quoteUnit, provedor, tipos capturados, isActive. O par é global e a edição alcança todos os tenants; é a permissão de plataforma que o GrydFiles já usa para expurgo e legal hold. |
| 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-rates | 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.
Generalizada em 09/09/2026. BRL nasce ativa em todo tenant, e a desativação de qualquer moeda é recusada enquanto ela for a functionalCurrency de alguma empresa: sem ela o tenant fica sem destino de conversão. Até 08/09 a regra era escrita só para BRL, porque a moeda funcional era obrigatoriamente BRL — e não é mais.
Recusada com CURRENCY_IN_USE_CONFLICT 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 derivado da união das ativações — e ligado a partir desta —, 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. supersedeReasonCodeId obrigatório — motivo codificado, uso ExchangeRateSupersede. 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 do rateio é apurado ali, na moeda do documento, pela RN-CC-23 — maior fatia, empate pelo menor order —, que é a regra única do produto e não se reescreve aqui. Depois da conversão não há um segundo resíduo: o valor do documento na moeda funcional é, por definição, a soma das fatias convertidas, nunca o total convertido. Converter o total daria outro número, e o número que vale é o que o razão orçamentário guarda — e o razão guarda fatias.
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.
Prévia de conversão é informativa. Submissão da revisão resolve e congela taxas; aprovação e lançamentos dela herdam esse snapshot, sem resolver novamente pela data corrente.
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.
autoFetch é derivado e a edição é de plataformaautoFetch é a união das ativações dos tenants — liga com a primeira, desliga com a última, nunca aceita escrita. quoteUnit, provedor, tipos capturados e isActive só mudam com manage:currency-pairs, que nenhum papel de tenant recebe, porque a mudança alcança todos os tenants de uma vez.
quoteUnit é da fonte, e a divergência é recusadaNova em 09/09/2026. O RateQuote devolve quoteUnit; a rotina compara com o do par e recusa a gravação quando diferem, sem ajustar a taxa. Par alimentado pelo BCB_PTAX tem quoteUnit = 1 em toda moeda, porque é assim que o boletim publica. Teste obrigatório do adaptador.
Cada revisão submetida congela taxa, quoteUnit, tipo e data. Lançamentos herdam a origem; liquidação usa a imputação vigente da parcela liquidada. Novo fato/documento tem snapshot próprio. Alteração de pedido aplica delta mantendo a versão anterior até ser efetivada.
Nova em 09/09/2026. approvalLimitCurrency é obrigatória com o limite preenchido, nó corporativo incluído. O valor da fatia é convertido para a moeda do limite com a taxa congelada na submissão, e o ApprovalEvent grava o valor comparado com a taxa e a data. Sem par vigente entre as duas moedas, a submissão é recusada com APPROVAL_LIMIT_CURRENCY_NOT_CONVERTIBLE_UNPROCESSABLE — nunca comparada em silêncio.
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ê |
|---|---|
| Conversão cruzada e triangulação | O boletim do Banco Central publica moeda contra real e nada mais; par que não passe pelo real precisa de fonte própria cadastrada. Não confundir com moeda funcional, que desde 09/09/2026 aceita qualquer ISO 4217. 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. A lista de dias não úteis do fluxo de aprovação não é esse calendário — é do escritório do tenant, não do boletim do BCB, e nenhuma rotina daqui a lê. Anotado em v1.4 |
| 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ê. |
Contrato vigente · revisão consolidada
Contrato monetário compartilhado
Valores monetários totalizados usam numeric(21,4); o domínio valida zero a quatro casas conforme Currency.minorUnits e não aceita moeda cuja precisão não possa representar. Preço unitário permanece numeric(19,10). Conversão usa amount × rate ÷ quoteUnit em precisão ampliada; arredondamento HALF_UP só no destino canônico.
Exemplos: 1,005 em BRL → 1,01; 100,5 em JPY → 101; 1,2345 em KWD → 1,235. Ratear BRL 0,05 em 50/50 produz 0,02 e 0,03: ambos arredondam inicialmente a 0,03 e o resíduo −0,01 vai à fatia de menor order. Converter cada fatia por 1,5 produz 0,03 e 0,05: total funcional 0,08, sem novo ajuste para coincidir com a conversão do total.
Alçada, materialidade e tolerância absoluta declaram suas próprias moedas. Cada revisão submetida congela as taxas utilizadas. Pedido efetivo mantém seu snapshot; novo documento de recebimento/nota pode ter outra taxa. Variação cambial ajusta Actual vinculado, sem reabrir artificialmente a cotação histórica ou criar verba.