Technical design — MOD-026 IFTI / CMIR reporting trigger¶
Module: MOD-026
System: SD04 Payments Processing
Repo: bank-payments
FR scope: FR-145
NFR scope: NFR-010 (latency), NFR-011 (availability), NFR-019 (RTO/RPO Tier 1), NFR-024 (audit log immutability)
Policies satisfied: AML-008 (AUTO), REP-003 (AUTO)
Author: AI coding agent (Claude)
Date: 2026-05-18
Jurisdiction: AU (v1); NZ deploy supported via JURISDICTION env
Objective¶
For every cross-border payment that completes on the bank-payments
platform, decide whether it crosses the configured IFTI threshold
(AUSTRAC IFTI-E in AU; RBNZ equivalent in NZ) and, if so, queue a
row for downstream regulatory submission. MOD-026 is the trigger
in the IFTI/CMIR pipeline; the actual filing to AUSTRAC/RBNZ is
owned by SD03 MOD-019 in the bank-aml repo, which reads QUEUED
rows from payments.ifti_cmir_queue and back-populates the
submission_id.
This is Tier C of SD04 (alongside MOD-081 reconciliation and MOD-082 nostro). MOD-026 is the first SD04 module whose only trigger is an EventBridge event from MOD-022 — no HTTP surface in prod, no S3 input, no scheduled invocation.
Architecture¶
MOD-022 publishes:
bank.payments.payment_completed (on bank-payments bus)
│
▼
EventBridge rule (infra/event-rules.ts):
source=bank.payments, detail-type=payment_completed
│
▼
threshold-evaluator.handler (Lambda)
│
│ 1. Powertools makeIdempotent — EB envelope `id` as key
│ (per j-6); persistence in payments.idempotency_records
│ 2. parse PaymentCompletedDetail (Zod)
│ 3. lookupPayment(payment_id) — enrich with direction
│ + beneficiary_country from payments.payments
│ (the event itself doesn't carry these)
│ 4. isCrossBorder(p): country !== null && direction !== INTERNAL
│ 5. evaluate(p, cfg): bigint-cents compare amount vs.
│ IFTI_THRESHOLD_AMOUNT → {report_type:IFTI, ...} | null
│ 6. INSERT payments.ifti_cmir_queue
│ ON CONFLICT (payment_id, report_type, jurisdiction) DO NOTHING
│ 7. publish bank.payments.ifti_threshold_crossed
│ (AJV-validated against schemas/*.json)
▼
payments.ifti_cmir_queue
│
│ queue_status: QUEUED → PROCESSING → SUBMITTED | FAILED
│ (transitions owned by SD03 MOD-019 — soft FK back to
│ aml.regulatory_submissions(id))
▼
HTTP API (dev/uat only):
GET /internal/v1/payments/ifti-cmir/queue
?queue_status=&jurisdiction=&limit=
admin.handler — read-only listing for ops smoke + manual review.
k-1 build-time skip in prod + runtime CONFIG.stage === "prod" →
404 ADMIN_ENDPOINT_DISABLED.
Configuration¶
Per the j-2 ruling, the IFTI/CMIR thresholds are config-driven via env vars on the threshold-evaluator Lambda. There is no hard-coded threshold in source.
| Env var | dev/uat default | prod default | Override |
|---|---|---|---|
IFTI_THRESHOLD_AMOUNT |
1000.00 |
0.00 |
always honoured |
IFTI_THRESHOLD_CURRENCY |
AUD (AU) / NZD (NZ) |
same | always honoured |
CMIR_THRESHOLD_AMOUNT |
10000.00 |
10000.00 |
always honoured |
CMIR_THRESHOLD_CURRENCY |
AUD / NZD |
same | always honoured |
JURISDICTION |
AU |
AU |
NZ for an NZ deploy |
Prod IFTI default is $0.00 because AUSTRAC IFTI-E has no
monetary minimum — every cross-border electronic transfer is
reportable. Dev/uat default of 1000.00 is a test convenience
to keep queue volume bounded; tests/verify-deployment.mjs enforces
the prod gate at the promotion step:
[j-2 promotion gate — prod IFTI threshold]
OK j-2 prod gate: IFTI_THRESHOLD_AMOUNT="0.00" (IFTI-E reality)
If a prod deploy lands with IFTI_THRESHOLD_AMOUNT != "0.00", the
smoke step fails and CI rejects promotion.
State machine¶
payments.ifti_cmir_queue.queue_status:
MOD-026 INSERT
│
▼
┌────────┐
│ QUEUED │
└───┬────┘
│ SD03 MOD-019 pickup
▼
┌────────────┐
│ PROCESSING │
└───┬────────┘
success │ │ failure
▼ ▼
┌───────────┐ ┌────────┐
│ SUBMITTED │ │ FAILED │ ← MOD-019 retries; manual ops
└───────────┘ └────────┘
MOD-026 only ever writes QUEUED rows. SD03 MOD-019 owns transitions
out of that state.
j-rulings applied¶
| Ruling | Implementation |
|---|---|
| j-1 No MOD-004 dependency | The wiki had MOD-004 on the dep list — removed. MOD-026 enriches directly from payments.payments (a same-domain table) and doesn't call into SD01 balance services. |
| j-2 Thresholds config-driven | IFTI_THRESHOLD_AMOUNT env var; prod default "0.00"; smoke-test promotion gate in verify-deployment.mjs. |
| j-3 Jurisdiction from deploy stage | JURISDICTION env (default "AU"); no cross-domain SD02 lookup. AU↔NZ transfers are cross-border and reportable (corrected initial proposal). |
j-4 Soft FK to aml.regulatory_submissions |
submission_id uuid column, no REFERENCES. Population by SD03 MOD-019 across the SD03↔SD04 cross-domain boundary. |
| j-5 CMIR reserved for future | The enum and config sit in place but v1 only ever produces IFTI. CMIR is physical-cash reporting (agent-banking scenarios), not electronic banking. |
j-6 EB envelope id as idempotency key |
eventKeyJmesPath: "id" on Powertools makeIdempotent config. Defence-in-depth via UNIQUE(payment_id, report_type, jurisdiction) on the queue table. |
| j-7 Payment-not-found is WARN not ERROR | Out-of-order delivery (event before payment row commit) returns a PAYMENT_NOT_FOUND outcome; alarm threshold = 5 per 5-min window before alerting. |
| k-1 Admin gated | Build-time skip in infra/functions.ts + infra/api.ts; runtime CONFIG.stage === "prod" → 404 ADMIN_ENDPOINT_DISABLED; admin handler is read-only (no insert/update/delete on the queue). |
SSM outputs¶
| Path | Type | Notes |
|---|---|---|
/bank/{stage}/mod-026/threshold-evaluator-lambda/arn |
String | EB consumer ARN |
/bank/{stage}/mod-026/admin-lambda/arn |
String | dev/uat only; absent in prod (k-1) |
/bank/{stage}/mod-026/api/base-url |
String | dev/uat only; absent in prod (k-1) |
/bank/{stage}/mod-026/ifti-cmir-queue-table |
String | constant "payments.ifti_cmir_queue"; published for SD03 consumers |
Upstream SSM consumed¶
| Path | Source | Used by |
|---|---|---|
/bank/{stage}/iam/lambda/bank-payments/arn |
MOD-104 | Lambda role for both functions |
/bank/{stage}/observability/adot-nodejs-layer-arn |
MOD-076 | ADOT layer for both functions |
/bank/{stage}/eventbridge/bank-payments/arn |
MOD-104 | EB rule on bank-payments bus |
/bank/{stage}/sns/alerts/arn |
MOD-104 | Alarm fan-out |
bank-neon/{stage}/payments_app_user (Secrets Manager) |
MOD-103 | DATABASE_URL resolved at deploy time (ADR-064) |
Events¶
Published (on bank-payments bus):
bank.payments.ifti_threshold_crossed— full schema inschemas/bank.payments.ifti_threshold_crossed.json(draft-04, AJV-validated beforePutEvents). Consumers: SD03 MOD-019 (regulatory submission filing), MOD-042 (CDC to Iceberg).
Consumed (on bank-payments bus):
bank.payments.payment_completed— published by MOD-022 after bank-coreposting_completedlands. MOD-026 only readspayment_id,customer_id,amount,currency,idempotency_key, plus the envelopeevent_id/trace_id.
Postgres tables¶
Written:
- payments.ifti_cmir_queue (V001) — owned by MOD-026. SOFT FK to
payments.payments(id) (async EB write; payment row already
committed) + SOFT FK to aml.regulatory_submissions(id) (back-
populated by SD03 MOD-019). UNIQUE(payment_id, report_type,
jurisdiction) provides defence-in-depth idempotency. GRANTS:
SELECT/INSERT/UPDATE for payments_app_user; no DELETE
(7-year statutory retention).
Read:
- payments.payments — enrichment for direction +
beneficiary_country not carried in the consumed event.
Used by Powertools idempotency:
- payments.idempotency_records — created by MOD-021 V007, shared
by every Lambda in the payments schema that uses
PostgresPersistenceLayer.
CloudWatch alarms¶
| Name | Trigger | Action |
|---|---|---|
bank-{stage}-MOD-026-threshold-evaluator-errors |
≥ 1 Lambda Error in 3 of 5 minute windows | SNS alerts topic |
bank-{stage}-MOD-026-payment-not-found-rate |
≥ 5 PaymentNotFound EMF datapoints in 2 of 3 five-minute windows | SNS alerts topic (signals out-of-order MOD-022 publishes or rail-caller bugs) |
Cross-domain handoffs filed¶
None required at build time. SD03 MOD-019 reads
payments.ifti_cmir_queue directly via the consolidated bank Neon
DB (cross-schema GRANT pattern, owned by MOD-103). No new IAM grant
or cross-bus rule needed.
Reused infrastructure¶
- EventBridge bus
bank-payments-{stage}(MOD-104) — one rule consumed, one event published. - Neon
bankconsolidated DB (MOD-103) — one new table inpaymentsschema; reuses MOD-021'sidempotency_records. - ADOT layer (MOD-076) — attached to both Lambdas.
- SNS alerts topic (MOD-104) — 2 alarms wired.
@bank-payments/{db,errors,events,observability}— shared workspace packages (no per-modulelib/directory).@bank-platform/mod-104-contracts/ssm— typed builders.@bank-core/idempotency+ PowertoolsmakeIdempotent— ADR-066 idempotency pattern.