B-10 · Dispatch unreconciled items to client
SOP:
SOP_AI_Bookkeeping_Automation.md§5.3.1 (Magic Link Generation and Dispatch)Actors: Bookkeeper. Pre-state:ReconciliationRuninAWAITING_REVIEWwith at least oneUnreconciledItemof statusPENDING. Post-state: Magic link emailed toclient_portal_email. Run transitionsAWAITING_REVIEW → AWAITING_CLIENT.dispatchedAtstamped.
0. Prerequisites
- Reviewer-side matching is finished (B-09).
- The client has at least one
canSubmit = truecontact (used as the dispatch target).
1. Steps
1.1 Click Dispatch
Web: /dashboard/bookkeeping/reconciliations/<runId> → Dispatch to client.
Or via API:
http
POST /ops/bookkeeping/reconciliations/<runId>/dispatch
Authorization: Bearer <staff-jwt>
{
"contactId": "<canSubmitContactId>",
"note": "Could you upload supporting docs by Friday?"
}Server:
- Validates the run is
AWAITING_REVIEW. - Validates the contact belongs to this client and has
canSubmit = true. - Mints a magic-link JWT (token TTL configurable per client; default 30 days, longer than payroll's 48 h because reconciliation is a longer-cycle interaction).
- Persists the SHA-256 token hash in
portal_invitationswithdelivery_mode = 'BOOKKEEPING_RECONCILIATION'andpayload = { runId, clientId }. - Transitions run
AWAITING_REVIEW → AWAITING_CLIENT. StampsdispatchedAt. - Enqueues a
send-bookkeeping-reconciliationoutbox event.
1.2 Email lands
Within ~10 s the contact receives:
- Subject:
{N} unreconciled bank items need your input — {clientName} {period} - Body:
- Number of unreconciled items + total value.
- The optional reviewer note (rendered as a quoted block).
- Single primary CTA: Open the portal →
http://localhost:3000/portal/<jwt>(the same[token]route as payroll; the page routes to bookkeeping based on the JWT payload'sproduct = BOOKKEEPING).
1.3 Magic link reuse
The link is single-use for the initial login but the session resumes for any subsequent visit while the token is valid. The client can submit responses across many sessions without a new email.
2. Verification
Database
sql
SELECT status, dispatched_at FROM reconciliation_runs WHERE id = '<runId>';
-- AWAITING_CLIENT, dispatched_at set
SELECT delivery_mode, expires_at, contact_id, payload_json
FROM portal_invitations WHERE payload_json ->> 'runId' = '<runId>'
ORDER BY created_at DESC LIMIT 1;
-- delivery_mode = 'BOOKKEEPING_RECONCILIATION', expires_at ≈ now + 30 daysMailpit
Subject + body verified above.
Audit log
bookkeeping.reconciliation.dispatched runId=<id> contactId=<id>
outbox.enqueued type=send-bookkeeping-reconciliation3. Negative & edge cases
- No PENDING unreconciled items → 400
"No unreconciled items to dispatch.". Skip dispatch and complete the run via B-13. - Contact without
canSubmit→ 400"Selected contact cannot submit for this client." - Inactive contact → 400
"Selected contact is inactive." - Re-dispatch — clicking again creates a new
portal_invitationsrow + outbox event. The latest token wins. Older tokens stay valid until theirexpires_at. UI label flips to Re-dispatch. - Run in
AWAITING_CLIENTalready — the button is hidden in the UI; direct API call returns 400 unlessforce: true. - SMTP failure — the handler rethrows to trigger pg-boss retry, mirroring payroll's approval-request retry pattern.
Next
Proceed to B-11 · Client portal — respond to unreconciled items.