Skip to content

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 in AWAITING_CLIENT. Contact has the magic-link email. Post-state: Each unreconciled item that the client touched flips to CLIENT_RESPONDED. Uploaded supporting docs feed back into Phase 1 (drafted as new journal entries). NO_SUPPORTING declarations 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:

  1. Verifies the JWT against portal_invitations.token_hash.
  2. Reads the JWT's product = BOOKKEEPING and runId payload.
  3. Routes to the bookkeeping unreconciled view (apps/web/src/app/portal/[token]/bookkeeping/unreconciled/page.tsx).

1.2 The unreconciled list

http
GET /portal/bookkeeping/<token>/unreconciled?month=2026-04&sort=date

Response: 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:

http
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:

http
POST /portal/bookkeeping/<token>/unreconciled/<id>/respond

{
  "response": "UPLOADED_SUPPORTING",
  "supportingDocumentId": "<fileId>",
  "note": "Receipt for the IRAS GST payment."
}

Server:

  1. Validates the run is AWAITING_CLIENT.
  2. Validates the unreconciled item belongs to this run and is PENDING.
  3. Persists the response: status = CLIENT_RESPONDED, clientResponse = UPLOADED_SUPPORTING, supportingDocumentId, respondedAt = now().
  4. Enqueues bookkeeping.process-client-response and bookkeeping.ingest-document (so the supporting doc itself flows through the Phase 1 OCR + classify pipeline → produces a new draft journal entry for the reviewer).
  5. Writes bookkeeping.unreconciled.responded audit 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:

http
POST /portal/bookkeeping/<token>/unreconciled/<id>/respond

{
  "response": "NO_SUPPORTING",
  "note": "Counter-branch cash withdrawal — no invoice trail."
}

Server:

  1. Persists status = CLIENT_RESPONDED, clientResponse = NO_SUPPORTING, clientNote = note, respondedAt = now().
  2. Compares bankTxn.amount against client.materialityThresholdSgd:
    • Below threshold (e.g. ZENITH's 50 SGD) → auto-resolves the item. status = RESOLVED. Writes bookkeeping.unreconciled.auto_resolved.
    • At or above threshold → stays CLIENT_RESPONDED. Surfaces in the reviewer's exception queue. Writes bookkeeping.unreconciled.escalated_to_reviewer.
  3. Enqueues bookkeeping.process-client-response regardless. The handler stamps the bank-txn linked JournalEntry flag NO_SUPPORTING_CLIENT_DECLARED and 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 Submitted and locks the controls.
  • Shows the progress indicator updated.

2. Verification

Database

sql
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-access with { email: contactEmail } and dispatches a fresh invitation. The reviewer is not blocked — the run stays in AWAITING_CLIENT until completion.
  • Run already COMPLETE → 400 "This reconciliation has been closed. Contact your reviewer if a correction is needed."
  • Empty note on 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.

Internal use only — BreezyCorp