Mapping ISO 20022 camt.053 to Double-Entry Postings
A camt.053 bank-to-customer statement reports every booked movement as a single-sided Ntry block: an amount, a currency, and a CdtDbtInd flag telling you whether the bank credited or debited the account. A general ledger has no concept of a single-sided movement — every posting must land as a balanced pair of debit and credit lines, or the trial balance breaks. This page narrows in on that one transform: turning one Ntry (and its nested NtryDtls sub-entries) into an explicit two-line JournalEntry, asserting Decimal debit and credit totals are equal before the write, and logging the decision so an auditor can replay it. It builds directly on Multi-Currency Ledger Mapping and assumes the message has already passed schema validation as covered in Mapping ISO 20022 to Internal GL Formats — this page is concerned only with the entry-to-journal-line arithmetic, not XSD pre-flight or FX conversion.
Prerequisites
Step 1 — Bind the camt.053 namespace and iterate Ntry
The camt.053.001.08 namespace is versioned in the URI itself. Binding the wrong version does not raise an error — lxml’s XPath simply returns an empty list, which is why a namespace check belongs at the top of the walk, not buried in a later KeyError.
from lxml import etree
from datetime import datetime, timezone
import logging
log = logging.getLogger("camt053.journal")
CAMT053_NS = "urn:iso:std:iso:20022:tech:xsd:camt.053.001.08"
def iter_entries(raw_xml: bytes, *, trace_id: str, source_hash: str):
"""Parse a camt.053 document and yield each Ntry element, namespace-checked."""
parser = etree.XMLParser(resolve_entities=False, no_network=True) # XXE-safe
root = etree.fromstring(raw_xml, parser=parser)
root_ns = etree.QName(root).namespace
if root_ns != CAMT053_NS:
log.error(
"camt053.namespace_mismatch",
extra={"trace_id": trace_id, "source_hash": source_hash,
"match_decision": "NAMESPACE_MISMATCH", "found_ns": root_ns},
)
raise ValueError("NAMESPACE_MISMATCH")
ns = {"ns": CAMT053_NS}
yield from root.findall(".//ns:Ntry", namespaces=ns)
Step 2 — Extract Amt, CdtDbtInd, BookgDt, and entry references as Decimal
Each Ntry carries the amount as element text with the currency as an Ccy attribute — findtext() resolves text only, so the currency must come from .find(...).get("Ccy"). BookgDt is parsed into a timezone-aware UTC datetime immediately; camt.053 dates are date-only or DtTm with an offset, and both must normalize to UTC before comparison or storage.
from decimal import Decimal, InvalidOperation
from dataclasses import dataclass
NS = {"ns": CAMT053_NS}
@dataclass(frozen=True)
class RawEntry:
amount: Decimal
currency: str
cdt_dbt_ind: str # CRDT or DBIT, per ISO 20022
booking_date: datetime # timezone-aware UTC
acct_svcr_ref: str
ntry_dtls_count: int
def extract_entry(ntry: etree._Element, *, trace_id: str, source_hash: str) -> RawEntry:
amt_el = ntry.find(".//ns:Amt", namespaces=NS)
if amt_el is None or not amt_el.get("Ccy"):
log.error(
"camt053.missing_ccy",
extra={"trace_id": trace_id, "source_hash": source_hash,
"match_decision": "MISSING_CCY"},
)
raise ValueError("MISSING_CCY")
try:
amount = Decimal(amt_el.text)
except (InvalidOperation, TypeError) as exc:
raise ValueError("MISSING_CCY") from exc # malformed amount, same quarantine path
bookg_text = ntry.findtext(".//ns:BookgDt/ns:Dt", namespaces=NS) \
or ntry.findtext(".//ns:BookgDt/ns:DtTm", namespaces=NS)
booking_date = datetime.fromisoformat(bookg_text).astimezone(timezone.utc) \
if "T" in (bookg_text or "") else \
datetime.fromisoformat(bookg_text).replace(tzinfo=timezone.utc)
return RawEntry(
amount=amount,
currency=amt_el.get("Ccy"),
cdt_dbt_ind=ntry.findtext(".//ns:CdtDbtInd", namespaces=NS) or "",
booking_date=booking_date,
acct_svcr_ref=ntry.findtext(".//ns:AcctSvcrRef", namespaces=NS) or "",
ntry_dtls_count=len(ntry.findall(".//ns:NtryDtls", namespaces=NS)),
)
ntry_dtls_count is carried forward so a batched Ntry (one bank line summarizing several underlying TxDtls) can be flagged for split-posting review rather than posted as one opaque total.
Step 3 — Map CdtDbtInd to a signed journal side
ISO 20022 uses the four-letter codes CRDT/DBIT, not the single-letter C/D seen in older camt.052 feeds. The mapping direction depends on which side of the transaction the bank clearing account sits on: a CRDT on the bank’s statement of our account means money came in, which is a debit to our clearing account and a credit to the routed GL account.
from enum import Enum
class JournalSide(str, Enum):
DEBIT = "DEBIT"
CREDIT = "CREDIT"
CDTDBT_TO_CLEARING_SIDE = {
"CRDT": JournalSide.DEBIT, # cash arrived: debit the bank clearing account
"DBIT": JournalSide.CREDIT, # cash left: credit the bank clearing account
}
def clearing_side(cdt_dbt_ind: str, *, trace_id: str, source_hash: str) -> JournalSide:
side = CDTDBT_TO_CLEARING_SIDE.get(cdt_dbt_ind)
if side is None:
log.error(
"camt053.cdtdbt_unmapped",
extra={"trace_id": trace_id, "source_hash": source_hash,
"match_decision": "CDTDBT_UNMAPPED", "value": cdt_dbt_ind},
)
raise ValueError("CDTDBT_UNMAPPED")
return side
def opposite(side: JournalSide) -> JournalSide:
return JournalSide.CREDIT if side is JournalSide.DEBIT else JournalSide.DEBIT
Step 4 — Build the balanced JournalEntry with Decimal and pydantic
Every Ntry becomes exactly two lines: one against the bank clearing account, one against the GL account resolved from BkTxCd. A model validator asserts the sum of debit amounts equals the sum of credit amounts before the object can even be constructed — an unbalanced pair never reaches the database layer.
from pydantic import BaseModel, ConfigDict, model_validator
class JournalLine(BaseModel):
model_config = ConfigDict(frozen=True)
account: str
side: JournalSide
amount: Decimal
currency: str
class JournalEntry(BaseModel):
model_config = ConfigDict(frozen=True)
entry_ref: str
booking_date: datetime
lines: list[JournalLine]
@model_validator(mode="after")
def debits_equal_credits(self) -> "JournalEntry":
debits = sum((l.amount for l in self.lines if l.side is JournalSide.DEBIT), Decimal("0"))
credits = sum((l.amount for l in self.lines if l.side is JournalSide.CREDIT), Decimal("0"))
if debits != credits:
raise ValueError(f"UNBALANCED_JOURNAL: debits={debits} credits={credits}")
return self
def build_journal_entry(raw: RawEntry, gl_account: str, clearing_account: str) -> JournalEntry:
dr_side = clearing_side(raw.cdt_dbt_ind, trace_id=raw.acct_svcr_ref, source_hash=raw.acct_svcr_ref)
return JournalEntry(
entry_ref=raw.acct_svcr_ref,
booking_date=raw.booking_date,
lines=[
JournalLine(account=clearing_account, side=dr_side,
amount=raw.amount, currency=raw.currency),
JournalLine(account=gl_account, side=opposite(dr_side),
amount=raw.amount, currency=raw.currency),
],
)
Because debits_equal_credits compares two Decimal sums built from the same source amount, this particular pair can never fail to balance by construction — the assertion earns its keep once a batch posting aggregates many entries and a partial write or a rounding step upstream can silently break the equality.
Step 5 — Post the batch and emit the audit-carrying log line
A statement posts as a list of JournalEntry objects. Re-run the debit/credit assertion across the whole batch — not just per entry — before any write commits, and log the outcome with the mandatory audit triple.
def post_batch(entries: list[JournalEntry], *, trace_id: str, source_hash: str,
strict_balance: bool = True) -> None:
total_dr = sum((l.amount for e in entries for l in e.lines if l.side is JournalSide.DEBIT), Decimal("0"))
total_cr = sum((l.amount for e in entries for l in e.lines if l.side is JournalSide.CREDIT), Decimal("0"))
if total_dr != total_cr:
log.error(
"camt053.batch_unbalanced",
extra={"trace_id": trace_id, "source_hash": source_hash,
"match_decision": "UNBALANCED_JOURNAL",
"total_debits": str(total_dr), "total_credits": str(total_cr)},
)
if strict_balance:
raise ValueError("UNBALANCED_JOURNAL")
log.info(
"camt053.batch_posted",
extra={"trace_id": trace_id, "source_hash": source_hash,
"match_decision": "POSTED",
"entry_count": len(entries),
"total_debits": str(total_dr), "total_credits": str(total_cr)},
)
strict_balance=False exists only for a quarantine replay path where entries are re-checked individually against the DLQ — production posting must always run with the default True. The match_decision="POSTED" log line above is what feeds an immutable append-only ledger: once written, a posted journal entry is never edited in place — a correction is always a new, offsetting entry with its own audit trail.
Configuration boundary table
| Parameter | Default | Valid range | Notes |
|---|---|---|---|
namespace |
urn:iso:std:iso:20022:tech:xsd:camt.053.001.08 |
must match feed version | Verified once per document via Step 1, not per entry. |
account_map |
version-controlled dict | BkTxCd regex → GL account |
Same rulebook style as GL routing. |
rounding |
ROUND_HALF_EVEN |
any decimal rounding constant |
Applied only if upstream FX conversion re-quantizes; camt.053 amounts already carry native precision. |
base_ccy |
entity functional currency | ISO 4217 code | Only used to flag entries needing FX before this stage runs. |
strict_balance |
true |
true / false |
false permitted only in the DLQ replay path, never in the primary posting run. |
Verification and testing
A minimal two-entry fixture — one CRDT, one DBIT — is enough to exercise both sign directions and confirm the batch nets to a balanced total.
CAMT053_FIXTURE = b"""<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.08">
<BkToCstmrStmt>
<Stmt>
<Ntry>
<Amt Ccy="EUR">1250.00</Amt>
<CdtDbtInd>CRDT</CdtDbtInd>
<BookgDt><Dt>2026-07-14</Dt></BookgDt>
<AcctSvcrRef>STMT-8842</AcctSvcrRef>
</Ntry>
<Ntry>
<Amt Ccy="EUR">430.50</Amt>
<CdtDbtInd>DBIT</CdtDbtInd>
<BookgDt><Dt>2026-07-14</Dt></BookgDt>
<AcctSvcrRef>STMT-8843</AcctSvcrRef>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>"""
def test_camt053_entries_balance():
entries = []
for ntry in iter_entries(CAMT053_FIXTURE, trace_id="t-1", source_hash="h-1"):
raw = extract_entry(ntry, trace_id="t-1", source_hash="h-1")
entries.append(build_journal_entry(raw, gl_account="4010-000",
clearing_account="1000-000"))
assert len(entries) == 2
post_batch(entries, trace_id="t-1", source_hash="h-1") # raises on imbalance
for entry in entries:
drs = sum(l.amount for l in entry.lines if l.side is JournalSide.DEBIT)
crs = sum(l.amount for l in entry.lines if l.side is JournalSide.CREDIT)
assert drs == crs
Run this fixture in CI on every change to account_map or the CdtDbtInd mapping table — a routing edit is the most common way a previously balanced feed starts throwing UNBALANCED_JOURNAL.
Troubleshooting
UNBALANCED_JOURNAL. Root cause: a batch aggregation step summed lines after a partial write, or a rounding pass ran on one side of the pair but not the other. Fix: re-rundebits_equal_credits(orpost_batch) against the full batch before commit, and confirm no code path re-quantizes only the GL-side amount.MISSING_CCY. Root cause:Amtelement present but itsCcyattribute is absent or the element text failedDecimal()parsing (a malformed feed, orfindtext()misused where.find().get(...)was required). Fix: reject the entry to the dead-letter queue; never default to a guessed currency.CDTDBT_UNMAPPED. Root cause: the feed emits the legacy single-letterC/D(older camt.052-style export) instead ofCRDT/DBIT, or the field is empty. Fix: extendCDTDBT_TO_CLEARING_SIDEonly after confirming the actual feed dialect with the bank — do not silently coerce single letters.NAMESPACE_MISMATCH. Root cause: the document declares a differentcamt.053.001.0xminor version than the one the XPath queries were written against, so everyfind/findallsilently returns nothing. Fix: read the root namespace first, as in Step 1, and fail loudly rather than let empty results masquerade as an empty statement.- Duplicate posting on statement re-delivery. Root cause:
AcctSvcrRefreused as the sole idempotency key without a batch or statement identifier. Fix: compose the idempotency key fromAcctSvcrRefplus the statement’sElctrncSeqNb, and gate posting the same way described in Mapping ISO 20022 to Internal GL Formats.
Related
- Mapping ISO 20022 to Internal GL Formats
- Immutable Append-Only Ledger Design
- OFX & MT940 Parser Design
Part of Multi-Currency Ledger Mapping within Core Architecture & Bank Feed Ingestion.