Mapping ISO 20022 to Internal GL Formats
This page solves one narrow but high-stakes scenario: taking a single deeply nested ISO 20022 message — a camt.053 account statement, a camt.054 debit/credit notification, or a pacs.008 credit transfer — and turning each booked entry into a deterministic, double-entry posting in a flat internal general ledger (GL). It sits inside the Multi-Currency Ledger Mapping stage of the pipeline: the transaction has already crossed the ingestion boundary, but its hierarchical XML still has to be flattened, routed to a GL account, FX-converted, and posted without introducing reconciliation drift, rounding artifacts, or a fractured audit trail. Everything below assumes the same non-negotiables as the rest of the Core Architecture & Bank Feed Ingestion layer — determinism, idempotency, and tamper-evident lineage.
Prerequisites
Confirm every upstream dependency below is satisfied before this mapping stage runs. A transaction that fails any item must be quarantined, never coerced.
Throughout, a structured audit logger is assumed in scope. Every step emits at least trace_id, source_hash, and match_decision so the decision can be reconstructed years later.
Step-by-step implementation
Step 1 — XSD pre-flight validation
Validate every payload against the official schema before a single field is read. An entry that does not validate must go to the dead-letter queue (DLQ) — the same exception path used across Exception Routing & Human-in-the-Loop Workflows — rather than risk a partial map.
from lxml import etree
import structlog
audit = structlog.get_logger("iso20022.gl_map")
def validate_camt(raw_payload: bytes, xsd_path: str, *, trace_id: str, source_hash: str) -> etree._Element:
"""Validate raw ISO 20022 XML against its XSD; raise on the first defect."""
schema = etree.XMLSchema(etree.parse(xsd_path))
parser = etree.XMLParser(resolve_entities=False, no_network=True) # XXE-safe
root = etree.fromstring(raw_payload, parser=parser)
if not schema.validate(root):
audit.error("xsd_invalid", trace_id=trace_id, source_hash=source_hash,
match_decision="DLQ_SCHEMA_INVALID",
detail=str(schema.error_log.last_error))
raise ValueError("CAMT_SCHEMA_INVALID")
audit.info("xsd_valid", trace_id=trace_id, source_hash=source_hash,
match_decision="VALIDATED")
return root
Step 2 — Flatten the Ntry block to a canonical record
ISO 20022 nests optional repeating groups (Ntry, NtryDtls, TxDtls) that must collapse to exactly one canonical record per ledger line. Read monetary precision immediately with Decimal. Note the trap on the currency code: findtext(".//ns:Amt/@Ccy", ...) does not work because findtext() resolves element text, not attributes — you must call .find(...) then .get("Ccy") on the returned element.
from decimal import Decimal, ROUND_HALF_EVEN
import hashlib
from typing import Dict
NS = {"ns": "urn:iso:std:iso:20022:tech:xsd:camt.053.001.08"}
def flatten_camt_entry(entry: etree._Element, *, trace_id: str, source_hash: str) -> Dict:
"""Deterministically flatten one Ntry block to a canonical GL dict."""
amt = entry.find(".//ns:Amt", namespaces=NS)
if amt is None or amt.text is None:
audit.error("missing_amount", trace_id=trace_id, source_hash=source_hash,
match_decision="DLQ_MISSING_FIELD")
raise ValueError("MISSING_AMOUNT")
record = {
"amount": Decimal(amt.text).quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN),
"currency": amt.get("Ccy"), # attribute, not child
"direction": entry.findtext(".//ns:CdtDbtInd", namespaces=NS), # CRDT / DBIT
"value_date": entry.findtext(".//ns:ValDt/ns:Dt", namespaces=NS),
"booking_date": entry.findtext(".//ns:BookgDt/ns:Dt", namespaces=NS),
"reference": entry.findtext(".//ns:AcctSvcrRef", namespaces=NS),
"bk_tx_cd": entry.findtext(".//ns:BkTxCd/ns:Prtry/ns:Cd", namespaces=NS),
"raw_xml_hash": hashlib.sha256(etree.tostring(entry)).hexdigest(),
}
audit.info("entry_flattened", trace_id=trace_id, source_hash=source_hash,
match_decision="FLATTENED", reference=record["reference"])
return record
Default fallbacks must never be applied to monetary or date fields; a missing critical field triggers the DLQ with an explicit code, not a silent zero.
Step 3 — Resolve direction and route to a GL account
GL routing is declarative, version-controlled, and strictly typed so a rule change is reviewable and reproducible. Validate the rulebook with pydantic, then match the proprietary bank-transaction code (BkTxCd) to a GL account and resolve CRDT/DBIT into a posting side.
import re
from pydantic import BaseModel, Field
class RouteRule(BaseModel):
pattern: str
gl_account: str = Field(..., pattern=r"^\d{4}-\d{3}$")
description: str
CRDR = {"CRDT": "CREDIT", "DBIT": "DEBIT"} # ISO 20022 uses 4-letter codes, not C/D
def route_to_gl(record: Dict, rules: list[RouteRule], *,
trace_id: str, source_hash: str) -> Dict:
side = CRDR.get(record["direction"])
if side is None:
audit.error("bad_cdtdbt", trace_id=trace_id, source_hash=source_hash,
match_decision="DLQ_DIRECTION_UNKNOWN", value=record["direction"])
raise ValueError("DIRECTION_UNKNOWN")
for rule in rules:
if record["bk_tx_cd"] and re.match(rule.pattern, record["bk_tx_cd"]):
audit.info("gl_routed", trace_id=trace_id, source_hash=source_hash,
match_decision="ROUTED", gl_account=rule.gl_account, side=side)
return {**record, "gl_account": rule.gl_account, "side": side}
audit.warning("no_route", trace_id=trace_id, source_hash=source_hash,
match_decision="DLQ_NO_ROUTE", bk_tx_cd=record["bk_tx_cd"])
raise ValueError("GL_ROUTE_MISSING")
The rulebook itself stays in version-controlled YAML, for example a ^PMNT|SEPA pattern mapping to 1010-000 (customer payments) and ^FEES|CHRG mapping to 6020-000 (bank fees).
Step 4 — Apply FX conversion with Decimal
When the entry currency differs from the entity’s functional currency, convert against the snapshot rate for the value_date before posting, so the GL never holds a mixed-currency imbalance. Realized and unrealized FX differences route to dedicated P&L accounts rather than contaminating operational balances.
def convert_to_functional(record: Dict, snapshot: Dict[str, Decimal], base_ccy: str, *,
trace_id: str, source_hash: str) -> Dict:
if record["currency"] == base_ccy:
return {**record, "functional_amount": record["amount"], "applied_rate": Decimal("1")}
pair = f"{record['currency']}/{base_ccy}"
rate = snapshot.get(pair)
if rate is None:
audit.error("rate_missing", trace_id=trace_id, source_hash=source_hash,
match_decision="DLQ_RATE_MISSING", pair=pair)
raise ValueError("RATE_MISSING")
converted = (record["amount"] * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN)
audit.info("fx_applied", trace_id=trace_id, source_hash=source_hash,
match_decision="CONVERTED", pair=pair, applied_rate=str(rate))
return {**record, "functional_amount": converted, "applied_rate": rate}
Step 5 — Enforce idempotency before posting
Idempotency is non-negotiable: a bank retransmission must never double-post. Check a distributed cache or a unique DB constraint keyed on the idempotency_key before writing the journal entry. This is the same guarantee leaned on by downstream exact-match and hash comparison during matching.
import redis
def claim_idempotency(key: str, client: redis.Redis, *,
trace_id: str, source_hash: str) -> bool:
"""Returns True if this is the first time we have seen `key`."""
is_new = bool(client.set(f"idem:{key}", "1", ex=604800, nx=True)) # 7-day TTL
audit.info("idempotency_check", trace_id=trace_id, source_hash=source_hash,
match_decision="NEW" if is_new else "DUPLICATE", idem_key=key)
return is_new
A duplicate returns HTTP 409 Conflict or is silently dropped with an audit line — never reprocessed.
Step 6 — Post the double entry and append the audit record
The final write is atomic: the balanced double entry and its immutable audit record commit together or not at all. Hash the normalized line and append it to a write-once table that captures the raw payload hash, the applied rate, the rule version, and the posting status.
def post_and_audit(record: Dict, *, idem_key: str, rule_version: str,
trace_id: str, source_hash: str) -> None:
audit.info("gl_posted",
trace_id=trace_id,
source_hash=source_hash,
match_decision="POSTED",
idem_key=idem_key,
gl_account=record["gl_account"],
side=record["side"],
original_amount=str(record["amount"]),
original_currency=record["currency"],
functional_amount=str(record["functional_amount"]),
applied_rate=str(record["applied_rate"]),
rule_version=rule_version,
raw_xml_hash=record["raw_xml_hash"])
Configuration boundary table
| Parameter | Default | Valid range | Notes |
|---|---|---|---|
rounding_mode |
ROUND_HALF_EVEN |
any decimal rounding constant |
Banker’s rounding; keep identical to the matching stage. |
precision (minor units) |
2 |
0–4 |
Per-currency: 0 for JPY, 3 for KWD/BHD. |
tolerance |
0.01 |
0.00–0.05 |
Two-decimal currencies; widen to ±1 minor unit for zero-decimal. |
idempotency_ttl |
604800 s |
86400–2592000 |
Redis key lifetime; must exceed the bank’s retransmission window. |
snapshot_max_age |
86400 s |
3600–172800 |
Reject the batch if the FX snapshot is older. |
dlq_on_missing_field |
true |
true / false |
Must stay true for monetary and date fields. |
Verification and testing
Run the mapper against a fixed sample statement and assert byte-for-byte stable output. A golden-fixture test catches rule drift before it reaches production.
def test_camt053_maps_to_balanced_gl():
raw = open("fixtures/camt053_multiccy.xml", "rb").read()
root = validate_camt(raw, "xsd/camt.053.001.08.xsd",
trace_id="t-1", source_hash="h-1")
entries = root.findall(".//ns:Ntry", namespaces=NS)
snapshot = {"EUR/USD": Decimal("1.0850")}
rules = [RouteRule(pattern="^PMNT|SEPA", gl_account="1010-000", description="Payments")]
posted = []
for e in entries:
rec = flatten_camt_entry(e, trace_id="t-1", source_hash="h-1")
rec = route_to_gl(rec, rules, trace_id="t-1", source_hash="h-1")
rec = convert_to_functional(rec, snapshot, "USD", trace_id="t-1", source_hash="h-1")
posted.append(rec)
debits = sum(r["functional_amount"] for r in posted if r["side"] == "DEBIT")
credits = sum(r["functional_amount"] for r in posted if r["side"] == "CREDIT")
assert debits == credits # double entry must balance
assert all(r["functional_amount"].as_tuple().exponent == -2 for r in posted)
Confirm three things: the entry count equals the number of Ntry blocks, debits equal credits in the functional currency, and every amount carries exactly the configured precision. Then re-run the suite against a historical statement archive to prove a rule change has not altered any past mapping — deploy rule updates as canaries and watch reconciliation success rates on a live dashboard.
Troubleshooting
| Symptom / code | Root cause | Fix |
|---|---|---|
CAMT_SCHEMA_INVALID |
Payload fails XSD; truncated transfer or wrong message version. | Bind the namespace and XSD to the actual camt/pacs version; re-pull from the bank; DLQ until valid. |
currency is None after flatten |
findtext(".//ns:Amt/@Ccy") used to read the Ccy attribute. |
Use amt.find(...).get("Ccy"); findtext() never resolves attributes. |
DIRECTION_UNKNOWN |
Feed uses single-letter C/D (older camt.052) instead of CRDT/DBIT. |
Extend the CRDR map to the feed’s actual codes; do not assume the four-letter form. |
RATE_MISSING |
No snapshot rate for the pair on the value_date (holiday, new rail). |
Fall back to the nearest prior business-day rate only if policy allows; otherwise hold for manual entry with an audit note. |
GL_ROUTE_MISSING |
A BkTxCd value not covered by any rule pattern. |
Add or widen the routing rule, bump rule_version, and replay the DLQ by idempotency_key. |
| Debits ≠ credits in fixture | FX conversion applied after, not before, posting. | Always convert in Step 4 before the Step 6 write so the GL never holds mixed-currency lines. |
Related
Part of Multi-Currency Ledger Mapping, within Core Architecture & Bank Feed Ingestion.