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'seventPattern(saves Lambda invocation cost) and re-checked in the handler (defence in depth). - v1 simplification (Ruling 4A):
narrativefromaccounts.postingsis used asraw_merchant_namein both the dictionary lookup and the published event. v2 either extendsposting_completedto carry a dedicatedraw_merchant_namefield 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).DictionaryEnrichmentProviderimplements theEnrichmentProviderinterface (src/shared/enrichment-provider.ts). v2 will addExternalApiEnrichmentProviderwithout touching the Lambda handler. logo_url,lat,lngare NULL in v1 (FR-763 reads "where available"). v2 populates them once the external API lands.initial_categoryis 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
platformschema + the three roles this module connects as (platform_migrate_user,platform_app_user, and the dedicatedplatform_enrichment_roadded 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 dedicatedplatform_enrichment_rorole 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_merchantssee 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)¶
ExternalApiEnrichmentProviderimplementation — Mastercard Merchant Insights via MOD-157 stub in dev/uat, real API in prod. Single swap increateProvider(); handler untouched.bank.core.posting_completedschema bump (oraccounts.postingscolumn add) to carryraw_merchant_namenatively — 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.