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.0 · 01/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. 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", com N = 1 no caso comum e 100 ou 1.000 para iene, guarani e peso chileno. É o TCURF do SAP e o fator de conversão do Oracle — os dois têm porque quem não tem acaba escondendo o fator dentro do número e perdendo casas.

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. Mantido automaticamente pela ativação de moeda. 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 na v1 só aceita BRL.

Convenção de nomes. O documento é escrito em português e todo identificador — entidade, atributo, valor de enum, rota, permissão e código de erro — é em inglês. As exceções são dados de negócio: os códigos ISO 4217 (BRL, USD, JPY) e os nomes dos boletins do Banco Central (Fechamento, Abertura), que são conteúdo e não código.

Campos

Currency · o catálogo do produto

Sem tenantId. Uma tabela de referência, semeada na migração e atualizada por versão do produto — do mesmo jeito que a tabela de NCM e a de natureza jurídica. O cliente que precisar de uma moeda que não está na lista abre chamado; o cliente que puder criar moeda vai criar US$, USD e DOLAR na mesma semana.

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

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.

Desativar é recusado enquanto houver documento em aberto

CURRENCY_IN_USE 409, com a contagem por tipo de documento na mensagem — "3 pedidos e 1 requisição em aberto em EUR". Documento fechado e histórico não impedem: eles continuam existindo, continuam legíveis e continuam com a taxa congelada. Impedir a desativação por causa de um pedido de 2024 seria transformar uma decisão de curadoria em impossibilidade permanente.

BRL é ativa por construção

Nasce ativa em todo tenant e a desativação é recusada na v1, porque é a moeda funcional obrigatória de toda empresa. Quando a moeda funcional deixar de ser só BRL, a regra vira "a moeda funcional de qualquer empresa do tenant não é desativável" — que é a mesma coisa, escrita de forma mais geral.

Campos

CurrencyPair · o que a rotina busca

Global, sem tenantId: a cotação oficial do Banco Central é a mesma para todo mundo e buscá-la uma vez por tenant seria pagar N vezes pelo mesmo número. A lista de pares é a união do que os tenants ativaram, e é pequena por natureza — dez a quinze linhas cobrem toda a base.

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; 100 para JPY e CLP, 1000 para PYG. Sem ele, ou a taxa perde casas significativas ou o fator vira convenção oral.
providerCodevarchar(30)BCB_PTAX na v1. Identifica o adaptador que sabe buscar este par.
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 · boolLigados pela ativação de moeda. Desligar autoFetch mantém o par válido para taxa manual — é como se cadastra um par que nenhum provedor cobre.
lastSuccessAt · 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_FOUND 422. Na prática o Nexio só precisa de um sentido: a empresa compra em moeda estrangeira e fecha em real.

Campos

ExchangeRate · a cotação

Uma linha por par, tipo e data. Duas origens convivem na mesma tabela: a oficial, capturada pela rotina e compartilhada por todos os tenants, e a própria do cliente — contratada, gerencial ou digitada. A do tenant vence a oficial, e a distinção é uma coluna, não um schema.

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 · supersedeReasonuuid? · timestamptz? · varchar(200)?Retificação. A linha antiga permanece e passa a ser invisível para a resolução. Motivo é obrigatório — substituir cotação é evento raro e sempre tem história.
Í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.

Não se edita cotação, substitui-se

UPDATE e DELETE recusados por grant e por trigger, mesma disciplina do razão orçamentário. Corrigir uma taxa é gravar a nova e apontar supersededById na antiga, na mesma transação. O motivo aparece na tela de cotações com a taxa que foi substituída ao lado.

E o que já foi convertido não muda. O BudgetEntry de ontem guarda exchangeRate, rateType e rateDate como valores, não como chave estrangeira para esta tabela. Substituir a cotação corrige o futuro; o passado é um fato registrado. Se a diferença for material, o caminho é um movimento de Adjustment com reasonCode próprio — visível, aprovado e rastreável — nunca uma reescrita silenciosa.

Taxa contratada é do documento, não da tabela

Compra com câmbio travado usa a taxa do contrato. O documento carrega exchangeRateOverride com rateType = Contracted, exige a permissão override:exchange_rate e registra quem informou e com que referência. É a única forma de a taxa não vir da resolução, e ela fica visível no extrato da verba, marcada. Opcionalmente a mesma taxa é persistida como ExchangeRate do tenant, para que outros documentos do mesmo contrato a encontrem sozinhos.

Campos

CurrencySettings · um por tenant

A spec de Orçamento cita BudgetSettings.defaultRateType e nunca define a entidade. O ajuste não é do orçamento — é do câmbio, e vale igual para o pedido e para a nota. Vive aqui.

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_rate 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 422, com par, tipo e o intervalo varrido na mensagem.O sistema nunca estima taxa, nunca troca de tipo e nunca reaproveita cotação de três semanas atrás sem dizer.

Dias corridos, não dias úteis — e o motivo é prático

Recuar por dia útil exigiria um calendário de feriado bancário mantido por alguém. Ninguém mantém, e feriado municipal não existe para o Banco Central. Cinco dias corridos cobrem o fim de semana comum, a segunda e a terça de carnaval e o Natal emendado ao fim de semana, que são os três casos reais. O preço é que uma rotina quebrada na sexta só é percebida na quarta — e é justamente por isso que a falha notifica na primeira ocorrência, em vez de esperar a janela estourar.

A idade da taxa é dado, não detalhe de implementação

O ConversionResult devolve rateDate e rateAgeDays, e ambos são gravados pelo consumidor. Acima de warnRateAgeDays a interface mostra "PTAX venda de 29/12 · 2 dias" ao lado do valor convertido. Esconder a idade é como esconder a taxa: o número fica indefensável na conversa com a controladoria.

Contraste deliberado com a resolução da verba orçamentária

Na BudgetLine, várias linhas podem cobrir a mesma fatia e vence a mais específica — dimensões aninhadas, empate impossível por construção de depth. Aqui a chave é exata e a cascata é por origem e por data. São problemas diferentes e é bom que as regras não se pareçam: quem lê as duas specs não deve tentar aplicar a intuição de uma na outra.

Integração

A rotina diária e o provedor

Uma interface de dois métodos, um adaptador implementado. IRateProvider expõe FetchDayAsync(pair, date) e FetchRangeAsync(pair, from, to), devolvendo RateQuote — tipo, taxa, data e referência de origem. O par escolhe o adaptador por providerCode. Na v1 existe BcbPtaxProvider e mais nada, e essa é a decisão: a abstração custa uma interface hoje e evita reescrever a rotina no primeiro cliente que exigir fonte própria.

O adaptador do Banco Central

Dados abertos, sem chave, sem autenticação, formato OData. O recurso é CotacaoMoedaDia e o boletim que interessa é o de Fechamento.

GET https://olinda.bcb.gov.br/olinda/servico/PTAX/versao/v1/odata/
    CotacaoMoedaDia(moeda=@moeda,dataCotacao=@dataCotacao)
    ?@moeda='USD'
    &@dataCotacao='12-29-2026'
    &$format=json
    &$select=cotacaoCompra,cotacaoVenda,dataHoraCotacao,tipoBoletim

A armadilha número um: a data vai em MM-DD-YYYY. Formato americano, numa API brasileira, sem validação de intervalo. O erro não estoura — em todo dia menor ou igual a 12 a chamada devolve alegremente o dia errado, e 03-04-2027 traz 4 de março quando você queria 3 de abril. Metade do ano o defeito é invisível. A formatação da data é ponto de teste unitário obrigatório do adaptador, com caso para dia ≤ 12.

Resposta vazia é ausência, não falha

Sábado, domingo e feriado bancário devolvem 200 com value: []. Isso não incrementa consecutiveFailures e não notifica ninguém: é a resposta correta para uma pergunta sobre um dia sem boletim. Falha é timeout, erro de rede, 5xx e resposta que não desserializa — essas contam, e são elas que alertam.

Um par por requisição, sequencial, com backoff

O serviço é público e não documenta limite de uso. Com dez a quinze pares, o custo total da passada é de segundos, e paralelizar contra um serviço gratuito de terceiro para economizar dois segundos é a forma mais rápida de ser bloqueado. Falha de par não interrompe a passada: os outros seguem e o relatório da execução lista o que deu certo e o que não deu.

Idempotente por construção

A gravação colide no índice único (tenantId, par, tipo, data) e é descartada sem efeito. Por isso as três execuções diárias são seguras, o botão "buscar agora" é seguro e reprocessar uma janela inteira é seguro. Uma taxa capturada só muda por substituição explícita, nunca por uma segunda passada da rotina.

Por que PtaxVenda de D−1 como padrão

O boletim de fechamento do próprio dia só sai no fim da tarde, e requisição aprovada às 10h não pode esperar. Venda porque é a taxa a que a empresa compra moeda estrangeira — que é o que uma ordem de compra representa. O boletim traz compra e venda na mesma resposta, então PtaxCompra é gravada junto de graça, para o relatório que precise da outra ponta.

Backfill na ativação, e nada de "primeiro dia sem histórico"

Ativar uma moeda enfileira FetchRangeAsync dos últimos 30 dias via CotacaoMoedaPeriodo — uma requisição só. Sem isso, o cliente ativa EUR na terça e a primeira nota com data de necessidade na semana anterior é recusada por falta de cotação, o que parece defeito e é só falta de histórico.

O que não usamos do boletim, e por quê

A resposta traz também paridadeCompra e paridadeVenda, que é a moeda contra o dólar, com a convenção invertida entre moedas tipo A (USD/moeda) e tipo B (moeda/USD) do recurso Moedas. A paridade é a matéria-prima da conversão cruzada — EUR→USD sem passar pelo real — e a v1 não faz conversão cruzada. Fica registrada como a fonte natural quando fizer, com a ressalva de que a decisão difícil não é obter a paridade, e sim qual ponta usar em cada perna da triangulação.

Contrato

O serviço de conversão

Um contrato de domínio interno, chamado pelos agregados dentro da própria transação. É o único ponto do sistema que converte dinheiro, e devolve sempre a prova junto com o número.

ConversionResult Convert(
    amount, fromCurrency, toCurrency, conversionDate,
    rateType?, tenantId, exchangeRateOverride?)

ConversionResult {
    amount        // convertido e arredondado
    rate, quoteUnit, rateType
    rateDate, rateAgeDays
    exchangeRateId?   // nulo quando veio de override
    isOverride
}

Um único arredondamento, nas casas da moeda de destino

amount × rate ÷ quoteUnit em precisão cheia, e um arredondamento no fim, para Currency.minorUnits do destino, HALF_UP. Arredondar no meio da conta — depois da multiplicação e antes da divisão pelo fator — é o defeito clássico de centavo, e ele só aparece nas moedas com quoteUnit diferente de 1, ou seja, tarde e em produção.

Converte-se a fatia, nunca o total

Quando a linha do documento é rateada entre centros de custo, o rateio acontece na moeda do documento e cada fatia converte separadamente. Converter o total e depois ratear dá uma soma diferente por um ou dois centavos, e a diferença aparece exatamente onde dói: a soma das linhas do extrato não bate com o total do documento.

O resíduo de arredondamento da conversão segue a mesma regra de resíduo do rateio já definida na spec de Centro de Custo — sobra para a fatia de maior percentual, com desempate por menor costCenterId. Mesma regra em dois lugares é uma regra; duas regras parecidas é uma armadilha.

Moeda igual é passagem direta, não conversão com taxa 1

from == to devolve o valor intacto, rate = 1, exchangeRateId nulo e rateAgeDays = 0, sem tocar na tabela. O caminho comum — documento em real, empresa em real — nunca depende de existir cotação de BRL→BRL, e nenhuma falha de câmbio pode alcançar o cliente que só compra no Brasil.

O consumidor congela o resultado inteiro

Não basta guardar o valor convertido, e não basta guardar o id da cotação. O documento grava valor de origem, moeda de origem, taxa, quoteUnit, tipo e data — como valores. É o que permite reconstruir a conta seis meses depois mesmo que a cotação tenha sido substituída, e é o que torna o extrato defensável.

Limite declarado

Por que só BRL como moeda funcional na v1

Company.functionalCurrency aceita apenas BRL na v1; qualquer outro valor é recusado com CURRENCY_FUNCTIONAL_NOT_SUPPORTED 422 e mensagem explícita — não é um campo que aparenta funcionar e produz número errado.

O motivo é a fonte, não a modelagem

O modelo já suporta qualquer par: CurrencyPair não sabe o que é real. O que não existe é fonte. O boletim do Banco Central publica moeda contra real, e mais nada. Uma subsidiária que orçasse em dólar e comprasse em euro precisaria de EUR→USD, que só sai de conversão cruzada — por dupla conversão via real, ou pela paridade do próprio boletim. As duas envolvem escolher qual ponta usar em cada perna, e as duas produzem números plausíveis quando a escolha está errada. Conversão cruzada errada é o defeito mais difícil de detectar que existe nesta área: ninguém desconfia de 5,43 quando o certo era 5,41.

O gancho, escrito

Quando houver demanda declarada: CurrencyPair ganha derivationMode (Direct | ViaAnchor | ViaParity) e anchorCurrency; a resolução ganha um passo 3.5 que compõe duas taxas e devolve isDerived com as duas pernas no ConversionResult; e a regra de qual ponta usar vira decisão documentada, não convenção de código. É o mesmo desenho de moeda de referência do SAP e de moeda âncora do Dynamics — a diferença é que eles precisam com dezenas de moedas funcionais, e nós ainda não temos a primeira.

Fronteiras

Quem consome e o que congela

DomínioO que faz com moeda e câmbio
CompanyFornece functionalCurrency, o destino de toda conversão. Só BRL na v1.
BudgetBudget.currency copia e congela a moeda funcional na criação do exercício. Cada BudgetEntry guarda amountDocument, currencyDocument, exchangeRate, rateType e rateDate, além do valor convertido. Um exercício não tem duas moedas.
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 emissão, congela no documento e no movimento de Obligation. O pedido inteiro tem uma moeda; o preço unitário e o total vivem nela.
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 funcional, com a taxa já congelada no documento. Um documento não pode mudar de faixa de alçada porque o dólar mexeu entre a submissão e a aprovação.

A regra que resume a tabela: converte-se uma vez, no evento que cria o compromisso, e o resultado vira dado do documento. Toda comparação entre dois documentos acontece na moeda de origem; toda comparação com verba e com alçada acontece na moeda funcional, com o número que já estava congelado. Nenhuma tela reconverte para exibir.

Interface

Telas

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.
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
PUT /tenant-currencies/{code}manage:currenciesTenantCurrencyDto — ativa ou desativa404 · 409 · 422
GET /currency-pairsread:exchange_ratesarray de CurrencyPairDto com a saúde da captura400
PUT /currency-pairs/{id}manage:currenciesCurrencyPairDto — quoteUnit, provedor, autoFetch404 · 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 obrigatório404 · 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 /currency:convertread:exchange_ratesConversionResultDto — pré-visualização para a tela400 · 422
GET /currency-settingsread:currenciesCurrencySettingsDto404
PUT /currency-settingsmanage:currency_settingsCurrencySettingsDto400 · 409 · 422

POST /currency:convert é pré-visualização e nunca autoriza gravação. Quem grava chama o serviço de domínio dentro da própria transação, resolvendo a taxa de novo — o mesmo princípio da consulta de saldo orçamentário, pela mesma razão: entre a tela e o submit passa tempo, e nesse intervalo a rotina pode ter gravado o boletim do dia.

Contrato

Erros

CódigoHTTPQuando
EXCHANGE_RATE_MISSING422Nenhuma cotação para o par e o tipo dentro da janela. A mensagem traz par, tipo, data pedida e o intervalo varrido. Substitui o BUDGET_EXCHANGE_RATE_MISSING da spec de Orçamento v1.0 — o fato é do câmbio e vai ter os mesmos consumidores no pedido e na nota; três códigos para o mesmo fato é ruído.
CURRENCY_NOT_ENABLED422Moeda válida no catálogo, não ativa neste tenant.
CURRENCY_IN_USE409Desativar moeda com documento em aberto. A mensagem traz a contagem por tipo.
CURRENCY_FUNCTIONAL_NOT_SUPPORTED422Moeda funcional diferente de BRL na v1.
CURRENCY_PAIR_NOT_FOUND422Conversão pedida num sentido sem par cadastrado. Não se inverte taxa.
CURRENCY_PAIR_INACTIVE422Par existe e está desativado.
EXCHANGE_RATE_DUPLICATE409Já existe cotação vigente para par, tipo e data. O caminho é substituir, com motivo.
EXCHANGE_RATE_IMMUTABLE409Tentativa de UPDATE ou DELETE. Recusado no banco, não na aplicação.
EXCHANGE_RATE_OVERRIDE_FORBIDDEN403Taxa informada no documento sem override:exchange_rate, ou com allowContractedOverride desligado.
EXCHANGE_RATE_INVALID400Taxa não positiva, quoteUnit não positivo, data futura, ou moeda fora do catálogo.
CURRENCY_PROVIDER_UNAVAILABLE503Só na busca manual. A rotina agendada nunca devolve erro HTTP — ela registra, conta a falha e notifica.

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, editar par. Ato com efeito colateral — cria par e dispara captura.
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_rateNingué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-03BRL nasce ativa e não é desativável

Em todo tenant. Enquanto a moeda funcional for obrigatoriamente BRL, desativá-la deixaria o tenant sem destino de conversão.

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

Recusada com CURRENCY_IN_USE enquanto houver documento em aberto na moeda. Histórico e documentos fechados permanecem legíveis, com a taxa que congelaram.

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

TenantCurrency + CurrencyPair (moeda→BRL, autoFetch ligado, provedor BCB_PTAX) + backfill de 30 dias enfileirado. O cadastro de pares não depende de ninguém lembrar de preenchê-lo.

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. supersedeReason obrigatório. 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 segue a regra de resíduo do rateio do Centro de Custo — maior percentual, desempate por menor id.

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-18A consulta não autoriza a gravação

POST /currency:convert é pré-visualização. Quem grava resolve a taxa de novo dentro da transação.

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.

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
rateTypeTCURVM 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ê
Moeda funcional diferente de BRLNão há fonte: o boletim do Banco Central publica moeda contra real e nada mais. O modelo suporta; a v1 recusa com erro nomeado em vez de produzir número plausível e errado.
Conversão cruzada e triangulaçãoConsequência da anterior. 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.
CriptomoedaFora da ISO 4217, sem boletim oficial, e nenhuma empresa compradora paga fornecedor assim. Se um dia acontecer, é provedor novo — a interface já prevê.