Skip to content

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 in ACTION_REQUIRED with at least one open WorkflowIssue or computed missing-document violation. Post-state: A ClientActionRequest row recorded; magic-link email dispatched; portal banner active for the client; cycle auto-revalidates and transitions back to READY_FOR_INTERNAL_REVIEW when 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 WorkflowIssue or a computed DocumentRequirementViolation exists. 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 = true contact. 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

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

  1. Re-validates that the cycle is still ACTION_REQUIRED.
  2. Verifies the contact belongs to this client and is isActive.
  3. Filters out any selected issue ids that are no longer open (defensive against stale UI state).
  4. Persists a ClientActionRequest row with payloadJson carrying { includedIssueIds, includedMissingDocs, note }.
  5. Writes a client_action.requested audit event.
  6. Enqueues send-client-action-request via 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 portalhttp://localhost:3000/portal/<jwt> (a fresh 48 h token; SHA-256 hash persisted in cycle_requests with delivery_mode = 'CLIENT_ACTION_REQUEST').

1.4 Client side — portal landing

Open the link in an incognito window:

  1. Portal routes the cycle to the intake view because ACTION_REQUIRED is mapped into the wizard.
  2. 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.
  3. 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_REVIEW

1.6 Cycle transitions on success

When the re-validation passes (no blocking failures), the validation-run handler:

  1. Transitions the cycle ACTION_REQUIRED → READY_FOR_INTERNAL_REVIEW.
  2. Calls clientActionService.markFulfilled(cycleId, clientId) — stamps fulfilled_at = now() on every still-open ClientActionRequest row for the cycle.
  3. Writes a single client_action.fulfilled audit event.

2. Verification

Database

sql
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 longer ACTION_REQUIRED).
  • The amber portal banner disappears on the client's next page load.
  • /portal/cycles/<token>/pending-action returns null.

Audit sequence

client_action.requested
file.finalized   (×N, one per upload)
client_action.fulfilled
cycle.action_required → cycle.ready_for_internal_review

3. 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 ClientActionRequest row + 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 retry and pg-boss retries with exponential backoff.

Next

Once READY_FOR_INTERNAL_REVIEW, proceed to P-06 · Internal reviewP-07 · Generate export.

Internal use only — BreezyCorp