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.

One camt.053 Ntry expands into a balanced two-line journal entry On the left, a box represents one parsed Ntry element carrying Amt of 1250.00 EUR, CdtDbtInd of CRDT, BookgDt, and AcctSvcrRef. An arrow leads right to a sign-mapping step that reads the CdtDbtInd flag and decides journal sides according to normal balance rules for a bank clearing account. Two arrows fan out from that step to two journal line boxes stacked vertically. The top line box is labelled Line 1, Debit, Bank Clearing Account 1000-000, amount 1250.00 EUR. The bottom line box is labelled Line 2, Credit, Routed GL Account 4010-000, amount 1250.00 EUR. Below both lines a balance-check box reads sum of debits equals sum of credits, 1250.00 equals 1250.00, and if that check fails the entry is rejected with the UNBALANCED_JOURNAL code instead of being posted. Ntry Amt 1250.00 EUR CdtDbtInd: CRDT BookgDt: 2026-07-14 AcctSvcrRef: STMT-8842 Sign Mapping CdtDbtInd → side Line 1 · Debit Bank Clearing 1000-000 1250.00 EUR Line 2 · Credit Routed GL 4010-000 1250.00 EUR Balance Check Dr == Cr 1250.00 = 1250.00

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.

python
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.

python
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.

python
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.

python
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.

python
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.

python
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-run debits_equal_credits (or post_batch) against the full batch before commit, and confirm no code path re-quantizes only the GL-side amount.
  • MISSING_CCY. Root cause: Amt element present but its Ccy attribute is absent or the element text failed Decimal() parsing (a malformed feed, or findtext() 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-letter C/D (older camt.052-style export) instead of CRDT/DBIT, or the field is empty. Fix: extend CDTDBT_TO_CLEARING_SIDE only after confirming the actual feed dialect with the bank — do not silently coerce single letters.
  • NAMESPACE_MISMATCH. Root cause: the document declares a different camt.053.001.0x minor version than the one the XPath queries were written against, so every find/findall silently 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: AcctSvcrRef reused as the sole idempotency key without a batch or statement identifier. Fix: compose the idempotency key from AcctSvcrRef plus the statement’s ElctrncSeqNb, and gate posting the same way described in Mapping ISO 20022 to Internal GL Formats.

Part of Multi-Currency Ledger Mapping within Core Architecture & Bank Feed Ingestion.