Especificação · Serviço Transversal

Moeda e Câmbio

Estas duas entidades nasceram dentro da spec de Orçamento porque foi ali que a conversão ganhou consequência pela primeira vez. Mas Currency já é referenciada pelo Catálogo (referencePriceCurrency, ItemSupplier.priceAmount), pelo Fornecedor (SupplierEstablishment.currency) e pela Empresa (functionalCurrency) — e vai ser pela Cotação, pelo Pedido e pela Nota. Este documento tira o câmbio de dentro do orçamento e o coloca onde ele pertence: um cadastro datado, com uma regra de resolução determinística e um serviço de conversão que devolve a taxa junto com o número.

depende de Company (moeda funcional) consumido por Budget · Item · Supplier · Quotation · PurchaseOrder · Invoice 4 entidades · 1 rotina · 1 serviço de domínio v1.8 · 09/09/2026

Ponto de partida

Oito decisões

Decisão 1 · Catálogo

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.

Decisão 2 · Pares

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.

Decisão 3 · Resolução

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.

Decisão 4 · Ausência

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.

Decisão 5 · Sentido

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ó.

Decisão 6 · Unidade

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.

Decisão 7 · Imutabilidade

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.

Decisão 8 · Fonte

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

Currency catálogo do produto · sem tenant ISO 4217. Código, nome, símbolo e minorUnits. Semeada e curada pelo produto; o tenant não cria nem edita. É a lista de onde tudo o mais escolhe.
TenantCurrency N por tenant · a ativação Quais moedas este cliente usa. Dá dono ao "deve estar entre as moedas ativas do tenant" que o Catálogo já exige. BRL nasce ativa e não é desativável na v1.
CurrencyPair global · o que a rotina busca Sentido, 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.
ExchangeRate append-only · a cotação Par, tipo, data, taxa em oito casas e rastro de origem. Oficial (tenantId nulo, compartilhada) ou própria do cliente. Corrigir é substituir, nunca editar.
CurrencySettings 1 por tenant Tipo padrão, janela máxima de idade da cotação, horários da rotina e destinatários do alerta. É onde mora o defaultRateType que a spec de Orçamento citava como BudgetSettings sem nunca definir.
Company.functionalCurrency campo existente · outro documento A moeda em que a pessoa jurídica orça e presta contas. Vive na spec de Empresa. É sempre o destino da conversão, e aceita qualquer ISO 4217 já na v1 (09/09/2026).

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.

Semente: BRL, USD, EUR, GBP, CHF, JPY, CNY, ARS, CLP, UYU, PYG, MXN, CAD, AUD, SEK — os que o boletim de fechamento do Banco Central publica e que aparecem em compra internacional de empresa brasileira.
CampoTipoRegra
codechar(3)Chave primária. Código alfabético ISO 4217, maiúsculo. Imutável.
numericCodechar(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 · symbolvarchar(60) · varchar(6)Exibição. O símbolo é opcional e nunca participa de cálculo nem de arquivo.
minorUnitssmallintCasas 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.
bcbSymbolchar(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.
isActiveboolCuradoria 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.

CampoTipoRegra
tenantId · currencyCodeuuid · char(3)Chave composta. Único por tenant.
isEnabledboolAparece 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 · enabledBytimestamptz · uuidRastro. 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.

CampoTipoRegra
fromCurrency · toCurrencychar(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.
quoteUnitintQuantas 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.
providerCodevarchar(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.
defaultRateTypeenumTipo 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.
alsoFetchRateTypesenum[]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 · isActivebool · boolautoFetch é 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 · lastFailureAttimestamptz?Saúde da captura. É o que a tela de diagnóstico mostra e o que responde "desde quando isto está quebrado".
consecutiveFailuresintZerado 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.

Índice único parcial em (tenantId, fromCurrency, toCurrency, rateType, rateDate) WHERE supersededById IS NULL — com tenantId nulo tratado como valor, via COALESCE ou coluna gerada. Índice de leitura em (fromCurrency, toCurrency, rateType, rateDate DESC), que é exatamente o formato da varredura para trás na janela.
CampoTipoRegra
tenantIduuid?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 · toCurrencychar(3) · char(3)Sempre o sentido de uso. Referencia um CurrencyPair ativo.
rateTypeenumPtaxVenda | PtaxCompra | Commercial | Contracted | Manual. Faz parte da chave: duas taxas do mesmo dia com tipos diferentes são dois fatos, não um conflito.
rateDatedateA data a que a cotação se refere — a data do boletim, não a da captura.
ratenumeric(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.
quoteUnitintCopiado 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 · sourceReferencevarchar(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 · capturedBytimestamptz · uuid?Quando entrou e por quem. Nulo em capturedBy quando a origem é a rotina.
supersededById · supersededAt · supersedeReasonCodeIduuid? · 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.

CampoTipoPadrãoRegra
defaultRateTypeenumPtaxVendaTipo usado quando o chamador não pede um. Mudar não reinterpreta nada do passado.
maxRateAgeDayssmallint5Janela de tolerância para trás, em dias corridos. Faixa 0–15. Zero significa "exige a data exata".
warnRateAgeDayssmallint1A 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.
fetchTimesLocaltime[]14:00, 18:00, 22:00Horá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.
notifyOnFailureAftersmallint1Falhas 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.
alertRecipientsuuid[]—Quem recebe. Vazio cai no papel de administrador do tenant.
allowContractedOverrideboolfalseLiga 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.

PassoO que fazPor quê
0 · OverrideSe 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 · TiporateType = 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 · TenantBusca (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 · OficialBusca (tenantId IS NULL, par, tipo, data) não substituída.O boletim do Banco Central, o caminho comum.
4 · JanelaRecua 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 · RecusaEXCHANGE_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ínioO que faz com moeda e câmbio
CompanyFornece 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.
BudgetBudget.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.
ItemreferencePriceCurrency 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 · SupplierEstablishmentGuardam 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 · RFQO 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.
PurchaseOrderConverte 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.
ReceiptNão converte nada de novo: reusa a taxa congelada no pedido para a parcela recebida. Recebimento é quantidade, não preço.
Invoice · three-way matchO 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.
ContractContrato 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çãoAlç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

TelaO que tem
Administração › MoedasLista 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çõesGrade 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 capturaUma 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çãoCadastro 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 estrangeiraO 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

RotaPermissãoDevolveErros
GET /currenciesread:currenciesPagedResult<CurrencyDto> — o catálogo400
GET /tenant-currenciesread:currenciesarray de TenantCurrencyDto400
PATCH /tenant-currencies/{code}/activatemanage:currenciesTenantCurrencyDto404 · 409 · 422
PATCH /tenant-currencies/{code}/deactivatemanage:currenciesTenantCurrencyDto — recusa com moeda em uso404 · 409 · 422
GET /currency-pairsread:exchange-ratesarray de CurrencyPairDto com a saúde da captura400
PUT /currency-pairs/{id}manage:currency-pairsCurrencyPairDto — 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 tenants404 · 422
GET /exchange-ratesread:exchange-ratesPagedResult<ExchangeRateDto>, filtrável por par, tipo e período400
POST /exchange-ratesmanage:exchange-ratesExchangeRateDto — manual ou contratada400 · 409 · 422
POST /exchange-rates/{id}:supersedemanage:exchange-ratesExchangeRateDto — a nova, com a antiga marcada. Motivo codificado obrigatório (ExchangeRateSupersede)404 · 409 · 422
POST /exchange-rates:fetchmanage:exchange-ratesFetchReportDto — por par: capturadas, sem boletim, falhas400 · 422 · 503
GET /exchange-rates:resolveread:exchange-ratesResolvedRateDto — a taxa que seria usada, com o passo que a achou400 · 422
POST /convert:currencyread:exchange-ratesConversionResultDto — pré-visualização para a tela400 · 422
GET /currency-settingsread:currenciesCurrencySettingsDto404
PUT /currency-settingsmanage:currency-settingsCurrencySettingsDto400 · 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ódigoHTTPQuando
EXCHANGE_RATE_MISSING_UNPROCESSABLE422Nenhuma 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_UNPROCESSABLE422Moeda válida no catálogo, não ativa neste tenant.
CURRENCY_IN_USE_CONFLICT409Desativar moeda com documento em aberto. A mensagem traz a contagem por tipo.
APPROVAL_LIMIT_CURRENCY_NOT_CONVERTIBLE_UNPROCESSABLE422Novo 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_UNPROCESSABLE422Conversão pedida num sentido sem par cadastrado. Não se inverte taxa.
CURRENCY_PAIR_INACTIVE_UNPROCESSABLE422Par existe e está desativado.
EXCHANGE_RATE_ALREADY_EXISTS409Já existe cotação vigente para par, tipo e data. O caminho é substituir, com motivo.
EXCHANGE_RATE_IMMUTABLE_CONFLICT409Tentativa de UPDATE ou DELETE. Recusado no banco, não na aplicação.
EXCHANGE_RATE_OVERRIDE_FORBIDDEN403Taxa informada no documento sem override:exchange-rates, ou com allowContractedOverride desligado.
EXCHANGE_RATE_INVALID400Taxa não positiva, quoteUnit não positivo, data futura, ou moeda fora do catálogo.
EXCHANGE_RATE_QUOTE_UNIT_MISMATCH_UNPROCESSABLE422Novo 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_UNAVAILABLE503Só 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ãoSemeada emObservação
read:currenciesTodos os papéis operacionaisLer catálogo, ativas e configurações. Sem ela nenhum combo de moeda carrega.
manage:currenciesAdministradorAtivar e desativar moeda. Ato com efeito colateral — garante o par e dispara captura; o par em si não é editado por aqui.
manage:currency-pairsPlataforma — nenhum papel de tenantEditar 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-ratesControladoria, CompradorConsultar cotações, pares e pré-visualizar conversão.
manage:exchange-ratesControladoriaCadastrar taxa manual ou contratada, substituir cotação, disparar busca.
override:exchange-ratesNinguém, por padrãoInformar taxa dentro de um documento. Registra autor e referência, e a marca fica visível no extrato.
manage:currency-settingsAdministradorTipo 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

RN-CUR-01O catálogo é do produto

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.

RN-CUR-02Todo campo de moeda valida contra as ativas

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.

RN-CUR-03Moeda funcional de empresa ativa não é desativável

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.

RN-CUR-04Desativação não apaga nem invalida o passado

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.

RN-CUR-05Ativar moeda garante o par, na mesma transação

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.

RN-CUR-06Não se inverte taxa

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.

RN-CUR-07A resolução nunca troca de rateType

O tipo é fixado no passo 1 e vale até a recusa. Não existe plano B por tipo — só por data, dentro da janela.

RN-CUR-08Taxa do tenant vence a oficial

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.

RN-CUR-09A janela é para trás, em dias corridos, e é visível

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.

RN-CUR-10Cotação é imutável; corrigir é substituir

UPDATE e DELETE recusados por grant e trigger. supersedeReasonCodeId obrigatório — motivo codificado, uso ExchangeRateSupersede. A resolução só enxerga linha com supersededById nulo.

RN-CUR-11Substituir cotação não mexe em movimento gravado

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.

RN-CUR-12Um único arredondamento, nas casas do destino

amount × rate ÷ quoteUnit em precisão cheia, arredondado uma vez para Currency.minorUnits, HALF_UP. Nunca se assume duas casas.

RN-CUR-13Rateio antes da conversão, fatia a fatia

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.

RN-CUR-14Moeda igual não consulta a tabela

from == to é passagem direta com rate = 1. Falha de câmbio nunca alcança o cliente que só compra no Brasil.

RN-CUR-15A captura é idempotente e dia sem boletim não é falha

Colisão no índice único descarta sem efeito. value: [] registra "sem boletim" e não incrementa consecutiveFailures.

RN-CUR-16Falha de captura notifica, nunca silencia

A partir de notifyOnFailureAfter falhas consecutivas de um par, notificação para alertRecipients. A tela de pares mostra desde quando está quebrado.

RN-CUR-17Confronto entre documentos é na moeda de origem

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.

RN-CUR-18Consulta não autoriza submissão

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.

RN-CUR-19Auditoria completa em cotação e ativaçã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.

RN-CUR-20O par é global: autoFetch é derivado e a edição é de plataforma

autoFetch é 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.

RN-CUR-21O quoteUnit é da fonte, e a divergência é recusada

Nova 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.

RN-CUR-22Conversão pertence à revisão e ao fato

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.

RN-CUR-23Alçada compara na moeda do limite, com a prova no evento

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

NexioSAPOracleDynamics 365
CurrencyTCURC + TCURX (casas decimais)CurrenciesCurrencies
ExchangeRateTCURR, por tipo e dataGL Daily RatesExchange rates, por par e data inicial
rateTypeTCURV — M média, B/G compra e venda, P planejamentoCorporate | Spot | UserExchange rate types
quoteUnitTCURF, o fator de conversãoConversion factorUnidade de cotação no par
Não implementado · ganchoMoeda de referência e triangulaçãoCross ratesMoeda âncora
Fora de escopoSegunda e terceira moeda, reavaliação (FAGL_FC_VAL)Revaluation, reporting currencyRevaluation, 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 quePor quê
Conversão cruzada e triangulaçãoO 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 taxaCompra e venda não são recíprocas. Quem precisar do sentido inverso cadastra o par com fonte própria.
Reavaliação cambial periódicaMudaria 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çãoO 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 entidadeSó 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çãoA 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órioVer 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árioSubstituí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
CriptomoedaFora 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.