P-05 · Request info from client (issue resolution loop)
SOP:
Payroll_Processing.md§7 — Exception handling / re-request loopActors: Payroll Executive (PE) —executive@spade.local. Client Submitter on the receiving side. Pre-state: Cycle inACTION_REQUIREDwith at least one openWorkflowIssueor computed missing-document violation. Post-state: AClientActionRequestrow recorded; magic-link email dispatched; portal banner active for the client; cycle auto-revalidates and transitions back toREADY_FOR_INTERNAL_REVIEWwhen all required uploads land.
This is the cleaner alternative to manually overriding issues — it bounces the gaps back to the client and re-validates automatically.
0. Prerequisites
- Cycle is in
ACTION_REQUIRED(set automatically by P-04 or P-07 when blocking validation fails). - At least one open
WorkflowIssueor a computedDocumentRequirementViolationexists. Without anything open the Request info from client button is hidden.
1. Steps
1.1 Open the dialog
On /dashboard/cycles/<id> (cycle in ACTION_REQUIRED), click Request info from client. The dialog loads:
- Recipient dropdown — defaults to the first active
canSubmit = truecontact. All active contacts appear so you can pick the right person. - Missing documents checkbox list — one row per
DocumentRequirementViolation, e.g. "Offer letter for JOINER Wei Lin". Pre-checked. Uncheck anything already received offline. - Open issues checkbox list — one row per open
WorkflowIssue, e.g. "IR21 filing missing", "Attendance report missing". Pre-checked. - Note to client (optional, ≤ 2000 chars) — free-text appended to the email body, e.g. "Could you send these by Friday?".
The submit button shows the running selected count: Send request (4).
1.2 Send
POST /ops/cycles/<id>/request-client-action
Content-Type: application/json
Authorization: Bearer <staff-jwt>
{
"contactId": "<contactId>",
"includedIssueIds": ["<issueId>", ...],
"includedMissingDocs": ["DocumentRequirementViolation:<...>", ...],
"note": "Could you send these by Friday?"
}The route:
- Re-validates that the cycle is still
ACTION_REQUIRED. - Verifies the contact belongs to this client and is
isActive. - Filters out any selected issue ids that are no longer open (defensive against stale UI state).
- Persists a
ClientActionRequestrow withpayloadJsoncarrying{ includedIssueIds, includedMissingDocs, note }. - Writes a
client_action.requestedaudit event. - Enqueues
send-client-action-requestvia the outbox.
The dialog closes with a Request sent to client toast.
1.3 Mailpit
Within ~10 s the chosen contact receives an email at http://localhost:8025:
- Subject:
Action needed for {month} payroll — {N} item(s) - Body:
- Eyebrow + heading + intro paragraph.
- The note (rendered as a styled blockquote) if you typed one.
- Missing documents bullet list with employee context, e.g. Offer letter (new joiner for Wei Lin).
- Open items bullet list — preflight headlines + plain-language reasons for any non-document blocking rules.
- Single primary CTA: Open the portal →
http://localhost:3000/portal/<jwt>(a fresh 48 h token; SHA-256 hash persisted incycle_requestswithdelivery_mode = 'CLIENT_ACTION_REQUEST').
1.4 Client side — portal landing
Open the link in an incognito window:
- Portal routes the cycle to the intake view because
ACTION_REQUIREDis mapped into the wizard. - Above the wizard a prominent amber banner renders the request:
- Headline: "Your payroll team is waiting on N item(s)".
- Note (if any) shown as a quoted block.
- Missing documents bullet list mirroring the email.
- Open items bullet list with headlines + details.
- The client uploads the requested files using the existing upload UI. There is no separate "respond to request" surface — they upload normally.
1.5 Auto-revalidate on upload
Each call to POST /portal/files/<id>/finalize checks the cycle status. When it is ACTION_REQUIRED, the route enqueues a validation-run pg-boss job with a singleton key, debouncing burst uploads into a single re-run.
Worker logs:
Auto-revalidate enqueued after upload into ACTION_REQUIRED cycle cycleId=<id>
Validation run completed cycleId=<id> failedBlocking=0 newStatus=READY_FOR_INTERNAL_REVIEW1.6 Cycle transitions on success
When the re-validation passes (no blocking failures), the validation-run handler:
- Transitions the cycle
ACTION_REQUIRED → READY_FOR_INTERNAL_REVIEW. - Calls
clientActionService.markFulfilled(cycleId, clientId)— stampsfulfilled_at = now()on every still-openClientActionRequestrow for the cycle. - Writes a single
client_action.fulfilledaudit event.
2. Verification
Database
SELECT id, contact_id, payload_json, requested_at, fulfilled_at
FROM client_action_requests WHERE cycle_id = '<cycleId>'
ORDER BY requested_at DESC LIMIT 1;
-- After client uploads: fulfilled_at should be set.Web UI
- After upload, refreshing
/dashboard/cycles/<id>shows the Request info from client button is gone (no longerACTION_REQUIRED). - The amber portal banner disappears on the client's next page load.
/portal/cycles/<token>/pending-actionreturnsnull.
Audit sequence
client_action.requested
file.finalized (×N, one per upload)
client_action.fulfilled
cycle.action_required → cycle.ready_for_internal_review3. Negative & edge cases
- Cycle not in
ACTION_REQUIRED→ button hidden in the UI; direct API call returns 400"Client action can only be requested while the cycle is awaiting fixes …". - Contact from a different client → 400
"Selected contact does not belong to this client." - Inactive contact selected → 400
"Selected contact is inactive — choose an active contact." - Empty selection (no issues, no docs ticked) → 400
"Select at least one issue or missing document to send." - Stale issue ids (UI cached an issue that has since been resolved) → silently dropped from
payloadJson.includedIssueIds; the request still sends with whatever remains valid. - Re-send to a different contact — clicking the button again creates a new
ClientActionRequestrow + a new outbox event. The button label flips to Resend request to client. Both magic-link tokens stay valid until 48 h; latest received-by-client wins. - SMTP failure — handler logs
Client action request email failed to send — rethrowing for retryand pg-boss retries with exponential backoff.
Next
Once READY_FOR_INTERNAL_REVIEW, proceed to P-06 · Internal review → P-07 · Generate export.