Skip to content

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 in REQUESTED with a valid magic-link invitation for the contact. Post-state: Cycle transitions REQUESTED → INTAKE_IN_PROGRESS → SUBMITTED with a Submission row 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:

  1. Sends GET /portal/cycles/<token> with the JWT in Authorization: Bearer <token>.
  2. Server verifies the SHA-256 hash against cycle_requests.token_hash, checks expires_at > now(), and resolves the cycle + contact.
  3. Cycle transitions REQUESTED → INTAKE_IN_PROGRESS on this first GET.
  4. The ?choice=yes query 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:

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

http
POST /portal/files/<fileId>/finalize
Content-Type: application/json

{ "sha256": "<browser-computed hex>", "sizeBytes": 124356 }

Server:

  • Marks the File row as finalized (immutable).
  • Enqueues ocr-process for the file.
  • If the cycle is currently ACTION_REQUIRED, also enqueues a singleton validation-run job (debounced).

For full coverage, upload at minimum:

FileTriggers / satisfies
2026-04-ACME-attendance.xlsxATTENDANCE_REQUIRED blocking rule
offer-letter-marcus-lim.pdf + nric-marcus-lim.pdf + bank-details-marcus-lim.pdfLocal joiner bundle
offer-letter-wei-lin.pdf + fin-wei-lin.pdf + bank-details-wei-lin.pdfForeign joiner bundle
change-letter-emp-001-increment.pdfSalary increment evidence
resignation-letter-rachel-goh.pdfLeaver 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-001 increment 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 ​

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

  1. Calls validateDeclaration to enforce required fields.
  2. Runs synchronous blocking rules: ATTENDANCE_REQUIRED, DOCUMENT_REQUIREMENTS_MET, FOREIGN_LEAVER_IR21, NS_DOCUMENTATION, DUPLICATE_RECEIPTS.
  3. If any blocking rule fails → 400 Bad Request with validationFailures[]. Cycle stays at INTAKE_IN_PROGRESS.
  4. On success, creates the Submission row (immutable, version 1) and transitions cycle INTAKE_IN_PROGRESS → SUBMITTED.

2. Verification ​

Database ​

sql
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) ​

EventOrder
cycle.intake_startedonce
file.finalizedper upload
ocr.completedper file
submission.submittedonce
cycle.submittedonce

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.pdf before submit) → 400 with DOCUMENT_REQUIREMENTS_MET failure naming the rule.
  • Foreign leaver without IR21 → 400 with FOREIGN_LEAVER_IR21 failure. 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 aboutWhat happens
Earlier, not yet exported, and this month is openMoved there and read there.
Earlier, Exported or Output imported, and this month is openA 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, finalizedNot 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 InfotechLeft 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.

Internal use only — BreezyCorp