Skip to content

011 — Cotação FX viva (port ExchangeRateProvider + adapter HTTP)

A pergunta-cabeça do produto: “Cabe a viagem pra Amsterdam?” A meta está em EUR, o casal aporta em BRL, e o FeasibilityCheck (cenário 006) precisa converter requiredMonthly pra moeda do household. Hoje a cotação é passada hard-coded nos specs (ExchangeRate.of({ from: "EUR", to: "BRL", rate: 5.0 })). Em produção, alguém precisa ir buscar essa taxa numa fonte externa — e essa fonte tem que ser invocada fora do domínio (gotcha registrada: “Câmbio injetado, nunca buscado pelo domínio”).

Esse cenário introduz o port ExchangeRateProvider (em shared-kernel/application/ — várias features financeiras vão precisar de FX, não é só de um context) e um adapter HTTP real que bate numa API pública de câmbio. O domínio continua agnóstico — o caller (UI, agente, tool) pede ao provider o rate, e passa o objeto pronto pro FeasibilityCheck.evaluate({ ..., rate }).

Avaliadas três opções free:

ProviderFormatoAuthHistóricoTrade-off
exchangerate.hostJSON simplesnenhumasim (?date=YYYY-MM-DD)API estável, parsing trivial, OK pra free tier
ECB (European Central Bank)XMLnenhumasim (XML feed)autoridade oficial, mas parsing XML pesa; só taxas vs EUR
open.er-api.comJSONnenhumalimitadospot-only no free tier

Escolha: exchangerate.host. Justificativa: JSON sem auth + suporte a data histórica + endpoint simples (https://api.exchangerate.host/convert?from=EUR&to=BRL&date=YYYY-MM-DD). Parsing JSON cabe num adapter pequeno, sem dependência extra além de fetch. Se aparecer rate limit ou downtime, swap pra ECB é mecânico (mesma port, adapter novo).

Trade-off aceito: depende de terceiro free. Mitigação: cache TTL (1h) no adapter pra reduzir hits; fallback pra ECB documentado como follow-up se virar dor.

Scenario: Caller pede cotação EUR→BRL e recebe ExchangeRate com timestamp

Section titled “Scenario: Caller pede cotação EUR→BRL e recebe ExchangeRate com timestamp”
  • Given o provider ExchangeRateHostProvider configurado com fetch mockado retornando { success: true, query: { from: "EUR", to: "BRL" }, info: { rate: 5.0 }, date: "2026-06-01" }
  • When o caller pede provider.rateFor({ from: "EUR", to: "BRL" })
  • Then o provider devolve um ExchangeRate com from = "EUR", to = "BRL", rate = 5.0
  • And o ExchangeRate tem date igual a 2026-06-01 (timestamp da cotação, vindo da resposta da API)
  • And o provider bateu no endpoint /convert?from=EUR&to=BRL exatamente uma vez

Scenario: FeasibilityCheck consome rate vindo do provider e devolve resultado convertido

Section titled “Scenario: FeasibilityCheck consome rate vindo do provider e devolve resultado convertido”
  • Given um MockExchangeRateProvider que devolve EUR→BRL = 5,0 na data 01/06/2026
  • And o household “Casa” em BRL com monthlyIncome R$ 14.000 e expenses expected somando R$ 5.000
  • And a meta “Amsterdam Setembro/2026” target €6.000, deadline 01/09/2026 → requiredMonthly €2.000
  • When o caller (UX/tool) pede provider.rateFor({ from: "EUR", to: "BRL" }) e passa o resultado pro FeasibilityCheck.evaluate({ goal, household, budget, today, rate })
  • Then o FeasibilityResult vem com requiredMonthly em BRL (R$ 10.000) e surplus em BRL (R$ 9.000)
  • And o status é tight (R$ 9.000 ≥ 80% de R$ 10.000, mas < R$ 10.000)
  • And o gap é R$ 1.000
  • And o rate.date usado na conversão é 2026-06-01 (UX pode mostrar “cotação de 01/06/2026 às …”)

Scenario: Cache hit — segunda chamada pra mesma par+data não bate a API de novo

Section titled “Scenario: Cache hit — segunda chamada pra mesma par+data não bate a API de novo”
  • Given um MockExchangeRateProvider configurado com TTL de 1h e contador de calls externos
  • And uma primeira chamada rateFor({ from: "EUR", to: "BRL" }) já foi feita e o rate foi entregue
  • When o caller pede rateFor({ from: "EUR", to: "BRL" }) de novo dentro da janela TTL
  • Then o provider devolve o mesmo ExchangeRate (mesma instância ou equivalente)
  • And o contador de calls externos continua em 1 (a API real não foi batida de novo)
  • PortExchangeRateProvider em src/shared-kernel/application/. Cross-context (planning, goals, eventual UX de aporte multi-currency). Convenção (gotcha “Port em application/, Domain Service em domain/services/”): port mora em application/ porque é infra-facing (rede). Domain (ExchangeRate VO) continua em domain/ puro.
  • Adapter realExchangeRateHostProvider em src/shared-kernel/infrastructure/. Implementa ExchangeRateProvider. Usa fetch nativo do Node (≥18) — sem dep extra. Parser de JSON inline (small surface). Cache layer simples (Map<key, {rate, expiresAt}>) com TTL configurável (default 1h).
  • Adapter mockMockExchangeRateProvider em src/shared-kernel/infrastructure/. Test fake legítimo (gotcha “Test fake é adapter legítimo, não mock inline”) — quando o agente conversacional, a planning tool e a UX de aporte precisarem de FX no spec, todos importam o mesmo mock. Configurável via fila ou função.
  • VO extensionExchangeRate ganha campo opcional date?: Date. Callers antigos (cenário 006) continuam funcionando sem passar date; novos callers (vindos do provider) incluem o timestamp da cotação. UX usa rate.date pra mostrar “cotação de DD/MM/YYYY”.
src/shared-kernel/application/ExchangeRateProvider.ts
export interface ExchangeRateProvider {
rateFor(args: {
from: string; // "EUR"
to: string; // "BRL"
on?: Date; // data da cotação; default = hoje
}): Promise<ExchangeRate>;
}
// src/shared-kernel/infrastructure/ExchangeRateHostProvider.ts
class ExchangeRateHostProvider implements ExchangeRateProvider {
constructor(args: {
fetch?: typeof globalThis.fetch; // injetável pra testar; default = globalThis.fetch
baseUrl?: string; // default = "https://api.exchangerate.host"
ttlMs?: number; // default = 3_600_000 (1h)
now?: () => Date; // injetável pra testar TTL; default = () => new Date()
});
rateFor(args: { from: string; to: string; on?: Date }): Promise<ExchangeRate>;
}
// src/shared-kernel/infrastructure/MockExchangeRateProvider.ts
class MockExchangeRateProvider implements ExchangeRateProvider {
static of(rates: Array<{ from: string; to: string; rate: number; date?: Date }>): MockExchangeRateProvider;
readonly externalCalls: number; // contador pra spec de cache
rateFor(args: { from: string; to: string; on?: Date }): Promise<ExchangeRate>;
}
// src/shared-kernel/ExchangeRate.ts (extensão do 006)
ExchangeRate.of({ from: string, to: string, rate: number, date?: Date }): ExchangeRate
exchangeRate.date?: Date // timestamp da cotação, undefined quando rate veio sem origem datada
  • Port em shared-kernel/application/, não em planning/ — FX não é capability de planning. Goal, FeasibilityCheck, futuras tools do agente, UX de aporte multi-currency: todos consomem o mesmo port. Colocar em planning/ ancoraria errado; em shared-kernel/ fica acessível a qualquer context. Mesma razão de Money/ExchangeRate viverem no shared-kernel.
  • Adapter HTTP cacheia, port não conhece cache — a port é só rateFor(...): Promise<ExchangeRate>. Cache é detalhe do adapter (HTTP custa, mock não cacheia porque não precisa). Caller não passa flag de cache. Se aparecer caso “preciso forçar refresh”, aí adiciona bypassCache?: boolean na port — não preventivamente.
  • date opcional no ExchangeRate, não obrigatório — preserva back-compat com cenário 006 (que constrói rate hard-coded sem date). Novos callers (vindos do provider) sempre incluem date. UX checa if (rate.date) ... pra decidir mostrar timestamp.
  • fetch injetável no adapter — permite teste com fetch mockado (msw, vi.fn(), ou stub inline). Sem isso, spec do adapter precisaria de cassette ou rede real. Default = globalThis.fetch (Node 18+).
  • now: () => Date injetável pra testar TTL — mesma razão de today em FeasibilityCheck.evaluate({today}) (gotcha “domínio puro, testável, sem clock implícito”). Adapter precisa de clock pra TTL — então recebe via construtor pra spec poder avançar tempo sem vi.useFakeTimers().
  • Provider sugerido (exchangerate.host) NÃO vira ADR ainda — a escolha vive no body desse doc por enquanto. Se aparecer fricção real (rate limit, downtime), aí abre ADR formal “FX provider escolhido” superseding essa nota. Documentação progressiva, sem cerimônia preventiva.
  • MockExchangeRateProvider mora em infrastructure/, não em application/ nem inline na spec — gotcha “Test fake é adapter legítimo, não mock inline”. Múltiplas specs (006 futuro, 008/009 com FeasibilityTool, UX) vão importar o mesmo mock. Single source of truth.
  • TTL hard-coded em 1h — número mágico documentado (mesmo padrão do threshold 0.8 do FeasibilityCheck). Sem VO Ttl enquanto não aparecer caso (“queremos cotação fresca a cada 5 min na hora de fechar a meta”).
  • Cross-rateExchangeRate continua direcional (gotcha 006: “sem inversa, sem cross-rate”). Se precisar BRL→EUR, caller pede outro objeto. Provider que faz cross-rate internamente fica pra cenário próprio.
  • Histórico de cotações persistidas — provider não armazena, só busca + cacheia em memória. Se aparecer “quero ver gráfico do câmbio no último mês”, vira repo + adapter próprio.
  • Múltiplos providers em fallback — só um adapter de cada vez. Se exchangerate.host cair, swap manual pra ECB (re-wiring). Fallback automático fica pra cenário futuro.
  • Rate authority / official rates — exchangerate.host não é fonte oficial. Pra contabilidade fiscal, BCB tem endpoint próprio (ptax) — fica pra cenário próprio se aparecer.
  • Conversão batch — provider devolve um rate por chamada. Múltiplas moedas (rateFor({ from: "EUR", to: ["BRL", "USD"] })) fica pra cenário se aparecer.
  • Spec real HTTP contra exchangerate.host — tier infra mocka HTTP (msw ou stub de fetch) per ADR 002. Live test fica gated por @real quando bug do mock aparecer.

Criar ExchangeRateProvider em src/shared-kernel/application/, ExchangeRateHostProvider + MockExchangeRateProvider em src/shared-kernel/infrastructure/. Estender ExchangeRate VO com date?: Date. Sem mexer no FeasibilityCheck — ele já aceita ExchangeRate por injeção desde 006. Specs domain (esse doc) + spec infra colocada (HTTP mockado via stub de fetch).