Skip to content

P-01 · Initiate cycle (request data)

SOP: Payroll_Processing.md §6 / Step 1.0 (S0 → S1)Actors: APOE (system) — manually triggered as executive@spade.local (PAYROLL_EXECUTIVE) Pre-state: Client is fully configured (contacts with canSubmit=true, auth policy, document rules, templates). No cycle exists for the target month. Post-state: PayrollCycle row in PLANNED → 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-04 already exists in INTAKE_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.
  • Alternatively follow P-01-bis below to provision a QA001 client first (recommended for clean trace).
  • Contacts to verify: ACME has jane@acme.sg (submitter) and john@acme.sg (approver). Only jane@acme.sg should receive the invitation.

1. Steps

1.1 Trigger the cycle (Option A — API)

Log in via the API as executive@spade.local, then:

http
POST /ops/clients/{clientId}/cycles
Content-Type: application/json

{ "cycleMonth": "2026-05" }

The handler (apps/api/src/routes/ops/cycles.ts):

  1. Validates the request via requireAction(Action.MANAGE_CYCLE).
  2. Calls CycleService.initiate({ clientId, cycleMonth }):
    • Creates the PayrollCycle row with status = PLANNED.
    • Snapshots the prior month's roster into EmployeeShadowSnapshot rows.
    • Resolves all active ClientContact rows with canSubmit = true.
    • For each submitter, creates a CycleRequest with a 48-hour SHA-256-hashed token.
    • Transitions the cycle PLANNED → REQUESTED.
    • Enqueues one send-cycle-request outbox event per submitter.

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

  1. Web: navigate to /dashboard/clients/new (as admin@spade.local).
  2. Code QA001, legal name QA Test Pte Ltd, timezone Asia/Singapore, defaultScheduleDay = <today's day-of-month>.
  3. Add three contacts (Alice submit-only, Bob approve-only, Dana both).
  4. Set Auth Policy: MAGIC_LINK, OTP off.
  5. Add document rules and ensure both UPLOAD + OUTPUT export templates are marked active.
  6. Now run the API call above against the new clientId for cycleMonth: "2026-05".

2. Verification

Database

sql
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 = true

Mailpit (http://localhost:8025)

  • One email per canSubmit = true contact; 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 to http://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 typeNotes
cycle.createdactor = executive@spade.local (or system for cron path)
cycle.requestedtransition PLANNED → REQUESTED
outbox.enqueuedone 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 = true contacts → 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 and cycle-initiate would have refused to mint invitations. Manual API call still goes through; the failure surfaces later at export time.
  • Idempotency — re-running with the same cycleMonth does not create a second cycle. The handler returns the existing cycle and a wasAlreadyInitiated: true flag.
  • 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.

Internal use only — BreezyCorp