FS-02 · Upload trial balance
SOP:
SOP_Financial_Statements.md§6 / Step 2.0 (DRAFT → TB_UPLOADED)Actors: FS Preparer (fs-preparer@spade.local) — holdsUPLOAD_TRIAL_BALANCE. Pre-state: Engagement atDRAFT(orTB_UPLOADED/TB_MAPPEDif you are re-uploading; re-upload resets state). Post-state: Engagement atTB_UPLOADED.FsTrialBalanceItemrows populated from the upload. Raw file persisted to S3.
0. Prerequisites
- Environment up per
_shared/00-environment-setup.md. - An engagement at
DRAFT(see FS-01). - A trial-balance file in CSV / XLSX / XLS, max 10 MB, with recognisable columns. The smart-column detector recognises:
account_code,description,debit,credit- OR
account_code,description,current_year,prior_year - Case-insensitive, underscore/space-agnostic.
TIP
If you do not have a real client file at hand, the AURORA seed loaded 13 mapped rows that approximate the shape of a small-entity TB. The CSV is hand-rolled by the seed; you can dump and re-upload it to exercise the parser:
docker exec breezycorp-monorepo-postgres-1 psql -U spade -d breezycorp -c "
COPY (SELECT account_code, account_description, current_year_balance, prior_year_balance FROM fs_trial_balance_items WHERE engagement_id = '<auroraEngagementId>') TO STDOUT WITH CSV HEADER" > /tmp/aurora-tb.csv1. Steps
1.1 Upload via Web UI
- Open the engagement detail page.
- Click Upload Trial Balance.
- Drag-drop the CSV/XLSX into the drop zone (single file per upload).
- The UI shows an upload progress bar, then a preview table with: row count, detected current-year and prior-year columns, balance-check badge (
Σ currentYearBalance = 0 ± 1.0 SGD). - Confirm → engagement transitions
DRAFT → TB_UPLOADED.
1.2 Upload via API
POST /ops/financial-statements/engagements/<engagementId>/trial-balance
Content-Type: multipart/form-data
Authorization: Bearer <fs-preparer-jwt>
(form-data: file=<tb.csv>)The handler:
- Asserts
UPLOAD_TRIAL_BALANCE. - Persists the bytes to S3 at
financial-statements/<clientId>/<engagementId>/tb-<timestamp>.<ext>. - Inserts a
Filerow withfileKind = FS_TRIAL_BALANCE. - Enqueues
parse-trial-balancewith{engagementId, fileId}. - Returns
202 Acceptedimmediately with the file id and aparseStatus = PENDINGindicator.
1.3 Background — parse-trial-balance
The worker handler:
- Streams the file from S3.
- Runs smart column detection (case-insensitive, underscore/space-agnostic).
- Normalises signs to debit-positive in storage. Credit columns flipped to negative
currentYearBalance. - Detects 4-digit year headers; sets
hasPriorYear. - Drops every existing
FsTrialBalanceItemfor the engagement (append-replace semantics). - Inserts one row per GL line.
- Transitions engagement
DRAFT → TB_UPLOADED. - Emits
AuditEventfs.tb.uploadedwith{rowCount, balanceCheckPassed, fileId}.
2. Verification
Database
SELECT count(*) AS row_count,
sum(current_year_balance) AS balance_check
FROM fs_trial_balance_items
WHERE engagement_id = '<engagementId>';
-- expect row_count > 0, balance_check ≈ 0 (within ±1.0)SELECT status FROM fs_engagements WHERE id = '<engagementId>';
-- expect 'TB_UPLOADED'S3
The raw upload should exist at financial-statements/<clientId>/<engagementId>/tb-<timestamp>.<ext>:
docker exec breezycorp-monorepo-minio-1 mc ls local/breezycorp/financial-statements/<clientId>/<engagementId>/Audit log
| Event type | Notes |
|---|---|
fs.tb.upload.requested | API hit, file persisted, job enqueued |
fs.tb.uploaded | Background job completed; payload shows rowCount + balanceCheckPassed |
3. Negative & edge cases
- Unbalanced TB — handler still imports the rows; balance-check badge flips amber on the preview but the engagement transitions normally. The Mapping screen prominently displays the unbalance and the Statements step will surface it as an anomaly.
- Unrecognised columns — handler emits
AuditEventfs.tb.parse_failedwith the column headers it saw. Engagement stays atDRAFT. Reviewer reformats the file and re-uploads. - File > 10 MB —
413 Payload Too Large. - Empty file or all-zero balances — accepted (parsers don't reject); the engagement transitions but Mapping is meaningless.
- Re-upload from
TB_MAPPEDor later — engagement returns toTB_UPLOADED(which is a regression fromTB_MAPPEDvia the explicitTB_MAPPED → TB_UPLOADEDtransition); mappings are dropped. The status badge regression is by design.
Next
Proceed to FS-03 · Auto-suggest and confirm mappings.