B-11 · Client portal — respond to unreconciled items
SOP:
SOP_AI_Bookkeeping_Automation.md§5.3.2 / §5.3.3 / §5.3.4 (Portal interface, save-and-resume, response processing)Actors: Client Submitter. Pre-state: Run inAWAITING_CLIENT. Contact has the magic-link email. Post-state: Each unreconciled item that the client touched flips toCLIENT_RESPONDED. Uploaded supporting docs feed back into Phase 1 (drafted as new journal entries).NO_SUPPORTINGdeclarations land in the reviewer's exception queue if above the materiality threshold.
0. Prerequisites
- Dispatch fired (B-10).
- Contact has the email.
1. Steps
1.1 Open the portal
In an incognito window, click Open the portal in the email. Browser lands on http://localhost:3000/portal/<jwt>.
The portal:
- Verifies the JWT against
portal_invitations.token_hash. - Reads the JWT's
product = BOOKKEEPINGandrunIdpayload. - Routes to the bookkeeping unreconciled view (
apps/web/src/app/portal/[token]/bookkeeping/unreconciled/page.tsx).
1.2 The unreconciled list
GET /portal/bookkeeping/<token>/unreconciled?month=2026-04&sort=dateResponse: array of unreconciled items the client owes a response on. Each shows:
- Transaction date
- Bank narration
- Amount (debit or credit)
- Currency
- Two response options:
- Upload supporting — file picker.
- No supporting — checkbox + mandatory description text field.
A progress indicator: 7 of 23 items resolved.
Already-responded rows are pre-populated with status Submitted and locked (no edits without reviewer override) — this is the save-and-resume guarantee per SOP §5.3.3.
1.3 Upload supporting
Drag a PDF or image into the row's upload zone. The portal uses the same presign / PUT / finalize ceremony:
POST /portal/bookkeeping/<token>/files/presign
{ "originalName": "iras-receipt.pdf", "mimeType": "application/pdf", "sizeBytes": 38201 }
PUT <presigned MinIO URL> ...
POST /portal/bookkeeping/<token>/files/finalize
{ "fileId": "<id>", "sha256": "<hex>", "sizeBytes": 38201, "unreconciledItemId": "<id>" }Then submit the response:
POST /portal/bookkeeping/<token>/unreconciled/<id>/respond
{
"response": "UPLOADED_SUPPORTING",
"supportingDocumentId": "<fileId>",
"note": "Receipt for the IRAS GST payment."
}Server:
- Validates the run is
AWAITING_CLIENT. - Validates the unreconciled item belongs to this run and is
PENDING. - Persists the response:
status = CLIENT_RESPONDED,clientResponse = UPLOADED_SUPPORTING,supportingDocumentId,respondedAt = now(). - Enqueues
bookkeeping.process-client-responseandbookkeeping.ingest-document(so the supporting doc itself flows through the Phase 1 OCR + classify pipeline → produces a new draft journal entry for the reviewer). - Writes
bookkeeping.unreconciled.respondedaudit event.
1.4 Declare no supporting
Tick the No supporting available checkbox; enter the mandatory description (e.g. "Petty cash reimbursement — no receipt obtained."); submit:
POST /portal/bookkeeping/<token>/unreconciled/<id>/respond
{
"response": "NO_SUPPORTING",
"note": "Counter-branch cash withdrawal — no invoice trail."
}Server:
- Persists
status = CLIENT_RESPONDED,clientResponse = NO_SUPPORTING,clientNote = note,respondedAt = now(). - Compares
bankTxn.amountagainstclient.materialityThresholdSgd:- Below threshold (e.g. ZENITH's 50 SGD) → auto-resolves the item.
status = RESOLVED. Writesbookkeeping.unreconciled.auto_resolved. - At or above threshold → stays
CLIENT_RESPONDED. Surfaces in the reviewer's exception queue. Writesbookkeeping.unreconciled.escalated_to_reviewer.
- Below threshold (e.g. ZENITH's 50 SGD) → auto-resolves the item.
- Enqueues
bookkeeping.process-client-responseregardless. The handler stamps the bank-txn linkedJournalEntryflagNO_SUPPORTING_CLIENT_DECLAREDand routes the reviewer.
1.5 Save & resume
Close the browser. Re-open the magic link. The portal:
- Re-loads the unreconciled list.
- Pre-populates every row that was already submitted with status chip
Submittedand locks the controls. - Shows the progress indicator updated.
2. Verification
Database
SELECT status, client_response, client_note, supporting_document_id, responded_at, resolved_at
FROM unreconciled_items WHERE run_id = '<runId>'
ORDER BY responded_at DESC NULLS LAST;Audit log
bookkeeping.unreconciled.responded itemId=<id> response=UPLOADED_SUPPORTING
bookkeeping.unreconciled.responded itemId=<id> response=NO_SUPPORTING
bookkeeping.unreconciled.auto_resolved itemId=<id> (when below threshold)
bookkeeping.unreconciled.escalated_to_reviewer itemId=<id> (when above threshold)
bookkeeping.document.ingested sourceChannel=CLIENT_PORTAL (uploaded supporting)Reviewer notification
Each batch of responses triggers a single summary notification to bookkeepingConfig.reviewerEmail (debounced via outbox singleton key reconciliation-summary-<runId>):
- Subject:
Client responses on {clientName} {period} — {N} items - Body lists per-item summary (uploaded vs declared) + a link to the run.
3. Negative & edge cases
- Token expired → 401. The portal renders a "Request a new link" CTA which calls
POST /portal/request-accesswith{ email: contactEmail }and dispatches a fresh invitation. The reviewer is not blocked — the run stays inAWAITING_CLIENTuntil completion. - Run already
COMPLETE→ 400"This reconciliation has been closed. Contact your reviewer if a correction is needed." - Empty
noteon NO_SUPPORTING → 400"Note is required when declaring no supporting document." - File upload size > 25 MB → 400 from the presign endpoint.
- Invalid
unreconciledItemId→ 404. - Cross-run attempt (item belongs to a different run) → 400
"Unreconciled item does not belong to this token's run." - Submit on a locked row → 409
"Item already responded. Contact your reviewer for a re-open."
Next
If any items are above materiality, proceed to B-12 · Reviewer exception queue. Otherwise jump to B-13 · Complete reconciliation & generate report.