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 walked PLANNED → REQUESTED → INTAKE_IN_PROGRESS. The month's request sent to each payroll sender — by email on their own thread for the month, or their own chat when they have no email address. The request ends with the company's payroll link only when it has a live one.

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.
    • Refuses a month after the company's current month in its own time (422 FUTURE_MONTH), and a month number outside 01-12 (400, the body pattern) (stage review S1-04, 28 Oct 2026).
    • Copies the employee list (EmployeeShadowSnapshot rows) from the newest finalised earlier month (Finalized, CPF pending, Closed, Archived), with last month's leavers left off, joiners added and raises applied. Each row records the month it came from (PAY-3, 26 Oct 2026). The copy writes one payroll.roster.copied audit row (from month, count, dropped leavers, added joiners, joiners not carried) and a PAY_JOINER_NOT_CARRIED warning per joiner it could not carry (S1-05, S1-06).
    • With nothing finalised to copy and an earlier month still open (a new client's second month), the month opens empty, the response says isFirstMonth: false, and the list fills in from the month before when that month is finalised (syncCarriedRoster, S1-01).
    • Transitions the cycle PLANNED → REQUESTED → INTAKE_IN_PROGRESS.
    • Queues one payroll.cycle-opened-notify outbox event. The worker sends the month's request to each payroll sender (canSubmit) — see §2.

1.2 Trigger via scheduler (Option B — passive) ​

The payroll.cycle-initiate worker handler runs hourly and fires per-entity at local 09:00 on each entity's defaultScheduleDay (Intl.DateTimeFormat against the entity's metadata.timezone), retrying hourly for 48 hours. When the previous month is not finalised yet, it opens anyway on the last finalised month's list, alerts the Payroll Leads once (PRIOR_MONTH_NOT_FINAL), and the month carries a sign-off guard (PAY_PRIOR_MONTH_NOT_FINAL) until the previous month is finalised; it waits only for a company with no finalised month at all (nothing to copy). ACME's seeded defaultScheduleDay = 17, so it fires from 09:00 SGT on the 17th of a month with no cycle yet.

To drive it on demand locally, rather than waiting for the top of the hour:

bash
pnpm --filter @breezycorp/worker exec tsx scripts/send-job.ts payroll.cycle-initiate

Still slower than Option A 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 = 'INTAKE_IN_PROGRESS'

The month's request ​

Sent through Chatwoot (sendPayrollCycleOpenConfirmation), not SMTP:

  • One copy per canSubmit = true contact; canApprove-only contacts get nothing at this step.
  • By email to anyone with an email address, on their own thread for the month, started from the firm-wide payroll mailbox chosen in Settings › Integrations. Subject: Payroll Input – May 2026 – {legal name} (the firm's own subject since 26 Oct 2026; a month started before keeps its old thread). Their own chat only when they have no email address (WhatsApp only within 24 h of their last message).
  • A person with an email is not moved to chat when no payroll mailbox is set up: nothing is sent to them and the month gets a staff task.
  • Body: "Your May 2026 payroll is now open.", then what this client needs this month — the firm's own input list, with what this client sends every month first; on a real first month only, a starting point first (last month's payroll report from the previous provider, or the employee list); known leavers (with the IR21 note for a foreign one) and changes; changes still waiting for a document; when the inputs are due (the company's Inputs due by day; "as soon as you can" for a month opened after it) and when salaries go out. Email copies start "Hi {name}," and end with the firm's sign-off from Settings → General ("Payroll team" until set); chat copies are short lines. When the company has a live payroll link the request ends with "You can also send everything on your payroll page: {link}"; otherwise no link.
  • Opened by the scheduler (Option B) the request is an automatic message: it sends unless automatic messages are off (the platform's MSG_AUTO_REPLY_ENABLED=false, or the company's switch on Settings → Payroll), otherwise staff get a task naming which switch. Opened by staff it always sends.
  • GET /ops/cycles/<cycleId>/client-notices lists it with the words each person was sent.

Audit log ​

/dashboard/cycles/<id>/audit should list:

Event typeNotes
cycle.createdactor = executive@spade.local (or system for cron path; the approver for a month a document opened)
cycle.requestedtransition PLANNED → REQUESTED
cycle.intake_openedtransition REQUESTED → INTAKE_IN_PROGRESS
outbox.enqueuedthe payroll.cycle-opened-notify row (not on the document path)
payroll.roster.copiedwhen a list was copied: from month, count, dropped leavers, added joiners, joiners not carried

A month a document opened writes the same cycle.created / cycle.requested / cycle.intake_opened rows since 28 Oct 2026 (S1-06); before that it had only a run event.

Outbox dispatcher ​

Within ~10 s the outbox-poller job dispatches the payroll.cycle-opened-notify row to the worker, which sends the request once per cycle.

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) → the cycle still opens and the readiness gauge on /dashboard/clients/<id> flips red; the failure surfaces later, at export time. Neither the manual route nor payroll.cycle-initiate refuses on this.
  • Idempotency — re-running with the same cycleMonth does not create a second cycle. The handler returns the existing cycle and a wasAlreadyInitiated: true flag.
  • A month after the current one → 422 FUTURE_MONTH ("{Month} has not started yet …"); nothing is created. The console's month picker stops at the current month.
  • Client goes quiet — reminders follow on day 3, 5 and 7 from 10:00 in the company's own time (P-12), counted from when the month's request reached someone. While the request is held or failed (the "The client was not told the month opened" task is open) nothing is counted and the day-7 fallback never applies.

Next ​

Proceed to P-02 · Client intake — Yes-changes path or P-03 · Zero-change fast path.

Internal use only — BreezyCorp