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; see P-05).

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
  • CHANGEEMP-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.

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