Skip to content

P-12 · Reminder escalation (day 2 / 5 / 7)

SOP: Payroll_Processing.md §6 / Step 2.0 — timeline / Internal SLAActors: APOE (system). Triggered by the check-pending-submissions worker job. Pre-state: Cycle in REQUESTED or INTAKE_IN_PROGRESS and not submitted within the configured reminder windows (default: day 2, 5, and 7 after opened_at). Post-state: Reminder email dispatched with a freshly minted 48-hour token; previous token is invalidated.

0. Prerequisites

  • A cycle exists in REQUESTED or INTAKE_IN_PROGRESS past the day-2 threshold. To exercise this without waiting:
    sql
    UPDATE 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:

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

  1. Compute days since opened_at (per client timezone).
  2. 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 = true contact via issueCycleAccess (this is the same helper as the initial invite).
    • Persist its SHA-256 hash in cycle_requests with delivery_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_requests row's expires_at is set to now() (effectively expiring it).
    • Enqueues send-reminder outbox event.
  3. 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

sql
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.enqueued

3. 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 EXPORTED between 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.

Internal use only — BreezyCorp