""" Fusion Accounting - SEPA Credit Transfer (pain.001) Generates ISO 20022 ``pain.001.001.03`` XML documents for SEPA Credit Transfers. The XML structure follows the published `ISO 20022 message definition `_ and uses ``xml.etree.ElementTree`` for construction. Namespace --------- ``urn:iso:std:iso:20022:tech:xsd:pain.001.001.03`` """ import hashlib import re import xml.etree.ElementTree as ET from datetime import datetime, date from odoo import api, fields, models, _ from odoo.exceptions import UserError, ValidationError # ISO 20022 pain.001.001.03 namespace PAIN_001_NS = 'urn:iso:std:iso:20022:tech:xsd:pain.001.001.03' # IBAN validation pattern (basic check: 2-letter country + 2 check digits + BBAN) _IBAN_RE = re.compile(r'^[A-Z]{2}\d{2}[A-Z0-9]{4,30}$') # BIC / SWIFT pattern (8 or 11 alphanumeric characters) _BIC_RE = re.compile(r'^[A-Z0-9]{8}([A-Z0-9]{3})?$') def _sanitise_text(value, max_len=140): """Remove characters not allowed in SEPA XML text fields. The SEPA character set is a subset of ASCII (Latin characters, digits, and a small set of special characters). :param str value: Original text. :param int max_len: Maximum allowed length. :return: Sanitised string. :rtype: str """ if not value: return '' # Keep only allowed characters (letters, digits, spaces, common punctuation) cleaned = re.sub(r"[^A-Za-z0-9 +\-/?.,:;'()\n]", '', value) return cleaned[:max_len].strip() def _validate_iban(iban): """Validate an IBAN string (basic structural check). :param str iban: The IBAN to validate. :raises ValidationError: If the IBAN format is invalid. :return: Normalised IBAN (upper-case, no spaces). :rtype: str """ if not iban: raise ValidationError(_("IBAN is required for SEPA transfers.")) normalised = iban.upper().replace(' ', '') if not _IBAN_RE.match(normalised): raise ValidationError(_( "'%(iban)s' does not look like a valid IBAN.", iban=iban, )) return normalised def _validate_bic(bic): """Validate a BIC / SWIFT code. :param str bic: The BIC to validate. :raises ValidationError: If the BIC format is invalid. :return: Normalised BIC (upper-case, no spaces). :rtype: str """ if not bic: raise ValidationError(_("BIC/SWIFT code is required for SEPA transfers.")) normalised = bic.upper().replace(' ', '') if not _BIC_RE.match(normalised): raise ValidationError(_( "'%(bic)s' does not look like a valid BIC/SWIFT code.", bic=bic, )) return normalised class FusionSEPACreditTransfer(models.Model): """Provides SEPA Credit Transfer XML generation (pain.001.001.03). This model adds company-level BIC storage and a utility method ``generate_pain_001`` that builds a compliant XML file from a set of ``account.payment`` records. """ _inherit = 'res.company' fusion_company_bic = fields.Char( string='Company BIC/SWIFT', size=11, help="BIC (Business Identifier Code) of the company's main bank " "account, used as the debtor agent in SEPA transfers.", ) fusion_company_iban = fields.Char( string='Company IBAN', size=34, help="IBAN of the company's main bank account, used as the " "debtor account in SEPA transfers.", ) # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ def generate_pain_001(self, payments): """Build an ISO 20022 pain.001.001.03 XML for SEPA Credit Transfers. :param payments: ``account.payment`` recordset to include. :return: UTF-8 encoded XML bytes. :rtype: bytes :raises UserError: when mandatory data is missing. """ self.ensure_one() if not payments: raise UserError(_("No payments provided for the SEPA file.")) company_iban = _validate_iban(self.fusion_company_iban) company_bic = _validate_bic(self.fusion_company_bic) # Root element with namespace root = ET.Element('Document', xmlns=PAIN_001_NS) cstmr_cdt_trf = ET.SubElement(root, 'CstmrCdtTrfInitn') # -- Group Header (GrpHdr) -- grp_hdr = ET.SubElement(cstmr_cdt_trf, 'GrpHdr') msg_id = self._pain001_message_id() ET.SubElement(grp_hdr, 'MsgId').text = msg_id ET.SubElement(grp_hdr, 'CreDtTm').text = datetime.utcnow().strftime( '%Y-%m-%dT%H:%M:%S' ) ET.SubElement(grp_hdr, 'NbOfTxs').text = str(len(payments)) ctrl_sum = sum(payments.mapped('amount')) ET.SubElement(grp_hdr, 'CtrlSum').text = f'{ctrl_sum:.2f}' initg_pty = ET.SubElement(grp_hdr, 'InitgPty') ET.SubElement(initg_pty, 'Nm').text = _sanitise_text( self.name, max_len=70 ) # -- Payment Information (PmtInf) -- pmt_inf = ET.SubElement(cstmr_cdt_trf, 'PmtInf') ET.SubElement(pmt_inf, 'PmtInfId').text = msg_id[:35] ET.SubElement(pmt_inf, 'PmtMtd').text = 'TRF' # Transfer ET.SubElement(pmt_inf, 'NbOfTxs').text = str(len(payments)) ET.SubElement(pmt_inf, 'CtrlSum').text = f'{ctrl_sum:.2f}' # Payment Type Information pmt_tp_inf = ET.SubElement(pmt_inf, 'PmtTpInf') svc_lvl = ET.SubElement(pmt_tp_inf, 'SvcLvl') ET.SubElement(svc_lvl, 'Cd').text = 'SEPA' ET.SubElement(pmt_inf, 'ReqdExctnDt').text = ( fields.Date.context_today(self).isoformat() ) # Debtor (company) dbtr = ET.SubElement(pmt_inf, 'Dbtr') ET.SubElement(dbtr, 'Nm').text = _sanitise_text(self.name, 70) dbtr_acct = ET.SubElement(pmt_inf, 'DbtrAcct') dbtr_acct_id = ET.SubElement(dbtr_acct, 'Id') ET.SubElement(dbtr_acct_id, 'IBAN').text = company_iban dbtr_agt = ET.SubElement(pmt_inf, 'DbtrAgt') dbtr_agt_id = ET.SubElement(dbtr_agt, 'FinInstnId') ET.SubElement(dbtr_agt_id, 'BIC').text = company_bic ET.SubElement(pmt_inf, 'ChrgBr').text = 'SLEV' # Service Level # -- Individual transactions -- for payment in payments: self._add_pain001_transaction(pmt_inf, payment) # Build and return the XML tree = ET.ElementTree(root) ET.indent(tree, space=' ') xml_bytes = ET.tostring( root, encoding='UTF-8', xml_declaration=True ) return xml_bytes # ------------------------------------------------------------------ # Private helpers # ------------------------------------------------------------------ def _pain001_message_id(self): """Generate a unique message identifier for the pain.001 file. Uses a hash of the company id and the current timestamp to produce a reference that is unique and fits the 35-character SEPA limit. :return: Message ID string (max 35 chars). :rtype: str """ raw = f'{self.id}-{datetime.utcnow().isoformat()}' digest = hashlib.sha256(raw.encode()).hexdigest()[:16] return f'FUSION-SCT-{digest}'.upper()[:35] def _add_pain001_transaction(self, pmt_inf, payment): """Append a ``CdtTrfTxInf`` element for a single payment. :param pmt_inf: The ``PmtInf`` XML element. :param payment: An ``account.payment`` record. :raises UserError: when creditor bank details are missing. """ partner = payment.partner_id if not partner: raise UserError(_( "Payment '%(payment)s' has no partner.", payment=payment.name, )) partner_bank = payment.partner_bank_id if not partner_bank or not partner_bank.acc_number: raise UserError(_( "Payment '%(payment)s' has no bank account set for " "partner '%(partner)s'.", payment=payment.name, partner=partner.display_name, )) creditor_iban = _validate_iban(partner_bank.acc_number) cdt_trf = ET.SubElement(pmt_inf, 'CdtTrfTxInf') # Payment Identification pmt_id = ET.SubElement(cdt_trf, 'PmtId') ET.SubElement(pmt_id, 'EndToEndId').text = _sanitise_text( payment.name or 'NOTPROVIDED', 35 ) # Amount amt = ET.SubElement(cdt_trf, 'Amt') instd_amt = ET.SubElement(amt, 'InstdAmt') instd_amt.text = f'{payment.amount:.2f}' instd_amt.set('Ccy', payment.currency_id.name or 'EUR') # Creditor Agent (BIC) - optional but recommended if partner_bank.bank_bic: cdtr_agt = ET.SubElement(cdt_trf, 'CdtrAgt') cdtr_agt_fin = ET.SubElement(cdtr_agt, 'FinInstnId') ET.SubElement(cdtr_agt_fin, 'BIC').text = ( partner_bank.bank_bic.upper().replace(' ', '') ) # Creditor cdtr = ET.SubElement(cdt_trf, 'Cdtr') ET.SubElement(cdtr, 'Nm').text = _sanitise_text( partner.name or '', 70 ) # Creditor Account cdtr_acct = ET.SubElement(cdt_trf, 'CdtrAcct') cdtr_acct_id = ET.SubElement(cdtr_acct, 'Id') ET.SubElement(cdtr_acct_id, 'IBAN').text = creditor_iban # Remittance Information if payment.ref or payment.name: rmt_inf = ET.SubElement(cdt_trf, 'RmtInf') ET.SubElement(rmt_inf, 'Ustrd').text = _sanitise_text( payment.ref or payment.name or '', 140 )