P-02 · Client intake — Yes-changes path
SOP:
Payroll_Processing.md§6 / Step 2.0 (S1 → S4)Actors: Client Submitter (CR) — magic-link authenticated. Pre-state: Cycle inREQUESTEDwith a valid magic-link invitation for the contact. Post-state: Cycle transitionsREQUESTED → INTAKE_IN_PROGRESS → SUBMITTEDwith aSubmissionrow containing items + uploaded files + signed monthly declaration.
0. Prerequisites
- P-01 has run for the target client + month.
- The submitter's mailpit inbox has the data-request email open.
- Sample documents live in
scripts/fixtures/(see_shared/00-environment-setup.md§5).
1. Steps
1.1 Open the portal — ?choice=yes
In the data-request email, click Yes, I have changes. Browser lands on http://localhost:3000/portal/<jwt>?choice=yes.
The portal:
- Sends
GET /portal/cycles/<token>with the JWT inAuthorization: Bearer <token>. - Server verifies the SHA-256 hash against
cycle_requests.token_hash, checksexpires_at > now(), and resolves the cycle + contact. - Cycle transitions
REQUESTED → INTAKE_IN_PROGRESSon this first GET. - The
?choice=yesquery param sets a session flag that skips the in-portal "do you have changes?" decision card and jumps directly to the wizard's upload step.
1.2 Upload documents (drives OCR + prefill)
Each upload follows a 3-call presign / PUT / finalize ceremony:
POST /portal/files/presign
Content-Type: application/json
{ "originalName": "offer-letter-marcus-lim.pdf", "mimeType": "application/pdf", "sizeBytes": 124356 }Response carries uploadUrl (presigned PUT) and fileId. Browser PUTs the bytes directly to MinIO.
POST /portal/files/<fileId>/finalize
Content-Type: application/json
{ "sha256": "<browser-computed hex>", "sizeBytes": 124356 }Server:
- Marks the
Filerow as finalized (immutable). - Enqueues
ocr-processfor the file. - If the cycle is currently
ACTION_REQUIRED, also enqueues a singletonvalidation-runjob (debounced; see P-05).
For full coverage, upload at minimum:
| File | Triggers / satisfies |
|---|---|
2026-04-ACME-attendance.xlsx | ATTENDANCE_REQUIRED blocking rule |
offer-letter-marcus-lim.pdf + nric-marcus-lim.pdf + bank-details-marcus-lim.pdf | Local joiner bundle |
offer-letter-wei-lin.pdf + fin-wei-lin.pdf + bank-details-wei-lin.pdf | Foreign joiner bundle |
change-letter-emp-001-increment.pdf | Salary increment evidence |
resignation-letter-rachel-goh.pdf | Leaver evidence |
Watch MinIO console (localhost:9001) → bucket breezycorp → <clientCode>/<month>/uploads/.
1.3 OCR prefill
Once ocr-process completes (mock adapter ≈ 1 s), the portal polls GET /portal/cycles/<token>/extracted-fields every 5 s. Fields with confidence ≥ 0.95 auto-fill the joiner / change forms. Fields between 0.80 and 0.95 are flagged for confirmation but pre-filled. Fields below 0.80 stay blank.
1.4 Baseline roster dropdown
Change and Leaver forms render a "Select existing employee" dropdown sourced from GET /portal/cycles/<token>/roster. The roster is the prior cycle's EmployeeShadowSnapshot. For a brand-new client this is empty — that's expected. ACME's snapshot has 6 employees.
1.5 Add submission items
Add at least one of each:
- JOINER — Marcus Lim, local, 5000 SGD, effective 2026-05-01
- JOINER — Wei Lin, foreign (FIN), 6500 SGD
- CHANGE —
EMP-001increment to 8200 SGD - LEAVER — Rachel Goh, local, last day 2026-05-15
1.6 Sign the monthly declaration
Wizard final step renders the declaration form (claims attestation, NS attestation, etc.). Without a signed declaration on a non-zero-item cycle the submit returns DECLARATION_INCOMPLETE.
1.7 Submit
POST /portal/cycles/<token>/submit
Content-Type: application/json
{
"attestationText": "I confirm the data is accurate.",
"monthlyDeclaration": {
"noChangesFromPriorMonth": false,
"claimsAttestation": "...",
"signedAt": "2026-04-25T10:00:00Z"
}
}Server:
- Calls
validateDeclarationto enforce required fields. - Runs synchronous blocking rules:
ATTENDANCE_REQUIRED,DOCUMENT_REQUIREMENTS_MET,FOREIGN_LEAVER_IR21,NS_DOCUMENTATION,DUPLICATE_RECEIPTS. - If any blocking rule fails → 400 Bad Request with
validationFailures[]. Cycle stays atINTAKE_IN_PROGRESS. - On success, creates the
Submissionrow (immutable, version 1) and transitions cycleINTAKE_IN_PROGRESS → SUBMITTED.
2. Verification
Database
SELECT version_no, submitted_at, monthly_declaration ->> 'signedAt' AS signed
FROM submissions WHERE cycle_id = '<cycleId>' ORDER BY version_no DESC LIMIT 1;
-- expect submitted_at IS NOT NULL
SELECT change_type, employee_ref, COUNT(*)
FROM submission_items WHERE submission_id = '<submissionId>'
GROUP BY 1, 2;S3 / MinIO
- Bucket
breezycorp→<clientCode>/<month>/uploads/contains every file. - File rows reference these via
storage_key. SHA-256 hashes match what the browser computed.
Audit log (/dashboard/cycles/<id>/audit)
| Event | Order |
|---|---|
cycle.intake_started | once |
file.finalized | per upload |
ocr.completed | per file |
submission.submitted | once |
cycle.submitted | once |
Mailpit
No email is sent at this step. The next outbound email is the approval request (see P-09).
3. Negative & edge cases
- Missing required document (e.g. delete
bank-details-marcus-lim.pdfbefore submit) → 400 withDOCUMENT_REQUIREMENTS_METfailure naming the rule. - Foreign leaver without IR21 → 400 with
FOREIGN_LEAVER_IR21failure. Either upload the IR21 doc or remove the foreign leaver to proceed. - Token expired (after 48 h) → 401. Use P-12 to mint a fresh one.
- Replay submit on already-submitted cycle → 409 with the existing
submissionId. Submissions are immutable; resubmits go through P-10 revision flow. - Approver-only contact opens the link → portal shows the read-only "Submitted" view, never the wizard.
Next
Cycle is now in SUBMITTED. Validation is run automatically as the first step of P-07 · Generate export, but you can also re-run it manually via P-04 · Validation.