Skip to content

Technical design — MOD-135 Batch payment & payroll file processing

Module: MOD-135 System: SD04 Payments Processing Repo: bank-payments FR scope: FR-601 (file upload + validation), FR-602 (aggregate balance gate), FR-603 (per-item screening + submission), FR-604 (rail-side return handling) NFR scope: NFR-012 (Postgres write latency p99 ≤ 10 ms), NFR-019 (RTO/RPO Tier 1), NFR-024 (audit immutability) Policies satisfied: PAY-001 (GATE), AML-007 (AUTO), PAY-002 (LOG), CON-005 (AUTO) Author: AI coding agent (Claude) Date: 2026-05-20 Jurisdictions: AU + NZ

Objective

Customers upload a batch payroll file (ABA for AU, CSV for either jurisdiction). MOD-135 validates the file, gates the aggregate amount against the source account's available balance via MOD-020, asks the customer to confirm the totals (CON-005), then per-item submits each beneficiary credit through MOD-020 → MOD-001 to settle.

The chain is intentionally pipelined: the synchronous customer-facing hops (upload, confirm) stay fast; the per-item fan-out runs asynchronously in the submission Lambda, sequentially over the items (j-3 ceiling = 3 000 items / batch in v1 — fits inside the 15-min Lambda budget).

Architecture

Customer HTTP API (internal)
  POST   /internal/v1/payments/batch                          ─► batch-upload      (FR-601)
                                                               │  mints pre-signed S3 PUT URL (j-1)
  GET    /internal/v1/payments/batch/{batch_id}
  GET    /internal/v1/payments/batch?party_id=...
  POST   /internal/v1/payments/batch/{batch_id}/confirm       ─► batch-confirm     (CON-005 / j-7)

  (dev/uat only — k-1)
  POST   /internal/v1/payments/batch/_admin/inject-batch
  POST   /internal/v1/payments/batch/{batch_id}/_admin/advance-submission
  POST   /internal/v1/payments/batch/{batch_id}/_admin/mark-returned

S3-drop ingestion (j-1)
  s3://bank-payments-batch-files-{stage}/incoming/batch/*.{aba,csv}
                            │ ObjectCreated:Put
                  batch-validation.handler                    (FR-601 + FR-602)
                            │ 1. selectByFileKey
                            │ 2. mark VALIDATING + BATCH_VALIDATING audit
                            │ 3. fetch body from S3
                            │ 4. validation-orchestrator (ABA / CSV)
                            │ 5. MOD-020 aggregate (call_purpose=AGGREGATE)
                            │      INSUFFICIENT_FUNDS → shortfall_amount
                            │      other VALIDATION_FAILED → REJECTED
                            │ 6. insertPending N items (mint payment_id each)
                            │ 7. markPendingApproval + BATCH_VALIDATED audit
                            │ 8. publish bank.payments.batch_validated

Customer confirms        ─► batch-confirm                    (CON-005 / FR-602)
                            │ totals must match (TOTALS_MISMATCH)
                            │ shortfall requires accept_partial_funding
                            │ mark PROCESSING + BATCH_CONFIRMED audit
                            │ publish bank.payments.batch_confirmed
                            │ fire-and-forget invoke ↓
                  batch-submission.handler                    (FR-603 / FR-604 / j-3)
                            │ per item (sequential, ≤ 3000):
                            │   1. ITEM_SUBMITTING + markSubmitting
                            │   2. runItem(orchestrator):
                            │        MOD-020 BATCH_ITEM screen (FR-603 — runs MOD-013
                            │         sanctions + MOD-023 fraud transitively per j-4)
                            │        SANCTIONS_HIT / FRAUD_HIGH_RISK / PENDING_AUTH
                            │          → QUARANTINED + publish batch_item_quarantined
                            │        AUTHORISED → MOD-001 PAYMENT posting
                            │   3. SETTLED → ITEM_SETTLED ; FAILED → ITEM_FAILED
                            ▼ after all items:
                  reconciliation (FR-608 / j-5 — MOD-135-owned for v1)
                  parsed_total + per-status sum must equal validated_total
                  matched   → markBatchSettled + publish batch_settled
                  variance  → markBatchFailed + publish batch_failed

Admin (dev/uat only) — FR-604 simulation
  mark-returned: MOD-001 REVERSAL (DEBIT clearing / CREDIT source)
                 + markReturned + publish batch_item_returned

State machine — payments.batch_files.status:
  UPLOADED → VALIDATING → PENDING_APPROVAL → PROCESSING → SETTLED | FAILED
                       → REJECTED

State machine — payments.batch_items.status:
  PENDING → SUBMITTING → SUBMITTED → SETTLED | QUARANTINED | FAILED | RETURNED

j-rulings applied (pre-build wiki amendments)

Ruling Implementation
j-1 S3 pre-signed PUT URL ingestion batch-upload mints a 15-min pre-signed PUT URL. ObjectCreated:Put on the bucket triggers batch-validation. Bucket owned by MOD-135; dependsOn the lambda:Permission per the MOD-114 fix.
j-2 Uppercase file format enum FileFormatEnum is "ABA" / "CSV".
j-3 Sequential fan-out, ~3000-item ceiling submission-orchestrator runs sequential; ceiling enforced in validation-orchestrator (cross-cutting check) + batch-submission (runtime guard). Documented in config.ts. v2 lifts to event-fan-out / Step Functions.
j-4 Sanctions + fraud transitive via MOD-020 Per-item invokeValidatePayment({ call_purpose: "BATCH_ITEM" }). MOD-020's design doc enumerates the transitive chain to MOD-013 (sanctions) + MOD-023 (fraud). MOD-135 makes no direct invocation.
j-5 MOD-135 owns FR-608 reconciliation in v1 reconciliation.ts classifies per-status totals; batch-submission reconciles at end-of-batch. MOD-081 picks up the BATCH rail in v2.
j-6 Distinct event schemas per outbound event 6 schemas, all draft-04, AJV-validated. event-publisher.ts schema-validates before putEvent.
j-7 CON-005 customer confirmation gate batch-confirm rejects mismatched totals (TOTALS_MISMATCH), refuses shortfall without explicit accept_partial_funding (SHORTFALL_NOT_ACCEPTED).
j-9 payment_id minted per item before MOD-020 call batch-validation mints a UUID per parsed item and writes it into payments.batch_items.payment_id; submission-orchestrator passes it to MOD-020 and MOD-001.
j-10 CSV with optional item_count=N preamble csv-parser handles both forms; mismatch → CSV_DECLARED_COUNT_MISMATCH.
j-11 Caller-managed idempotency on upload payments.batch_files.idempotency_key UNIQUE; replay returns the original upload_url + file_key. Powertools makeIdempotent wraps the upload handler so the pre-signed URL is regenerated deterministically per replay.
k-1 Admin gated Three-layer (build-time skip in infra/functions.ts + infra/api.ts; runtime 404 ADMIN_ENDPOINT_DISABLED).

SSM outputs

Path Type Notes
/bank/{stage}/mod-135/api/base-url String API Gateway URL
/bank/{stage}/mod-135/batch-upload-lambda/arn String
/bank/{stage}/mod-135/batch-validation-lambda/arn String S3-event triggered
/bank/{stage}/mod-135/batch-confirm-lambda/arn String
/bank/{stage}/mod-135/batch-submission-lambda/arn String Async fan-out (Event-invoked)
/bank/{stage}/mod-135/admin-lambda/arn String dev/uat only (k-1)
/bank/{stage}/mod-135/batch-files-bucket-name String j-1 ingestion bucket
/bank/{stage}/mod-135/batch-files-table String payments.batch_files
/bank/{stage}/mod-135/batch-items-table String payments.batch_items
/bank/{stage}/mod-135/batch-events-table String payments.batch_events (immutable)

Upstream SSM consumed

Path Source Use
/bank/{stage}/iam/lambda/bank-payments/arn MOD-104 Role
/bank/{stage}/observability/adot-nodejs-layer-arn MOD-076 OTel
/bank/{stage}/sns/alerts/arn MOD-104 Alarms
/bank/{stage}/eventbridge/bank-payments/arn MOD-104 Publish bus
/bank/{stage}/neon/pooler-host + bank-neon/{stage}/payments_app_user MOD-103 DB
/bank/{stage}/mod-001/lambda/arn MOD-001 Cross-domain MOD-001 PAYMENT + REVERSAL (grant in place from MOD-119/120/122/114/136)
/bank/{stage}/mod-020/validate-payment-lambda/arn MOD-020 Same-domain aggregate (FR-602) + per-item (FR-603) screening
/bank/{stage}/mod-135/batch-clearing-account-id (treasury seed; prod-only) Batch clearing GL account

Events

Published (on bank-payments bus, all 6 are distinct per j-6): - bank.payments.batch_validated — file passed format + aggregate balance gate; awaiting customer confirmation. - bank.payments.batch_confirmed — customer confirmed; batch transitions to PROCESSING. - bank.payments.batch_item_quarantined — sanctions / fraud / step-up — item held for ops review (AML-007). - bank.payments.batch_item_returned — sponsor rail returned the item; re-credit posted (FR-604). - bank.payments.batch_settled — all items terminal, reconciliation matched. - bank.payments.batch_failed — reconciliation variance or items_submitted = 0.

Schemas in schemas/ (draft-04, AJV-validated). MOD-135 owns the EB Schema Registry upload.

Consumed: none directly. MOD-022 + MOD-063 catch-all rules on bank.payments.* already cover them.

Postgres tables

Owned: - payments.batch_files (V001) — batch metadata + status. - payments.batch_items (V002) — per-line beneficiary instruction. - payments.batch_events (V003) — append-only audit (ADR-048 Cat 1 immutability).

Soft FKs (cross-DB, no enforced FK): - payments.batch_files.party_id / account_id — SD05 customer + SD01 account. - payments.batch_items.payment_id — SD04 MOD-020's payments.payments (minted per item).

Idempotency: - payments.batch_files.idempotency_key UNIQUE (caller-managed, j-11). - payments.idempotency_records (MOD-021 V007) — Powertools store for the upload Lambda.

CloudWatch alarms

Name Trigger
bank-{stage}-MOD-135-batch-upload-errors ≥ 1 Lambda Error in 3 of 5 min
bank-{stage}-MOD-135-batch-validation-errors ≥ 1 Lambda Error in 3 of 5 min
bank-{stage}-MOD-135-batch-confirm-errors ≥ 1 Lambda Error in 3 of 5 min
bank-{stage}-MOD-135-batch-submission-errors ≥ 1 Lambda Error in 3 of 5 min
bank-{stage}-MOD-135-batch-submission-p99-latency p99 ≥ 12 min (j-3 budget guard)
bank-{stage}-MOD-135-item-quarantined-rate ≥ 20 QUARANTINED in 1 h (AML-007 signal)
bank-{stage}-MOD-135-batch-failed-rate ≥ 3 FAILED batches in 1 h (FR-608 escalation)
bank-{stage}-MOD-135-shortfall-rate ≥ 5 shortfalls in 1 h (FR-602 customer-attention)

v1 known limitations

  • Sequential fan-out (j-3) — ~3 000 items / batch ceiling at 150 ms / item. v2 lifts to event-fan-out (Step Functions or SQS) for batches

    3 000 items and to parallelise the per-item invokes.

  • MOD-081 reconciliation deferred (j-5) — v1 reconciliation lives inside batch-submission; v2 lifts FR-608 into MOD-081's V1_MATCH_RAILS = [BATCH, …].
  • No real ABA writer-side hash variant probe (j-9) — accepts both the standard "last-digit-stripped" hash AND the full-10-digit variant. Real sponsor bank quirks will surface; v2 hardens to one.
  • No RTGS-style high-value batch path — v1 is payroll batch only. v2 adds high-value individual instructions via a separate flow.
  • Production back-office authoriser absent — the customer-facing HTTP routes are wired but presumed customer-portal authenticated upstream of the API Gateway. v2 adds an IAM/JWT authoriser.
  • Hard-coded GL codes2100 (customer-deposits) + 2260 (batch clearing). v2 derives from accounts.account_products.
  • Rail submission out-of-band — the actual MOD-001 CREDIT goes to the batch clearing GL; the real beneficiary settlement happens via the sponsor bank's batch-out file (not modelled in v1). v2 wires in the sponsor-out file once the rail integration is built.