Skip to content

Bank statements

Backend domain: app/graphql/bank_statements/ Related: app/graphql/bank_reconciliations/ (closes against a statement) Migration: add_bank_statements RBAC Path: falls under BANK_RECONCILIATIONS (no separate Path.BANK_STATEMENTS)

Overview

A BankStatement is an uploaded PDF/CSV bank file plus its parsed-out transactions. The user uploads a file → a background task parses it via the AI parser → transactions land in bank_statement_transactions with status=UNMATCHED → the matcher service proposes invoice/expense matches → the user confirms in the UI → the statement is later "closed against" a BankReconciliation.

Lifecycle: PENDING → PARSING → RECONCILING → READY_FOR_REVIEW → COMPLETED (or FAILED at any step).

Setup prerequisites

A BankAccount row (Settings → Banking). The upload can be made without a pre-selected bank account — the parser detects the bank and account-number tail from the file and the user can confirm the link later.

GraphQL surface

Queries

bankStatement(id: UUID!): BankStatement!
bankStatements(status: BankStatementStatus): [BankStatement!]!
bankStatementTransactions(
  statementId: UUID!,
  status: TransactionStatus
): [BankStatementTransaction!]!

BankStatement fields: id, bankAccountId (nullable until detection), fileId, bankDetected, accountNumberMasked, periodStart, periodEnd, openingBalance, closingBalance, status, parseStrategyUsed, errorMessage, createdAt.

BankStatementTransaction fields: id, statementId, postedAt, amount (Decimal 18,2), direction (DEBIT | CREDIT), rawDescription, normalizedCounterparty, referenceNumber, balanceAfter, transactionKind (UNKNOWN | YAPPY | TRANSFER | ACH | CARD | WIRE | FEE | INTEREST | CASH), status (UNMATCHED | SUGGESTED | CONFIRMED | IGNORED | SPLIT), matchSourceType, matchSourceId, matchConfidence, matchedBy (AI | USER), matchedByUserId, matchedAt, plus suggestions: [BankStatementMatchSuggestion!]!.

BankStatementMatchSuggestion fields: id, transactionId, sourceType, sourceId, confidence (0.0–1.0), reasoning, accepted.

Mutations

uploadBankStatement(file: Upload!, bankAccountId: UUID): BankStatement!
confirmTransactionMatch(
  transactionId: UUID!,
  sourceType: BankStatementSourceType!,
  sourceId: UUID!,
  reasoning: String
): BankStatementTransaction!
ignoreBankStatementTransaction(transactionId: UUID!): BankStatementTransaction!

uploadBankStatement returns immediately with status=PENDING and kicks off the Render parse task in the background. Poll bankStatement(id) until status becomes READY_FOR_REVIEW (or FAILED).

Behaviour to handle in the UI

  • Upload flow: drag-and-drop PDF/CSV; optionally pre-select a bank account. Show a "Parsing…" state until the statement transitions out of PARSING / RECONCILING. The file hash is deduped server-side — re-uploading the same file returns the existing statement instead of creating a duplicate.
  • Review screen (when status=READY_FOR_REVIEW):
  • Header: bank, account tail, period, opening/closing balance, transaction count, "match" progress (CONFIRMED + IGNORED / total).
  • Transaction table: filter by status. Each row shows direction, amount, counterparty, description, and the top suggestion (if any). Clicking expands suggestions with confidence + reasoning.
  • Row actions:
    • Confirm: confirmTransactionMatch(transactionId, sourceType, sourceId) — moves to CONFIRMED.
    • Ignore: ignoreBankStatementTransaction(transactionId) — moves to IGNORED (e.g. cash withdrawals, fees handled manually).
    • Suggest: when no suggestion exists, open a picker that searches open invoices (CREDIT) or expenses (DEBIT) within the amount tolerance.
  • Match suggestions are SQL-only today (amount ±$0.02, date ±7 days) — fast but not always right. Always show the user the suggestion source (matchedBy=AI vs USER) and let them override.
  • Failure state (status=FAILED): surface errorMessage prominently with a "Re-upload" CTA. Common cause is a bank format we haven't taught the parser yet.

Error states

Backend behaviour When Frontend message
Duplicate file_hash Re-uploading the same file "This statement was already uploaded — opening the existing review." (return the existing BankStatement)
status=FAILED with errorMessage Parser couldn't read the file Show errorMessage verbatim; the most common cause is an unsupported bank format.
ValueError (transaction already matched) confirmTransactionMatch on a CONFIRMED row "This transaction is already matched. Refresh to see latest state."

Suggested UX flow

  1. Bank statements list — per bank account or "All", with status badge + parse-progress indicator.
  2. Upload modal — file picker, optional bank-account hint, "Parsing in the background, we'll notify you" toast.
  3. Review workspace — transaction table with bulk-select for batch confirm/ignore. The matcher-suggestion column is the workhorse: most matches are 1-click confirms.
  4. Handoff to reconciliation — once enough transactions are confirmed, the user clicks "Reconcile" which navigates to openBankReconciliation pre-populated with this statement's id, period dates, and balances.

Interaction with bank reconciliations

The two modules are deliberately decoupled:

  • Bank statements: own the parsed transaction list and per-transaction match state (UNMATCHED/CONFIRMED/IGNORED). The source of truth for "what did the bank actually show me".
  • Bank reconciliations: own the closing state — opening + closing balances reconciled against the GL, optionally pointing back at a bankStatement.id for traceability. The reconciliation's markClearedStatementTransactions mutation sets cleared_date on the transaction and links it via reconciliation_id.

A statement transaction can be CONFIRMED (matched to an invoice/expense) without being part of a closed reconciliation, and vice-versa. The reconciliation workspace reads the statement's transactions but does not mutate their match state.

Open follow-ups (not yet implemented)

  • The matcher is SQL-only (Phase 1). An LLM-driven matcher (Phase 2) is planned but not yet wired into BankStatementMatcherService.
  • No bank feeds (Plaid/Belvo). Manual upload only.
  • Split transactions (status=SPLIT) are a schema-level concept but no mutation currently produces them — UI can hide that filter until a split-transaction mutation ships.