Skip to content

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: ReconciliationRun in AWAITING_REVIEW with at least one UnreconciledItem of status PENDING. Post-state: Magic link emailed to client_portal_email. Run transitions AWAITING_REVIEW → AWAITING_CLIENT. dispatchedAt stamped.

0. Prerequisites

  • Reviewer-side matching is finished (B-09).
  • The client has at least one canSubmit = true contact (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:

  1. Validates the run is AWAITING_REVIEW.
  2. Validates the contact belongs to this client and has canSubmit = true.
  3. 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).
  4. Persists the SHA-256 token hash in portal_invitations with delivery_mode = 'BOOKKEEPING_RECONCILIATION' and payload = { runId, clientId }.
  5. Transitions run AWAITING_REVIEW → AWAITING_CLIENT. Stamps dispatchedAt.
  6. Enqueues a send-bookkeeping-reconciliation outbox 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 portalhttp://localhost:3000/portal/<jwt> (the same [token] route as payroll; the page routes to bookkeeping based on the JWT payload's product = BOOKKEEPING).

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 days

Mailpit

Subject + body verified above.

Audit log

bookkeeping.reconciliation.dispatched  runId=<id> contactId=<id>
outbox.enqueued                        type=send-bookkeeping-reconciliation

3. 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_invitations row + outbox event. The latest token wins. Older tokens stay valid until their expires_at. UI label flips to Re-dispatch.
  • Run in AWAITING_CLIENT already — the button is hidden in the UI; direct API call returns 400 unless force: 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.

Internal use only — BreezyCorp