P-12 · Reminder escalation (day 2 / 5 / 7)
SOP:
Payroll_Processing.md§6 / Step 2.0 — timeline / Internal SLAActors: APOE (system). Triggered by thecheck-pending-submissionsworker job. Pre-state: Cycle inREQUESTEDorINTAKE_IN_PROGRESSand not submitted within the configured reminder windows (default: day 2, 5, and 7 afteropened_at). Post-state: Reminder email dispatched with a freshly minted 48-hour token; previous token is invalidated.
0. Prerequisites
- A cycle exists in
REQUESTEDorINTAKE_IN_PROGRESSpast the day-2 threshold. To exercise this without waiting:sqlUPDATE payroll_cycles SET opened_at = now() - interval '2 days' WHERE id = '<cycleId>'; - Worker is running.
1. Steps
1.1 Trigger
The check-pending-submissions worker job runs at 0 9 * * * UTC (09:00 daily). For ad-hoc triggering during testing, kick it manually:
pnpm --filter @breezycorp/worker exec ts-node ./src/dev/run-handler.ts check-pending-submissions(or simply wait until 09:00 UTC.)
1.2 Handler logic
For each unsubmitted cycle:
- Compute days since
opened_at(per client timezone). - If matches a reminder window (2 / 5 / 7 days), and no reminder has fired for that window:
- Mint a new 48-hour magic-link JWT for each
canSubmit = truecontact viaissueCycleAccess(this is the same helper as the initial invite). - Persist its SHA-256 hash in
cycle_requestswithdelivery_mode = 'REMINDER'. - The handler does not delete or expire the previous token explicitly; it relies on the 48-hour expiry. To make stale tokens visibly fail immediately, the older
cycle_requestsrow'sexpires_atis set tonow()(effectively expiring it). - Enqueues
send-reminderoutbox event.
- Mint a new 48-hour magic-link JWT for each
- Marks the reminder window as fired in
cycle_request_reminders(or equivalent ledger), so the same window does not double-fire.
1.3 Email
Within ~10 s the submitter contacts receive:
- Subject:
Reminder: Payroll submission due for {month}. - Body has a single Open portal button with the freshly minted magic-link.
2. Verification
Database
SELECT delivery_mode, expires_at, sent_at
FROM cycle_requests WHERE cycle_id = '<cycleId>'
ORDER BY created_at DESC LIMIT 5;
-- The latest row is delivery_mode = 'REMINDER'. The previous row is expires_at <= now() (stale).Mailpit
A new email per submitter contact, subject as above.
Token replay test
- Open the original (pre-reminder) magic-link → 401
Token expired. - Open the new one → portal loads normally.
Audit log
cycle.reminder_fired (window=2, contact=<email>)
outbox.enqueued3. Negative & edge cases
- Cycle already submitted — handler skips it; no reminder fires.
- Multiple submitters — one email per contact, each with its own JWT.
- Reminder windows overlap with finalization — if a cycle moves to
EXPORTEDbetween the cron's read and the dispatch, the handler aborts before sending. Idempotent. - Configurable windows — defaults are 2/5/7 days. If you need to test custom windows, modify the handler config or seed an environment-specific override.
- SMTP failure — same retry behaviour as other email handlers; the rethrow + pg-boss retry pattern applies.
Next
If the client now submits, the reminder ledger stays in place as audit evidence; the cycle proceeds normally through P-04 and beyond.