Source code for pain001.xml.envelope

# Copyright (C) 2023-2026 Pain001. All rights reserved.
# SPDX-License-Identifier: Apache-2.0 OR MIT
#
# Licensed under either of the Apache License, Version 2.0 or the MIT
# License, at your option. You may not use this file except in
# compliance with one of those licences. Copies are provided in
# LICENSE-APACHE and LICENSE-MIT.
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the Licences is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied. See the applicable Licence for the specific language
# governing permissions and limitations.

"""ISO 20022 Business Application Header (head.001.001.03) and Envelope."""

from __future__ import annotations

import re
import uuid
import xml.etree.ElementTree as et  # nosec B405
from dataclasses import dataclass
from datetime import datetime, timezone

import defusedxml.ElementTree as defused_et

BAH_NAMESPACE = "urn:iso:std:iso:20022:tech:xsd:head.001.001.03"
BIZDATA_NAMESPACE = "urn:iso:std:iso:20022:tech:xsd:head.003.001.01"
XML_DSIG_NAMESPACE = "http://www.w3.org/2000/09/xmldsig#"


[docs] @dataclass class BusinessApplicationHeader: """ISO 20022 Business Application Header (BAH head.001.001.03) model. Attributes: from_bic: Sender Financial Institution BIC (BICFI). from_id: Sender Organisation Identifier if not a BIC. to_bic: Receiver Financial Institution BIC (BICFI). to_id: Receiver Organisation Identifier if not a BIC. biz_msg_idr: Business Message Identifier (max 35 chars). msg_def_idr: Message Definition Identifier (e.g. pain.001.001.03). cre_dt: Message Creation Date Time in ISO format (UTC). char_set: Optional character set encoding string. biz_svc: Optional business service name. cpy_dplct: Optional copy / duplicate indicator (CODU, COPY, DUPL). pssbl_dplct: Optional possible duplicate flag (True/False). prty: Optional priority code (e.g. HIGH, NORM). signature: Optional XML-DSig signature element or raw XML string. """ from_bic: str | None = None from_id: str | None = None to_bic: str | None = None to_id: str | None = None biz_msg_idr: str | None = None msg_def_idr: str | None = None cre_dt: str | None = None char_set: str | None = None biz_svc: str | None = None cpy_dplct: str | None = None pssbl_dplct: bool | None = None prty: str | None = None signature: str | et.Element | None = None
[docs] def to_element(self) -> et.Element: """Construct the XML ElementTree representation of head.001.001.03. Returns: An ElementTree.Element representing <AppHdr>. """ app_hdr = et.Element(f"{{{BAH_NAMESPACE}}}AppHdr") if self.char_set: char_set_el = et.SubElement(app_hdr, f"{{{BAH_NAMESPACE}}}CharSet") char_set_el.text = self.char_set # <Fr> fr_el = et.SubElement(app_hdr, f"{{{BAH_NAMESPACE}}}Fr") if self.from_bic: fi_id = et.SubElement(fr_el, f"{{{BAH_NAMESPACE}}}FIId") fin_instn = et.SubElement(fi_id, f"{{{BAH_NAMESPACE}}}FinInstnId") bicfi = et.SubElement(fin_instn, f"{{{BAH_NAMESPACE}}}BICFI") bicfi.text = self.from_bic elif self.from_id: org_id = et.SubElement(fr_el, f"{{{BAH_NAMESPACE}}}OrgId") id_el = et.SubElement(org_id, f"{{{BAH_NAMESPACE}}}Id") org_sub = et.SubElement(id_el, f"{{{BAH_NAMESPACE}}}OrgId") othr = et.SubElement(org_sub, f"{{{BAH_NAMESPACE}}}Othr") other_id = et.SubElement(othr, f"{{{BAH_NAMESPACE}}}Id") other_id.text = self.from_id else: fi_id = et.SubElement(fr_el, f"{{{BAH_NAMESPACE}}}FIId") fin_instn = et.SubElement(fi_id, f"{{{BAH_NAMESPACE}}}FinInstnId") bicfi = et.SubElement(fin_instn, f"{{{BAH_NAMESPACE}}}BICFI") bicfi.text = "NOTPROVIDED" # <To> to_el = et.SubElement(app_hdr, f"{{{BAH_NAMESPACE}}}To") if self.to_bic: fi_id = et.SubElement(to_el, f"{{{BAH_NAMESPACE}}}FIId") fin_instn = et.SubElement(fi_id, f"{{{BAH_NAMESPACE}}}FinInstnId") bicfi = et.SubElement(fin_instn, f"{{{BAH_NAMESPACE}}}BICFI") bicfi.text = self.to_bic elif self.to_id: org_id = et.SubElement(to_el, f"{{{BAH_NAMESPACE}}}OrgId") id_el = et.SubElement(org_id, f"{{{BAH_NAMESPACE}}}Id") org_sub = et.SubElement(id_el, f"{{{BAH_NAMESPACE}}}OrgId") othr = et.SubElement(org_sub, f"{{{BAH_NAMESPACE}}}Othr") other_id = et.SubElement(othr, f"{{{BAH_NAMESPACE}}}Id") other_id.text = self.to_id else: fi_id = et.SubElement(to_el, f"{{{BAH_NAMESPACE}}}FIId") fin_instn = et.SubElement(fi_id, f"{{{BAH_NAMESPACE}}}FinInstnId") bicfi = et.SubElement(fin_instn, f"{{{BAH_NAMESPACE}}}BICFI") bicfi.text = "NOTPROVIDED" # <BizMsgIdr> biz_msg_idr_val = ( self.biz_msg_idr or f"MSG-{uuid.uuid4().hex[:16].upper()}" ) biz_msg_el = et.SubElement(app_hdr, f"{{{BAH_NAMESPACE}}}BizMsgIdr") biz_msg_el.text = biz_msg_idr_val # <MsgDefIdr> msg_def_idr_val = self.msg_def_idr or "pain.001.001.03" msg_def_el = et.SubElement(app_hdr, f"{{{BAH_NAMESPACE}}}MsgDefIdr") msg_def_el.text = msg_def_idr_val if self.biz_svc: biz_svc_el = et.SubElement(app_hdr, f"{{{BAH_NAMESPACE}}}BizSvc") biz_svc_el.text = self.biz_svc # <CreDt> cre_dt_val = self.cre_dt or datetime.now(timezone.utc).strftime( "%Y-%m-%dT%H:%M:%SZ" ) cre_dt_el = et.SubElement(app_hdr, f"{{{BAH_NAMESPACE}}}CreDt") cre_dt_el.text = cre_dt_val if self.cpy_dplct: cpy_el = et.SubElement(app_hdr, f"{{{BAH_NAMESPACE}}}CpyDplct") cpy_el.text = self.cpy_dplct if self.pssbl_dplct is not None: pssbl_el = et.SubElement(app_hdr, f"{{{BAH_NAMESPACE}}}PssblDplct") pssbl_el.text = "true" if self.pssbl_dplct else "false" if self.prty: prty_el = et.SubElement(app_hdr, f"{{{BAH_NAMESPACE}}}Prty") prty_el.text = self.prty # <Sgntr> if self.signature is not None: sgntr_el = et.SubElement(app_hdr, f"{{{BAH_NAMESPACE}}}Sgntr") if isinstance(self.signature, str): sig_parsed = defused_et.fromstring(self.signature) sgntr_el.append(sig_parsed) else: sgntr_el.append(self.signature) return app_hdr
[docs] def to_xml(self) -> str: """Serialize the BAH to an XML string. Returns: The serialized <AppHdr> XML string. """ element = self.to_element() return et.tostring(element, encoding="unicode")
[docs] def extract_msg_def_idr(document_xml: str | et.Element) -> str: """Extract or infer the ISO 20022 message definition identifier. Args: document_xml: The raw XML string or root Element. Returns: The detected identifier such as 'pain.001.001.03'. """ if isinstance(document_xml, et.Element): tag = document_xml.tag if tag.startswith("{"): ns = tag[1 : tag.find("}")] match = re.search(r"urn:iso:std:iso:20022:tech:xsd:([\w.]+)", ns) if match: return match.group(1) return "pain.001.001.03" match = re.search( r'xmlns(?::\w+)?=["\']urn:iso:std:iso:20022:tech:xsd:([\w.]+)["\']', document_xml, ) if match: return match.group(1) return "pain.001.001.03"
[docs] def envelop_iso20022_message( document_xml: str | et.Element, header: BusinessApplicationHeader, wrapped: bool = True, ) -> str: """Envelop an ISO 20022 payment document inside a head.003 BizData envelope. Args: document_xml: The ISO 20022 document XML string or Element. header: The BusinessApplicationHeader metadata instance. wrapped: If True, wrap AppHdr in <Hdr> and Document in <Pyld> per ISO 20022 head.003 standard schema. If False, place AppHdr and Document directly under <BizData>. Returns: The complete enveloped ISO 20022 XML document with XML declaration. """ if header.msg_def_idr is None: header.msg_def_idr = extract_msg_def_idr(document_xml) if isinstance(document_xml, str): doc_root = defused_et.fromstring(document_xml) else: doc_root = document_xml et.register_namespace("", BIZDATA_NAMESPACE) et.register_namespace("head", BAH_NAMESPACE) if header.msg_def_idr: et.register_namespace( "doc", f"urn:iso:std:iso:20022:tech:xsd:{header.msg_def_idr}" ) et.register_namespace("ds", XML_DSIG_NAMESPACE) biz_data = et.Element(f"{{{BIZDATA_NAMESPACE}}}BizData") app_hdr_el = header.to_element() if wrapped: hdr_el = et.SubElement(biz_data, f"{{{BIZDATA_NAMESPACE}}}Hdr") hdr_el.append(app_hdr_el) pyld_el = et.SubElement(biz_data, f"{{{BIZDATA_NAMESPACE}}}Pyld") pyld_el.append(doc_root) else: biz_data.append(app_hdr_el) biz_data.append(doc_root) xml_body = et.tostring(biz_data, encoding="unicode") return f'<?xml version="1.0" encoding="UTF-8"?>\n{xml_body}'
[docs] def unpack_iso20022_message( xml_content: str | bytes, ) -> tuple[BusinessApplicationHeader | None, str]: """Unpack an enveloped ISO 20022 message, extracting BAH and Document. Args: xml_content: The XML payload string or bytes. Returns: A tuple of (BusinessApplicationHeader, document_xml_str). If the input is not enveloped inside BizData or AppHdr, returns (None, xml_content). """ if isinstance(xml_content, bytes): raw_str = xml_content.decode("utf-8") else: raw_str = xml_content if "AppHdr" not in raw_str: return None, raw_str root = defused_et.fromstring(raw_str) # Search for AppHdr element app_hdr_el = None for el in root.iter(): if el.tag.endswith("AppHdr"): app_hdr_el = el break if app_hdr_el is None: return None, raw_str header = _parse_app_hdr_element(app_hdr_el) # Search for Document element doc_el = None for el in root.iter(): if el.tag.endswith("Document"): doc_el = el break if doc_el is not None: if header.msg_def_idr: et.register_namespace( "", f"urn:iso:std:iso:20022:tech:xsd:{header.msg_def_idr}" ) doc_xml = et.tostring(doc_el, encoding="unicode") doc_xml_str = f'<?xml version="1.0" encoding="UTF-8"?>\n{doc_xml}' else: doc_xml_str = raw_str return header, doc_xml_str
def _parse_app_hdr_element( app_hdr_el: et.Element, ) -> BusinessApplicationHeader: """Parse an <AppHdr> element into a BusinessApplicationHeader instance. Args: app_hdr_el: The XML element for AppHdr. Returns: A parsed BusinessApplicationHeader object. """ char_set: str | None = None from_bic: str | None = None from_id: str | None = None to_bic: str | None = None to_id: str | None = None biz_msg_idr: str | None = None msg_def_idr: str | None = None biz_svc: str | None = None cre_dt: str | None = None cpy_dplct: str | None = None pssbl_dplct: bool | None = None prty: str | None = None signature: str | None = None for child in app_hdr_el: tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag if tag == "CharSet": char_set = child.text elif tag == "Fr": bic_el = child.find(f".//{{{BAH_NAMESPACE}}}BICFI") if bic_el is None: bic_el = child.find(".//BICFI") if bic_el is not None and bic_el.text: from_bic = bic_el.text else: for cand in child.iter(): if cand.tag.endswith("Id") and cand.text: from_id = cand.text break elif tag == "To": bic_el = child.find(f".//{{{BAH_NAMESPACE}}}BICFI") if bic_el is None: bic_el = child.find(".//BICFI") if bic_el is not None and bic_el.text: to_bic = bic_el.text else: for cand in child.iter(): if cand.tag.endswith("Id") and cand.text: to_id = cand.text break elif tag == "BizMsgIdr": biz_msg_idr = child.text elif tag == "MsgDefIdr": msg_def_idr = child.text elif tag == "BizSvc": biz_svc = child.text elif tag == "CreDt": cre_dt = child.text elif tag == "CpyDplct": cpy_dplct = child.text elif tag == "PssblDplct": pssbl_dplct = child.text == "true" elif tag == "Prty": prty = child.text elif tag == "Sgntr": signature = ( et.tostring(child[0], encoding="unicode") if len(child) > 0 else None ) return BusinessApplicationHeader( from_bic=from_bic, from_id=from_id, to_bic=to_bic, to_id=to_id, biz_msg_idr=biz_msg_idr, msg_def_idr=msg_def_idr, cre_dt=cre_dt, char_set=char_set, biz_svc=biz_svc, cpy_dplct=cpy_dplct, pssbl_dplct=pssbl_dplct, prty=prty, signature=signature, )