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 expandssuggestionswith confidence + reasoning. - Row actions:
- Confirm:
confirmTransactionMatch(transactionId, sourceType, sourceId)— moves toCONFIRMED. - Ignore:
ignoreBankStatementTransaction(transactionId)— moves toIGNORED(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.
- Confirm:
- 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=AIvsUSER) and let them override. - Failure state (
status=FAILED): surfaceerrorMessageprominently 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¶
- Bank statements list — per bank account or "All", with status badge + parse-progress indicator.
- Upload modal — file picker, optional bank-account hint, "Parsing in the background, we'll notify you" toast.
- Review workspace — transaction table with bulk-select for batch confirm/ignore. The matcher-suggestion column is the workhorse: most matches are 1-click confirms.
- Handoff to reconciliation — once enough transactions are confirmed, the user clicks "Reconcile" which navigates to
openBankReconciliationpre-populated with this statement'sid, 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.idfor traceability. The reconciliation'smarkClearedStatementTransactionsmutation setscleared_dateon the transaction and links it viareconciliation_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.