Skip to content

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 chama expense.addToActual(period, money) + budgetRepo.save na primeira call. Receipt sai com fastPath: 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 + mesmo operationId.

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"}) (sem confirm)
  • Then a tool internamente chama budget.matchExpense("gastei 50 no mercado") e encontra Mercado (substring “mercado” bate)
  • And detecta amount <= 200 (threshold) e currency === 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 ToolReceipt com operationId: "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.load mostra Mercado.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"}) (sem confirm)

  • Then a tool encontra Mercado mas detecta amount > 200 → cai no fluxo 013 (propose)

  • And retorna ToolReceipt com fastPath: false, confirmed: false, persisted: false, preview populado

  • And budgetRepo.save nã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} (mesmo operationId)

  • When a tool roda

  • Then retorna ToolReceipt com fastPath: false, confirmed: true, persisted: true

  • And budgetRepo.save foi 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"}) (sem confirm)
  • Then budget.matchExpense("gastei 30 na padaria") retorna undefined (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 ToolReceipt com fastPath: false, confirmed: false, persisted: false
  • And preview contém shape sinalizando “expense não encontrada” — sugerindo registrar como novo expense (preview.unmatched === true e preview.description === "gastei 30 na padaria")
  • And budgetRepo.save não foi chamado
  • And o Budget no repo continua sem alteração
  • 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 operationId no log e curto-circuita
  • And retorna ToolReceipt com operationId:"op-1", confirmed: true, persisted: false (idempotente — não persistiu dessa vez)
  • And fastPath: true (estado preservado do receipt original)
  • And budgetRepo.save nã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"}) (sem confirm)
  • Then a tool encontra Mercado e amount <= 200, mas detecta currency !== budget.currencynão entra no fast-path
  • And retorna ToolReceipt com fastPath: false, confirmed: false, persisted: false
  • And preview sinaliza moeda divergente (preview.currencyMismatch === true, preview.budgetCurrency === "BRL", preview.deltaCurrency === "USD") pra UX/LLM pedir confirm explícito do casal
  • And budgetRepo.save não foi chamado
  • Refina RecordSpendTool existente — não cria nova tool. Mesmo schema input do 013 + description: string (renomeia o expense do 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ém confirm?: boolean opcional 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 inline private 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 do 0.8 do FeasibilityCheck (006) e dos 80% / 100% do BudgetAlerts (015).
  • fastPath?: boolean opcional no ToolReceipt — 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 sobre this.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 iterar budget.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;
  • 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.8 do FeasibilityCheck e 80%/100% do BudgetAlerts. 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).
  • description substitui expense no schema — 013 passava expense: "Mercado" (nome exato, LLM mapeou antes de chamar). 020 troca por description: "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.
  • fastPath flag separada de confirmed/persistedconfirmed: true, persisted: true, fastPath: true (primeira call no fast) é semanticamente diferente de confirmed: true, persisted: true, fastPath: false (segunda call do propose com confirm: true). Caller diferencia UX. Reforça gotcha “ToolReceipt.persistedToolReceipt.confirmed” (013) — flags ortogonais, cada uma com seu sinal.
  • preview.unmatched e preview.currencyMismatch como flags opcionais — não viram kind: "unmatched" | "matched-low-amount" | ... union. Mantém preview como POJO flat com opcionais, igual a régua gap? do FeasibilityResult (006) e expense? do Alert (015). UX checa flags conforme precisa, sem narrowing de tipo obrigatório.
  • Idempotência no fast preserva fastPath: true — segunda call com mesmo operationId retorna confirmed: true, persisted: false, fastPath: true. O fastPath reflete 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” → chama RegisterExpenseTool (013) separadamente. Princípio: write tool faz uma coisa. Compor tools é responsabilidade do LLM/UX, não da tool individual.
  • date? opcional + fallback pra today — 013 não tinha date no schema do recordSpend (period vinha como string “2026-06”). 020 simplifica: casal fala “gastei 50 mercado” sem data, tool assume today injetado (mesma régua do today em FeasibilityCheck/BudgetAlerts). Period derivado de today quando date ausente. Manter compatibilidade com 013 (period explícito) entra como touchup.
  • 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-basedmatches() 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 recordSpend com valor negativo (futuro — gotcha “Sobrescreve, não acumula” do 003 vs addToActual incremental do 004 precisa de nova decisão).
  • Audit log “esse gasto foi fast”receipt.fastPath: true fica 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.

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 (expensedescription) ou continuam validando o branch propose com input compatível.