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 }).
Provider escolhido: exchangerate.host
Section titled “Provider escolhido: exchangerate.host”Avaliadas três opções free:
| Provider | Formato | Auth | Histórico | Trade-off |
|---|---|---|---|---|
| exchangerate.host | JSON simples | nenhuma | sim (?date=YYYY-MM-DD) | API estável, parsing trivial, OK pra free tier |
| ECB (European Central Bank) | XML | nenhuma | sim (XML feed) | autoridade oficial, mas parsing XML pesa; só taxas vs EUR |
| open.er-api.com | JSON | nenhuma | limitado | spot-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
ExchangeRateHostProviderconfigurado comfetchmockado 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
ExchangeRatecomfrom = "EUR",to = "BRL",rate = 5.0 - And o
ExchangeRatetemdateigual a2026-06-01(timestamp da cotação, vindo da resposta da API) - And o provider bateu no endpoint
/convert?from=EUR&to=BRLexatamente 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
MockExchangeRateProviderque devolve EUR→BRL = 5,0 na data 01/06/2026 - And o household “Casa” em BRL com
monthlyIncomeR$ 14.000 e expensesexpectedsomando 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 proFeasibilityCheck.evaluate({ goal, household, budget, today, rate }) - Then o
FeasibilityResultvem comrequiredMonthlyem BRL (R$ 10.000) esurplusem 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.dateusado 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
MockExchangeRateProviderconfigurado 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)
Modelo
Section titled “Modelo”- Port —
ExchangeRateProvideremsrc/shared-kernel/application/. Cross-context (planning, goals, eventual UX de aporte multi-currency). Convenção (gotcha “Port emapplication/, Domain Service emdomain/services/”): port mora emapplication/porque é infra-facing (rede). Domain (ExchangeRateVO) continua emdomain/puro. - Adapter real —
ExchangeRateHostProvideremsrc/shared-kernel/infrastructure/. ImplementaExchangeRateProvider. Usafetchnativo 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 mock —
MockExchangeRateProvideremsrc/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 extension —
ExchangeRateganha campo opcionaldate?: Date. Callers antigos (cenário 006) continuam funcionando sem passar date; novos callers (vindos do provider) incluem o timestamp da cotação. UX usarate.datepra mostrar “cotação de DD/MM/YYYY”.
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.tsclass 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.tsclass 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 }): ExchangeRateexchangeRate.date?: Date // timestamp da cotação, undefined quando rate veio sem origem datadaDecisões de design
Section titled “Decisões de design”- Port em
shared-kernel/application/, não emplanning/— FX não é capability de planning. Goal, FeasibilityCheck, futuras tools do agente, UX de aporte multi-currency: todos consomem o mesmo port. Colocar emplanning/ancoraria errado; emshared-kernel/fica acessível a qualquer context. Mesma razão deMoney/ExchangeRateviverem 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í adicionabypassCache?: booleanna port — não preventivamente. dateopcional noExchangeRate, 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 checaif (rate.date) ...pra decidir mostrar timestamp.fetchinjetável no adapter — permite teste comfetchmockado (msw,vi.fn(), ou stub inline). Sem isso, spec do adapter precisaria de cassette ou rede real. Default =globalThis.fetch(Node 18+).now: () => Dateinjetável pra testar TTL — mesma razão detodayemFeasibilityCheck.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 semvi.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.
MockExchangeRateProvidermora eminfrastructure/, não emapplication/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
Ttlenquanto não aparecer caso (“queremos cotação fresca a cada 5 min na hora de fechar a meta”).
Fora de escopo
Section titled “Fora de escopo”- Cross-rate —
ExchangeRatecontinua 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@realquando bug do mock aparecer.
Próximo passo
Section titled “Próximo passo”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).