004 — Reconciliar fatura do cartão com os custos do orçamento
Toda fatura do cartão importada (cenário 001) é cheia de transações que, na real, são instâncias dos custos recorrentes do orçamento (cenário 000) — Netflix vira RecurringExpense “Netflix”, compra no mercado vira a linha “Mercado”, e assim por diante. A gente quer que o sistema categorize automaticamente essas transações no RecurringExpense correspondente, alimentando o actual do mês (cenário 003), sem a gente precisar lançar gasto a gasto na mão.
Matching é simples e previsível: cada RecurringExpense declara uma lista de aliases (substrings) e o reconciler casa por substring case-insensitive entre transaction.description e os aliases — primeiro expense que casa, ganha.
Scenario: Reconciliar uma fatura simples
Section titled “Scenario: Reconciliar uma fatura simples”- Given um orçamento da casa com três custos: Netflix (R$ 120, aliases
["netflix"]), Mercado (R$ 1.500, aliases["mercado livre", "carrefour"]) e Aluguel (R$ 2.500, sem aliases) - And uma fatura do cartão de junho/2026 com três transações: “NETFLIX.COM” R$ 120, “MERCADO LIVRE” R$ 380, “LATAM AIRLINES” R$ 4.000
- When a gente roda
ExpenseReconciler.applyInvoice(orcamento, fatura) - Then a transação da Netflix casa com o expense Netflix (case-insensitive, “netflix” ⊂ “NETFLIX.COM”) e o
actualFor(junho/2026)da Netflix passa a ser R$ 120 - And a transação do Mercado Livre casa com o expense Mercado (primeiro alias bate) e o
actualFor(junho/2026)do Mercado passa a ser R$ 380 - And a transação da LATAM fica em
unmatched(nenhum alias bate) - And o Aluguel não casa nada (não tem alias e não tem transação correspondente na fatura), o que é OK — aluguel não vem no cartão
- And
totalCategorizedé R$ 500 (R$ 120 + R$ 380)
Scenario: Múltiplas transações casam o mesmo expense e somam no actual
Section titled “Scenario: Múltiplas transações casam o mesmo expense e somam no actual”- Given o mesmo orçamento da casa
- And uma fatura de junho/2026 com três transações que caem no Mercado: “MERCADO LIVRE” R$ 200, “CARREFOUR” R$ 180, “MERCADO LIVRE” R$ 50
- When a gente reconcilia a fatura
- Then as três transações aparecem em
matched, todas apontando pro expense Mercado - And
actualFor(junho/2026)do Mercado é R$ 430 (soma incremental: 200 + 180 + 50) - And
totalCategorizedé R$ 430
Scenario: Faturas em meses diferentes alimentam períodos independentes
Section titled “Scenario: Faturas em meses diferentes alimentam períodos independentes”- Given o mesmo orçamento da casa
- And a fatura de junho/2026 com “MERCADO LIVRE” R$ 380
- And a fatura de julho/2026 com “MERCADO LIVRE” R$ 520
- When a gente reconcilia as duas faturas
- Then
actualFor(junho/2026)do Mercado é R$ 380 - And
actualFor(julho/2026)do Mercado é R$ 520 - And os dois períodos são independentes (junho não vaza pra julho nem vice-versa)
Modelo
Section titled “Modelo”- Domain Service novo —
ExpenseReconcilerembudget/domain/services/. Lógica pura, sem I/O, sem repositório. LêInvoicedo contextaccountse escreve emRecurringExpensedo contextbudget. RecurringExpenseganha:aliases: readonly string[](opcional na criação; sem aliases = não reconcilia automaticamente).matches(description: string): boolean— true se algum alias está contido emdescription.toLowerCase().addToActual(period: Period, money: Money): void— incrementa o actual do período (soma quando já existe, parte do zero quando não).
ReconciliationResult:interface ReconciliationResult {matched: Array<{ transaction: Transaction; expense: RecurringExpense }>;unmatched: Transaction[];totalCategorized: Money;}
RecurringExpense.create({ ..., aliases?: string[] })recurringExpense.aliases: readonly string[]recurringExpense.matches(description: string): booleanrecurringExpense.addToActual(period: Period, money: Money): void
ExpenseReconciler.applyInvoice(budget: Budget, invoice: Invoice): ReconciliationResultDecisões de design
Section titled “Decisões de design”- First-match substring case-insensitive. Pra cada transação, varre os expenses do Budget na ordem de registro e pega o primeiro cujo
matches(description)retorna true. Múltiplos aliases por expense é OR (qualquer um bate). Se dois expenses casariam a mesma transação, vence quem foi registrado primeiro — sem warning, sem conflito explícito. Por quê: ordem de registro é determinística e suficiente; quando aparecer conflito real, a UX edita aliases. - Sem fuzzy/regex/LLM matching. Substring case-insensitive é trivial de explicar, debugar e ajustar manualmente. Fuzzy match (Levenshtein, regex, LLM) é cenário futuro — só entra se a gente sentir falta na prática.
ExpenseReconcileré Domain Service, não Application Service. Lógica é pura (Budget + Invoice → resultado), sem I/O nem repositório. Mora embudget/domain/services/porque escreve no Budget; lê o Invoice como input read-only.- Cross-context read-only de Budget pra Accounts. O reconciler importa
Invoice/Transactiondo contextaccountsna direção domain service → entities. Sem inversão de dependência (interface/port) por enquanto — quando aparecer um segundo consumidor desse tipo de leitura, aí refatora. addToActualé incremental por design. Múltiplas transações da mesma fatura caem no mesmo expense (Mercado Livre + Carrefour + outra compra) — somar é a operação natural.recordSpend(sobrescreve, do cenário 003) continua existindo pra entrada manual de mês fechado.aliasesopcional. Sem aliases = expense não reconcilia automaticamente (caso do Aluguel, que não vem no cartão). Não é regra estranha, é a default natural: se você não declarou como casar, não casa.
Fora de escopo
Section titled “Fora de escopo”- Fuzzy match, regex, Levenshtein, LLM-based matching → cenário futuro se substring não der conta.
- Editar manualmente uma transação categorizada errada (mover transação X do expense A pro expense B) → cenário futuro.
- Undo / desfazer reconciliação (reverter
addToActual) → cenário futuro. - Conflitos quando vários expenses casam a mesma transação — resolvido por first-match, sem alertar; quando virar problema na prática, a gente faz UI de revisão.
- Reconciliar várias faturas na mesma chamada (
applyInvoices(budget, invoices[])) — uma por vez por enquanto; loop no caller resolve. - Income / receita (creditar entradas no orçamento) → cenário futuro.
- Re-reconciliação idempotente (rodar
applyInvoiceduas vezes na mesma fatura não duplica) — happy path assume uma única chamada por fatura.
Próximo passo
Section titled “Próximo passo”Adicionar aliases/matches/addToActual a RecurringExpense, criar budget/domain/services/ExpenseReconciler.ts e exportar pelo barrel até os três scenarios do spec passarem.