Skip to content

B-07 · Upload bank statement & parse ​

SOP: SOP_AI_Bookkeeping_Automation.md §5.1 (Bank Statement Ingestion and Parsing)Actors: Bookkeeper / Senior Accountant. Pre-state: Bookkeeping-enabled client with at least one approved JournalBatch for the period to reconcile against. Post-state: BankStatement row with parseStatus = PARSED, one BankTransaction row per line, opening + closing balance arithmetic verified.

ZENITH already has a parsed UOB Current statement for April 2026 (6 transactions). Use this flow either to upload a fresh statement against TESTBK or to re-run parsing manually for testing.

0. Prerequisites ​

  • A bookkeeping-enabled client with bookkeepingConfig populated.
  • A bank statement file (CSV / XLSX / PDF). For testing, the seed fixture ZENITH/bookkeeping/2026-04/uob-current-202604.pdf is in MinIO.

1. Steps ​

1.1 Open Upload Statement dialog ​

Web: /dashboard/bookkeeping/clients/<clientId>/bank-statements (or the Bank Statements tab on the client). Click Upload statement.

Or via API:

http
POST /ops/bookkeeping/bank-statements
Authorization: Bearer <staff-jwt>

{
  "clientId": "<clientId>",
  "bankAccountRef": "UOB-0001234567",
  "fileId": "<fileId>",   // produced by presign / PUT / finalize
  "originalName": "uob-current-202604.pdf",
  "periodStart": "2026-04-01",
  "periodEnd": "2026-04-30",
  "currency": "SGD"
}

1.2 Server flow ​

The route:

  1. Validates the file exists (immutable + fileKind = BANK_STATEMENT).
  2. Persists BankStatement with parseStatus = PENDING.
  3. Enqueues bookkeeping.parse-bank-statement.

1.3 Worker parses ​

bookkeeping.parse-bank-statement handler:

  1. Downloads the file from S3.
  2. Detects format:
    • CSV / XLSX → headers parsed structurally.
    • PDF → routed through ocr-process first; the OCR adapter applies the bank-statement field schema.
  3. Extracts per line: txnDate, valueDate, description, reference, debitAmount OR creditAmount, balance. Persists BankTransaction rows.
  4. Validates the arithmetic chain:
    openingBalance − sum(debits) + sum(credits) = closingBalance
    Tolerance: 0.01 (rounding).
  5. If arithmetic fails:
    • parseStatus = FAILED.
    • parseError = "Opening + sum(credits) − sum(debits) = X.XX, expected Y.YY".
    • Reviewer is notified via the in-app exception queue.
    • Phase 2 reconciliation does not start until the reviewer either fixes the parse or overrides.
  6. If arithmetic passes:
    • parseStatus = PARSED.
    • parsedAt = now().
    • Writes bookkeeping.bank_statement.parsed audit event.

2. Verification ​

Database ​

sql
SELECT id, parse_status, opening_balance, closing_balance, currency, parse_error
  FROM bank_statements WHERE client_id = '<zenithId>'
  ORDER BY ingested_at DESC LIMIT 1;
-- expect parse_status = 'PARSED'

SELECT line_index, txn_date, description, debit_amount, credit_amount, balance
  FROM bank_transactions WHERE statement_id = '<statementId>'
  ORDER BY line_index;
-- ZENITH has 6 rows: SP GROUP 450, UBER 18, FIGMA 101.25, IRAS 25.50, ATM 500.00, CUSTOMER PAYMENT credit 5000

Worker logs ​

bookkeeping.parse-bank-statement statementId=<id> rows=6 result=PARSED

Web UI ​

/dashboard/bookkeeping/clients/<clientId>/bank-statements shows the row with PARSED status, transaction count, opening + closing balances.

3. Negative & edge cases ​

  • Arithmetic mismatch → parseStatus = FAILED, run does not auto-start. Reviewer must fix the source file or override.
  • Missing currency in file vs bookkeepingConfig.baseCurrency → BankStatement.currency is whatever the file declares; the reconciliation matcher tolerates currency mismatch by converting at the journal entry's FX rate.
  • PDF with poor OCR — parseError carries the OCR confidence summary; reviewer can re-upload a cleaner copy or extract manually and upload as CSV.
  • Re-upload of same file — dedupe by SHA-256. Returns the existing BankStatement id and writes bookkeeping.bank_statement.duplicate_rejected.
  • Statement for a period with no journal entries — allowed. The reconciliation run will create only UnreconciledItem rows.

4. Coverage, corrected statements and bank-line evidence ​

Reference: SOP SOP_AI_Bookkeeping_Automation.md §2.1 (bank_account_details), §5.1 and §5.3. The behaviour below is what ships; where the SOP is silent, the open decisions are listed rather than guessed.

Expected statements (coverage). One rule, statementCoverage in packages/domain/src/bookkeeping/statement-coverage.ts (loaded by apps/api/src/lib/statement-coverage.ts), serves the client's upload page (GET /portal/bookkeeping/:token/intake → expected[], coverage, gaps, unlisted), the staff reminder draft, and the staff endpoint GET /ops/bookkeeping/coverage?entityId&periodStart&periodEnd. Each expected account (company settings → bank accounts, last4, frequency, and optional openedAt / closedAt, YYYY-MM-DD) gets a status.

Days owed. An account owes a statement for every day of its cycle (the period, or its quarter or year) on which it was open: requiredFrom–requiredTo is the cycle cut to openedAt/closedAt (opened 15 Feb → 15–28 Feb). No date means unspecified: the whole cycle is owed, as before. PATCH /ops/bookkeeping/setup refuses a date that is not a real day or a closedAt earlier than openedAt (400); dates are stored only when set, and the change is audited (bookkeeping.setup.changed → accountDates). Full coverage, not overlap: read statements (one or several together) must cover every owed day; a statement that starts one day late or ends one day early leaves those days in uncovered[].

  • RECEIVED: statements in use, read and balanced (PARSED), with dates read from them, cover every owed day; matched on the account number's digits.
  • PARTIAL: read statements arrived but leave owed days uncovered (uncovered[]). Not counted; the client is asked for the rest.
  • PROCESSING: something arrived and is still being read. Not counted yet.
  • UNREADABLE: something arrived but could not be read. Not counted; the client is asked for a clearer copy.
  • NOT_DUE: the owed days run past the period's end (a QUARTERLY or ANNUAL cycle not yet ended). Quarters and years follow the project's cadence rule (batchPeriodFor), aligned to the company's financial year (fyEndMonth/fyEndDay) when set, else calendar. An account closed part-way through a quarter owes its final statement once it closes.
  • NOT_ACTIVE: the account was not open on any day of the cycle (before openedAt or after closedAt). Nothing is owed.
  • MISSING: the owed days have ended and nothing usable arrived. The dates owed are shown.
  • UNCONFIRMED: a read statement matches the account but a person must confirm it first (unconfirmedReason): ACCOUNT_HOLDER (the name printed on it is not the company) or STATEMENT_DATES (its dates were not read from it; see below). It does not count.

"Pending". There is no separate pending status: a file being read is PROCESSING, a statement owed and not received is MISSING, and one not owed yet is NOT_DUE.

Inferred statement dates. When the reader does not return both dates as unambiguous YYYY-MM-DD (missing, "03/04/2026", a day that does not exist, or an end before the start), the worker still creates the statement with the upload month and records periodDerived: true, periodIssue and accountRefSource on bookkeeping.bank_statement.routed_from_document. Such a statement never counts toward coverage or closes a gap. Staff see a PERIOD_INFERRED warning on it and confirm the printed dates (POST /ops/bookkeeping/bank-statements/:id/confirm-period, optional { periodStart, periodEnd }; on a read statement the dates must still hold every stored line; audited as bookkeeping.bank_statement.period_confirmed). A PATCH that sets its dates also counts as confirming them. Without an account number the reference is the bank's name or UNKNOWN, never invented digits, so it matches no listed account.

Two read statements for the same account and dates count once and are flagged possibleDuplicate until a person confirms the correction. A statement for an account not on the list counts toward nothing and is listed under unlisted. It is not attached to a reconciliation period on its own, and a reconciliation cannot be started from it until the account is added in company settings. A statement whose printed account holder is not the company is UNCONFIRMED until a person confirms it (POST /ops/bookkeeping/bank-statements/:id/confirm-entity, "This statement is this company's"). With no accounts listed, configured is false and coverage is never reported complete; the upload page and the reminder say the list is missing instead of "nothing missing". complete is true only when every listed account is RECEIVED, NOT_DUE or NOT_ACTIVE. Coverage is calculated on read (not stored), so it updates as soon as a statement finishes reading or a person confirms the holder. A missing statement still only shows and reminds; it does not block reconciliation or export.

Missing months. statementGaps reports stretches with no statement between two statements that did arrive for a listed account (January and March received → February missing). It is shown on the upload page as gaps[]. It says nothing before the first or after the last statement, because no rule yet says when an account starts owing statements.

Corrected or re-issued statements. A second statement for the same account with overlapping dates never replaces the first by itself. It is flagged: a POSSIBLE_CORRECTION warning on the statement list (with the ids it overlaps), the audit event bookkeeping.bank_statement.possible_correction, and a reviewer notification. A person confirms with "This corrects the earlier one" (POST /ops/bookkeeping/bank-statements/:id/supersede, body { replacesStatementId }; same company, same account, overlapping dates). The replaced statement keeps its row, file, lines, runs and audit history (bookkeeping.bank_statement.superseded); it only gains supersededById/At/By, after which it no longer counts toward coverage, is not offered for a new reconciliation, cannot start or respawn a run, and cannot be attached to a period. A period run that had already started keeps the lines it started with.

Attaching a document to a bank line at intake. The upload page lists the period's bank lines (and any line we asked the client about, marked first). A file sent with a chosen line is stored with BookkeepingDocument.supportsBankTxnId. If we had asked about that line, the upload is recorded as the client's answer through the same path as the lines page (UPLOADED_SUPPORTING), so the file is read once. Linking never creates an entry: if the line is already explained by an entry, the document is filed as evidence (bookkeeping.document.filed_as_line_evidence) instead of being booked again; otherwise it is drafted as usual and the link is kept for traceability. Staff can remove a wrong link from the document's menu ("Remove the link") and link it again.

Other channels. A bookkeeping file that arrives on chat (including WhatsApp or Telegram through Chatwoot) is ingested the same way as a file from the upload page. The same bytes sent again stay one document. Chat is also used for reminders and questions. A file from a shared folder is still processed.

Interrupted uploads. Every finished upload is saved server-side and is there when the client comes back, on any later link for the company. A file finished but not yet read by the worker (queued, or the worker is behind) is listed too, as being read (readState = PENDING), found from its bookkeeping.portal.intake_uploaded audit event, so nothing sent looks lost; once read it is listed once, as its document.

A file over 5 MB is sent in 5 MB parts (POST /portal/bookkeeping/:token/intake/files/resumable, then POST …/files/:fileId/parts for a URL per missing part), so an upload cut off part-way continues from the first part that did not arrive. Nothing new is stored: the open multipart upload is found on the file's server-owned key, and the bucket says which parts arrived. Coming back, the page lists the file under "Not finished uploading" (unfinished[], with how much arrived); choosing the same file again (same name and size) sends only the rest, even from another link or after the page was closed. finalize joins the parts only when every part is there and they add up to the reserved size, else it answers 409 and the client sends the rest. The parts route only ever reaches the link's own company's unfinished upload (404 otherwise; 409 once finished or cleared). An upload left unfinished for a day is cleared, parts and row, by storage.pending-sweep; the client then sends it again. A file of 5 MB or less still goes up in one piece and is simply sent again if cut off. Byte-identical files are de-duplicated either way. Both portal finalize routes also refuse a file reserved for another company (it used to be checked by tenant only).

Open decisions (not implemented, need the product owner):

  • Whether a quarterly or annual bank statement follows the company's financial year (as implemented, matching the bookkeeping cadence) or the bank's calendar quarter.
  • A bookkeeping file sent on chat is ingested into the same pipeline as an upload. The same bytes sent again stay one document. The reminder includes the upload link already emailed for that month, when that link has not expired. With no chat thread, and a mail server configured, the same reminder is emailed to the bookkeeping contact.
  • Whether a missing statement should block reconciliation or export (today it only shows and reminds).

Next ​

Proceed to B-08 · Run reconciliation matcher.

Internal use only — BreezyCorp