Skip to content

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 in schemas/bank.payments.ifti_threshold_crossed.json (draft-04, AJV-validated before PutEvents). 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-core posting_completed lands. MOD-026 only reads payment_id, customer_id, amount, currency, idempotency_key, plus the envelope event_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 bank consolidated DB (MOD-103) — one new table in payments schema; reuses MOD-021's idempotency_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-module lib/ directory).
  • @bank-platform/mod-104-contracts/ssm — typed builders.
  • @bank-core/idempotency + Powertools makeIdempotent — ADR-066 idempotency pattern.