Skip to content

MOD-087 — Transaction enrichment engine

Purpose

Foundation of the Expense Intelligence Platform. Receives raw transaction events from the bank-core ledger and publishes canonical merchant-identity enrichments to the bank-platform EventBridge bus. v1 ships dictionary-based normalisation; v2 swaps in an external API behind the same EnrichmentProvider interface.

ADRs: none new — module sits within ADR-029 (EventBridge domain events) and ADR-064 (consolidated bank Neon DB).

FRs: FR-762 (canonical merchant resolution + caching), FR-763 (geolocation where available), FR-764 (event publish for MOD-088/089/091/077).

Scope (v1, this build)

  • Hard gate on input: only posting_type = 'PAYMENT' postings proceed to enrichment. ACCRUAL / FX_CONVERSION / ADJUSTMENT / REVERSAL / PROVISION are silently skipped — both at the EventBridge rule's eventPattern (saves Lambda invocation cost) and re-checked in the handler (defence in depth).
  • v1 simplification (Ruling 4A): narrative from accounts.postings is used as raw_merchant_name in both the dictionary lookup and the published event. v2 either extends posting_completed to carry a dedicated raw_merchant_name field or lets the external API accept narrative + account context — mechanical swap given the EnrichmentProvider interface.
  • v1 enrichment is a curated ~50-entry NZ/AU merchant dictionary (src/shared/dictionary.ts). DictionaryEnrichmentProvider implements the EnrichmentProvider interface (src/shared/enrichment-provider.ts). v2 will add ExternalApiEnrichmentProvider without touching the Lambda handler.
  • logo_url, lat, lng are NULL in v1 (FR-763 reads "where available"). v2 populates them once the external API lands.
  • initial_category is out of scope (Ruling 3) — MOD-088 owns spend classification.

Architecture

   bank-core EventBridge bus
   `bank.core.posting_completed`
        │  (rule: posting_type=='PAYMENT' only)
   ┌──────────────────────────────────────────┐
   │ MOD-087 enrich Lambda (VPC)              │
   │                                          │
   │ 1. SELECT narrative + posting_type from  │
   │    accounts.postings (platform_enrichment_ro)│
   │ 2. SELECT party_id from                  │
   │    accounts.account_party_relationships  │
   │ 3. Cache lookup in                       │
   │    platform.enrichment_merchants by      │
   │    raw_name (= narrative)                │
   │ 4. Cache miss → provider.resolve(raw)    │
   │    + upsert ON CONFLICT (raw_name)       │
   │ 5. PRI-001 LOG: structured audit entry   │
   │ 6. PutEvents on bank-platform bus        │
   └──────────────────┬───────────────────────┘
   bank-platform EventBridge bus
   `bank.platform.transaction_enriched`
   Consumers: MOD-088, MOD-089, MOD-091, MOD-077

Data model

platform.enrichment_merchants in the consolidated bank DB. See SD07 data model for the canonical column spec. Mutable cache — upserts on every miss. No immutability trigger; column-level CHECK on source ∈ {DICTIONARY, MCC_INFERENCE, MANUAL}.

Module type

Lambda (with IaC for the cross-bus rule + Lambda + IAM + SSM contract). Per-module IAM role (bank-platform-mod087-enrich-{env}) follows the MOD-079 pattern — not the centralised BankPlatformRole.

Dependencies

  • MOD-001 (bank-core) — emits bank.core.posting_completed. No design-doc dependency; consumes only the documented event schema 1.1.0.
  • MOD-043 — EventBridge governance contract; event delivery log captures the published event.
  • MOD-076 — observability; PRI-001 LOG entries flow through the standard CloudWatch Logs → OTel → MOD-076 pipeline.
  • MOD-103 — provides the platform schema + the three roles this module connects as (platform_migrate_user, platform_app_user, and the dedicated platform_enrichment_ro added in the prereq commit for this build).
  • MOD-104 — VPC + subnets, KMS operational key, bank-platform + bank-core bus ARNs.

SSM outputs (downstream contract)

Path Value Consumed by
/bank/{env}/mod087/lambda-arn Enrich Lambda ARN Ops, manual invokes
/bank/{env}/mod087/lambda-name Enrich Lambda name Ops
/bank/{env}/mod087/enrichment-table platform.enrichment_merchants Compliance reports, direct readonly probes
/bank/{env}/mod087/rule-arn Cross-bus rule ARN Ops audit trail

Typed contract package at contract/ssm/index.ts.

Policy obligations and tests

Policy Mode Test file Assertion
PRI-001 LOG __tests__/policy/pri-001-log.test.ts The handler's enrichment_audit log entry carries exactly the source signals required for data-minimisation audit (posting_id, raw_merchant_name, canonical_merchant_name, enrichment_source, mcc) and EXCLUDES amount, currency, balance, party_id, counterparty — the audit retention surface stays minimal.

The integration suite (__tests__/integration/enrichment-lifecycle.test.ts) also probes the live platform_enrichment_ro role to confirm column-level GRANTs hold — least-privilege gate against future schema drift.

Constraints

  • PAYMENT-only. Enriching a fee / accrual / adjustment posting with a merchant name would be a data quality defect. The gate is checked at the EventBridge rule layer (cost optimisation) AND in the handler (defence in depth).
  • Read-only on accounts. The dedicated platform_enrichment_ro role can SELECT only four columns total across two tables — no INSERT/UPDATE/DELETE, no other accounts.* table reachable. Integration test asserts denied access.
  • Cache is eventually consistent. Direct readers of platform.enrichment_merchants see the latest-resolved canonical for any given raw_name; if v2 ships better enrichment, the next cache miss for the same raw_name overwrites the row.

Open follow-ups (v2)

  • ExternalApiEnrichmentProvider implementation — Mastercard Merchant Insights via MOD-157 stub in dev/uat, real API in prod. Single swap in createProvider(); handler untouched.
  • bank.core.posting_completed schema bump (or accounts.postings column add) to carry raw_merchant_name natively — retires the narrative-as-raw-merchant simplification.
  • Per-merchant lat/lng + logo populated from the external API.

Status

  • Build: this commit.
  • Deploy: pending CI green + SST deploy.