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 approvedJournalBatchfor the period to reconcile against. Post-state:BankStatementrow withparseStatus = PARSED, oneBankTransactionrow 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
bookkeepingConfigpopulated. - A bank statement file (CSV / XLSX / PDF). For testing, the seed fixture
ZENITH/bookkeeping/2026-04/uob-current-202604.pdfis 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:
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:
- Validates the file exists (immutable +
fileKind = BANK_STATEMENT). - Persists
BankStatementwithparseStatus = PENDING. - Enqueues
bookkeeping.parse-bank-statement.
1.3 Worker parses
bookkeeping.parse-bank-statement handler:
- Downloads the file from S3.
- Detects format:
- CSV / XLSX → headers parsed structurally.
- PDF → routed through
ocr-processfirst; the OCR adapter applies thebank-statementfield schema.
- Extracts per line:
txnDate,valueDate,description,reference,debitAmountORcreditAmount,balance. PersistsBankTransactionrows. - Validates the arithmetic chain:Tolerance: 0.01 (rounding).
openingBalance − sum(debits) + sum(credits) = closingBalance - 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.
- If arithmetic passes:
parseStatus = PARSED.parsedAt = now().- Writes
bookkeeping.bank_statement.parsedaudit event.
2. Verification
Database
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 5000Worker logs
bookkeeping.parse-bank-statement statementId=<id> rows=6 result=PARSEDWeb 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.currencyis whatever the file declares; the reconciliation matcher tolerates currency mismatch by converting at the journal entry's FX rate. - PDF with poor OCR —
parseErrorcarries 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
BankStatementid and writesbookkeeping.bank_statement.duplicate_rejected. - Statement for a period with no journal entries — allowed. The reconciliation run will create only
UnreconciledItemrows.
Next
Proceed to B-08 · Run reconciliation matcher.