Skip to content

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 AgentChat configurado com ImportInvoiceTool apontando 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.importInvoice com { 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" }, transactions com 3 entries, e reconciliation com matched: [], 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 AgentChat configurado com ImportInvoiceTool apontando 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.importInvoice com 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.matched com Netflix (R$ 120) e Mercado (R$ 380), reconciliation.unmatched com LATAM (R$ 4.000), e reconciliation.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 AgentChat configurado 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”
  • Tool novaImportInvoiceTool em agent/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 em agent/domain/tools/ (ou accounts/application/, decidir na impl). Injetada na tool via construtor — spec domain passa mock, prod passa NubankPdfParser.
  • Adapter NubankPdfParser — implementação real em accounts/infrastructure/. Usa unpdf (text extraction) + banksheet (plugin Nubank) per ADR 001. Spec colocada em src/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 Account
    period: string; // "YYYY-MM"
    total: { amount: number; currency: string };
    transactions: Array<{
    date: string; // ISO date
    amount: { amount: number; currency: string };
    description: string;
    }>;
    reconciliation: {
    matched: Array<{
    expense: string; // ExpenseName
    transaction: { 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 content multimodal: [{type:"text", text}, {type:"file", data: Uint8Array, mediaType: "application/pdf"}]. Convenção Vercel AI SDK (CoreMessage shape). Tool recebe os bytes via mecanismo interno (file attachment é passado pra tool junto com os args).
src/contexts/agent/domain/tools/PdfParser.ts
interface PdfParser {
parse(bytes: Uint8Array | Buffer): {
transactions: Transaction[];
total: Money;
period: Period;
};
}
// src/contexts/agent/domain/tools/ImportInvoiceTool.ts
ImportInvoiceTool.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.ts
class 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;
}
  • 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 toolImportInvoiceTool.create({ parser }) recebe a dependência. Spec domain passa mock (sem PDF real, sem unpdf), spec infra valida NubankPdfParser contra PDF real, tier e2e usa o real através do impl wiring. Mesma fronteira que ExchangeRate (ADR 006): domínio nunca busca, sempre recebe.
  • Adapter mora em accounts/infrastructure/, port mora em agent/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ão accountId — alinhado com gotcha “fronteira LLM é EN, domínio interno PT/EN mix”. LLM passa nomes humanos (“Nubank”), tool resolve internamente (loop em accounts[] por nome.equals). UUID não vaza. Mesmo padrão de GoalTool.goalStatus({name: "Amsterdam"}) em 008.
  • PDF bytes não vão como argumento da tool (não cabem em JSON LLM-friendly)args da tool é { account: "Nubank" } (string), bytes chegam por canal separado (attachments do ask). AgentChat extrai o file attachment da última mensagem do user e passa pra tool junto com os args. Convenção Vercel AI SDK: messages com content: [{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á vazioExpenseReconciler.applyInvoice é seguro (retorna tudo em unmatched quando não há expenses) e a tool sempre reporta reconciliation. UX consistente: o casal sempre vê o mesmo shape de resposta.
  • InvoiceSummary espelha o shape de ReconciliationResult (004) mais o do Invoice (001), tudo POJO — sem Money/Date crus na saída, tudo {amount, currency} / ISO string. Mesma regra de BudgetTool.budgetTotal em 008.
  • Multimodal input via Vercel AI SDK CoreMessagecontent aceita 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.
  • Outros bancos / formatos de PDF — só Nubank por enquanto (ADR 001 escolheu banksheet Nubank 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). unpdf aceita 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).

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).