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:
Budget.actualsByCurrency(period): Map<string, Money>— novo método no aggregate. Agrega osactualFor(period)de cadaRecurringExpensepor 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.BudgetTool.budgetTotal({period, displayCurrency?})— schema input do tool ganhadisplayCurrency?: stringopcional. Quando presente, a tool resolve rates viaExchangeRateProviderinjetado (port do 011) pra cada moeda diferente da display, soma e devolvetotalconsolidado. Quando ausente, devolve só obreakdownpor 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
displayCurrencymas 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
MockExchangeRateProviderinjetado na tool com seeds programados (mas nenhum precisa ser consumido nesse cenário) - When o casal pergunta via tool
budgetTotal({period: "2026-07"})(semdisplayCurrency) - Then o
breakdowncontém entries pra BRL e EUR no formato stringly-typed (["BRL 3000", "EUR 80"]) - And
totaléundefined(sem displayCurrency, sem soma consolidada) - And
unconvertibleCurrencieséundefinedou ausente (não houve tentativa de conversão) - And
provider.externalCallscontinua 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
MockExchangeRateProvidercom seedEUR→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
breakdowncontém"BRL 3000"e"EUR 80 (BRL 434.40)"(conversão explícita por linha, transparência da taxa aplicada) - And
unconvertibleCurrencieséundefinedou 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
MockExchangeRateProvidercom 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
unconvertibleCurrenciesinclui"JPY"(sinaliza pra UX/LLM admitir limite) - And o
breakdownainda 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
MockExchangeRateProvidersem nenhum seed (qualquerrateForlançaError) - When o casal pergunta
budgetTotal({period: "2026-07", displayCurrency: "BRL"}) - Then a tool NÃO propaga o throw (absorvido internamente)
- And
unconvertibleCurrenciesinclui"EUR"(única moeda que falhou) - And
totaléundefined - And o
breakdownainda 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ê
unconvertibleCurrenciese decide explicar pro casal (“não consegui pegar a cotação do EUR hoje, tenta de novo em uns minutos”)
Modelo
Section titled “Modelo”- 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 noBudgetaggregate. Iterathis.expenses, pegaactualFor(period)de cada, agrega no map pormoney.currency. Expenses semactualFor(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.BudgetToolestendido — construtor passa a aceitarexchangeRateProvider?: ExchangeRateProvideropcional. Schema input dobudgetTotalganhadisplayCurrency?: string. Lógica:- Pega
budget.actualsByCurrency(period). - Se
displayCurrencyausente: devolvebreakdownstringly-typed (["BRL 3000", "EUR 80"]) +total: undefined. - Se
displayCurrencypresente: 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"); }.
- Se
unconvertibleCurrencies.length > 0:total = undefined. Senão:total = "${displayCurrency} ${acumulador.amount.toFixed(2)}".
- Pega
- Output shape (TypeScript):
interface BudgetTotalMultiResult {period: string; // "2026-07"total?: string; // "BRL 3434.40" — só presente se TUDO converteubreakdown: 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}Decisões de design
Section titled “Decisões de design”- Output stringly-typed na fronteira LLM —
total: "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 campoconverted, 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. actualsByCurrencyignora 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 (expectedByCurrencyou variante).- Rate provider failure = dado-ausente, NÃO throw propagado —
try/catchem volta de cadaprovider.rateFor. Falha = adiciona moeda emunconvertibleCurrencies, marcatotal: 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”). totalausente quando há qualquer moeda não-convertível — total parcial seria mentira (“BRL 3434.40 + ??? JPY”). Melhortotal: undefinede breakdown completo. UX decide se mostra “BRL 3434.40 + ¥10.000 (não convertidos)” ou pede pro casal forçar refresh. Não confundir comtotal: 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 devolveMoneyem moeda original. Tool é quem chama o provider, traduz formatos, monta strings. Mesma fronteira do gotcha 008 (“ConversãoMoney→ 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. exchangeRateProvideropcional no construtor da tool — tool sem provider funciona pradisplayCurrencyausente (path “só breakdown”). Tool com provider funciona pros dois paths. Se caller passardisplayCurrencymas 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.allpra acelerar batch de rates; spec mantém sequência simples (awaitpor moeda). Performance é detalhe do impl. O contract é: 1 chamada arateForpor moeda distinta da display. actualsByCurrencyagrupa por moeda original, NÃO mescla — gasto EUR 80 + EUR 30 viram entrada única"EUR" → €110no map. Domínio agrega antes da tool ver. Tool não precisa iterar expenses. Mesmo padrão dovarianceReport.byExpense.displayCurrencyausente 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.unconvertibleCurrenciesausente quando vazio — segue padrão “Result flat com optional fields, não union discriminada” (gotchas 015 + 021). UX checaif (result.unconvertibleCurrencies?.length) .... Evita campounconvertibleCurrencies: []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
tocorrespondente 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.
Cross-context impacto
Section titled “Cross-context impacto”- Cenário 020 —
RecordSpendTooljá temcurrencyMismatchpath no propose. 024 valida o caminho que persiste em moeda diferente (segunda call comconfirm: true). Sem touchup em 020 — só exercita o que ele já cobre. - Cenário 008 —
BudgetTool.budgetTotalganha segundo overload de schema. Specs 008 não passamdisplayCurrencyentã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 viaunconvertibleCurrencies. Mesmo princípio: read tool nunca propaga falha de infra; sempre devolve POJO coerente pro LLM consumir.
Fora de escopo
Section titled “Fora de escopo”- Conversão histórica — 024 usa rate “atual” (sem
onnorateFor). Quando aparecer “qual o saldo de janeiro convertido pra BRL com câmbio de janeiro?”, caller passaonno provider. Provider já aceitaon(cenário 011). Tool repassa quando schema do tool ganharasOf?. - 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-currency —
actualsByCurrencyé 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 gasto —
RecurringExpense.actualsguardaMoney(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 comConversionLogou similar. - Rate batch (
rateForplural) — provider continua singular (cenário 011 fora-de-escopo). Tool faz N awaits sequenciais ouPromise.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).
Próximo passo
Section titled “Próximo passo”- Adicionar
Budget.actualsByCurrency(period: Period): Map<string, Money>emsrc/contexts/budget/domain/Budget.ts(loop sobrethis.expenses, agrega pormoney.currencyviaMap.set(currency, existing?.plus(actual) ?? actual)). - Estender
BudgetTool.createemsrc/contexts/agent/domain/tools/BudgetTool.tsaceitandoexchangeRateProvider?: ExchangeRateProvider. - Estender
BudgetTool.budgetTotalaceitando{period, displayCurrency?}no input — virar async, retornarBudgetTotalMultiResult | AbsenceResult. - Implementar conversão por moeda + montagem de breakdown stringly-typed +
unconvertibleCurrenciesna falha derateFor. - 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. - Atualizar barrel
src/contexts/agent/domain/index.tsse novo typeBudgetTotalMultiResultfor exportado.