Skip to content

P-09 · Request client approval (manual fallback)

SOP: Payroll_Processing.md §6 / Step 6.0 — client approval requestActors: Payroll Executive (PE). Pre-state: Cycle in EXPORTED or OUTPUT_IMPORTED and approvalRoundNo = 0 (no round open yet). Post-state: ApprovalRound row created (round 1 or higher), Spade-format approval report generated, magic-link email sent to the approver, cycle transitions to CLIENT_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:

  1. 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.
  2. You want to target a different approver than the one auto-picked.

0. Prerequisites

  • Cycle in EXPORTED or OUTPUT_IMPORTED.
  • At least one active ClientContact with canApprove = 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

http
POST /ops/cycles/<id>/request-approval
Authorization: Bearer <staff-jwt>

{ "contactId": "<contactId>" }

Server (in order):

  1. Validates that the cycle is EXPORTED or OUTPUT_IMPORTED. Any other state → 400 BadRequest.
  2. Validates that the contact belongs to this client (cross-tenant attempt → 400 BadRequest).
  3. Validates that the contact has canApprove = true → otherwise 400 "Selected contact cannot approve payroll for this client.".
  4. If no APPROVAL_REPORT file exists yet for the cycle, generates one inline from submission items (Spade-format xlsx, uploaded to S3, persisted as File with fileKind = APPROVAL_REPORT) and writes an approval_report.generated audit event.
  5. Creates an ApprovalRound with roundNo = currentMax + 1.
  6. Transitions the cycle to CLIENT_REVIEW.
  7. Enqueues send-approval-request via the outbox carrying { cycleId, clientId, approvalRoundId, contactId, contactEmail, roundNo }.

1.4 Worker dispatches email

send-approval-request then:

  1. Mints a fresh magic-link JWT for the approver contact (48 h TTL) using the same issueCycleAccess helper used for intake invitations.
  2. Persists its SHA-256 hash in cycle_requests with delivery_mode = 'APPROVAL_REQUEST'.
  3. Sends the email.

2. Verification

Database

sql
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 + 48h

Mailpit

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.tsx handles 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."
  • contactId belongs to a different client → 400 "Selected contact does not belong to this client."
  • contactId without canApprove → 400 "Selected contact cannot approve payroll for this client."
  • SMTP failure retry — handler wraps sender.send in try/catch, logs the errno, 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.

Internal use only — BreezyCorp