009 — Importar fatura Nubank via chat (upload PDF, parse, registrar, reconciliar)
O Nubank manda email mensal com o PDF da fatura. A gente encaminha o PDF pro chat (“aqui está a fatura desse mês”) e o agente faz o resto: parseia o PDF, registra o Invoice no Account correspondente, dispara o ExpenseReconciler contra o Budget, e responde no chat com um resumo estruturado — total da fatura, quantas transações, quanto bateu em cada custo recorrente do orçamento, e quais ficaram sem categoria.
O upload é o gesto natural — o casal não vai aprender API, vai arrastar PDF. O agente vira a porta de entrada de tudo que hoje precisa de comando manual (cenário 001 + 004 fundidos em “manda o PDF e pronto”).
Scenario: Upload da fatura registra Invoice e responde com resumo
Section titled “Scenario: Upload da fatura registra Invoice e responde com resumo”- Given o cartão Nubank já cadastrado (fechamento dia 25, vencimento dia 10, currency BRL), sem nenhuma fatura ainda
- And um orçamento da casa vazio (sem custos recorrentes registrados — reconciliação fica zerada nesse cenário)
- And um
AgentChatconfigurado comImportInvoiceToolapontando pro Account, Budget e um parser mockado que devolve fatura de maio/2026 com 3 transações somando R$ 4.500 - And o LLM mockado decide chamar
importInvoice({ account: "Nubank" })e depois responde em PT-BR - When o casal envia “aqui está a fatura desse mês” anexando o PDF
- Then o agente chama
ImportInvoiceTool.importInvoicecom{ account: "Nubank" }e os bytes do PDF (vindos do attachment da mensagem) - And o parser é invocado com os bytes do PDF
- And o Invoice de maio/2026 fica anexado ao Account Nubank, com 3 transações e total R$ 4.500
- And o resultado da tool inclui
account: "Nubank",period: "2026-05",total: { amount: 4500, currency: "BRL" },transactionscom 3 entries, ereconciliationcommatched: [],unmatched: [3 transactions],totalCategorized: { amount: 0, currency: "BRL" } - And a resposta final do agente menciona “3 transações” e “R$ 4.500”
Scenario: Upload da fatura dispara reconciliação automática contra o orçamento
Section titled “Scenario: Upload da fatura dispara reconciliação automática contra o orçamento”- Given o cartão Nubank cadastrado
- And um orçamento da casa com Netflix (R$ 120, aliases
["netflix"]), Mercado (R$ 1.500, aliases["mercado livre", "carrefour"]) e Aluguel (R$ 2.500, sem aliases) - And um parser mockado que devolve fatura de junho/2026 com “NETFLIX.COM” R$ 120, “MERCADO LIVRE” R$ 380, “LATAM AIRLINES” R$ 4.000 (total R$ 4.500)
- And um
AgentChatconfigurado comImportInvoiceToolapontando pro Account + Budget + parser - And o LLM mockado decide chamar
importInvoice({ account: "Nubank" })e depois responde em PT-BR - When o casal envia “fatura de junho” anexando o PDF
- Then o agente chama
ImportInvoiceTool.importInvoicecom bytes +{ account: "Nubank" } - And o Invoice de junho/2026 fica anexado ao Nubank com as 3 transações
- And o
ExpenseReconciler.applyInvoice(budget, invoice)é disparado internamente pela tool - And o resultado da tool inclui
reconciliation.matchedcom Netflix (R$ 120) e Mercado (R$ 380),reconciliation.unmatchedcom LATAM (R$ 4.000), ereconciliation.totalCategorized: { amount: 500, currency: "BRL" } - And o Netflix.actualFor(junho/2026) passa a ser R$ 120 e Mercado.actualFor(junho/2026) passa a ser R$ 380 (efeito colateral observável no Budget)
- And a resposta final do agente menciona “Netflix R$ 120” e “Mercado R$ 380” e cita “5” (sic — uma transação fora da categoria; aqui só uma, mas o wording é controlado pelo mock)
Scenario: Upload de fatura seguinte anexa ao Account existente
Section titled “Scenario: Upload de fatura seguinte anexa ao Account existente”- Given o cartão Nubank com a fatura de maio/2026 já importada (cenário 1 acima)
- And um parser mockado que, nessa segunda invocação, devolve fatura de junho/2026 com 2 transações somando R$ 1.000
- And o
AgentChatconfigurado igual aos anteriores - And o LLM mockado decide chamar
importInvoice({ account: "Nubank" })e depois responde em PT-BR - When o casal envia “fatura nova” anexando o PDF de junho
- Then o Account Nubank passa a ter 2 faturas (maio e junho), ordenadas por período
- And cada fatura preserva seu próprio total (maio R$ 4.500, junho R$ 1.000) e suas próprias transações
- And o resultado da tool reporta apenas a fatura recém-importada (period “2026-06”, total R$ 1.000), não a histórica
- And a resposta final do agente menciona “R$ 1.000” ou “junho”
Modelo
Section titled “Modelo”- Tool nova —
ImportInvoiceToolemagent/domain/tools/. Wrapper que cola três passos: (1) parser extrai estrutura do PDF, (2) Account.importInvoice anexa o Invoice (cenário 001), (3) ExpenseReconciler.applyInvoice categoriza contra o Budget (cenário 004). Tool retorna POJO serializável combinando os outputs. - Port
PdfParser— interface mínima{ parse(bytes: Uint8Array | Buffer): { transactions: Transaction[]; total: Money; period: Period } }. Mora emagent/domain/tools/(ouaccounts/application/, decidir na impl). Injetada na tool via construtor — spec domain passa mock, prod passaNubankPdfParser. - Adapter
NubankPdfParser— implementação real emaccounts/infrastructure/. Usaunpdf(text extraction) +banksheet(plugin Nubank) per ADR 001. Spec colocada emsrc/contexts/accounts/infrastructure/NubankPdfParser.spec.ts(sem Gherkin sibling per ADR 003), valida contra fixture PDF real. - VO
InvoiceSummary— POJO de saída da tool. Combina dados do Invoice + ReconciliationResult em shape serializável pro LLM consumir:interface InvoiceSummary {account: string; // nome do Accountperiod: string; // "YYYY-MM"total: { amount: number; currency: string };transactions: Array<{date: string; // ISO dateamount: { amount: number; currency: string };description: string;}>;reconciliation: {matched: Array<{expense: string; // ExpenseNametransaction: { date: string; amount: { amount: number; currency: string }; description: string };}>;unmatched: Array<{date: string;amount: { amount: number; currency: string };description: string;}>;totalCategorized: { amount: number; currency: string };};} - AgentChat estende — passa a aceitar mensagens com
contentmultimodal:[{type:"text", text}, {type:"file", data: Uint8Array, mediaType: "application/pdf"}]. Convenção Vercel AI SDK (CoreMessageshape). Tool recebe os bytes via mecanismo interno (file attachment é passado pra tool junto com os args).
interface PdfParser { parse(bytes: Uint8Array | Buffer): { transactions: Transaction[]; total: Money; period: Period; };}
// src/contexts/agent/domain/tools/ImportInvoiceTool.tsImportInvoiceTool.create({ accounts: Account[]; // lookup por name (fronteira EN — LLM passa nome humano) budget: Budget; // pra ExpenseReconciler.applyInvoice parser: PdfParser; // adapter injetado (NubankPdfParser em prod, mock no spec)}): ImportInvoiceTool
importInvoiceTool.importInvoice( args: { account: string }, attachments: { pdfBytes: Uint8Array }): InvoiceSummary
// src/contexts/accounts/infrastructure/NubankPdfParser.tsclass NubankPdfParser implements PdfParser { parse(bytes: Uint8Array | Buffer): { transactions: Transaction[]; total: Money; period: Period };}
// src/contexts/agent/domain/AgentChat.ts (estende cenário 008)type ChatContent = | string | Array< | { type: "text"; text: string } | { type: "file"; data: Uint8Array; mediaType: "application/pdf" } >;interface ChatMessage { role: ChatRole; content: ChatContent; // string continua válido (back-compat 008); array é o novo toolCalls?: Array<{ id: string; name: string; args: unknown }>; toolCallId?: string;}Decisões de design
Section titled “Decisões de design”- Tool combina três operações de domínio (parse + importInvoice + reconcile), não as expõe separado — a fronteira com o LLM é a intenção do usuário (“registra essa fatura”), não a anatomia de passos. Expor três tools (parse / importInvoice / reconcile) força o LLM a orquestrar — e LLM erra orquestração. Combinar mantém o caminho feliz determinístico e ainda assim retorna o detalhe completo via
InvoiceSummary. Quando aparecer necessidade real de chamar parse sem importar (preview?), aí separa. - Parser injetado, nunca instanciado dentro da tool —
ImportInvoiceTool.create({ parser })recebe a dependência. Spec domain passa mock (sem PDF real, sem unpdf), spec infra validaNubankPdfParsercontra PDF real, tier e2e usa o real através do impl wiring. Mesma fronteira queExchangeRate(ADR 006): domínio nunca busca, sempre recebe. - Adapter mora em
accounts/infrastructure/, port mora emagent/domain/tools/— port é onde a tool consome (agent/), adapter é onde a impl real vive (accounts/, próximo do schema de Invoice/Transaction). Domain nunca importa de application/infrastructure (gotcha existente), então a port fica do lado de quem chama. Quando aparecer um segundo consumidor de PDF (extrato bancário, por exemplo), reavalia. - Fronteira tool ↔ LLM passa
account: string(nome), nãoaccountId— alinhado com gotcha “fronteira LLM é EN, domínio interno PT/EN mix”. LLM passa nomes humanos (“Nubank”), tool resolve internamente (loop emaccounts[]pornome.equals). UUID não vaza. Mesmo padrão deGoalTool.goalStatus({name: "Amsterdam"})em 008. - PDF bytes não vão como argumento da tool (não cabem em JSON LLM-friendly) —
argsda tool é{ account: "Nubank" }(string), bytes chegam por canal separado (attachmentsdo ask).AgentChatextrai o file attachment da última mensagem do user e passa pra tool junto com os args. Convenção Vercel AI SDK: messages comcontent: [{type:"file", data, mediaType}]— o AI SDK já entrega o file pro tool execute como segundo argumento ou contexto. - Reconciliação dispara mesmo quando o orçamento está vazio —
ExpenseReconciler.applyInvoiceé seguro (retorna tudo emunmatchedquando não há expenses) e a tool sempre reportareconciliation. UX consistente: o casal sempre vê o mesmo shape de resposta. InvoiceSummaryespelha o shape deReconciliationResult(004) mais o do Invoice (001), tudo POJO — sem Money/Date crus na saída, tudo{amount, currency}/ ISO string. Mesma regra deBudgetTool.budgetTotalem 008.- Multimodal input via Vercel AI SDK CoreMessage —
contentaceita array com{type:"file", data, mediaType:"application/pdf"}. AgentChat passa por isso pro model; em multimodal-capable LLMs (Gemini 2.5 Flash via OpenRouter — ADR 001) o modelo “vê” o PDF nativamente. No mock LLM, a gente só verifica que o file foi passado adiante pra tool.
Fora de escopo
Section titled “Fora de escopo”- Outros bancos / formatos de PDF — só Nubank por enquanto (ADR 001 escolheu
banksheetNubank plugin). Itaú/BB entram com cenário próprio + adapter próprio. - Múltiplos PDFs no mesmo turno — assume um attachment por mensagem. Múltiplos files entram quando aparecer caso real.
- Idempotência de upload duplicado — subir a mesma fatura duas vezes não warna nem rejeita; happy path assume uma única upload por fatura (mesma regra do cenário 001). Cenário futuro se virar dor real.
- Edição de transação categorizada errada via chat — “essa LATAM era viagem, categoriza no Mercado” é cenário futuro (mutações via chat — fora do escopo do 008 também).
- PDF protegido por senha — Nubank manda fatura com senha (CPF).
unpdfaceita senha; UX/wiring decide como pedir. Fora do escopo desse spec. - Account novo via upload — assume Account já cadastrado. Criar Account na primeira upload é o cenário 001 (manual) hoje; integrar isso no chat é cenário futuro.
- Mais de uma fatura no mesmo Account no mesmo período — assume natural key (account, period) único. Override entra com cenário futuro se aparecer.
- Persistência do Invoice depois do registro — domain sobe em memória; persistência via
BudgetRepository/AccountRepositoryé cenário separado (007 cobriu Budget; Account ainda não tem).
Próximo passo
Section titled “Próximo passo”Criar ImportInvoiceTool + PdfParser (port) em agent/domain/tools/, InvoiceSummary no mesmo lugar, estender ChatMessage/AgentChat pra aceitar multimodal content. Implementar NubankPdfParser em accounts/infrastructure/ com unpdf + banksheet (ADR 001). Spec infra valida parser contra data/Nubank_2026-05-28.pdf (já existe no repo); spec e2e valida UI cross-stack com fixture em e2e/fixtures/nubank-sample.pdf (impl agent gera ou sanitiza).