Skip to content

024 — Multi-currency real-use (casal viajando, saldo consolidado)

Casal mora no BR mas viaja. Em julho de 2026, eles passam duas semanas em Paris e gastam €80 num jantar, depois mais coisas em EUR. Voltam, conferem o orçamento: querem ver o total do mês consolidado em BRL (“quanto a gente gastou esse mês, somando tudo em real?”), mas também querem ver a moeda original preservada (“aquele jantar foi em euro mesmo, não me converte sem perguntar”).

Esse cenário não introduz aggregate novo. Refina dois pontos já existentes:

  1. Budget.actualsByCurrency(period): Map<string, Money> — novo método no aggregate. Agrega os actualFor(period) de cada RecurringExpense por moeda original, sem converter. Domínio segue agnóstico de FX (gotcha 002: “Domínio não converte moeda. UX converte”). Map {"BRL" → R$ 3.000, "EUR" → €80} quando o mês tem gastos mistos.
  2. BudgetTool.budgetTotal({period, displayCurrency?}) — schema input do tool ganha displayCurrency?: string opcional. Quando presente, a tool resolve rates via ExchangeRateProvider injetado (port do 011) pra cada moeda diferente da display, soma e devolve total consolidado. Quando ausente, devolve só o breakdown por moeda (sem converter nada). Rate provider failure (throw / undefined) é tratada como dado-ausente — cross-link gotcha 021 (anti-hallucination: melhor admitir “não consegui converter JPY” do que inventar saldo).

A decisão central: conversão acontece no tool layer, nunca no aggregate. A tool é o lugar natural — ela já existe pra traduzir fronteira LLM ↔ domínio (gotcha 008), e já segura POJO serializável. O aggregate continua devolvendo Money puro por moeda.

Schema input do tool (fronteira LLM):

budgetTotal({period: "2026-07", displayCurrency?: "BRL"})

Output stringly-typed POJO (fronteira LLM, gotcha 008 + 012 “Stringly-typed na fronteira LLM”):

  • Com displayCurrency + conversão OK: {period: "2026-07", total: "BRL 3434.40", breakdown: ["BRL 3000", "EUR 80 (BRL 434.40)"]}.
  • Sem displayCurrency: {period: "2026-07", breakdown: ["BRL 3000", "EUR 80"], total: undefined}.
  • Com displayCurrency mas rate indisponível pra alguma moeda: {period: "2026-07", breakdown: ["BRL 3000", "JPY 10000"], unconvertibleCurrencies: ["JPY"], total: undefined} (parcial = sem total; UX sabe que tem moeda fora de cobertura).

Scenario: Casal registra gasto EUR via whats — persistido na moeda original

Section titled “Scenario: Casal registra gasto EUR via whats — persistido na moeda original”
  • Given um Budget da casa em BRL com RecurringExpense “Viagem” (expected R$ 0, aliases ["viagem", "restaurante"], vencimento dia 5)
  • And today = 2026-07-15
  • When o casal manda recordSpend({description: "gastei 80 euros restaurante Paris", amount: 80, currency: "EUR", operationId: "op-1"}) (sem confirm)
  • Then a tool detecta currency !== budget.currency (EUR vs BRL) → cai no propose (cenário 020)
  • And o receipt sai com currencyMismatch: true, fastPath: false, persisted: false
  • When segunda call com {..., operationId: "op-1", confirm: true} (casal confirma explicitamente o gasto em moeda estrangeira)
  • Then o receipt vira confirmed: true, persisted: true
  • And viagem.actualFor(julho/2026) é Money.of(80, "EUR") (moeda original preservada, domínio NÃO converteu)
  • And budget.actualsByCurrency(julho/2026) retorna Map com entrada "EUR" → Money(80, "EUR") (única moeda do mês)

Scenario: BudgetTool.budgetTotal sem displayCurrency — retorna breakdown por moeda

Section titled “Scenario: BudgetTool.budgetTotal sem displayCurrency — retorna breakdown por moeda”
  • Given o Budget com Aluguel BRL R$ 3.000 (actualFor julho registrado) e Viagem EUR €80 (actualFor julho registrado)
  • And um MockExchangeRateProvider injetado na tool com seeds programados (mas nenhum precisa ser consumido nesse cenário)
  • When o casal pergunta via tool budgetTotal({period: "2026-07"}) (sem displayCurrency)
  • Then o breakdown contém entries pra BRL e EUR no formato stringly-typed (["BRL 3000", "EUR 80"])
  • And total é undefined (sem displayCurrency, sem soma consolidada)
  • And unconvertibleCurrencies é undefined ou ausente (não houve tentativa de conversão)
  • And provider.externalCalls continua em 0 (rate NÃO foi pedido — sem displayCurrency, não há conversão)

Scenario: BudgetTool.budgetTotal com displayCurrency BRL — converte EUR e soma

Section titled “Scenario: BudgetTool.budgetTotal com displayCurrency BRL — converte EUR e soma”
  • Given o mesmo Budget (Aluguel BRL R$ 3.000 + Viagem EUR €80) em julho/2026
  • And o MockExchangeRateProvider com seed EUR→BRL = 5.43 (data 2026-07-15)
  • When o casal pergunta budgetTotal({period: "2026-07", displayCurrency: "BRL"})
  • Then a tool chama provider.rateFor({from: "EUR", to: "BRL"}) exatamente uma vez (BRL não precisa converter sobre si mesmo)
  • And o total é "BRL 3434.40" (3000 + 80 * 5.43)
  • And o breakdown contém "BRL 3000" e "EUR 80 (BRL 434.40)" (conversão explícita por linha, transparência da taxa aplicada)
  • And unconvertibleCurrencies é undefined ou ausente (tudo converteu)

Scenario: Rate indisponível pra alguma moeda — fallback gracioso, total ausente

Section titled “Scenario: Rate indisponível pra alguma moeda — fallback gracioso, total ausente”
  • Given o Budget com actuals em BRL (Aluguel R$ 3.000), EUR (Viagem €80) e JPY (Restaurante Tokyo ¥10.000) em julho/2026
  • And o MockExchangeRateProvider com seed só pra EUR→BRL (sem seed pra JPY→BRL — provider vai dar throw nesse par)
  • When o casal pergunta budgetTotal({period: "2026-07", displayCurrency: "BRL"})
  • Then o total é undefined (cobertura parcial = sem consolidado)
  • And unconvertibleCurrencies inclui "JPY" (sinaliza pra UX/LLM admitir limite)
  • And o breakdown ainda lista todas as moedas: "BRL 3000", "EUR 80 (BRL 434.40)" (EUR convertido OK), "JPY 10000" (raw, sem parêntese — não converteu)
  • And a tool NÃO propagou o throw do provider (cross-link gotcha 021 — read tool trata erro de provider como dado-ausente)

Scenario: ExchangeRateProvider throw → tool absorve, breakdown raw, sem total

Section titled “Scenario: ExchangeRateProvider throw → tool absorve, breakdown raw, sem total”
  • Given o Budget com gasto único em EUR (Viagem €80) em julho/2026
  • And um MockExchangeRateProvider sem nenhum seed (qualquer rateFor lança Error)
  • When o casal pergunta budgetTotal({period: "2026-07", displayCurrency: "BRL"})
  • Then a tool NÃO propaga o throw (absorvido internamente)
  • And unconvertibleCurrencies inclui "EUR" (única moeda que falhou)
  • And total é undefined
  • And o breakdown ainda mostra ["EUR 80"] (raw, sem conversão entre parênteses)
  • And a tool ainda devolve um result coerente (não throw, não null) — caller (LLM via AgentChat) lê unconvertibleCurrencies e decide explicar pro casal (“não consegui pegar a cotação do EUR hoje, tenta de novo em uns minutos”)
  • Sem aggregate novo — 024 não cria context novo, não promove VO. Só estende API de leitura.
  • Budget.actualsByCurrency(period: Period): Map<string, Money> — novo método no Budget aggregate. Itera this.expenses, pega actualFor(period) de cada, agrega no map por money.currency. Expenses sem actualFor(period) são ignorados (não caem no fallback expected — 024 é sobre gasto real, não planning view do 003). Domínio puro, agnóstico de FX.
  • BudgetTool estendido — construtor passa a aceitar exchangeRateProvider?: ExchangeRateProvider opcional. Schema input do budgetTotal ganha displayCurrency?: string. Lógica:
    1. Pega budget.actualsByCurrency(period).
    2. Se displayCurrency ausente: devolve breakdown stringly-typed (["BRL 3000", "EUR 80"]) + total: undefined.
    3. Se displayCurrency presente: pra cada moeda no map:
      • Igual à display → soma direto no acumulador, formato breakdown line sem parênteses.
      • Diferente da display → try { rate = await provider.rateFor({from, to: displayCurrency}); converted = rate.convert(money); acumulador.plus(converted); breakdown.push("EUR 80 (BRL 434.40)"); } catch { unconvertibleCurrencies.push(from); breakdown.push("EUR 80"); }.
    4. Se unconvertibleCurrencies.length > 0: total = undefined. Senão: total = "${displayCurrency} ${acumulador.amount.toFixed(2)}".
  • Output shape (TypeScript):
    interface BudgetTotalMultiResult {
    period: string; // "2026-07"
    total?: string; // "BRL 3434.40" — só presente se TUDO converteu
    breakdown: string[]; // ["BRL 3000", "EUR 80 (BRL 434.40)", "JPY 10000"]
    unconvertibleCurrencies?: string[]; // ["JPY"] quando parcial; ausente quando tudo OK
    }
  • Back-compat 008 — output existente {expected, period} continua valendo pro path “Budget vazio / sem displayCurrency / cenário 008”. Specs 008 ficam verdes sem touchup (ou ganham overload no shape se necessário; 024 NÃO pede asserção do 008).
// src/contexts/budget/domain/Budget.ts (novo método)
budget.actualsByCurrency(period: Period): Map<string, Money>
// chave: currency code ("BRL", "EUR", "JPY")
// valor: Money agregando todos os actualFor(period) daquela moeda
// expenses sem actualFor(period) NÃO entram (não usa fallback expected — 024 é sobre gasto real)
// src/contexts/agent/domain/tools/BudgetTool.ts (estendido)
BudgetTool.create(props: {
budget: Budget;
exchangeRateProvider?: ExchangeRateProvider; // opcional — só obrigatório quando displayCurrency vier no input
}): BudgetTool
budgetTool.budgetTotal(args: {
period: string; // "YYYY-MM"
displayCurrency?: string; // ex: "BRL"; ausente = sem consolidação
}): Promise<BudgetTotalMultiResult | AbsenceResult>;
interface BudgetTotalMultiResult {
period: string;
total?: string; // stringly-typed: "BRL 3434.40"
breakdown: string[]; // ["BRL 3000", "EUR 80 (BRL 434.40)", ...]
unconvertibleCurrencies?: string[]; // ausente quando tudo converteu; ["JPY"] quando parcial
}
  • Output stringly-typed na fronteira LLMtotal: "BRL 3434.40", breakdown ["EUR 80 (BRL 434.40)"] (com unit junto e conversão em parênteses) em vez de {amount, currency, converted: {amount, currency}}. Mesmo padrão da working memory do cenário 012 (activeGoals[].target = "EUR 6000"). LLM lê melhor strings curtas que objetos aninhados, e a tool não precisa explicar “olha o campo converted, mostra ele se existir”. UX/LLM faz só result.breakdown.join(", "). Se UI web precisar de campos estruturados pra renderizar verde/vermelho, vira shape paralelo (não breaking) — quando aparecer.
  • actualsByCurrency ignora expenses sem actual — 024 mostra gasto real do mês, não planning view. actualTotal(period) do 003 cai no fallback expected pra dar planning estimate; 024 quer ver só fatos. Quando UX precisar de “planning view multi-currency” (futuro), abre método paralelo (expectedByCurrency ou variante).
  • Rate provider failure = dado-ausente, NÃO throw propagadotry/catch em volta de cada provider.rateFor. Falha = adiciona moeda em unconvertibleCurrencies, marca total: undefined, e ainda devolve breakdown raw daquela moeda. Cross-link gotcha 021 (“agente admite limite”) — read tool nunca quebra fluxo do agente por causa de infra externa. LLM lê o array e admite limite pro casal (“a cotação do JPY tá fora hoje, mas EUR e BRL converteram”).
  • total ausente quando há qualquer moeda não-convertível — total parcial seria mentira (“BRL 3434.40 + ??? JPY”). Melhor total: undefined e breakdown completo. UX decide se mostra “BRL 3434.40 + ¥10.000 (não convertidos)” ou pede pro casal forçar refresh. Não confundir com total: 0 — zero é resposta válida (nenhum gasto no mês), undefined é “não sei somar”.
  • Conversão acontece na tool, NÃO no Budget.actualsByCurrency — método de domínio devolve Money em moeda original. Tool é quem chama o provider, traduz formatos, monta strings. Mesma fronteira do gotcha 008 (“Conversão Money → POJO acontece na tool, não no aggregate”) — mantém aggregate agnóstico de I/O e de fronteira LLM. Provider injetado na tool, não no aggregate.
  • exchangeRateProvider opcional no construtor da tool — tool sem provider funciona pra displayCurrency ausente (path “só breakdown”). Tool com provider funciona pros dois paths. Se caller passar displayCurrency mas tool não tem provider, tool comporta como “todas as moedas diferentes da display são unconvertible” (degrada gracioso, gotcha 021). Specs 008 que não passam provider continuam funcionando (back-compat).
  • Rate é resolvido por moeda, paralelizável mas spec sequencial — implementação real pode fazer Promise.all pra acelerar batch de rates; spec mantém sequência simples (await por moeda). Performance é detalhe do impl. O contract é: 1 chamada a rateFor por moeda distinta da display.
  • actualsByCurrency agrupa por moeda original, NÃO mescla — gasto EUR 80 + EUR 30 viram entrada única "EUR" → €110 no map. Domínio agrega antes da tool ver. Tool não precisa iterar expenses. Mesmo padrão do varianceReport.byExpense.
  • displayCurrency ausente NÃO é erro — caso natural (“me mostra o orçamento do mês, sem consolidar”). Output coerente: breakdown + total: undefined. LLM/UX renderiza lista por moeda.
  • unconvertibleCurrencies ausente quando vazio — segue padrão “Result flat com optional fields, não union discriminada” (gotchas 015 + 021). UX checa if (result.unconvertibleCurrencies?.length) .... Evita campo unconvertibleCurrencies: [] que confunde com “tudo convertível”.
  • Breakdown line format: "EUR 80 (BRL 434.40)" quando converteu, "EUR 80" raw quando não — formato visual unifica casos. UX só lê linha. Quando tudo converter, breakdown e total batem. Quando algum não converter, breakdown mostra qual e total falta.
  • Money.amount é number cru (sem casas decimais fixas) — tool formata via .toFixed(2) no momento de montar a string. Mantém domínio agnóstico de display precision. Quando virar dinero.js (ADR 001), .toFixed(2) continua valendo no boundary.
  • Reusa MockExchangeRateProvider intacto — seed sem to correspondente faz o provider throw (linha 41-43 do impl). 024 usa esse comportamento existente pra simular “rate indisponível”. Sem novo método de mock; sem mock inline.
  • Cenário 020RecordSpendTool já tem currencyMismatch path no propose. 024 valida o caminho que persiste em moeda diferente (segunda call com confirm: true). Sem touchup em 020 — só exercita o que ele já cobre.
  • Cenário 008BudgetTool.budgetTotal ganha segundo overload de schema. Specs 008 não passam displayCurrency então caem no path “breakdown sem total” ou no path absence (Budget vazio). Decidir no impl se preserva o output {expected, period} quando há expenses mas displayCurrency ausente (back-compat estrita), ou se 008 ganha touchup pra novo shape. 024 NÃO força essa decisão — só pede que os 5 cenários do 024 passem.
  • Cenário 021 — pattern “tool absorve falha de infra” estendido. 021 cobre tool throw via ToolErrorEnvelope. 024 cobre provider (dependência externa da tool) throw via unconvertibleCurrencies. Mesmo princípio: read tool nunca propaga falha de infra; sempre devolve POJO coerente pro LLM consumir.
  • Conversão histórica — 024 usa rate “atual” (sem on no rateFor). Quando aparecer “qual o saldo de janeiro convertido pra BRL com câmbio de janeiro?”, caller passa on no provider. Provider já aceita on (cenário 011). Tool repassa quando schema do tool ganhar asOf?.
  • Multi-currency consolidation pra Goal — cenário 002 já tem savedByCurrency() (mesmo pattern). Goal não consolida pra moeda do target — UX faz por fora. Quando virar caso real (“mostra meu goal em BRL”), abre cenário próprio espelhando 024 (GoalTool.goalStatus({displayCurrency?}) com mesma régua).
  • Threshold de “muito convertido” / alerta de spread — UX-side. Domínio não decide se “EUR 80 → BRL 434” é “tá caro o euro hoje” ou “tá normal”. Sinal qualitativo fica fora.
  • Forecast de gastos futuros multi-currencyactualsByCurrency é retrospectivo. Forecast multi-currency (média móvel por moeda) fica pra cenário próprio.
  • Cache do rate dentro do BudgetTool — provider já cacheia (cenário 011, MockExchangeRateProvider + ExchangeRateHostProvider). Tool não cacheia em camada própria.
  • Persistir cotação histórica do gastoRecurringExpense.actuals guarda Money (amount + currency). Não guarda taxa aplicada na hora de exibir. Se aparecer caso “quero ver com QUE taxa converti em julho/2026”, abre cenário próprio com ConversionLog ou similar.
  • Rate batch (rateFor plural) — provider continua singular (cenário 011 fora-de-escopo). Tool faz N awaits sequenciais ou Promise.all. Otimização é impl, não contract.
  • Currency mismatch fast-path — gotcha 020 mantém: gasto em moeda diferente do Budget vai por propose. 024 NÃO afrouxa essa régua (mesmo com displayCurrency setado).
  1. Adicionar Budget.actualsByCurrency(period: Period): Map<string, Money> em src/contexts/budget/domain/Budget.ts (loop sobre this.expenses, agrega por money.currency via Map.set(currency, existing?.plus(actual) ?? actual)).
  2. Estender BudgetTool.create em src/contexts/agent/domain/tools/BudgetTool.ts aceitando exchangeRateProvider?: ExchangeRateProvider.
  3. Estender BudgetTool.budgetTotal aceitando {period, displayCurrency?} no input — virar async, retornar BudgetTotalMultiResult | AbsenceResult.
  4. Implementar conversão por moeda + montagem de breakdown stringly-typed + unconvertibleCurrencies na falha de rateFor.
  5. Decidir back-compat do schema antigo {month} em 008 (manter overload ou touchup do spec 008 pra novo schema {period}). 024 só exige que seus 5 cenários passem.
  6. Atualizar barrel src/contexts/agent/domain/index.ts se novo type BudgetTotalMultiResult for exportado.