defmodule ISO20022.Camt053.Parser do
@moduledoc false
# Internal parser for camt.053 messages using Saxy.SimpleForm.
#
# Saxy returns a simple-form tree: {tag, attrs, children}
# where children is a list of either {tag, attrs, children} or binary text nodes.
#
# Tag names in Saxy simple form include the namespace prefix when present
# in the source XML, but the actual element name (local part) is what we
# pattern match on after stripping any prefix.
alias ISO20022.Camt053.{
Account,
Balance,
BankTxCode,
Document,
Entry,
EntryDetails,
GroupHeader,
Statement,
TransactionDetails
}
@supported_namespaces ~w(
urn:iso:std:iso:20022:tech:xsd:camt.053.001.02
urn:iso:std:iso:20022:tech:xsd:camt.053.001.03
urn:iso:std:iso:20022:tech:xsd:camt.053.001.04
urn:iso:std:iso:20022:tech:xsd:camt.053.001.06
urn:iso:std:iso:20022:tech:xsd:camt.053.001.08
urn:iso:std:iso:20022:tech:xsd:camt.053.001.11
urn:iso:std:iso:20022:tech:xsd:camt.053.001.12
urn:iso:std:iso:20022:tech:xsd:camt.053.001.13
urn:iso:std:iso:20022:tech:xsd:camt.053.001.14
)
@doc """
Parses raw XML into a `%Document{}`. Returns `{:ok, doc}` or `{:error, reason}`.
"""
@spec parse(binary()) :: {:ok, Document.t()} | {:error, term()}
def parse(xml) when is_binary(xml) do
case Saxy.SimpleForm.parse_string(xml, []) do
{:ok, root} ->
with :ok <- validate_namespace(root),
{:ok, doc} <- build_document(root) do
{:ok, doc}
end
{:error, reason} ->
{:error, {:parse_error, reason}}
end
end
# ---------------------------------------------------------------------------
# Namespace validation
# ---------------------------------------------------------------------------
defp validate_namespace({_tag, attrs, _children}) do
ns = find_attr(attrs, "xmlns")
cond do
is_nil(ns) ->
# Accept documents without a namespace (some older senders omit it)
:ok
ns in @supported_namespaces ->
:ok
String.contains?(ns, "camt.053") ->
# Unknown minor version — attempt to parse anyway
:ok
true ->
{:error, {:unsupported_version, ns}}
end
end
# ---------------------------------------------------------------------------
# Document
# ---------------------------------------------------------------------------
defp build_document({_tag, _attrs, children}) do
# The root is <Document>; the payload is <BkToCstmrStmt>
case find_child(children, "BkToCstmrStmt") do
nil ->
{:error, {:missing_required_field, [:bk_to_cstmr_stmt]}}
{_, _, stmt_children} ->
with {:ok, group_header} <- parse_group_header(stmt_children),
{:ok, statements} <- parse_statements(stmt_children) do
{:ok, %Document{group_header: group_header, statements: statements}}
end
end
end
# ---------------------------------------------------------------------------
# GroupHeader
# ---------------------------------------------------------------------------
defp parse_group_header(children) do
case find_child(children, "GrpHdr") do
nil ->
{:error, {:missing_required_field, [:group_header]}}
{_, _, hdr_children} ->
with {:ok, message_id} <- require_text(hdr_children, "MsgId", [:group_header, :message_id]),
{:ok, created_at} <- require_datetime(hdr_children, "CreDtTm", [:group_header, :created_at]) do
pagination = parse_pagination(hdr_children)
{:ok,
%GroupHeader{
message_id: message_id,
created_at: created_at,
pagination: pagination
}}
end
end
end
defp parse_pagination(children) do
case find_child(children, "MsgPgntn") do
nil ->
nil
{_, _, pg_children} ->
%{
page_number: get_text(pg_children, "PgNb"),
last_page: get_text(pg_children, "LastPgInd") == "true"
}
end
end
# ---------------------------------------------------------------------------
# Statements
# ---------------------------------------------------------------------------
defp parse_statements(children) do
stmts =
children
|> Enum.filter(&match_tag?(&1, "Stmt"))
|> Enum.with_index()
|> Enum.reduce_while({:ok, []}, fn {{_, _, stmt_children}, idx}, {:ok, acc} ->
case parse_statement(stmt_children, idx) do
{:ok, stmt} -> {:cont, {:ok, [stmt | acc]}}
{:error, _} = err -> {:halt, err}
end
end)
case stmts do
{:ok, list} -> {:ok, Enum.reverse(list)}
err -> err
end
end
defp parse_statement(children, idx) do
with {:ok, id} <- require_text(children, "Id", [:statements, idx, :id]),
{:ok, account} <- parse_account(children, idx),
{:ok, balances} <- parse_balances(children, idx),
{:ok, entries} <- parse_entries(children, idx) do
{:ok,
%Statement{
id: id,
electronic_seq_number: get_integer(children, "ElctrncSeqNb"),
created_at: get_datetime(children, "CreDtTm"),
from_to_date: parse_from_to_date(children),
account: account,
balances: balances,
entries: entries
}}
end
end
defp parse_from_to_date(children) do
case find_child(children, "FrToDt") do
nil ->
nil
{_, _, dt_children} ->
%{
from: get_datetime(dt_children, "FrDtTm"),
to: get_datetime(dt_children, "ToDtTm")
}
end
end
# ---------------------------------------------------------------------------
# Account
# ---------------------------------------------------------------------------
defp parse_account(children, idx) do
case find_child(children, "Acct") do
nil ->
{:error, {:missing_required_field, [:statements, idx, :account]}}
{_, _, acct_children} ->
id_children =
case find_child(acct_children, "Id") do
nil -> []
{_, _, ch} -> ch
end
svcr_bic =
case find_child(acct_children, "Svcr") do
nil -> nil
{_, _, svcr_ch} -> get_nested_text(svcr_ch, ["FinInstnId", "BIC"])
end
{:ok,
%Account{
iban: get_text(id_children, "IBAN"),
other_id: get_nested_text(id_children, ["Othr", "Id"]),
other_scheme: get_nested_text(id_children, ["Othr", "SchmeNm", "Cd"]),
currency: get_text(acct_children, "Ccy"),
servicer_bic: svcr_bic,
name: get_text(acct_children, "Nm")
}}
end
end
# ---------------------------------------------------------------------------
# Balances
# ---------------------------------------------------------------------------
defp parse_balances(children, idx) do
bals =
children
|> Enum.filter(&match_tag?(&1, "Bal"))
|> Enum.with_index()
|> Enum.reduce_while({:ok, []}, fn {{_, _, bal_children}, bal_idx}, {:ok, acc} ->
case parse_balance(bal_children, idx, bal_idx) do
{:ok, bal} -> {:cont, {:ok, [bal | acc]}}
{:error, _} = err -> {:halt, err}
end
end)
case bals do
{:ok, list} -> {:ok, Enum.reverse(list)}
err -> err
end
end
defp parse_balance(children, stmt_idx, bal_idx) do
path = [:statements, stmt_idx, :balances, bal_idx]
type_code =
children
|> find_child("Tp")
|> then(fn
nil -> nil
{_, _, tp_ch} -> get_text(tp_ch, "CdOrPrtry") || get_nested_text(tp_ch, ["CdOrPrtry", "Cd"])
end)
with {:ok, {amount, currency}} <- require_amount(children, "Amt", path),
{:ok, cdt_dbt} <- require_cdt_dbt(children, path),
{:ok, date} <- require_date_from_dt(children, path) do
{:ok,
%Balance{
type: Balance.parse_type(type_code || ""),
amount: amount,
currency: currency,
credit_debit: cdt_dbt,
date: date
}}
end
end
# camt.053 wraps the balance date in <Dt><Dt> or <Dt><DtTm>
defp require_date_from_dt(children, path) do
case find_child(children, "Dt") do
nil ->
{:error, {:missing_required_field, path ++ [:date]}}
{_, _, dt_children} ->
cond do
(v = get_text(dt_children, "Dt")) != nil ->
parse_date(v)
(v = get_text(dt_children, "DtTm")) != nil ->
case DateTime.from_iso8601(v) do
{:ok, dt, _} -> {:ok, DateTime.to_date(dt)}
_ -> {:error, {:invalid_date, v}}
end
true ->
{:error, {:missing_required_field, path ++ [:date]}}
end
end
end
# ---------------------------------------------------------------------------
# Entries
# ---------------------------------------------------------------------------
defp parse_entries(children, stmt_idx) do
entries =
children
|> Enum.filter(&match_tag?(&1, "Ntry"))
|> Enum.with_index()
|> Enum.reduce_while({:ok, []}, fn {{_, _, ntry_children}, entry_idx}, {:ok, acc} ->
case parse_entry(ntry_children, stmt_idx, entry_idx) do
{:ok, entry} -> {:cont, {:ok, [entry | acc]}}
{:error, _} = err -> {:halt, err}
end
end)
case entries do
{:ok, list} -> {:ok, Enum.reverse(list)}
err -> err
end
end
defp parse_entry(children, stmt_idx, entry_idx) do
path = [:statements, stmt_idx, :entries, entry_idx]
with {:ok, ref} <- require_text(children, "NtryRef", path ++ [:ref]),
{:ok, {amount, currency}} <- require_amount(children, "Amt", path),
{:ok, cdt_dbt} <- require_cdt_dbt(children, path) do
reversal = get_text(children, "RvslInd") == "true"
bank_tx_code = parse_bank_tx_code(children)
details = parse_entry_details_list(children)
{:ok,
%Entry{
ref: ref,
amount: amount,
currency: currency,
credit_debit: cdt_dbt,
reversal: reversal,
status: :booked,
booking_date: get_date_nested(children, "BookgDt"),
value_date: get_date_nested(children, "ValDt"),
account_servicer_ref: get_text(children, "AcctSvcrRef"),
bank_transaction_code: bank_tx_code,
additional_info: get_text(children, "AddtlNtryInf"),
details: details
}}
end
end
defp get_date_nested(children, tag) do
case find_child(children, tag) do
nil -> nil
{_, _, dt_ch} ->
cond do
(v = get_text(dt_ch, "Dt")) != nil ->
case parse_date(v) do
{:ok, d} -> d
_ -> nil
end
(v = get_text(dt_ch, "DtTm")) != nil ->
case DateTime.from_iso8601(v) do
{:ok, dt, _} -> DateTime.to_date(dt)
_ -> nil
end
true -> nil
end
end
end
defp parse_bank_tx_code(children) do
case find_child(children, "BkTxCd") do
nil ->
nil
{_, _, btc_children} ->
{domain, family, sub_family} =
case find_child(btc_children, "Domn") do
nil ->
{nil, nil, nil}
{_, _, domn_ch} ->
d = get_text(domn_ch, "Cd")
{fam, sub} =
case find_child(domn_ch, "Fmly") do
nil -> {nil, nil}
{_, _, fam_ch} -> {get_text(fam_ch, "Cd"), get_text(fam_ch, "SubFmlyCd")}
end
{d, fam, sub}
end
{prop_code, prop_issuer} =
case find_child(btc_children, "Prtry") do
nil -> {nil, nil}
{_, _, p_ch} -> {get_text(p_ch, "Cd"), get_text(p_ch, "Issr")}
end
%BankTxCode{
domain: domain,
family: family,
sub_family: sub_family,
proprietary_code: prop_code,
proprietary_issuer: prop_issuer
}
end
end
# ---------------------------------------------------------------------------
# Entry Details
# ---------------------------------------------------------------------------
defp parse_entry_details_list(children) do
children
|> Enum.filter(&match_tag?(&1, "NtryDtls"))
|> Enum.map(fn {_, _, nd_children} -> parse_entry_details(nd_children) end)
end
defp parse_entry_details(children) do
batch = parse_batch(children)
tx_details =
children
|> Enum.filter(&match_tag?(&1, "TxDtls"))
|> Enum.map(fn {_, _, tx_children} -> parse_transaction_details(tx_children) end)
%EntryDetails{batch: batch, transaction_details: tx_details}
end
defp parse_batch(children) do
case find_child(children, "Btch") do
nil ->
nil
{_, _, btch_ch} ->
total =
case get_text(btch_ch, "TtlAmt") do
nil -> nil
v -> parse_decimal(v)
end
nb =
case get_text(btch_ch, "NbOfTxs") do
nil -> nil
v -> String.to_integer(v)
end
%{
message_id: get_text(btch_ch, "MsgId"),
payment_info_id: get_text(btch_ch, "PmtInfId"),
number_of_transactions: nb,
total_amount: total
}
end
end
# ---------------------------------------------------------------------------
# Transaction Details
# ---------------------------------------------------------------------------
defp parse_transaction_details(children) do
{amount, currency} = parse_amount_optional(children, "Amt")
%TransactionDetails{
refs: parse_tx_refs(children),
amount: amount,
currency: currency,
credit_debit: parse_cdt_dbt_optional(children),
related_parties: parse_related_parties(children),
related_agents: parse_related_agents(children),
remittance_info: parse_remittance_info(children),
purpose: get_nested_text(children, ["Purp", "Cd"])
}
end
defp parse_tx_refs(children) do
case find_child(children, "Refs") do
nil ->
nil
{_, _, ref_ch} ->
%{
message_id: get_text(ref_ch, "MsgId"),
account_servicer_ref: get_text(ref_ch, "AcctSvcrRef"),
payment_info_id: get_text(ref_ch, "PmtInfId"),
instruction_id: get_text(ref_ch, "InstrId"),
end_to_end_id: get_text(ref_ch, "EndToEndId"),
tx_id: get_text(ref_ch, "TxId"),
mandate_id: get_text(ref_ch, "MndtId"),
uetr: get_text(ref_ch, "UETR")
}
end
end
defp parse_related_parties(children) do
case find_child(children, "RltdPties") do
nil ->
nil
{_, _, rp_ch} ->
%{
debtor: parse_party_with_account(rp_ch, "Dbtr", "DbtrAcct"),
creditor: parse_party_with_account(rp_ch, "Cdtr", "CdtrAcct"),
ultimate_debtor: parse_party(rp_ch, "UltmtDbtr"),
ultimate_creditor: parse_party(rp_ch, "UltmtCdtr")
}
end
end
defp parse_party(children, tag) do
case find_child(children, tag) do
nil -> nil
{_, _, party_ch} -> %{name: get_text(party_ch, "Nm")}
end
end
defp parse_party_with_account(children, party_tag, acct_tag) do
party = parse_party(children, party_tag)
account_id = parse_account_id(children, acct_tag)
if party || account_id, do: Map.merge(party || %{}, account_id || %{}), else: nil
end
defp parse_account_id(children, tag) do
case find_child(children, tag) do
nil ->
nil
{_, _, acct_ch} ->
id_children =
case find_child(acct_ch, "Id") do
nil -> []
{_, _, ch} -> ch
end
%{
iban: get_text(id_children, "IBAN"),
other_id: get_nested_text(id_children, ["Othr", "Id"])
}
end
end
defp parse_related_agents(children) do
case find_child(children, "RltdAgts") do
nil ->
nil
{_, _, ra_ch} ->
%{
debtor_agent: parse_agent(ra_ch, "DbtrAgt"),
creditor_agent: parse_agent(ra_ch, "CdtrAgt")
}
end
end
defp parse_agent(children, tag) do
case find_child(children, tag) do
nil ->
nil
{_, _, agt_ch} ->
case find_child(agt_ch, "FinInstnId") do
nil ->
nil
{_, _, fi_ch} ->
%{
bic: get_text(fi_ch, "BIC") || get_text(fi_ch, "BICFI"),
name: get_text(fi_ch, "Nm")
}
end
end
end
defp parse_remittance_info(children) do
case find_child(children, "RmtInf") do
nil ->
nil
{_, _, rmt_ch} ->
cond do
(text = get_text(rmt_ch, "Ustrd")) != nil ->
{:unstructured, text}
(strd = find_child(rmt_ch, "Strd")) != nil ->
{_, _, strd_ch} = strd
{:structured, parse_structured_remittance(strd_ch)}
true ->
nil
end
end
end
defp parse_structured_remittance(children) do
cdtr_ref_inf = find_child(children, "CdtrRefInf")
{ref, ref_type} =
case cdtr_ref_inf do
nil ->
{nil, nil}
{_, _, cri_ch} ->
r = get_text(cri_ch, "Ref")
rt = get_nested_text(cri_ch, ["Tp", "CdOrPrtry", "Cd"])
{r, rt}
end
%{
ref: ref,
ref_type: ref_type,
creditor_ref: get_nested_text(children, ["AddtlRmtInf"])
}
end
# ---------------------------------------------------------------------------
# Low-level XML helpers
# ---------------------------------------------------------------------------
# Returns the first child element whose local tag name matches.
# Handles both bare tag names ("MsgId") and namespace-prefixed ones ("ns2:MsgId").
@spec find_child([term()], String.t()) :: {String.t(), list(), list()} | nil
defp find_child(children, local_name) do
Enum.find(children, fn
{tag, _attrs, _ch} -> local_name(tag) == local_name
_ -> false
end)
end
defp match_tag?(node, local_name) do
case node do
{tag, _attrs, _ch} -> local_name(tag) == local_name
_ -> false
end
end
# Strip namespace prefix from a tag: "ns2:Stmt" -> "Stmt", "Stmt" -> "Stmt"
defp local_name(tag) when is_binary(tag) do
case String.split(tag, ":", parts: 2) do
[_prefix, local] -> local
[local] -> local
end
end
# Returns trimmed text content from the first matching child, or nil.
defp get_text(children, local_name) do
case find_child(children, local_name) do
nil -> nil
{_, _, text_children} -> collect_text(text_children)
end
end
# Traverses a path of tags to extract text. E.g. ["Othr", "Id"] walks <Othr><Id>.
defp get_nested_text(_children, []), do: nil
defp get_nested_text(children, [tag]) do
get_text(children, tag)
end
defp get_nested_text(children, [tag | rest]) do
case find_child(children, tag) do
nil -> nil
{_, _, ch} -> get_nested_text(ch, rest)
end
end
defp collect_text(children) do
result =
children
|> Enum.filter(&is_binary/1)
|> Enum.join()
|> String.trim()
if result == "", do: nil, else: result
end
defp find_attr(attrs, name) do
Enum.find_value(attrs, fn
{^name, val} -> val
_ -> nil
end)
end
# ---------------------------------------------------------------------------
# Typed field extraction helpers
# ---------------------------------------------------------------------------
defp require_text(children, tag, path) do
case get_text(children, tag) do
nil -> {:error, {:missing_required_field, path}}
v -> {:ok, v}
end
end
defp require_datetime(children, tag, path) do
case get_text(children, tag) do
nil ->
{:error, {:missing_required_field, path}}
v ->
case DateTime.from_iso8601(v) do
{:ok, dt, _} -> {:ok, dt}
_ -> {:error, {:invalid_datetime, v, path}}
end
end
end
defp get_datetime(children, tag) do
case get_text(children, tag) do
nil -> nil
v ->
case DateTime.from_iso8601(v) do
{:ok, dt, _} -> dt
_ -> nil
end
end
end
defp get_integer(children, tag) do
case get_text(children, tag) do
nil -> nil
v ->
case Integer.parse(v) do
{n, _} -> n
:error -> nil
end
end
end
defp parse_date(v) do
case Date.from_iso8601(v) do
{:ok, d} -> {:ok, d}
_ -> {:error, {:invalid_date, v}}
end
end
# Parses <Amt Ccy="EUR">100.00</Amt> or just <Amt>100.00</Amt>.
# Returns {:ok, {decimal, currency_string}} or error.
defp require_amount(children, tag, path) do
case find_child(children, tag) do
nil ->
{:error, {:missing_required_field, path ++ [:amount]}}
{_, attrs, text_children} ->
text = collect_text(text_children) || ""
ccy = find_attr(attrs, "Ccy")
case parse_decimal(text) do
nil -> {:error, {:invalid_amount, text, path}}
dec -> {:ok, {dec, ccy}}
end
end
end
defp parse_amount_optional(children, tag) do
case find_child(children, tag) do
nil ->
{nil, nil}
{_, attrs, text_children} ->
text = collect_text(text_children) || ""
ccy = find_attr(attrs, "Ccy")
{parse_decimal(text), ccy}
end
end
defp parse_decimal(text) when is_binary(text) do
case Decimal.parse(String.trim(text)) do
{dec, ""} -> dec
_ -> nil
end
end
defp parse_decimal(_), do: nil
defp require_cdt_dbt(children, path) do
case get_text(children, "CdtDbtInd") do
nil -> {:error, {:missing_required_field, path ++ [:credit_debit]}}
v -> {:ok, parse_cdt_dbt(v)}
end
end
defp parse_cdt_dbt_optional(children) do
case get_text(children, "CdtDbtInd") do
nil -> nil
v -> parse_cdt_dbt(v)
end
end
defp parse_cdt_dbt("CRDT"), do: :credit
defp parse_cdt_dbt("DBIT"), do: :debit
# Some senders use lowercase
defp parse_cdt_dbt(v), do: if(String.upcase(v) == "CRDT", do: :credit, else: :debit)
end