Ponto de partida
Oito decisões
Currency é catálogo do produto, não do tenant. ISO 4217 é uma lista fechada e mantida por terceiro. Deixar o cliente criar moeda produz dois USD com nomes diferentes e mata qualquer relatório que cruze tenants. O que o tenant faz é ativar — e é isso que TenantCurrency resolve, dando dono ao conceito de "moedas ativas do tenant" que o Catálogo já cita sem definir.
A rotina não busca moeda, busca par — e a lista de pares se mantém sozinha. Ativar EUR no tenant garante o par EUR→BRL com autoFetch ligado — e autoFetch é exatamente isso: a união das ativações, derivada, não um interruptor. Sem esse cadastro, a rotina diária ou varre as 150 moedas do boletim todo dia ou não sabe o que buscar. É a lacuna que faltava para a captura automática existir de fato.
Cascata determinística, sem empate e sem troca de tipo. Taxa do documento, e depois a tabela, no mesmo rateType e na mesma data — e o tipo decide a origem: PtaxVenda e PtaxCompra só existem como cotação oficial; Manual, Contracted e Commercial, só como taxa do tenant. Se não achar, anda para trás enquanto rateAgeDays ≤ maxRateAgeDays. Nunca troca de rateType como plano B — cair de PtaxVenda para PtaxCompra muda o número em ~0,3% e ninguém consegue explicar de onde veio.
Janela, não silêncio nem paralisia. Fim de semana, carnaval e Natal emendado não podem travar requisição, e rotina quebrada há três semanas não pode converter em silêncio. A saída é a janela de maxRateAgeDays (padrão 5 dias corridos) com a rateDate efetivamente usada gravada no movimento e exibida no extrato. Fora da janela, recusa.
Não se inverte taxa. O par é armazenado no sentido em que é usado — USD→BRL, porque a compra é em dólar e a empresa fecha em real. 1 ÷ cotação de venda não é a cotação de compra, e o relatório que faz isso não fecha por alguns décimos que ninguém rastreia. Conversão no domínio parte sempre da moeda do documento — para a moeda funcional, BRL na v1, ou para a moeda do limite de alçada —, um sentido só.
Existe quoteUnit. A cotação é "tantos reais por N unidades". N é dado da fonte, devolvido pelo provedor junto com a taxa e conferido contra o par — e no PTAX N é 1 para todas as moedas. Os fatores 100 e 1.000 que se vê em iene, guarani e peso chileno são de outras fontes. É o TCURF do SAP e o fator de conversão do Oracle — os dois têm porque quem não tem acaba escondendo o fator dentro do número e perdendo casas.
Taxa não é editada; é substituída. Retificação de boletim e erro de digitação criam linha nova que marca a anterior como superseded. A resolução só enxerga a vigente. E movimento já gravado não se mexe — a taxa congelada no BudgetEntry é um fato do passado, não uma referência à tabela.
Provedor plugável com um adaptador implementado. A interface IRateProvider nasce com BcbPtaxProvider e mais nada. O custo hoje é uma interface de dois métodos; o custo de não ter é reescrever a rotina no primeiro cliente que exigir fonte própria ou moeda fora do boletim do Banco Central.
Fundamento
Por que taxa é cadastro, e não uma chamada de API
O desenho ingênuo é buscar a cotação na hora de converter
A implementação óbvia é uma função ConvertAsync que chama o serviço do Banco Central no momento em que precisa do número. Ela funciona na demonstração e falha em produção por quatro motivos ao mesmo tempo: a aprovação de uma requisição passa a depender de um host externo estar de pé; o mesmo documento reprocessado converte com número diferente; ninguém consegue responder "que taxa você usou naquele dia" seis meses depois; e a latência de uma chamada externa entra no caminho de uma tela de digitação que precisa responder em milissegundos.
SAP, Oracle e Dynamics chegaram os três ao mesmo desenho: a taxa é uma linha de tabela com data e tipo, alimentada por rotina, e o documento guarda a taxa que usou. A chamada externa fica na rotina, longe do caminho crítico — e quando ela falha, o efeito é um alerta, não uma aprovação travada.
Converter é um ato datado, e o resultado carrega a prova
Nenhum consumidor recebe só o valor convertido. O serviço devolve um ConversionResult com o valor, a taxa, o rateType, a rateDate realmente usada, a idade em dias e o id da linha de cotação. O consumidor congela o conjunto inteiro, e é isso que permite o extrato dizer "US$ 10.000 × 5,2013 (PTAX venda de 29/12, 2 dias) = R$ 52.013,00" em vez de mostrar um número que ninguém sabe defender.
Reavaliação retroativa não existe
Se a taxa de ontem mudasse o consumo de hoje, o saldo orçamentário mudaria sozinho durante a madrugada e ninguém conseguiria explicar ao gestor por que a verba encolheu sem ninguém ter comprado nada. A variação cambial entre estágios não é corrigida: ela aparece, como consumo real, no momento da liquidação — e é atribuível por relatório.
O que esta spec deliberadamente não é. Não é tesouraria. Não há posição cambial, hedge, contrato de câmbio como entidade, conta de variação cambial, moeda de reporte para consolidação nem reavaliação de saldo em aberto. O Nexio compra; converter para saber quanto custou em real é necessário, e tudo além disso é do sistema financeiro.
Modelo
Mapa de entidades
minorUnits. Semeada e curada pelo produto; o tenant não cria nem edita. É a lista de onde tudo o mais escolhe.
BRL nasce ativa e não é desativável na v1.
quoteUnit, provedor, tipo padrão e autoFetch derivado. Mantido automaticamente pela ativação de moeda; editável só com permissão de plataforma. Guarda também a saúde da captura: último sucesso, última falha, falhas consecutivas.
tenantId nulo, compartilhada) ou própria do cliente. Corrigir é substituir, nunca editar.
defaultRateType que a spec de Orçamento citava como BudgetSettings sem nunca definir.
BRL na v1.
Convenção de nomes. O documento é escrito em português e todo identificador — entidade, atributo, valor de enum, rota, permissão e código de erro — é em inglês. As exceções são dados de negócio: os códigos ISO 4217 (BRL, USD, JPY) e os nomes dos boletins do Banco Central (Fechamento, Abertura), que são conteúdo e não código.
Campos
Currency · o catálogo do produto
Sem tenantId. Uma tabela de referência, semeada na migração e atualizada por versão do produto — do mesmo jeito que a tabela de NCM e a de natureza jurídica. O cliente que precisar de uma moeda que não está na lista abre chamado; o cliente que puder criar moeda vai criar US$, USD e DOLAR na mesma semana.
| Campo | Tipo | Regra |
|---|---|---|
code | char(3) | Chave primária. Código alfabético ISO 4217, maiúsculo. Imutável. |
numericCode | char(3) | Código numérico ISO 4217 (986 para BRL, 840 para USD). Vem com o catálogo ISO e custa uma coluna; nenhuma regra do Nexio o lê. Não é o código da declaração de importação, que usa a tabela de moedas do Banco Central (USD = 220), e a NF-e não tem campo de moeda. |
name · symbol | varchar(60) · varchar(6) | Exibição. O símbolo é opcional e nunca participa de cálculo nem de arquivo. |
minorUnits | smallint | Casas decimais. Não é sempre 2. Iene, guarani e peso chileno têm zero; o dinar tunisiano tem três. Todo arredondamento consulta este campo em vez de assumir centavos. Faixa 0–4. |
bcbSymbol | char(3)? | Símbolo da moeda no boletim do Banco Central, quando existe. Coincide com o ISO na esmagadora maioria dos casos, e o campo existe para que a divergência seja dado e não exceção no código. |
isActive | bool | Curadoria do produto. Falso esconde a moeda de toda tela de ativação, sem afetar dado histórico. |
Campos
TenantCurrency · a ativação
A spec de Catálogo já valida referencePriceCurrency contra "as moedas ativas do tenant" e a de Fornecedor grava currency no estabelecimento. Nenhuma das duas define onde essa lista mora. É aqui. Sem essa entidade o combo de moeda oferece 180 opções e o comprador escolhe XOF por engano num pedido de R$ 800.
| Campo | Tipo | Regra |
|---|---|---|
tenantId · currencyCode | Guid · char(3) | Chave composta. Único por tenant. |
isEnabled | bool | Aparece nos combos e é aceita nos campos de moeda. Desativar nunca apaga nem invalida o passado — impede uso novo e mantém tudo o que já existe legível. |
enabledAt · enabledBy | instante (UTC) · Guid | Rastro. Ativar moeda é ato administrativo com consequência: cria par, dispara backfill e liga a rotina diária. |
Ativar tem três efeitos, e é isso que faz a captura automática funcionar
Ativar EUR num tenant, na mesma transação: garante TenantCurrency; garante o CurrencyPair EUR→BRL global, com autoFetch ligado e provedor BCB_PTAX; e enfileira um backfill dos últimos 30 dias. O cadastro de pares não é uma tela que alguém precisa lembrar de preencher — ele é consequência de um ato que a pessoa já ia praticar de qualquer jeito. A tela de pares existe para curadoria e diagnóstico, não para o caminho comum — e a curadoria é da plataforma, porque o par é de todos.
Desativar é recusado enquanto houver documento em aberto
CURRENCY_IN_USE_CONFLICT 409, com a contagem por tipo de documento na mensagem — "3 pedidos e 1 requisição em aberto em EUR". Documento fechado e histórico não impedem: eles continuam existindo, continuam legíveis e continuam com a taxa congelada. Impedir a desativação por causa de um pedido de 2024 seria transformar uma decisão de curadoria em impossibilidade permanente.
BRL é ativa por construção
Nasce ativa em todo tenant e não é desativável: na v1 é a moeda funcional de toda empresa e o destino de toda conversão de verba. A regra fica escrita de forma geral — moeda funcional de empresa ativa não se desativa (RN-CUR-03) — porque o campo functionalCurrency continua existindo para quando a v2 abrir outras moedas funcionais.
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.
| Campo | Tipo | Regra |
|---|---|---|
fromCurrency · toCurrency | char(3) · char(3) | O sentido em que a taxa é usada. USD→BRL significa "quantos reais vale quoteUnit dólares". Único por par. O par inverso é outro par, não uma leitura invertida deste. |
quoteUnit | int | Quantas unidades da moeda de origem a taxa cota. Padrão 1. É propriedade da fonte, não da moeda: 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. A rotina confere contra o que o provedor devolve e recusa a divergência. Sem o campo, o fator viraria convenção oral; com ele errado, vira número errado gravado. |
providerCode | varchar(30)? | BCB_PTAX na v1. Identifica o adaptador que sabe buscar este par. Nulo é par sem provedor: fica fora da rotina e aceita só taxa manual — é como se cadastra um par que nenhum provedor cobre. |
defaultRateType | enum | Tipo que a rotina captura para este par. Padrão PtaxVenda — é a taxa a que a empresa compra moeda estrangeira, e é o que o custo da compra representa. |
alsoFetchRateTypes | enum[] | Tipos adicionais capturados na mesma passada, sem custo de requisição extra — o boletim traz compra e venda juntos. Padrão [PtaxCompra], para o relatório que precise da outra ponta. |
autoFetch · isActive | bool · bool | autoFetch é derivado, nunca editado: verdadeiro enquanto o par tiver provedor e ao menos um tenant com a moeda de origem ativa — a união das ativações —, recalculado na ativação e na desativação de moeda. isActive é curadoria de plataforma: falso tira o par da rotina e da resolução para todos os tenants, e por isso só se desliga com permissão de plataforma. |
lastSuccessAt · lastFailureAt | instante (UTC)? | Saúde da captura. É o que a tela de diagnóstico mostra e o que responde "desde quando isto está quebrado". |
consecutiveFailures | int | Zerado a cada sucesso. Ao cruzar CurrencySettings.notifyOnFailureAfter, dispara notificação. Dia sem boletim não conta como falha — sábado não é erro. |
Por que não se inverte taxa. A tentação é cadastrar USD→BRL e resolver BRL→USD com 1 ÷ rate. Não funciona: a cotação de compra e a de venda existem justamente porque as duas pontas não são simétricas, e 1 ÷ 5,2013 não é a cotação de compra do real em dólar — é um número sem contraparte no mundo. Some-se o arredondamento em oito casas e o relatório passa a fechar com diferença de alguns décimos que ninguém consegue rastrear. Pedir conversão num sentido sem par cadastrado devolve CURRENCY_PAIR_NOT_CONFIGURED_UNPROCESSABLE 422. Na prática o Nexio só precisa de um sentido: a empresa compra em moeda estrangeira e fecha em real.
Campos
ExchangeRate · a cotação
Uma linha por par, tipo e data. Duas origens convivem na mesma tabela, separadas pelo tipo: a oficial, capturada pela rotina e compartilhada por todos os tenants, só em PtaxVenda e PtaxCompra; e a própria do cliente — digitada, contratada ou comercial —, só em Manual, Contracted e Commercial. Como as duas nunca têm o mesmo tipo, a taxa do tenant não sombreia a oficial, e nenhum tenant grava, substitui ou esconde uma linha que outro tenant lê (RN-CUR-24).
| Campo | Tipo | Regra |
|---|---|---|
tenantId | Guid? | 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. Linha oficial é gravada só pela rotina, como serviço de sistema, e substituída só com permissão de plataforma. |
fromCurrency · toCurrency | char(3) · char(3) | Sempre o sentido de uso. Referencia um CurrencyPair ativo. |
rateType | enum | PtaxVenda | PtaxCompra | Commercial | Contracted | Manual. Faz parte da chave: duas taxas do mesmo dia com tipos diferentes são dois fatos, não um conflito. O tipo decide a origem: PtaxVenda e PtaxCompra exigem tenantId nulo; os outros três exigem tenantId preenchido. |
rateDate | date | A data a que a cotação se refere — a data do boletim, não a da captura. |
rate | numeric(18,8) | Quantas unidades de toCurrency valem quoteUnit unidades de fromCurrency. Oito casas porque o boletim publica quatro e a conta de conversão multiplica antes de arredondar. |
quoteUnit | int | Copiado do par no instante da gravação e congelado aqui. A cotação precisa ser autossuficiente: mudar o fator no par não pode reinterpretar as taxas já gravadas. |
source · sourceReference | varchar(30) · varchar(120) | BCB_PTAX / MANUAL / CONTRACT, e a referência dentro da origem — o tipoBoletim e o dataHoraCotacao do Banco Central, ou o número do contrato de câmbio. |
capturedAt · capturedBy | instante (UTC) · Guid? | Quando entrou e por quem. Nulo em capturedBy quando a origem é a rotina. |
supersededById · supersededAt · supersedeReasonCodeId | Guid? · instante (UTC)? · Guid? | 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. É motivo codificado, uso ExchangeRateSupersede, com supersedeReasonCode, supersedeReasonLabel e o complemento em texto. Ver Motivo. |
Não se edita cotação, substitui-se
Requisito de imutabilidade: a linha não é alterada nem excluída, mesma disciplina do razão orçamentário. Revogar alteração e exclusão do usuário da aplicação no banco, ou um gatilho de recusa, são exemplos de implementação; o equivalente é o repositório não oferecer essas operações, com teste que prove. 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, quoteUnit, 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 o documento precisar refletir a taxa corrigida, o caminho é estorno e relançamento no próprio estágio, com entryReason = RateCorrection, citando a substituição que o motivou — o motivo é o supersedeReasonCode dela, sem código novo. Nunca Adjustment: Adjustment muda a verba e só nasce de alteração de orçamento aprovada, importação ou virada, e usá-lo para taxa criaria verba. A mecânica do razão é do Orçamento.
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 update: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. A taxa do documento nunca vira ExchangeRate do tenant: taxa que outros documentos encontram sozinhos só nasce por POST /exchange-rates, ato de controle (RN-CUR-26). Tipo diferente do configurado para o processo também só entra pelo exchangeRateOverride, sem taxa informada. E documento com override leva a controladoria à aprovação (RN-CUR-27).
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. defaultRateType e processRateTypes não mudam pelo PUT: a mudança é uma CurrencySettingsChange — N por tenant, registro de ato de controle — com items ({ field, processCode?, before, after }), status, proposedByUserId · proposedAt e resolvedByUserId · resolvedAt, proposta e efetivada por pessoas diferentes (RN-CUR-28).
| Campo | Tipo | Padrão | Regra |
|---|---|---|---|
defaultRateType | enum | PtaxVenda | Tipo do movimento sem documento de origem — o Manual e o Import do Orçamento — e semente de toda entrada nova de processRateTypes. Mudar não reinterpreta nada do passado. Parâmetro crítico: muda só por proposta e efetivação de pessoas diferentes (RN-CUR-28). |
processRateTypes | { processCode, rateType }[] | PtaxVenda | O tipo de taxa de cada processo com valor do Fluxo de Aprovação — uma entrada por processo, nenhuma a menos —, semeada com o defaultRateType no provisionamento e na migração que acrescenta processo. É daqui, e só daqui, que sai o tipo da conversão de um documento (passo 1, RN-CUR-27): quem pede, prepara ou submete não escolhe. Documento que converte sem processo de aprovação próprio usa o tipo congelado no documento que confronta — a nota, o do pedido. Guardado em linhas próprias por (tenantId, processCode). Parâmetro crítico (RN-CUR-28). |
maxRateAgeDays | smallint | 5 | Janela de tolerância para trás, em dias corridos, contada a partir da conversionDate. Faixa 4–15; fora dela, 422 CURRENCY_SETTINGS_MAX_RATE_AGE_DAYS_UNPROCESSABLE. O mínimo é 4 porque é o maior intervalo sem fechamento nos feriados bancários nacionais, contado da conversionDate: na quarta-feira de cinzas ela é a terça de carnaval, e o último fechamento é o da sexta, quatro dias antes (tabela em "Por que PtaxVenda do dia anterior"). Com menos, a quarta de cinzas seria recusada todo ano. O padrão 5 cobre também a quinta depois do carnaval num ano em que a quarta, que não é feriado bancário, venha sem fechamento. Mudar o valor é configuração crítica: proposta e efetivação por pessoas diferentes (RN-CUR-28). |
warnRateAgeDays | smallint | 3 | A partir de quantos dias de idade — rateAgeDays maior ou igual a este valor — a interface destaca a taxa como antiga. Semente 3: toda segunda-feira grava rateAgeDays = 2 (a conversionDate é o domingo e o fechamento, o da sexta), e destacar a taxa de toda segunda ensinaria a ignorar o destaque. Não bloqueia nada; muda a cor e o texto ao lado do número. |
notifyOnFailureAfter | smallint | 1 | Falhas consecutivas de um par antes de notificar. Um, porque câmbio quebrado em silêncio é o modo de falha que esta spec inteira existe para evitar. |
alertRecipients | Guid[] | — | Quem recebe. Vazio cai no papel de administrador do tenant. |
allowContractedOverride | bool | false | Liga o exchangeRateOverride no documento — taxa informada, ou tipo diferente do do processo. Mesmo ligada, ainda exige a permissão. |
O algoritmo
Como a taxa é escolhida
Dada a tupla (fromCurrency, toCurrency, rateDate, processCode?, 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 pela unicidade da cotação entre as não substituídas. Não há critério de desempate porque não há empate.
| Passo | O que faz | Por quê |
|---|---|---|
| 0 · Override | Se o documento traz exchangeRateOverride — { rateType, rate?, quoteUnit?, reference } —: com rate, usa a taxa informada e para; sem rate, fixa o tipo informado e segue nos passos 2 a 4. O quoteUnit segue o ramo. Com rate, em que não há linha ExchangeRate nenhuma: o do corpo, quando vem, tem de ser o do par vigente — divergente é 422 EXCHANGE_RATE_OVERRIDE_QUOTE_UNIT_MISMATCH_UNPROCESSABLE e nada é convertido —, omitido vale o do par, e o fator conferido é congelado no override, no ato que recebe o documento; é ele que a conversão da submissão usa, e par editado entre um ato e outro é a mesma 422 na submissão. Sem rate, em que a taxa vem dos passos 2 a 4: vale o quoteUnit da própria linha achada, congelado nela na gravação (RN-CUR-11) — o fator do par não entra nessa conta, o congelado do override não o sobrescreve e a 422 de divergência não alcança este ramo; quoteUnit no corpo sem rate não tem taxa que descrever e é recusado com a mesma 422 no ato que recebe o documento (RN-CUR-27). Nos dois casos devolve isOverride = true e overriddenBy = quem registrou o override; quando a taxa veio dos passos 2 a 4, devolve também capturedBy = o autor dessa linha. Exige update:exchange-rates e allowContractedOverride ligado. Nada é gravado na tabela de cotações (RN-CUR-27). | Câmbio travado por contrato é um fato do documento e nenhuma tabela sabe dele; e tipo diferente do configurado é exceção revisada, nunca escolha silenciosa. |
| 1 · Tipo e data | rateType = o de CurrencySettings.processRateTypes para o processo do documento — ou defaultRateType, no movimento sem documento de origem —, salvo o tipo do passo 0; o chamador não informa tipo (RN-CUR-27). Override que só troca o tipo, sem rate, é override como qualquer outro: isOverride — e, por ele, a flag — nasce de o override existir, inclusive quando o tipo informado é igual ao configurado (RN-CUR-27). A data é a que o chamador passa — num documento, a conversionDate da RN-CUR-25. Os dois ficam fixados aqui e não mudam mais. | Trocar de tipo como plano B produz um número plausível e errado — o defeito mais caro de detectar. |
| 2 · Origem | Tipo PtaxVenda ou PtaxCompra: busca (tenantId IS NULL, par, tipo, data). Manual, Contracted ou Commercial: busca (tenantId = X, par, tipo, data). Sempre não substituída. | O tipo já diz de onde a taxa vem, então nunca há duas candidatas — e a taxa de um tenant não sombreia a oficial. |
| 3 · Janela | Recua um dia e repete o passo 2 enquanto rateAgeDays ≤ maxRateAgeDays, com rateAgeDays = conversionDate − data procurada. O primeiro achado vence. | Fim de semana e feriado não podem travar aprovação, e a data efetivamente usada volta no resultado. |
| 4 · Recusa | EXCHANGE_RATE_MISSING_UNPROCESSABLE 422, com par, tipo e o intervalo varrido na mensagem. | O sistema nunca estima taxa, nunca troca de tipo e nunca reaproveita cotação de três semanas atrás sem dizer. |
Dias corridos, não dias úteis — e o motivo é prático
Recuar por dia útil exigiria um calendário de feriado bancário mantido por alguém. Ninguém mantém, e feriado municipal não existe para o Banco Central. Cinco dias corridos, o padrão, 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; quatro é o mínimo configurável, porque é o intervalo da quarta-feira de cinzas. 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 tem uma lista de dias não úteis, e ela não serve 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.
A idade da taxa é dado, não detalhe de implementação
O ConversionResult devolve rateDate e rateAgeDays, e ambos são gravados pelo consumidor. A interface mostra sempre "PTAX venda de 29/12 · 2 dias" ao lado do valor convertido, e a partir de warnRateAgeDays destaca a taxa como antiga. 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 verba, várias BudgetLine cobrem a mesma tupla concreta, todas são avaliadas, cada uma com a própria política, e vence o pior sinal — é a regra do Orçamento. Aqui a chave é exata e a origem vem do tipo. 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 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. A rotina roda como serviço de sistema, em horário de plataforma — 14h, 18h e 22h de Brasília, todos depois da divulgação do fechamento —, e é a única que grava linha com tenantId nulo.
O adaptador do Banco Central
Dados abertos, sem chave, sem autenticação, formato OData. O recurso é CotacaoMoedaDia, e ele devolve todos os boletins do dia: Abertura, os Intermediário e o de fechamento (Fechamento ou Fechamento PTAX, conforme o recurso — o adaptador reconhece os dois literais pelo tipoBoletim). Só o de fechamento é gravado (RN-CUR-25).
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
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 correção de taxa (RateCorrection), uma 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 na unicidade (tenantId, par, tipo, data) entre as não substituídas e é descartada sem efeito. Por isso as três execuções diárias são seguras, a busca manual da plataforma é segura 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 do dia anterior, e por regra
O Banco Central faz quatro consultas ao mercado — das 10h às 10h10, das 11h às 11h10, das 12h às 12h10 e das 13h às 13h10 — e divulga o fechamento, calculado sobre elas, por volta das 13h15. Antes disso o dia só tem boletins de abertura e intermediários, que não são PTAX. Por isso a data de conversão de um documento é conversionDate = D−1 civil — o dia anterior à data da submissão (ou do ato que abre rodada nova, RN-CUR-22) em America/Sao_Paulo —, por regra, e a taxa usada é a do último boletim de fechamento com data até a conversionDate: a resolução nunca olha o dia da submissão, parte da conversionDate e recua até achar um fechamento. O dia útil é o que a janela acha, não o que a regra calcula — não há calendário de feriado. rateAgeDays = conversionDate − rateDate, em dias corridos. Requisição submetida às 10h e às 16h do mesmo dia converte com a mesma taxa; uma regra que dependesse da hora daria duas.
| Submissão | conversionDate | Fechamento usado | rateAgeDays |
|---|---|---|---|
| Terça comum · 15/09/2026 | seg 14/09 | seg 14/09 | 0 |
| Segunda-feira · 14/09/2026 | dom 13/09 | sex 11/09 | 2 — abaixo do warnRateAgeDays semeado, 3 |
| Terça depois de feriado de segunda · 08/09/2026 | seg 07/09 (Independência) | sex 04/09 | 3 |
| Segunda depois da Sexta-feira Santa · 29/03/2027 | dom 28/03 | qui 25/03 | 3 |
| Segunda depois do Natal · 28/12/2026 | dom 27/12 | qui 24/12 | 3 |
| Segunda depois do Ano-Novo · 04/01/2027 | dom 03/01 | qui 31/12 | 3 |
| Quarta-feira de cinzas · 10/02/2027 | ter 09/02 (carnaval) | sex 05/02 | 4 — o maior intervalo, e o mínimo de maxRateAgeDays |
Feriados considerados: 1/1, segunda e terça de carnaval, Sexta-feira Santa, 21/4, 1/5, Corpus Christi, 7/9, 12/10, 2/11, 15/11, 20/11 e 25/12. Os dias 24 e 31/12 têm boletim, com regra especial de apuração do BCB; se não tivessem, o maior intervalo continuaria 4. A quarta-feira de cinzas não é feriado bancário; num ano em que ela venha sem fechamento, a quinta seguinte tem idade 5, que o padrão cobre.
E só o boletim de fechamento é gravado. Gravar um intermediário como taxa do dia seria duas vezes errado: o número não é PTAX, e a unicidade da cotação faria a passada seguinte descartar o fechamento verdadeiro como duplicata. Resposta que ainda não traz fechamento conta como "sem boletim", igual ao sábado. 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 numa segunda-feira e a primeira requisição submetida no mesmo dia é recusada por falta de cotação: a conversionDate é o domingo, a janela procura o fechamento da sexta, e a rotina diária só começou a capturar agora — o que parece defeito e é só falta de histórico. Os 30 dias cobrem com folga a janela máxima de 15 e o movimento sem documento de origem (Manual, Import), que resolve taxa pela própria data.
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,
processCode?, tenantId, exchangeRateOverride?) // processCode nulo só em movimento sem documento de origem
ConversionResult {
amount // convertido e arredondado
rate, quoteUnit, rateType
rateDate, rateAgeDays
exchangeRateId? // nulo quando a taxa veio informada no override
capturedBy? // autor da linha de taxa efetivamente usada; nulo na oficial (o operador de plataforma que a substitui não é do tenant) e quando o override traz rate
overriddenBy? // quem registrou o override; nunca nulo quando isOverride
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 tem uma regra só: a do rateio, apurada na moeda do documento pela RN-CC-23 do Centro de Custo. Depois da conversão não há segundo resíduo (RN-CUR-13).
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
Company.functionalCurrency é BRL na v1 — o campo existe e fica imutável depois do primeiro Budget da empresa. Documentos, preços, cotações e limites de alçada continuam em qualquer código ISO 4217: a multimoeda da v1 é a dos documentos, não a da moeda em que a verba fecha. Moeda funcional diferente de BRL e conversão cruzada ficam para a v2, juntas, porque uma não funciona sem a outra.
Por que BRL, e o que teria quebrado
Empresa com CNPJ fecha em real: é a moeda do SPED e de toda NF-e. Com moeda funcional em USD, a compra nacional — em reais — precisaria de BRL→USD, par que o boletim do Banco Central não publica e que a RN-CUR-06 proíbe derivar: toda compra nacional seria recusada. Com funcional BRL, o par que a verba precisa é sempre moeda do documento → BRL, exatamente o que o PTAX publica, e a regra de não inverter taxa deixa de travar a compra.
O limite de alçada tem moeda própria
CostCenterAssignment.approvalLimit tem approvalLimitCurrency, obrigatória sempre que o limite estiver preenchido — inclusive no nó corporativo, que não tem empresa e por isso não teria de onde herdar moeda. 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. É a regra única de comparação do produto: converte-se o valor do documento para a moeda de quem limita — a do orçamento, BRL, na verba; a do limite, na alçada —, sempre com a taxa congelada na submissão.
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. Consequência na v1: limite em moeda diferente de BRL só serve a documento na mesma moeda ou com par cadastrado da moeda do documento para a do limite. Como o PTAX publica só moeda → BRL, um limite em USD recusa documento em reais — o recomendado é limite em BRL.
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
Na v1, sem fonte não há par — cada par que um documento ou um limite 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, junto com a moeda funcional fora de BRL: CurrencyPair ganha derivationMode (Direct | ViaAnchor | ViaParity) e anchorCurrency; a resolução ganha um passo 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.
Fronteiras
Quem consome e o que congela
| Domínio | O que faz com moeda e câmbio |
|---|---|
Company | Fornece functionalCurrency, o destino de toda conversão de verba: BRL na v1, imutável depois do primeiro Budget. |
Budget | Budget.currency copia e congela a moeda funcional na criação do exercício. Cada BudgetEntry guarda amountDocument, currencyDocument, exchangeRate, quoteUnit, rateType e rateDate, além do valor convertido. quoteUnit está na lista porque taxa sem fator não reconstitui a conta em moeda cotada por 100. O mesmo vale para o ApprovalEvent, que guarda a prova da comparação de alçada. Um exercício não tem duas moedas. |
Item | referencePriceCurrency valida contra TenantCurrency ativa. Não converte — preço de referência é benchmark, e converter benchmark com taxa de hoje esconde que o preço é de dois anos atrás. |
ItemSupplier · SupplierEstablishment | Guardam moeda como dado comercial negociado, validada contra as ativas. A moeda do estabelecimento é o padrão sugerido ao documento; a do ItemSupplier acompanha o preço. |
Quotation · RFQ | O caso mais delicado. Propostas em moedas diferentes só são comparáveis convertidas. A taxa usada no mapa comparativo é a do dia da abertura do mapa, gravada no próprio mapa, e cada valor convertido aparece com a taxa ao lado. A decisão é humana e a conversão é declarada — nunca uma coluna "valor em R$" cuja origem ninguém consegue reconstituir. Reabrir o mapa depois não reconverte. |
PurchaseOrder | Converte na submissão — não na emissão, não no empenho —, congela no documento e o movimento de Obligation herda a taxa do documento. O pedido inteiro tem uma moeda; o preço unitário e o total vivem nela. |
Receipt | Grava o realizado (Actual): recebimento é o custo incorrido da mercadoria; no serviço, o aceite. Não converte nada de novo — a parcela recebida herda a taxa congelada no pedido. Recebimento é quantidade, não preço. |
Invoice · three-way match | O confronto é sempre na moeda do documento. Comparar pedido e nota depois de converter faz a variação cambial virar divergência de preço e trava a nota por um fato que não é do fornecedor. A nota converte na sua própria submissão e não grava consumo novo, exceto a diferença: a variação entre a conversão herdada do pedido e a da nota entra no realizado com entryReason = ExchangeVariance, e a diferença de preço aceita, com InvoicePriceVariance — pela mecânica do Orçamento. É consumo real e atribuível, não erro de match, e não some. |
Contract | Contrato em moeda estrangeira guarda o valor na moeda dele. As liberações parciais convertem cada uma na sua data. Congelar a taxa da assinatura por 36 meses descreveria um mundo que não existe. |
| Motor de aprovação | Alçada é comparada na moeda do limite (approvalLimitCurrency), com a taxa já congelada no documento, e o ApprovalEvent guarda a taxa e a data usadas. Um documento não pode mudar de faixa de alçada porque o dólar mexeu entre a submissão e a aprovação — nem ser comparado com um limite em outra moeda sem que a conversão apareça. |
A regra que resume a tabela: converte-se na submissão do documento — que é o evento em que ele passa a valer um número — e de novo só no ato que abre rodada nova de aprovação, e o resultado vira dado do documento (RN-CUR-22). Não se reconverte na aprovação, no empenho nem no ajuste do documento aprovado: o Commitment consome a taxa que a submissão — ou a rodada nova — gravou, 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, quoteUnit, rateType 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. O recebimento herda do pedido; a nota converte na sua submissão e lança só a variação. Toda comparação entre dois documentos acontece na moeda de origem; comparação com verba acontece em BRL, e com alçada, na moeda do limite, sempre com o número que já estava congelado. Nenhuma tela reconverte para exibir.
Interface
Telas
| Tela | O que tem |
|---|---|
| Administração › Moedas | Lista do catálogo com um interruptor de ativação por moeda, minorUnits e símbolo visíveis. Ativar mostra em uma frase o que vai acontecer — par criado, captura ligada, 30 dias de histórico buscados. Desativar com uso aberto mostra a contagem por tipo de documento e recusa. |
| Câmbio › Cotações | Grade por par e período, com origem, tipo, idade e a marca de substituída. Filtro por par, tipo e intervalo. Exportação em CSV com as mesmas colunas — inclusive a taxa e a data, nunca só o valor. |
| Câmbio › Pares e captura | Uma linha por par com quoteUnit, provedor, último sucesso, última falha e falhas consecutivas. Semáforo por par. Para o tenant a tela é leitura. Editar o par e o botão buscar agora, que roda a passada do par e mostra o resultado na hora, são atos de plataforma. |
| Câmbio › Nova cotação | Taxa do tenant: manual, contratada ou comercial — os tipos PTAX não aparecem no combo. 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. O tipo de taxa por processo não se edita no formulário: a tela propõe, mostra a proposta pendente com o diff, e só outra pessoa efetiva (RN-CUR-28). |
| Em todo campo de valor em moeda estrangeira | O convertido aparece abaixo, em cinza, com a taxa e a data: "≈ R$ 52.013,00 · PTAX venda de 29/12 (2 dias)". É a diferença entre um número que o comprador defende e um número que ele repassa. |
API
Endpoints
| Rota | Permissão | Devolve | Erros |
|---|---|---|---|
| GET /currencies | read:currencies | PagedResult<CurrencyDto> — o catálogo | 400 |
| GET /tenant-currencies | read:currencies | array de TenantCurrencyDto | 400 |
| PATCH /tenant-currencies/{code}/activate | update:currencies | TenantCurrencyDto | 404 · 409 · 422 |
| PATCH /tenant-currencies/{code}/deactivate | delete:currencies | TenantCurrencyDto — recusa com moeda em uso | 404 · 409 · 422 |
| GET /currency-pairs | read:exchange-rates | array de CurrencyPairDto com a saúde da captura | 400 |
| PUT /currency-pairs/{id} | update:currency-pairs | CurrencyPairDto — quoteUnit, provedor, defaultRateType, alsoFetchRateTypes, isActive. autoFetch não vai no corpo — é derivado. Permissão de plataforma: o par é global e a edição vale para todos os tenants | 403 · 404 · 422 |
| GET /exchange-rates | read:exchange-rates | PagedResult<ExchangeRateDto>, filtrável por par, tipo e período | 400 |
| POST /exchange-rates | create:exchange-rates | ExchangeRateDto — taxa do tenant, só em Manual, Contracted ou Commercial. Ato de controle: registro e evento exchange-rate.recorded (RN-CUR-26) | 400 · 409 · 422 |
| POST /exchange-rates/{id}:supersede | update:exchange-rates | ExchangeRateDto — a nova, com a antiga marcada. Motivo codificado obrigatório (ExchangeRateSupersede). Só linha do próprio tenant; linha oficial é 403 | 403 · 404 · 409 · 422 |
| POST /official-exchange-rates/{id}:supersede | update:official-exchange-rates | ExchangeRateDto — substitui cotação oficial, com motivo codificado. Rota de plataforma: a linha é lida por todos os tenants | 403 · 404 · 409 · 422 |
| POST /exchange-rates:fetch | create:official-exchange-rates | FetchReportDto — por par: capturadas, sem boletim de fechamento, falhas. Rota de plataforma; a rotina agendada não passa por ela | 400 · 403 · 422 · 503 |
| GET /exchange-rates:resolve | read:exchange-rates | ResolvedRateDto — a taxa que seria usada, com o passo que a achou | 400 · 422 |
| POST /convert:currency | read:exchange-rates | ConversionResultDto — pré-visualização para a tela | 400 · 422 |
| GET /currency-settings | read:currencies | CurrencySettingsDto | 404 |
| PUT /currency-settings | update:currency-settings | CurrencySettingsDto. Corpo que muda defaultRateType, processRateTypes ou maxRateAgeDays é 409 (RN-CUR-28) | 400 · 409 · 422 |
| GET /currency-settings/changes | read:currencies | PagedResult<CurrencySettingsChangeDto>, filtrável por status | 400 |
| POST /currency-settings/changes | create:currency-settings | CurrencySettingsChangeDto — proposta Pending de mudança de campo crítico — tipo de taxa ou maxRateAgeDays —, com o diff (RN-CUR-28) | 400 · 422 |
| POST /currency-settings/changes/{id}:apply | update:currency-settings | CurrencySettingsChangeDto — efetiva, por pessoa diferente de quem propôs. Ato de controle: registro e evento currency-settings-change.applied | 403 · 404 · 409 |
| POST /currency-settings/changes/{id}:discard | delete:currency-settings | CurrencySettingsChangeDto — encerra sem efeito | 404 · 409 |
POST /convert:currency é pré-visualização e nunca autoriza gravação. Quem grava chama o serviço de domínio dentro da própria transação, resolvendo a taxa de novo — o mesmo princípio da consulta de saldo orçamentário, pela mesma razão: entre a tela e o submit passa tempo, e nesse intervalo a rotina pode ter gravado o boletim do dia.
Contrato
Erros
| Código | HTTP | Quando |
|---|---|---|
| EXCHANGE_RATE_MISSING_UNPROCESSABLE | 422 | Nenhuma cotação para o par e o tipo dentro da janela. A mensagem traz par, tipo, data pedida e o intervalo varrido. Substitui o BUDGET_EXCHANGE_RATE_MISSING_UNPROCESSABLE da spec de Orçamento — o fato é do câmbio e vai ter os mesmos consumidores no pedido e na nota; três códigos para o mesmo fato é ruído. |
| CURRENCY_NOT_ENABLED_UNPROCESSABLE | 422 | Moeda válida no catálogo, não ativa neste tenant. |
| CURRENCY_IN_USE_CONFLICT | 409 | Desativar moeda com documento em aberto. A mensagem traz a contagem por tipo. |
| APPROVAL_LIMIT_CURRENCY_NOT_CONVERTIBLE_UNPROCESSABLE | 422 | 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. |
| CURRENCY_PAIR_NOT_CONFIGURED_UNPROCESSABLE | 422 | Conversão pedida num sentido sem par cadastrado. Não se inverte taxa. |
| CURRENCY_PAIR_INACTIVE_UNPROCESSABLE | 422 | Par existe e está desativado. |
| EXCHANGE_RATE_ALREADY_EXISTS | 409 | Já existe cotação vigente para par, tipo e data. O caminho é substituir, com motivo. |
| EXCHANGE_RATE_IMMUTABLE_CONFLICT | 409 | Tentativa de alterar ou excluir cotação gravada. A imutabilidade é requisito; recusar também no banco é exemplo de implementação, não premissa. |
| CURRENCY_SETTINGS_MAX_RATE_AGE_DAYS_UNPROCESSABLE | 422 | maxRateAgeDays fora da faixa 4–15 |
| CURRENCY_SETTINGS_CRITICAL_FIELD_CONFLICT | 409 | PUT /currency-settings com corpo que muda defaultRateType, processRateTypes ou maxRateAgeDays. Os três mudam por proposta e efetivação de pessoas diferentes (RN-CUR-28). |
| CURRENCY_SETTINGS_PROCESS_RATE_TYPE_UNPROCESSABLE | 422 | Proposta com processo que não é processo com valor do Fluxo, que remove a entrada de um processo com valor, ou com tipo fora do enum (RN-CUR-28). |
| CURRENCY_SETTINGS_CHANGE_SELF_APPLY_FORBIDDEN | 403 | Efetivar proposta por quem a propôs (RN-CUR-28). |
| CURRENCY_SETTINGS_CHANGE_STALE_CONFLICT | 409 | Efetivar ou descartar proposta que não está Pending, ou efetivar a que tem como "antes" um valor que já não é o vigente (RN-CUR-28). |
| EXCHANGE_RATE_OVERRIDE_FORBIDDEN | 403 | exchangeRateOverride no documento — taxa informada ou tipo diferente do do processo — sem update:exchange-rates, ou com allowContractedOverride desligado. |
| EXCHANGE_RATE_INVALID | 400 | Taxa não positiva, quoteUnit não positivo, data futura, ou moeda fora do catálogo. |
| EXCHANGE_RATE_OVERRIDE_QUOTE_UNIT_MISMATCH_UNPROCESSABLE | 422 | O quoteUnit do exchangeRateOverride difere do do par vigente. Nada é convertido; a mensagem traz os dois fatores (RN-CUR-27). |
| EXCHANGE_RATE_QUOTE_UNIT_MISMATCH_UNPROCESSABLE | 422 | 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. |
| EXCHANGE_RATE_TYPE_RESERVED_UNPROCESSABLE | 422 | Taxa de tenant em PtaxVenda ou PtaxCompra. PTAX só existe como cotação oficial (RN-CUR-24). |
| EXCHANGE_RATE_TYPE_NOT_SELECTABLE_UNPROCESSABLE | 422 | Documento com rateType fora do exchangeRateOverride e diferente do tipo configurado para o processo. O tipo vem de processRateTypes; outro tipo só pelo override (RN-CUR-27). |
| EXCHANGE_RATE_OFFICIAL_FORBIDDEN | 403 | Substituir linha oficial (tenantId nulo) pela rota do tenant. Cotação oficial só se substitui por rota de plataforma (RN-CUR-24). |
| CURRENCY_PLATFORM_OPERATION_FORBIDDEN | 403 | Rota de plataforma — PUT /currency-pairs/{id}, POST /official-exchange-rates/{id}:supersede, POST /exchange-rates:fetch — chamada por quem não é operador de plataforma com update:currency-pairs ou update:official-exchange-rates (RN-CUR-24). |
| CURRENCY_PROVIDER_UNAVAILABLE | 503 | Só na busca manual da plataforma. A rotina agendada nunca devolve erro HTTP — ela registra, conta a falha e notifica. _UNAVAILABLE → 503 é um dos dez sufixos vigentes. |
Segurança
Permissões
| Permissão | Semeada em | Observação |
|---|---|---|
| read:currencies | Todos os papéis operacionais | Ler catálogo, ativas e configurações. Sem ela nenhum combo de moeda carrega. |
| update:currencies e delete:currencies | Administrador | Ativar e desativar moeda. Ato com efeito colateral — garante o par e dispara captura; o par em si não é editado por aqui. |
| update:currency-pairs | Plataforma — nenhum papel de tenant | Editar par: quoteUnit, provedor, tipos capturados, isActive. O par é global e a edição alcança todos os tenants. |
| update:official-exchange-rates | Plataforma — nenhum papel de tenant | Substituir cotação oficial e disparar a busca manual. A linha oficial é lida por todos os tenants: substituí-la é ato de controle, com autor, motivo, antes e depois, e evento exchange-rate.superseded. |
| read:exchange-rates | Controladoria, Comprador | Consultar cotações, pares e pré-visualizar conversão. |
| create:exchange-rates e update:exchange-rates | Controladoria | Cadastrar e substituir taxa do próprio tenant — manual, contratada ou comercial. Não toca cotação oficial. |
| update:exchange-rates | Ninguém, por padrão | Informar taxa, ou tipo diferente do configurado para o processo, dentro de um documento. Registra autor e referência, a marca fica visível no extrato e a controladoria entra na cadeia (RN-CUR-27). |
| create:currency-settings, update:currency-settings e delete:currency-settings | Administrador | Janela de idade, destinatários do alerta e liga do override; propor, efetivar e descartar mudança de tipo de taxa — efetivar, só quem não propôs (RN-CUR-28). |
read:currencies, update:currencies e delete:currencies, read:exchange-rates, create:exchange-rates e update:exchange-rates, update:exchange-rates e create:currency-settings, update:currency-settings e delete:currency-settings são linhas no catálogo de permissões que o gryd mantém por tenant, e chegam achatadas no token como qualquer outra. update:currency-pairs e create:official-exchange-rates e update:official-exchange-rates não são: ficam fora do catálogo do tenant e não são atribuíveis a papel de tenant — não aparecem na tela de papéis do tenant, nenhum administrador de tenant as concede, e rota que as exige recusa quem não é operador de plataforma com 403 CURRENCY_PLATFORM_OPERATION_FORBIDDEN (RN-CUR-24). Como chegam ao operador é da plataforma. As duas que já apareciam na spec de Orçamento (read:exchange-rates e create:exchange-rates e update:exchange-rates) são as mesmas linhas, documentadas aqui.
Invariantes
Regras de negócio
Currency não tem tenantId e não é editável por API de cliente. Moeda nova entra por migração, com code, numericCode e minorUnits conferidos contra a ISO 4217.
referencePriceCurrency, ItemSupplier.currency, SupplierEstablishment.currency, moeda de documento e Budget.currency recusam código que não seja TenantCurrency com isEnabled. Validação no servidor, não no combo.
BRL nasce ativa em todo tenant e, sendo a moeda funcional de toda empresa na v1, não é desativável. A regra vale para qualquer moeda que seja functionalCurrency de alguma empresa: sem ela o tenant fica sem destino de conversão.
Recusada com CURRENCY_IN_USE_CONFLICT enquanto houver documento em aberto na moeda. Histórico e documentos fechados permanecem legíveis, com a taxa que congelaram.
TenantCurrency + CurrencyPair (moeda→BRL, autoFetch derivado da união das ativações — e ligado a partir desta —, provedor BCB_PTAX) + backfill de 30 dias enfileirado. O cadastro de pares não depende de ninguém lembrar de preenchê-lo.
A conversão exige par cadastrado no sentido pedido. 1 ÷ rate não é a cotação inversa, e derivá-la produz divergência que ninguém rastreia.
rateTypeO tipo é fixado no passo 1 e vale até a recusa. Não existe plano B por tipo — só por data, dentro da janela.
Tipos PtaxVenda e PtaxCompra só existem com tenantId nulo; Manual, Contracted e Commercial, só com tenantId preenchido. Em cada passo existe uma linha candidata, garantida pela unicidade de (tenantId, par, tipo, data) só entre as linhas não substituídas, com tenantId nulo contando como valor — requisito, não recurso de banco: um índice único filtrado é o exemplo de implementação, e conferir sob trava do par na gravação, o equivalente. A cascata é determinística e não tem desempate.
A taxa vale enquanto rateAgeDays ≤ maxRateAgeDays, com maxRateAgeDays configurável entre 4 e 15 — 4 é o maior intervalo sem fechamento nos feriados bancários nacionais, o da quarta-feira de cinzas (422 CURRENCY_SETTINGS_MAX_RATE_AGE_DAYS_UNPROCESSABLE fora da faixa). 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.
A cotação não é alterada nem excluída — requisito, com a recusa no banco só como exemplo de implementação. supersedeReasonCodeId obrigatório — motivo codificado, uso ExchangeRateSupersede. A resolução só enxerga linha com supersededById nulo.
O documento guarda taxa, tipo, data e quoteUnit como valores, não como referência. Refletir a taxa corrigida num documento é estorno e relançamento no próprio estágio, com entryReason = RateCorrection e o motivo da substituição — nunca Adjustment, que muda verba.
amount × rate ÷ quoteUnit em precisão cheia, arredondado uma vez para Currency.minorUnits, HALF_UP. Nunca se assume duas casas.
O rateio ocorre na moeda do documento e cada fatia converte separadamente. O resíduo do rateio é apurado ali, na moeda do documento, pela RN-CC-23 — maior fatia, empate pelo menor order —, que é a regra única do produto e não se reescreve aqui. Depois da conversão não há um segundo resíduo: o valor do documento na moeda funcional é, por definição, a soma das fatias convertidas, nunca o total convertido. Converter o total daria outro número, e o número que vale é o que o razão orçamentário guarda — e o razão guarda fatias.
from == to é passagem direta com rate = 1. Falha de câmbio nunca alcança o cliente que só compra no Brasil.
Colisão na unicidade da cotação descarta sem efeito. value: [] registra "sem boletim" e não incrementa consecutiveFailures.
A partir de notifyOnFailureAfter falhas consecutivas de um par, notificação para alertRecipients. A tela de pares mostra desde quando está quebrado.
Three-way match compara pedido, recebimento e nota na moeda do documento. Variação cambial não é divergência de preço e não trava nota: entra no realizado como ExchangeVariance, lançada pela nota.
POST /convert:currency é pré-visualização. Quem grava resolve a taxa de novo dentro da transação.
Toda gravação de ExchangeRate, substituição, ativação e desativação de moeda e alteração de CurrencySettings gera registro com autor, antes e depois.
autoFetch é derivado e a edição é de plataformaautoFetch é a união das ativações dos tenants — liga com a primeira, desliga com a última, nunca aceita escrita. quoteUnit, provedor, tipos capturados e isActive só mudam com update:currency-pairs, que nenhum papel de tenant recebe, porque a mudança alcança todos os tenants de uma vez.
quoteUnit é da fonte, e a divergência é recusadaO 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.
O documento converte na submissão e, depois dela, só no ato que abre rodada nova de aprovação: o reenvio de documento devolvido e a alteração de documento aprovado que reabre com rodada do zero (Fluxo de Aprovação, RN-AP-71). Nesses dois atos o documento inteiro é reconvertido pela cascata da RN-CUR-27, com a conversionDate contada do dia do ato (RN-CUR-25) e as mesmas recusas da submissão, e o ConversionResult novo passa a ser a taxa do documento: rodada nova é decisão nova, e quem decide vê o valor de hoje — a alçada pode mudar de dono, e a verba pode não comportar. O ajuste do documento aprovado não converte: o valor novo é convertido pela taxa já gravada no documento — exchangeRate, quoteUnit, rateType, rateDate e conversionDate. Fora desses atos nada reconverte: aprovar, empenhar, receber e o passar do tempo não mexem na taxa, e não há reavaliação de saldo em aberto.
A ordem é fixa. Na alteração de documento aprovado, ajuste ou reabertura se decide pela taxa gravada, e só a reabertura, já decidida, reconverte — o câmbio do dia nunca transforma reabertura em ajuste. No reenvio, a condição de herança “valor que não subiu” vale nas duas moedas, a do documento e a funcional convertida pela taxa nova (Fluxo, RN-AP-21): US$ 100 mil que viram US$ 104 mil não herdam a aprovação porque o dólar caiu de 5,40 para 5,15 (R$ 535.600 contra R$ 540.000), e US$ 100 mil que ficam iguais não herdam se o dólar subiu. Sem cotação na reconversão, o ato é recusado com EXCHANGE_RATE_MISSING_UNPROCESSABLE, como na submissão: o reenvio não acontece e o documento continua Returned, ou a alteração não acontece e o documento continua na versão aprovada. Trocar a taxa por ato explícito — incluir, alterar ou remover o exchangeRateOverride, ou trocar a moeda do documento — muda o retrato, nunca é ajuste e reabre com rodada do zero, e por isso reconverte (RN-CUR-27). Risco aceito: quem reenvia escolhe o dia, e com ele a taxa — a mesma escolha que a submissão já dá; a data e a idade da taxa ficam gravadas e aparecem no extrato (RN-CUR-09).
Todo movimento do razão originado por documento herda exchangeRate, quoteUnit, rateType e rateDate dele; a liquidação herda da entrada que liquida. A resolução própria de taxa do Orçamento vale só para movimento sem documento de origem — Manual e Import. O recebimento não converte: herda a taxa do pedido. A nota converte na sua submissão e lança só a variação, como ExchangeVariance. A reconversão da rodada nova entra no razão como estorno do aberto pela taxa antiga e relançamento pela nova, no mesmo estágio, com entryReason = Reconversion e sujeita ao bloqueio (Orçamento, RN-O59); a parte já liquidada fica na taxa com que foi liquidada.
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, o fator 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. É a regra única de comparação: converte-se para a moeda de quem limita.
Linha com tenantId nulo é gravada só pela rotina, como serviço de sistema, e substituída só com create:official-exchange-rates e update:official-exchange-rates, permissão fora do catálogo do tenant e não atribuível a papel de tenant — como update:currency-pairs; a rota do tenant sobre linha oficial é 403 EXCHANGE_RATE_OFFICIAL_FORBIDDEN, e rota de plataforma chamada por quem não é operador é 403 CURRENCY_PLATFORM_OPERATION_FORBIDDEN. Taxa de tenant só em Manual, Contracted ou Commercial; tipo PTAX com tenantId preenchido é 422 EXCHANGE_RATE_TYPE_RESERVED_UNPROCESSABLE. A busca manual é de plataforma. Nenhum tenant grava, substitui ou sombreia uma linha que outro lê.
Num documento, conversionDate = D−1 civil: o dia anterior à data do ato que converte — a submissão, ou o ato que abre rodada nova (RN-CUR-22) — em America/Sao_Paulo, qualquer que seja a hora, sem calendário de feriado. A taxa usada é a do último boletim de fechamento com data até a conversionDate, que a janela acha recuando dia a dia (RN-CUR-09); rateAgeDays = conversionDate − rateDate. Toda segunda-feira grava idade 2, e a quarta-feira de cinzas, 4. A rotina grava só o boletim de fechamento, reconhecido pelo tipoBoletim; abertura e intermediários são descartados antes da gravação, e resposta sem fechamento conta como dia sem boletim. Teste obrigatório do adaptador: resposta com intermediários e sem fechamento não grava nada.
Gravar taxa em Manual, Contracted ou Commercial — e substituí-la — muda o valor de documento convertido e, com ele, alçada e verba. Por isso é ato de controle: registro isolado por tenant, gravado na mesma transação da taxa, com autor, par, tipo, data, taxa e quoteUnit, sem alteração posterior; e evento exchange-rate.recorded publicado depois do commit (na substituição, exchange-rate.superseded). O registro é preparado por IAuditTrail.Stage (Gryd.IO 11.1.0) e fica legível depois da entrega pelo Worker.
O tipo vem da configuração. O tipo de taxa de um documento vem de CurrencySettings.processRateTypes, pela entrada do processo dele; defaultRateType vale só para movimento sem documento de origem. É este o contrato, com estes dois nomes, que o Fluxo de Aprovação (RN-AP-70) e o Orçamento citam. O rateType da conversão de um documento é, portanto, o de CurrencySettings.processRateTypes para o processo dele (passo 1); quem pede, prepara ou submete não escolhe, e a configuração só muda com duas pessoas (RN-CUR-28). O documento não carrega tipo próprio: rateType enviado fora do exchangeRateOverride e diferente do configurado é recusado com 422 EXCHANGE_RATE_TYPE_NOT_SELECTABLE_UNPROCESSABLE no ato que o recebe, e nada é gravado. Outro tipo, ou taxa informada, só por exchangeRateOverride, com update:exchange-rates e allowContractedOverride ligado: congela o tipo, a taxa quando informada, o quoteUnit do par quando há taxa informada, o autor e a referência, e devolve isOverride = true (passo 0). O quoteUnit depende do ramo do override, e os dois ramos têm fatores diferentes. Com taxa informada — rate no corpo — não existe linha ExchangeRate nenhuma, e o fator é o do CurrencyPair: quando o corpo traz quoteUnit, ele tem de ser igual ao do par vigente; divergente é 422 EXCHANGE_RATE_OVERRIDE_QUOTE_UNIT_MISMATCH_UNPROCESSABLE, no ato que recebe o documento, e nada é convertido. Omitido, vale o do par. E o fator conferido é congelado no override, no ato que o recebe: o quoteUnit gravado com o override é o do CurrencyPair naquele instante — informado ou herdado —, e é ele que a conversão usa na submissão — e em toda reconversão de rodada nova, com a mesma conferência abaixo (RN-CUR-22). Se o quoteUnit do par vigente na submissão diverge do congelado — o par foi editado pela rota de plataforma PUT /currency-pairs/{id} entre um ato e outro —, a submissão é recusada com o mesmo 422 EXCHANGE_RATE_OVERRIDE_QUOTE_UNIT_MISMATCH_UNPROCESSABLE, com os dois fatores na mensagem. A recusa é de submissão, e o documento fica onde estava: nada é convertido, nenhuma instância de aprovação é criada, nada é lançado no razão e o documento continua não submetido, editável, com o override registrado nele — refazê-lo contra o par novo, ou removê-lo e submeter pelo tipo do processo, é o caminho de volta, e quem o registrou é quem o refaz. Sem o congelamento, a conferência do ato que recebe o documento não prova nada sobre a conversão, que acontece depois. Sem taxa informada — override só de tipo — a taxa vem dos passos 2 a 4, e a linha achada traz quoteUnit próprio, copiado do par no instante da gravação e congelado nela (RN-CUR-11): é o fator da linha que a conversão da submissão usa, e não há segundo fator a conferir, porque a linha e o quoteUnit dela são resolvidos juntos, no mesmo passo. O fator do par não entra nessa conta, o congelado do override não o sobrescreve e a 422 de divergência não alcança este ramo — é o que a RN-CUR-11 exige: mudar o fator no par não reinterpreta as taxas já gravadas, e reinterpretá-las é exatamente o que o fator do par faria sobre uma taxa que ele não descreve. quoteUnit no corpo sem rate não tem taxa que descrever e é recusado com a mesma 422, no ato que recebe o documento. Sem essa guarda, { Contracted, rate 5,40, quoteUnit 100 } num par de quoteUnit 1 converte US$ 100 mil em R$ 5.400: alçada e verba cem vezes menores, e a controladoria lendo "5,40", uma taxa plausível. A taxa informada nunca é gravada como ExchangeRate do tenant — nem por opção: taxa de tenant só nasce por POST /exchange-rates, ato de controle (RN-CUR-26).
A flag. O módulo dono deriva exchangeRateOverridden do ConversionResult, na submissão, na reavaliação e no reenvio — no ajuste, o que já está gravado no documento; na rodada nova, o da reconversão (RN-CUR-22); quem pede nunca a declara —, em dois casos: (a) isOverride — o documento traz exchangeRateOverride, com rate ou só com o tipo, e mesmo quando o tipo informado é igual ao configurado: o que aciona a controladoria é o override existir, não o tipo ter mudado —; (b) a taxa usada é do tenant e o capturedBy dela é quem submete o documento ou está entre os excluídos do documento pela RN-AP-60 do Fluxo de Aprovação por outra razão que não RateOverrider — requisitante, LastEditor, Beneficiary e os demais que ela lista. Com a flag, o motor acrescenta o passo complementar da controladoria; a regra do passo e a semente nos processos com valor moram no Fluxo (RN-AP-70), e esta página declara a origem da flag.
O autor da taxa não decide. O capturedBy de toda taxa do tenant usada na conversão entra em excludedUsers com a razão RateOverrider, com ou sem flag e qualquer que seja o papel dele no documento. No override, capturedBy é o autor da linha efetivamente usada: quando o override só troca o tipo, a linha vem dos passos 2 a 4 e o autor dela é excluído como qualquer outro. E quem registra o override entra também, com taxa informada ou só com o tipo: o overriddenBy do ConversionResult é excluído com a mesma razão. Os dois juntos garantem que a flag nunca chegue ao motor sem ao menos um RateOverrider. A cotação oficial não tem autor no tenant: a capturada pela rotina não tem autor nenhum, e a substituída por POST /official-exchange-rates/{id}:supersede tem como autor o operador de plataforma, que não é usuário do tenant e não decide passo nenhum de documento dele. Por isso o capturedBy do ConversionResult é nulo quando a taxa usada é oficial, e taxa oficial, sozinha, não exclui ninguém. Contrato das duas pontas: a flag se chama exchangeRateOverridden e a razão, RateOverrider; o Fluxo cita com esses nomes.
Caso de ataque. A taxa decide a alçada. (1) Quem pede escolhia uma taxa Contracted ou Commercial antiga, gravada para outro fim, baixava o valor em reais e caía em alçada menor sem aviso: agora o tipo é o do processo, e outro tipo só entra como exceção, com a controladoria na cadeia. (2) Com o processo configurado em Manual, um aprovador com update:exchange-rates grava 4,90 com a PTAX a 5,40; o pedido de US$ 100 mil de um colega vira R$ 490 mil em vez de R$ 540 mil e cai na alçada dele — sem ser requisitante, editor nem quem submete, ele não acionava a flag e decidia. Agora ele está em excludedUsers como RateOverrider e não decide nenhum passo do documento. (3) O mesmo aprovador grava a Contracted baixa e, em vez de informar taxa, manda exchangeRateOverride só com o tipo: com capturedBy nulo no override, ele não era RateOverrider e decidia o próprio passo complementar — agora o autor da linha usada volta a ser o capturedBy. (4) E, com capturedBy e overriddenBy nulos, o documento com flag e nenhum RateOverrider era recusado com 422 pelo Fluxo e nunca era submetido: o override só de tipo virava um jeito de travar o documento. (5) Taxa informada. O par JPY→BRL passa de quoteUnit 1 para 100 pela rota de plataforma depois de o override { Contracted, rate 0,0357 } estar gravado no documento: sem o fator congelado, ou a submissão converte por um fator que ninguém conferiu — ¥10.000.000 × 0,0357 ÷ 100 = R$ 3.570,00, em vez de ¥10.000.000 × 0,0357 ÷ 1 = R$ 357.000,00, cem vezes menos —, ou a guarda passa a recusar um documento cujo override estava certo quando foi registrado. Com o congelamento, a conversão usa o fator conferido, e a divergência aparece nomeada, na submissão. (6) Override só de tipo. Mesmo par e mesma edição de 1 para 100, com uma linha Contracted de rate 0,0357 gravada quando o par ainda valia 1 e, por isso, com quoteUnit 1 congelado nela (RN-CUR-11). O override só de tipo é registrado depois da edição: o fator do par congelado com ele é 100, igual ao do par vigente na submissão, então nenhuma 422 dispara — e converter por ele daria os mesmos R$ 3.570,00 num documento que vale R$ 357.000,00 pelo fator da linha, com alçada e verba cem vezes menores e nada nomeado na tela. Por isso o fator do par congelado com o override vale só onde há taxa informada: neste ramo, quem descreve a taxa é a linha.
defaultRateType, processRateTypes e maxRateAgeDays mudam como a política do Fluxo de Aprovação (RN-AP-55 e RN-AP-69). PUT /currency-settings com corpo que muda um deles é 409 CURRENCY_SETTINGS_CRITICAL_FIELD_CONFLICT. maxRateAgeDays está na lista porque alargar a janela para trás é escolher taxa mais velha sem tocar em tipo nenhum — com 15 dias, o documento de hoje alcança o fechamento da semana passada, e a diferença cai direto na alçada. Os demais campos da configuração seguem no PUT de uma pessoa só. A mudança nasce como CurrencySettingsChange Pending por POST /currency-settings/changes, com o diff item a item — campo, processo, valor vigente e valor novo — e proposedByUserId; a proposta nunca é editada: mudar é descartar e propor de novo. Processo que não é processo com valor do Fluxo, entrada de processo com valor removida ou tipo fora do enum é 422 CURRENCY_SETTINGS_PROCESS_RATE_TYPE_UNPROCESSABLE. Efetiva por POST /currency-settings/changes/{id}:apply, que grava o valor novo na mesma transação, por pessoa com update:currency-settings diferente de quem propôs — senão 403 CURRENCY_SETTINGS_CHANGE_SELF_APPLY_FORBIDDEN. Proposta que não está Pending, ou cujo valor vigente não é mais o "antes" dela, é 409 CURRENCY_SETTINGS_CHANGE_STALE_CONFLICT. :discard encerra sem efeito. Documento já convertido não muda: a configuração nova só o alcança se ele reconverter numa rodada nova (RN-CUR-22).
A prova e o aviso. É ato de controle: a CurrencySettingsChange guarda o diff, quem propôs, quem efetivou ou descartou e quando, isolada pelo tenant e gravada na transação do ato, e não sofre escrita além da transição de status (Pending · Applied · Discarded). Depois do commit sai currency-settings-change.applied, e a controladoria recebe aviso obrigatório com o diff.
Caso de ataque. Com o tipo fora do alcance de quem pede, o atalho passaria a ser a configuração: um administrador sozinho trocaria o tipo do pedido de compra de PtaxVenda para Commercial, em que há uma taxa baixa gravada, submeteria os pedidos da semana e voltaria a configuração. Agora a troca exige uma segunda pessoa e chega à controladoria com o diff.
Contexto
Referência de mercado
| Nexio | SAP | Oracle | Dynamics 365 |
|---|---|---|---|
Currency | TCURC + TCURX (casas decimais) | Currencies | Currencies |
ExchangeRate | TCURR, por tipo e data | GL Daily Rates | Exchange rates, por par e data inicial |
rateType | TCURV — M média, B/G compra e venda, P planejamento | Corporate | Spot | User | Exchange rate types |
quoteUnit | TCURF, o fator de conversão | Conversion factor | Unidade de cotação no par |
| Não implementado · gancho | Moeda de referência e triangulação | Cross rates | Moeda âncora |
| Fora de escopo | Segunda e terceira moeda, reavaliação (FAGL_FC_VAL) | Revaluation, reporting currency | Revaluation, moeda de reporte |
Três notas. A primeira: o fator de unidade existe nos três, e existe porque é inevitável — quem não o tem acaba embutindo o fator no número e perdendo casas em iene e guarani. A segunda: o tipo faz parte da chave nos três, e nenhum deles tem fallback de tipo — a razão é a mesma que a nossa, um número plausível e errado é pior que uma recusa. A terceira, sobre o que não se copia: a TCURX do SAP existe porque o campo de valor é fixo em duas casas e as exceções precisam de uma tabela à parte; aqui minorUnits está no catálogo desde a primeira linha, e o problema não chega a nascer.
Fonte da integração: serviço de dados abertos do Banco Central do Brasil — olinda.bcb.gov.br, serviço OData PTAX v1, recursos Moedas, CotacaoMoedaDia e CotacaoMoedaPeriodo. Licença Open Data Commons ODbL. Sem chave, sem autenticação e sem limite de uso documentado — o que é motivo para a rotina ser educada, não para confiar que sempre responderá.
Limites
Fora de escopo
| O que | Por quê |
|---|---|
| Conversão cruzada e triangulação | O boletim do Banco Central publica moeda contra real e nada mais; par que não passe pelo real precisa de fonte própria cadastrada. Vai para a v2 junto com a moeda funcional diferente de BRL, que depende dela. O gancho está escrito — derivationMode, anchorCurrency e um passo a mais na resolução. |
| Inversão de taxa | Compra e venda não são recíprocas. Quem precisar do sentido inverso cadastra o par com fonte própria. |
| Reavaliação cambial periódica | Mudaria saldo orçamentário sem ninguém ter comprado nada. A variação aparece na liquidação, que é quando ela é real. |
| Conta de variação cambial e contabilização | O Nexio não gera lançamento contábil. A variação é consumo de verba e sai por relatório, não por partida dobrada. |
| Contrato de câmbio e hedge como entidade | Só a taxa contratada entra, como dado do documento. Posição cambial e operação de proteção são do sistema financeiro. |
| Moeda de reporte e consolidação | A segunda e a terceira moeda do SAP existem para consolidação de grupo. Somar orçamento de várias empresas já está fora de escopo na spec de Orçamento pela mesma razão. |
| Conversão de apresentação em relatório | Ver o orçamento em dólar é relatório com conversão declarada, não um segundo orçamento. Nasce quando existir o módulo de relatórios. |
| Calendário de feriado bancário | Substituído pela janela em dias corridos. Cadastro que ninguém mantém é pior que uma heurística explícita. A lista de dias não úteis do fluxo de aprovação não é esse calendário — é do escritório do tenant, não do boletim do BCB, e nenhuma rotina daqui a lê. |
| Criptomoeda | Fora da ISO 4217, sem boletim oficial, e nenhuma empresa compradora paga fornecedor assim. Se um dia acontecer, é provedor novo — a interface já prevê. |
Registro
Histórico de revisões
| Versão | Data | O que mudou |
|---|---|---|
| v1.17 | 30/10/2026 | Permissões padronizadas em cinco verbos (decisão do Roger, 30/10/2026). De/para: manage:currencies → update:currencies (ativar) e delete:currencies (desativar); manage:exchange-rates → create:exchange-rates (cadastrar) e update:exchange-rates (substituir); override:exchange-rates → update:exchange-rates (a conversão com taxa informada continua exigindo também a liga do tenant, RN-CUR-27); manage:currency-settings → create:currency-settings (propor), update:currency-settings (alterar e efetivar) e delete:currency-settings (descartar). De plataforma: manage:currency-pairs → update:currency-pairs; manage:official-exchange-rates → create:official-exchange-rates (busca manual) e update:official-exchange-rates (substituir). Nenhuma regra de negócio muda. |
| v1.16 | 19/09/2026 | Decisão D3 (validação de 18/09). O documento converte na submissão e em cada rodada nova (RN-CUR-22): o reenvio de devolvido e a reabertura de aprovado com rodada do zero reconvertem o documento inteiro, com a conversionDate contada do dia do ato (RN-CUR-25); o ajuste do aprovado converte o valor novo pela taxa gravada; ajuste ou reabertura se decide pela taxa gravada; a herança no reenvio exige valor que não subiu nas duas moedas; sem cotação, o ato é recusado; a reconversão entra no razão como Reconversion, sujeita ao bloqueio. A flag da RN-CUR-27 é derivada também no reenvio, do resultado vigente. Antes, a RN-CUR-22 dizia que a submissão era o único momento de conversão, e a RN-CUR-27 derivava a flag na reavaliação sem dizer de qual conversão. |
| v1.15 | 13/09/2026 | Correção da validação R7. O quoteUnit do override passa a ser dito por ramo (RN-CUR-27 e passo 0): o fator do par congelado com o override vale para a taxa informada, onde não há linha ExchangeRate; no override só de tipo, em que a taxa vem dos passos 2 a 4, a conversão usa o quoteUnit da própria linha, congelado nela (RN-CUR-11), e o congelado do par não o sobrescreve. quoteUnit no corpo sem rate é recusado. A recusa da submissão por fator divergente ganha o estado do documento: fica não submetido e editável, sem instância, sem conversão e sem lançamento no razão. Caso de ataque (6) escrito, com a conta refeita. EV-36 (decisão do Roger, 12/09): conversionDate = D−1 civil em America/Sao_Paulo, sem calendário de feriado — a regra da RN-CUR-25 já era essa e não muda; a janela maxRateAgeDays absorve fim de semana e feriado, e a emenda corrige a Ata B16, que dizia "dia útil anterior" |
| v1.14 | 12/09/2026 | Correção da validação R6. O override congela o quoteUnit do par no ato que recebe o documento, e a conversão da submissão usa o congelado; par editado entre os dois atos é 422 EXCHANGE_RATE_OVERRIDE_QUOTE_UNIT_MISMATCH_UNPROCESSABLE na submissão. Tabela de rotas alinhada à RN-CUR-28 (maxRateAgeDays entre os campos críticos; a proposta é de campo crítico, não "de tipo de taxa"). "Taxa oficial não tem autor" corrigida: a capturada pela rotina não tem autor, a substituída por :supersede tem o operador de plataforma, que não é usuário do tenant — por isso capturedBy segue nulo na oficial |
| v1.13 | 11/09/2026 | Correção da validação R5. Override: capturedBy passa a ser o autor da linha efetivamente usada — inclusive no override que só troca o tipo — e o ConversionResult ganha overriddenBy, quem registrou o override; os dois entram em excludedUsers como RateOverrider, de modo que a flag nunca chega ao motor sem nenhum (RN-CUR-27). A flag nasce de o override existir, com ou sem rate e mesmo com o tipo igual ao configurado. quoteUnit do override é o do par; divergente é 422 EXCHANGE_RATE_OVERRIDE_QUOTE_UNIT_MISMATCH_UNPROCESSABLE. Contrato do tipo escrito numa frase: o documento vem de processRateTypes, defaultRateType só para movimento sem documento de origem. maxRateAgeDays passa a configuração crítica (RN-CUR-28). |
| v1.12 | 11/09/2026 | Correção da validação R4. O tipo de taxa do documento vem da configuração por processo (CurrencySettings.processRateTypes): o chamador não escolhe — rateType fora do override é 422 EXCHANGE_RATE_TYPE_NOT_SELECTABLE_UNPROCESSABLE — e outro tipo, ou taxa informada, só por exchangeRateOverride (passos 0 e 1; Convert recebe processCode). defaultRateType fica para o movimento sem documento de origem. Tipo de taxa é configuração crítica: proposta e efetivação por pessoas diferentes, CurrencySettingsChange, evento e aviso à controladoria (RN-CUR-28). O autor de toda taxa do tenant usada vai a excludedUsers como RateOverrider; a flag nasce do override, ou do autor que submete ou está entre os excluídos da RN-AP-60 (RN-CUR-27). Resíduo de mudança saiu das permissões. |
| v1.11 | 11/09/2026 | Correção da validação R3. Taxa do tenant gravada ou substituída por quem pede, prepara ou submete o documento também deriva exchangeRateOverridden, com o autor em excludedUsers; ConversionResult devolve capturedBy (RN-CUR-27). Rotas de plataforma com 403 CURRENCY_PLATFORM_OPERATION_FORBIDDEN. Saiu a frase que atribuía update:currency-pairs ao GrydFiles. Janela escrita como rateAgeDays ≤ maxRateAgeDays. |
| v1.10 | 11/09/2026 | Correção da validação R2. update:currency-pairs e create:official-exchange-rates e update:official-exchange-rates declaradas fora do catálogo do tenant e não atribuíveis a papel de tenant. conversionDate = D−1 civil, taxa do último fechamento até ela, com tabela de idade por dia de submissão; maxRateAgeDays com faixa 4–15 (conferido em python, quarta-feira de cinzas) e warnRateAgeDays semeado em 3 (RN-CUR-09, RN-CUR-25). Sai "opcionalmente persistida como taxa do tenant": taxa informada nunca vira ExchangeRate e aciona a controladoria (RN-CUR-27). Unicidade da cotação como requisito em toda a página; motivo do backfill alinhado à data de conversão |
| v1.9 | 11/09/2026 | Correção pós-validação. maxRateAgeDays com faixa 3–15 e erro CURRENCY_SETTINGS_MAX_RATE_AGE_DAYS_UNPROCESSABLE (zero recusaria toda segunda-feira). conversionDate escrita como dia útil anterior. Taxa do tenant é ato de controle, com registro e exchange-rate.recorded (RN-CUR-26). Imutabilidade e unicidade da cotação escritas como requisito, com recurso de banco só como exemplo; tipos uuid/timestamptz passam a Guid/instante. Saiu do corpo a nota de que até 08/09 o fator prescrito era 100 para JPY e CLP e 1000 para PYG |
| v1.8 | 10/09/2026 | Validação de 10/09. Moeda funcional BRL na v1, documentos e limites seguem multimoeda, e uma regra única de comparação: converte-se para a moeda de quem limita. O tipo decide a origem da taxa: PTAX só oficial, Manual/Contracted/Commercial só do tenant; substituir cotação oficial e buscar manualmente passam a ser de plataforma (create:official-exchange-rates e update:official-exchange-rates, RN-CUR-24); horário da rotina sai de CurrencySettings. conversionDate = dia anterior por regra e só o boletim de fechamento é gravado (RN-CUR-25). Correção de taxa por RateCorrection, nunca Adjustment (RN-CUR-11). Recebimento grava o Actual pela taxa do pedido; a nota lança só ExchangeVariance. Resíduo com uma regra só (RN-CC-23). numericCode sem a justificativa da NF-e/DI. |
| v1.7 | 09/09/2026 | Ajustes de convenção do fechamento do ciclo de 08/09. |
| v1.6 | 09/09/2026 | quoteUnit passa a ser coluna do BudgetEntry e do ApprovalEvent; nome do erro repassado pelo Fluxo corrigido. |
| v1.5 | 09/09/2026 | Moeda funcional aberta a qualquer ISO 4217 (revogado na v1.8); approvalLimitCurrency e comparação na moeda do limite (RN-CUR-23); conversão única na submissão (RN-CUR-22); quoteUnit vindo da fonte (RN-CUR-21). |
| v1.4 | 08/09/2026 | A lista de dias não úteis do Fluxo registrada como calendário distinto do bancário. |
| v1.2 | 04/09/2026 | CurrencyPair global: autoFetch derivado e edição do par só com permissão de plataforma (RN-CUR-20). |
| v1.1 | 03/09/2026 | Motivo codificado na substituição de cotação (ExchangeRateSupersede). |
| v1.0 | 01/09/2026 | Criação: catálogo ISO 4217, ativação por tenant, pares, cotação datada e imutável, cascata de resolução, rotina do PTAX e serviço de conversão. |