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).
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.
A document about another month (since 28 Oct 2026, stage review S2-02)
Once read, a change letter, offer or contract, attendance sheet or resignation letter is filed under the month it is about (its effective date, start date, period or last working day), not the month it arrived in:
| The month it is about | What happens |
|---|---|
| Earlier, not yet exported, and this month is open | Moved there and read there. |
| Earlier, Exported or Output imported, and this month is open | A late correction to a file Infotech already has. It is moved there all the same (so nothing is made on this month), read there, and its card waits for staff. That month gets an EXPORT_STALE warning naming the document (regenerate the export, key the change into Infotech, upload the new report before finalizing), and its Payroll Leads and preparers are emailed once ("a document arrived after the … payroll was sent to Infotech"). Staff still cannot move documents into an exported month themselves; only this step does. |
| Earlier, finalized | Not moved. The Leads and that month's preparers are emailed; a change letter is paid from this month with the backdated warning; an attendance sheet is not used. |
| No payroll month for it, or this month is itself with Infotech | Left where it is. |
An attendance sheet for another month that is not used says only what can be done: "Move it to the month it is for" only when both months are still before export; otherwise it says which month has gone to Infotech, or that there is no month to move it to. An attendance sheet for a month not yet open (an October sheet sent while September is still being prepared is filed under September) says "October has not opened yet: the sheet is read again by itself when it opens". When October opens (on schedule, by staff, or from a document review), every such sheet still carrying its not used warning moves to October, the warning closes, and it is read there; this works even if September has been exported meanwhile, since the sheet made nothing on September.
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.