P-01 · Initiate cycle (request data)
SOP:
Payroll_Processing.md§6 / Step 1.0 (S0 → S1)Actors: APOE (system) — manually triggered asexecutive@spade.local(PAYROLL_EXECUTIVE) Pre-state: Client is fully configured (contacts withcanSubmit=true, auth policy, document rules, templates). No cycle exists for the target month. Post-state:PayrollCyclerow inPLANNED → REQUESTED. One magic-link invitation per submitter contact. Outbound email per submitter in mailpit.
0. Prerequisites
- Environment up per
_shared/00-environment-setup.md. - Anchor client: ACME (
clientCode: ACME).- Existing seed: cycle for
2026-04already exists inINTAKE_IN_PROGRESS. To exercise this flow, target a month that does not yet exist (e.g.2026-05) or stand up a fresh client first.
- Existing seed: cycle for
- Alternatively follow
P-01-bisbelow to provision aQA001client first (recommended for clean trace). - Contacts to verify: ACME has
jane@acme.sg(submitter) andjohn@acme.sg(approver). Onlyjane@acme.sgshould receive the invitation.
1. Steps
1.1 Trigger the cycle (Option A — API)
Log in via the API as executive@spade.local, then:
POST /ops/clients/{clientId}/cycles
Content-Type: application/json
{ "cycleMonth": "2026-05" }The handler (apps/api/src/routes/ops/cycles.ts):
- Validates the request via
requireAction(Action.MANAGE_CYCLE). - Calls
CycleService.initiate({ clientId, cycleMonth }):- Creates the
PayrollCyclerow withstatus = PLANNED. - Snapshots the prior month's roster into
EmployeeShadowSnapshotrows. - Resolves all active
ClientContactrows withcanSubmit = true. - For each submitter, creates a
CycleRequestwith a 48-hour SHA-256-hashed token. - Transitions the cycle
PLANNED → REQUESTED. - Enqueues one
send-cycle-requestoutbox event per submitter.
- Creates the
1.2 Trigger via scheduler (Option B — passive)
The cycle-initiate worker handler runs hourly and fires per-client at local 09:00 SGT on each client's defaultScheduleDay using Intl.DateTimeFormat against client.timezone. ACME's seeded defaultScheduleDay = 17 so the handler will fire at 09:00 SGT on the 17th of any month with no prior cycle. This is impractical for ad-hoc testing — prefer Option A.
Alternative: fresh test client
If you want a fully isolated trace, create a client first (the seeded ACME comes pre-loaded with its own state):
- Web: navigate to
/dashboard/clients/new(asadmin@spade.local). - Code
QA001, legal nameQA Test Pte Ltd, timezoneAsia/Singapore,defaultScheduleDay = <today's day-of-month>. - Add three contacts (Alice submit-only, Bob approve-only, Dana both).
- Set Auth Policy:
MAGIC_LINK, OTP off. - Add document rules and ensure both UPLOAD + OUTPUT export templates are marked active.
- Now run the API call above against the new
clientIdforcycleMonth: "2026-05".
2. Verification
Database
SELECT id, status, opened_at FROM payroll_cycles
WHERE client_id = '<clientId>' AND cycle_month = '2026-05';
-- expect status = 'REQUESTED'
SELECT contact_id, delivery_mode, expires_at, token_hash IS NOT NULL AS has_hash
FROM cycle_requests
WHERE cycle_id = '<cycleId>';
-- expect one row per submitter contact, expires_at ≈ now + 48h, has_hash = trueMailpit (http://localhost:8025)
- One email per
canSubmit = truecontact;canApprove-only contacts get nothing at this step. - Subject:
Payroll data request for 2026-05 — {clientLegalName}. - Body: two CTAs (per the 2026-04-22 client requirement) — Yes, I have changes (
?choice=yes) and No changes this month (?choice=no). Both link tohttp://localhost:3000/portal/<jwt>. - The raw JWT is not stored — only its SHA-256 in
cycle_requests.token_hash.
Audit log
/dashboard/cycles/<id>/audit should list:
| Event type | Notes |
|---|---|
cycle.created | actor = executive@spade.local (or system for cron path) |
cycle.requested | transition PLANNED → REQUESTED |
outbox.enqueued | one per outbound email |
Outbox dispatcher
Within ~10 s the outbox-poller job marks each row's dispatched_at. Watch worker logs for Outbox dispatched: send-cycle-request.
3. Negative & edge cases
- Cycle for the month already exists → 409 Conflict, response body lists the existing cycle id.
- Client has no
canSubmit = truecontacts → 400 Bad Request"At least one submitter contact is required to initiate a cycle." - Client missing UPLOAD or OUTPUT export template (active) → cycle created in
PLANNED, but the readiness gauge on/dashboard/clients/<id>flips red andcycle-initiatewould have refused to mint invitations. Manual API call still goes through; the failure surfaces later at export time. - Idempotency — re-running with the same
cycleMonthdoes not create a second cycle. The handler returns the existing cycle and awasAlreadyInitiated: trueflag. - Token replay — opening the same magic link twice is allowed (intake is save-and-resume). After 48 hours the route returns 401; use the reminder flow (P-12) to mint a fresh token.
Next
Proceed to P-02 · Client intake — Yes-changes path or P-03 · Zero-change fast path.