P-09 · Request client approval (manual fallback)
SOP:
Payroll_Processing.md§6 / Step 6.0 — client approval requestActors: Payroll Executive (PE). Pre-state: Cycle inEXPORTEDorOUTPUT_IMPORTEDandapprovalRoundNo = 0(no round open yet). Post-state:ApprovalRoundrow created (round 1 or higher), Spade-format approval report generated, magic-link email sent to the approver, cycle transitions toCLIENT_REVIEW.
Important: P-07 · Generate export already auto-fires the approval request when the client has an active approver. In the golden path this section is already done by the time you arrive here. The manual steps below exist for two fallback cases:
- The client had no active approver when Generate Export was clicked → server skipped auto-request with
approvalSkipped.reason = "no_approver_configured". Add an approver, then either re-run the export or use this manual button.- You want to target a different approver than the one auto-picked.
0. Prerequisites
- Cycle in
EXPORTEDorOUTPUT_IMPORTED. - At least one active
ClientContactwithcanApprove = true.
1. Steps
1.1 Open Request Approval dialog
On /dashboard/cycles/<id>, click Request Approval. The button is visible whenever the cycle is EXPORTED or OUTPUT_IMPORTED and approvalRoundNo === 0. Once a round is open, the button hides.
If you skipped P-08, the dialog shows an amber warning: "No payroll output uploaded yet — the approval report will be generated from submission items, so CPF / SDL columns will be blank." You can still proceed.
1.2 Pick the approver
The dropdown filters by canApprove = true, so only CLIENT_APPROVER-capable contacts appear. A canSubmit-only contact is hidden.
1.3 Submit
POST /ops/cycles/<id>/request-approval
Authorization: Bearer <staff-jwt>
{ "contactId": "<contactId>" }Server (in order):
- Validates that the cycle is
EXPORTEDorOUTPUT_IMPORTED. Any other state → 400 BadRequest. - Validates that the contact belongs to this client (cross-tenant attempt → 400 BadRequest).
- Validates that the contact has
canApprove = true→ otherwise 400"Selected contact cannot approve payroll for this client.". - If no
APPROVAL_REPORTfile exists yet for the cycle, generates one inline from submission items (Spade-format xlsx, uploaded to S3, persisted asFilewithfileKind = APPROVAL_REPORT) and writes anapproval_report.generatedaudit event. - Creates an
ApprovalRoundwithroundNo = currentMax + 1. - Transitions the cycle to
CLIENT_REVIEW. - Enqueues
send-approval-requestvia the outbox carrying{ cycleId, clientId, approvalRoundId, contactId, contactEmail, roundNo }.
1.4 Worker dispatches email
send-approval-request then:
- Mints a fresh magic-link JWT for the approver contact (48 h TTL) using the same
issueCycleAccesshelper used for intake invitations. - Persists its SHA-256 hash in
cycle_requestswithdelivery_mode = 'APPROVAL_REQUEST'. - Sends the email.
2. Verification
Database
SELECT id, round_no, requested_at, contact_id
FROM approval_rounds WHERE cycle_id = '<cycleId>'
ORDER BY round_no DESC LIMIT 1;
SELECT delivery_mode, expires_at, contact_id
FROM cycle_requests WHERE cycle_id = '<cycleId>'
ORDER BY created_at DESC LIMIT 1;
-- delivery_mode = 'APPROVAL_REQUEST', expires_at ≈ now + 48hMailpit
Within ~10 s a new email lands for the approver:
- Subject:
Payroll approval required for {month} — Round {N}. - Body has an Approve payroll button →
/portal/<jwt>(a fresh JWT, not/portal/approval/<approvalRoundId>— that route does not exist). - Click the link — the portal auto-routes to the approval view because the cycle is in
CLIENT_REVIEW. The same[token]/page.tsxhandles intake and approval based on cycle status.
3. Negative & edge cases
- Cycle in
SUBMITTED→ 400"Generate the export first — this cycle is Submitted right now." contactIdbelongs to a different client → 400"Selected contact does not belong to this client."contactIdwithoutcanApprove→ 400"Selected contact cannot approve payroll for this client."- SMTP failure retry — handler wraps
sender.sendin try/catch, logs theerrno, and rethrows so pg-boss retries with exponential backoff. Watch worker logs for"Approval request email failed to send — rethrowing for retry". - Round already open — button hidden. To target a different approver after a round was opened, the existing round must be revoked or completed first (no UI for revoke today; a follow-up revision round handles it via P-10 revision branch).
Next
Proceed to P-10 · Client approval decision.