020 — Gasto rápido sem confirm (fricção zero pra micro-transações)
Pesquisa NotebookLM (mel_finance, 2026-06-03) sobre PLG fintech conversacional BR: brasileiro abre WhatsApp 87 vezes por dia, e “fricção de registro” é o que mata a UX no segundo mês — 30% dos gastos variáveis ficam esquecidos se não anotados em 48h. O cenário 013 introduziu propose → confirm como default seguro (UX seguro > amigável: erros caros precisam de double-tap). Mas pedir confirmação pra cada “gastei 50 no mercado” destrói exatamente o uso natural do canal whats — duas mensagens pra registrar R$ 50 é cerimônia demais.
Esse cenário refina o pattern 013, não substitui. Introduz um modo fast-path condicional dentro da própria RecordSpendTool:
- Fast (auto-persiste, sem preview): o gasto é micro e o match contra o
Budgeté unambíguo. A tool chamaexpense.addToActual(period, money)+budgetRepo.savena primeira call. Receipt sai comfastPath: true,confirmed: true,persisted: true. Sem segunda chamada. - Propose (default 013): o gasto é alto ou o match é ambíguo/ausente ou a currency não bate. Cai no fluxo já existente — preview na primeira call, persiste na segunda com
confirm: true+ mesmooperationId.
As duas portas de saída (“fast” vs “propose”) ficam escondidas dentro da tool. A fronteira LLM continua a mesma do 013 — schema único {description, amount, currency, date?, operationId, confirm?}. O LLM não decide o modo; a tool decide com base no estado do Budget. Caller (whats router em wa-002, UI web em 008/013) lê receipt.fastPath pra decidir UX de resposta — “anotado R$ 50 mercado” curto no fast vs “vou registrar… confirma?” formal no propose.
Threshold R$ 200 hard-coded — mesma régua do 0.8 do FeasibilityCheck (006) e do 80% warn / 100% critical do BudgetAlerts (015). Constante mágica documentada como decisão consciente. Promove a parâmetro/VO quando aparecer caso real (“nosso casal quer fast só até R$ 50”, “fast pra mercado mas propose pra eletrônicos”). Override por parâmetro é o primeiro passo antes de virar VO (gotcha 015).
Budget.matchExpense(description): RecurringExpense | undefined — novo método, wrapper de uma linha sobre this.expenses.find(e => e.matches(description)). Reusa RecurringExpense.matches() do 004 (substring case-insensitive sobre aliases). Não cria aggregate root duplicado — só sobe a busca por descrição livre pro Budget pra a tool não precisar conhecer a coleção interna.
Scenario: Valor baixo + match unambíguo → fast path, sem confirm
Section titled “Scenario: Valor baixo + match unambíguo → fast path, sem confirm”- Given um Budget da casa em BRL com
RecurringExpense“Mercado” (expected R$ 800, aliases["mercado"], vencimento dia 5) - And o Budget persistido via
BudgetRepository.save - And
today = 2026-06-15,actualFor(junho/2026)ainda undefined (nenhum gasto registrado) - When a tool recebe
recordSpend({description: "gastei 50 no mercado", amount: 50, currency: "BRL", operationId: "op-1"})(semconfirm) - Then a tool internamente chama
budget.matchExpense("gastei 50 no mercado")e encontra Mercado (substring “mercado” bate) - And detecta
amount <= 200(threshold) ecurrency === budget.currency→ entra no fast-path - And chama
expense.addToActual(junho/2026, R$ 50)direto (sem preview gate) - And chama
budgetRepo.save(budget)na primeira call - And retorna
ToolReceiptcomoperationId: "op-1",fastPath: true,confirmed: true,persisted: true,preview: {expense:"Mercado", period:"2026-06", delta:{amount:50,currency:"BRL"}, newActual:{amount:50,currency:"BRL"}} - And recarregar o Budget via
budgetRepo.loadmostraMercado.actualFor(junho/2026) = R$ 50
Scenario: Valor alto (> R$ 200) → cai pra propose → confirm
Section titled “Scenario: Valor alto (> R$ 200) → cai pra propose → confirm”-
Given o mesmo Budget com Mercado (expected R$ 800, aliases
["mercado"]) -
And
actualFor(junho/2026)ainda undefined -
When a tool recebe
recordSpend({description: "gastei 350 no mercado", amount: 350, currency: "BRL", operationId: "op-2"})(semconfirm) -
Then a tool encontra Mercado mas detecta
amount > 200→ cai no fluxo 013 (propose) -
And retorna
ToolReceiptcomfastPath: false,confirmed: false,persisted: false,previewpopulado -
And
budgetRepo.savenão foi chamado -
And o Budget no repo continua intacto —
actualFor(junho/2026)ainda undefined -
Given segunda call com
{description: "gastei 350 no mercado", amount: 350, currency: "BRL", operationId: "op-2", confirm: true}(mesmooperationId) -
When a tool roda
-
Then retorna
ToolReceiptcomfastPath: false,confirmed: true,persisted: true -
And
budgetRepo.savefoi chamado uma vez -
And recarregar o Budget mostra
Mercado.actualFor(junho/2026) = R$ 350
Scenario: Match ambíguo (nenhum expense bate) → propose
Section titled “Scenario: Match ambíguo (nenhum expense bate) → propose”- Given o mesmo Budget com Mercado (aliases
["mercado"]) — nada que match “padaria” - When a tool recebe
recordSpend({description: "gastei 30 na padaria", amount: 30, currency: "BRL", operationId: "op-3"})(semconfirm) - Then
budget.matchExpense("gastei 30 na padaria")retornaundefined(nenhum alias bate) - And mesmo
amount <= 200, a tool não entra no fast-path (sem match unambíguo, não tem expense pra mutar) - And retorna
ToolReceiptcomfastPath: false,confirmed: false,persisted: false - And
previewcontém shape sinalizando “expense não encontrada” — sugerindo registrar como novo expense (preview.unmatched === trueepreview.description === "gastei 30 na padaria") - And
budgetRepo.savenão foi chamado - And o Budget no repo continua sem alteração
Scenario: Idempotência no fast-path
Section titled “Scenario: Idempotência no fast-path”- Given o mesmo Budget com Mercado (aliases
["mercado"]) - And primeira call
recordSpend({description:"gastei 50 no mercado", amount:50, currency:"BRL", operationId:"op-1"})já rodou com sucesso (fast-path,persisted: true) - And
Mercado.actualFor(junho/2026)já é R$ 50 no repo - When segunda call com mesmos args + mesmo
operationId:"op-1" - Then a tool detecta
operationIdno log e curto-circuita - And retorna
ToolReceiptcomoperationId:"op-1",confirmed: true,persisted: false(idempotente — não persistiu dessa vez) - And
fastPath: true(estado preservado do receipt original) - And
budgetRepo.savenão foi chamado de novo - And
Mercado.actualFor(junho/2026)continua R$ 50 (não virou R$ 100)
Scenario: Currency mismatch → propose (não fast)
Section titled “Scenario: Currency mismatch → propose (não fast)”- Given o mesmo Budget em BRL com Mercado (expected R$ 800)
- When a tool recebe
recordSpend({description: "gastei 20 no mercado", amount: 20, currency: "USD", operationId: "op-4"})(semconfirm) - Then a tool encontra Mercado e
amount <= 200, mas detectacurrency !== budget.currency→ não entra no fast-path - And retorna
ToolReceiptcomfastPath: false,confirmed: false,persisted: false - And
previewsinaliza moeda divergente (preview.currencyMismatch === true,preview.budgetCurrency === "BRL",preview.deltaCurrency === "USD") pra UX/LLM pedir confirm explícito do casal - And
budgetRepo.savenão foi chamado
Modelo
Section titled “Modelo”- Refina
RecordSpendToolexistente — não cria nova tool. Mesmo schema input do 013 +description: string(renomeia oexpensedo 013, que vinha como nome exato vindo do LLM no propose→confirm — fast-path lê descrição livre vinda do whats e descobre o expense). Schema mantémconfirm?: booleanopcional pra preservar o fluxo 013 nos outros cenários. - Decisão de modo é da tool, não do LLM — fronteira LLM continua com schema único. LLM passa
{description, amount, currency, operationId, confirm?}e fica agnóstico se vai virar fast ou propose. Tool decide olhando o estado do Budget. Vantagem: mesmo prompt do whats router funciona pra micro e macro transações. - Threshold R$ 200 = constante hard-coded — exportada do módulo como
FAST_PATH_THRESHOLD_BRL(ou inlineprivate static readonly). Documentada como decisão consciente. Não vira VO enquanto não aparecer caso de variação (varia por casal/categoria/contexto). Mesma postura do0.8doFeasibilityCheck(006) e dos80% / 100%doBudgetAlerts(015). fastPath?: booleanopcional noToolReceipt— flag adicional pra caller diferenciar UX. Ausente/false = fluxo 013 normal. True = persistiu na primeira call sem confirm. Caller (whats router em wa-002) pode renderizar “anotado R$ 50 mercado” no fast vs preview formal no propose.preview.unmatched/preview.currencyMismatch— flags opcionais no preview pra sinalizar por que caiu no propose. UX/LLM lê e formata a pergunta certa (“não achei ‘padaria’ — registra como expense novo?” vs “moeda diferente da do orçamento — confirma mesmo assim?”). Reforça gotcha “Result flat com optional fields, não union discriminada” (015).Budget.matchExpense(description)— wrapper único de uma linha sobrethis.expenses.find(e => e.matches(description)). Não duplica lógica do 004; só sobe ela do RecurringExpense pro aggregate root pra a tool não precisar iterarbudget.list()externamente.
// src/contexts/budget/domain/Budget.ts (novo método)budget.matchExpense(description: string): RecurringExpense | undefined // retorna o PRIMEIRO expense cujo matches(description) === true; undefined se nenhum
// src/contexts/agent/domain/tools/ToolReceipt.ts (campo opcional novo)interface ToolReceipt { operationId: string; confirmed: boolean; persisted: boolean; fastPath?: boolean; // novo — true quando tool pulou propose→confirm preview: Record<string, unknown>;}
// src/contexts/agent/domain/tools/RecordSpendTool.ts (refina 013)interface RecordSpendArgs { description: string; // descrição livre vinda do casal ("gastei 50 no mercado") amount: number; currency: string; date?: string; // ISO YYYY-MM-DD — period derivado se ausente, usa today operationId: string; confirm?: boolean; // ignorado no fast-path; usado no propose pra confirmar}
recordSpendTool.recordSpend(args: RecordSpendArgs): Promise<ToolReceipt> // 1. Se operationId já no log → retorna receipt anterior com persisted:false (idempotente). // 2. budget = await budgetRepo.load(budgetId). // 3. expense = budget.matchExpense(args.description). // 4. Se expense && amount <= FAST_PATH_THRESHOLD && currency === budget.currency: // → fast-path: addToActual + save → receipt {fastPath:true, confirmed:true, persisted:true}. // 5. Senão (sem match | valor alto | currency diferente): // → propose→confirm: receipt {fastPath:false, confirmed:!!confirm, persisted:!!confirm}.
// Constante (hard-coded, doc-as-decision)const FAST_PATH_THRESHOLD_BRL = 200;Decisões de design
Section titled “Decisões de design”- Fast-path é exceção curada à régua 013, não revogação — propose→confirm continua default. Fast só corta o flow quando os três sinais convergem: (a) match unambíguo via
matches(), (b) amount ≤ threshold, (c) currency bate com Budget. Se qualquer um falha, cai no propose — segurança 013 preservada. Fast ≠ “agente faz o que quer”; fast = “o casal já disse explicitamente onde + quanto + na moeda da casa, em valor que erro custa pouco”. Combinação rara em magnitude (R$ 350 num cartão de crédito é gasto consciente, não trivial). - Threshold R$ 200 BRL hard-coded — escolha empírica (mais ou menos uma compra de mercado típica), documentada como número mágico. Promove a parâmetro/VO quando aparecer caso real (“varia por casal” — segundo passo via override; “varia por categoria/preset” — terceiro passo virando VO). Mesma trajetória do
0.8doFeasibilityChecke80%/100%doBudgetAlerts. Currency hard-coded como BRL é intencional pro MVP — quando aparecer Budget em EUR/USD com fast, o threshold passa a ser por-currency (Map ou VO). descriptionsubstituiexpenseno schema — 013 passavaexpense: "Mercado"(nome exato, LLM mapeou antes de chamar). 020 troca pordescription: "gastei 50 no mercado"(descrição livre do casal, tool resolve via match). É breaking do schema 013 — justificado porque (a) fronteira LLM fica mais natural (LLM passa o input do casal direto), (b) habilita match ambíguo como sinal pra propose, (c) reduz round-trip “qual expense você quis dizer?” antes de chamar a tool. Specs 013 precisam de touchup quando rodar; vale o custo.fastPathflag separada deconfirmed/persisted—confirmed: true, persisted: true, fastPath: true(primeira call no fast) é semanticamente diferente deconfirmed: true, persisted: true, fastPath: false(segunda call do propose com confirm: true). Caller diferencia UX. Reforça gotcha “ToolReceipt.persisted≠ToolReceipt.confirmed” (013) — flags ortogonais, cada uma com seu sinal.preview.unmatchedepreview.currencyMismatchcomo flags opcionais — não viramkind: "unmatched" | "matched-low-amount" | ...union. Mantémpreviewcomo POJO flat com opcionais, igual a réguagap?doFeasibilityResult(006) eexpense?doAlert(015). UX checa flags conforme precisa, sem narrowing de tipo obrigatório.- Idempotência no fast preserva
fastPath: true— segunda call com mesmooperationIdretornaconfirmed: true, persisted: false, fastPath: true. OfastPathreflete a primeira execução, não a chamada atual. Garante observabilidade (“essa op foi um fast-path”) mesmo depois de retry. Mesma régua do receipt log 013, expandida. - Tool não cria expense novo no caso “unmatched” — só sinaliza via
preview.unmatched: true. Casal/LLM decide: “registra como expense novo” → chamaRegisterExpenseTool(013) separadamente. Princípio: write tool faz uma coisa. Compor tools é responsabilidade do LLM/UX, não da tool individual. date?opcional + fallback pratoday— 013 não tinha date no schema dorecordSpend(period vinha como string “2026-06”). 020 simplifica: casal fala “gastei 50 mercado” sem data, tool assumetodayinjetado (mesma régua dotodayemFeasibilityCheck/BudgetAlerts). Period derivado detodayquando date ausente. Manter compatibilidade com 013 (period explícito) entra como touchup.
Fora de escopo
Section titled “Fora de escopo”- Threshold por categoria / por casal — hoje único, hard-coded em BRL. Quando aparecer “fast só pra mercado”, “casal X tem threshold 50”, vira parâmetro no construtor → VO.
- Currency fast-path em moeda diferente do Budget — hoje só BRL ou só currency-matching com Budget. Multi-currency fast (Budget BRL + gasto EUR pequeno → cota e persiste sob conversão) precisa de FX (cenário 011) e decisão UX explícita.
- Match fuzzy/LLM-based —
matches()continua substring case-insensitive (gotcha 004). Fast só dispara em match seco. Sem expansão pra Levenshtein/embedding. - Tool sugere criar expense novo no “unmatched” — só sinaliza com flag. Composição “unmatched → RegisterExpenseTool” fica pro LLM/UX orquestrar (multi-tool em um turno é fora do escopo do 013 também).
- Recall do fast-path — sem “desfazer” automático. Casal corrige via
recordSpendcom valor negativo (futuro — gotcha “Sobrescreve, não acumula” do 003 vsaddToActualincremental do 004 precisa de nova decisão). - Audit log “esse gasto foi fast” —
receipt.fastPath: truefica em memória da tool (mesmo limite do receipt log 013). Persistência audit é cenário futuro. - Confirmação “default-on” pro casal cuidadoso — opt-out do fast-path por configuração de casal (“sempre me pergunte”). Quando aparecer demanda, vira flag no construtor da tool ou config do Household.
Próximo passo
Section titled “Próximo passo”Adicionar Budget.matchExpense(description) em src/contexts/budget/domain/Budget.ts (wrapper sobre expenses.find(e => e.matches(description))). Refinar RecordSpendTool em src/contexts/agent/domain/tools/RecordSpendTool.ts: trocar expense por description no schema, adicionar branch fast-path com threshold FAST_PATH_THRESHOLD_BRL = 200, preservar fluxo propose→confirm pra os outros casos. Adicionar fastPath?: boolean em ToolReceipt. Specs 013 ganham touchup pra novo schema (expense → description) ou continuam validando o branch propose com input compatível.