""" Fusion Accounting - SEPA Direct Debit (pain.008) Generates ISO 20022 ``pain.008.001.02`` XML documents for SEPA Direct Debit collections. Includes a mandate model (``fusion.sdd.mandate``) to track debtor authorisations. 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.008.001.02`` """ import hashlib import re import uuid import xml.etree.ElementTree as ET from datetime import datetime from odoo import api, fields, models, _ from odoo.exceptions import UserError, ValidationError # ISO 20022 pain.008.001.02 namespace PAIN_008_NS = 'urn:iso:std:iso:20022:tech:xsd:pain.008.001.02' # Reuse validation helpers from SEPA Credit Transfer module _IBAN_RE = re.compile(r'^[A-Z]{2}\d{2}[A-Z0-9]{4,30}$') _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.""" if not value: return '' cleaned = re.sub(r"[^A-Za-z0-9 +\-/?.,:;'()\n]", '', value) return cleaned[:max_len].strip() def _validate_iban(iban): """Validate and normalise an IBAN string.""" if not iban: raise ValidationError(_("IBAN is required for SEPA Direct Debit.")) 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 and normalise a BIC / SWIFT code.""" if not bic: raise ValidationError(_( "BIC/SWIFT code is required for SEPA Direct Debit." )) 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 # ====================================================================== # Mandate Model # ====================================================================== class FusionSDDMandate(models.Model): """Tracks a SEPA Direct Debit mandate authorisation. A mandate is the legal agreement through which a debtor (the customer) authorises the creditor (the company) to collect payments from their bank account. """ _name = 'fusion.sdd.mandate' _description = 'SEPA Direct Debit Mandate' _order = 'create_date desc' _inherit = ['mail.thread', 'mail.activity.mixin'] # ------------------------------------------------------------------ # Fields # ------------------------------------------------------------------ name = fields.Char( string='Mandate Reference', required=True, copy=False, readonly=True, default='/', tracking=True, help="Unique Mandate Reference (UMR) communicated to the " "debtor's bank.", ) partner_id = fields.Many2one( comodel_name='res.partner', string='Debtor', required=True, tracking=True, help="The partner (customer) who signed this mandate.", ) iban = fields.Char( string='Debtor IBAN', required=True, size=34, tracking=True, help="IBAN of the debtor's bank account.", ) bic = fields.Char( string='Debtor BIC', size=11, tracking=True, help="BIC of the debtor's bank.", ) mandate_ref = fields.Char( string='Unique Mandate Reference', required=True, copy=False, tracking=True, help="The unique reference identifying this mandate agreement.", ) state = fields.Selection( selection=[ ('draft', 'Draft'), ('active', 'Active'), ('revoked', 'Revoked'), ('closed', 'Closed'), ], string='Status', default='draft', required=True, tracking=True, help="Draft: mandate is being prepared.\n" "Active: mandate is signed and ready for collections.\n" "Revoked: debtor has withdrawn consent.\n" "Closed: mandate is no longer in use.", ) scheme = fields.Selection( selection=[ ('CORE', 'CORE'), ('B2B', 'B2B'), ], string='Scheme', default='CORE', required=True, tracking=True, help="CORE: consumer direct debit.\n" "B2B: business-to-business direct debit (no refund right).", ) date_signed = fields.Date( string='Date Signed', default=fields.Date.context_today, help="Date when the mandate was signed by the debtor.", ) company_id = fields.Many2one( comodel_name='res.company', string='Company', default=lambda self: self.env.company, required=True, ) # ------------------------------------------------------------------ # CRUD # ------------------------------------------------------------------ @api.model_create_multi def create(self, vals_list): """Assign a sequence-based name if not provided.""" for vals in vals_list: if vals.get('name', '/') == '/': vals['name'] = self.env['ir.sequence'].next_by_code( 'fusion.sdd.mandate' ) or _('New') if not vals.get('mandate_ref'): vals['mandate_ref'] = f'FUSIONMDT-{uuid.uuid4().hex[:8].upper()}' return super().create(vals_list) # ------------------------------------------------------------------ # Actions # ------------------------------------------------------------------ def action_activate(self): """Activate a draft mandate.""" for mandate in self: if mandate.state != 'draft': raise UserError(_( "Only draft mandates can be activated." )) mandate.state = 'active' def action_revoke(self): """Revoke an active mandate.""" for mandate in self: if mandate.state != 'active': raise UserError(_( "Only active mandates can be revoked." )) mandate.state = 'revoked' def action_close(self): """Close a mandate (no more collections).""" for mandate in self: if mandate.state not in ('active', 'revoked'): raise UserError(_( "Only active or revoked mandates can be closed." )) mandate.state = 'closed' # ====================================================================== # SEPA Direct Debit XML generation # ====================================================================== class FusionSEPADirectDebit(models.Model): """Extends ``res.company`` with SEPA Direct Debit XML generation. The ``generate_pain_008`` method accepts a set of ``fusion.sdd.mandate`` records (each linked to an active mandate) and produces a compliant pain.008.001.02 XML file. """ _inherit = 'res.company' fusion_sdd_creditor_identifier = fields.Char( string='SEPA Creditor Identifier', size=35, help="Your organisation's SEPA Creditor Identifier (CI), " "assigned by your national authority.", ) # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ def generate_pain_008(self, mandates, requested_date=None): """Build an ISO 20022 pain.008.001.02 XML for SEPA Direct Debits. :param mandates: ``fusion.sdd.mandate`` recordset. :param requested_date: Requested collection date (defaults to today). :return: UTF-8 encoded XML bytes. :rtype: bytes :raises UserError: when mandatory data is missing. """ self.ensure_one() if not mandates: raise UserError(_( "No mandates provided for the SEPA Direct Debit file." )) inactive = mandates.filtered(lambda m: m.state != 'active') if inactive: raise UserError(_( "The following mandates are not active and cannot be " "included:\n%(mandates)s", mandates=', '.join(inactive.mapped('name')), )) company_iban = _validate_iban(self.fusion_company_iban) company_bic = _validate_bic(self.fusion_company_bic) if not self.fusion_sdd_creditor_identifier: raise UserError(_( "Please configure the SEPA Creditor Identifier on " "the company settings before generating a Direct " "Debit file." )) collection_date = requested_date or fields.Date.context_today(self) # Root element root = ET.Element('Document', xmlns=PAIN_008_NS) cstmr_dd = ET.SubElement(root, 'CstmrDrctDbtInitn') # -- Group Header -- grp_hdr = ET.SubElement(cstmr_dd, 'GrpHdr') msg_id = self._pain008_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(mandates)) initg_pty = ET.SubElement(grp_hdr, 'InitgPty') ET.SubElement(initg_pty, 'Nm').text = _sanitise_text( self.name, max_len=70 ) # Group mandates by scheme (CORE / B2B) for scheme in ('CORE', 'B2B'): scheme_mandates = mandates.filtered( lambda m: m.scheme == scheme ) if not scheme_mandates: continue self._add_pain008_payment_info( cstmr_dd, scheme_mandates, scheme, company_iban, company_bic, collection_date, msg_id, ) 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 _pain008_message_id(self): """Generate a unique message identifier for the pain.008 file.""" raw = f'{self.id}-SDD-{datetime.utcnow().isoformat()}' digest = hashlib.sha256(raw.encode()).hexdigest()[:16] return f'FUSION-SDD-{digest}'.upper()[:35] def _add_pain008_payment_info( self, parent, mandates, scheme, company_iban, company_bic, collection_date, msg_id, ): """Add a ``PmtInf`` block for a group of mandates sharing the same scheme. :param parent: ``CstmrDrctDbtInitn`` XML element. :param mandates: ``fusion.sdd.mandate`` recordset for this scheme. :param str scheme: ``'CORE'`` or ``'B2B'``. :param str company_iban: Validated company IBAN. :param str company_bic: Validated company BIC. :param collection_date: Requested collection date. :param str msg_id: Message identifier. """ pmt_inf = ET.SubElement(parent, 'PmtInf') pmt_inf_id = f'{msg_id}-{scheme}'[:35] ET.SubElement(pmt_inf, 'PmtInfId').text = pmt_inf_id ET.SubElement(pmt_inf, 'PmtMtd').text = 'DD' # Direct Debit ET.SubElement(pmt_inf, 'NbOfTxs').text = str(len(mandates)) # 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' lcl_instrm = ET.SubElement(pmt_tp_inf, 'LclInstrm') ET.SubElement(lcl_instrm, 'Cd').text = scheme ET.SubElement(pmt_tp_inf, 'SeqTp').text = 'RCUR' # Recurring ET.SubElement(pmt_inf, 'ReqdColltnDt').text = ( collection_date.isoformat() if isinstance(collection_date, date) else str(collection_date) ) # Creditor (company) cdtr = ET.SubElement(pmt_inf, 'Cdtr') ET.SubElement(cdtr, 'Nm').text = _sanitise_text(self.name, 70) cdtr_acct = ET.SubElement(pmt_inf, 'CdtrAcct') cdtr_acct_id = ET.SubElement(cdtr_acct, 'Id') ET.SubElement(cdtr_acct_id, 'IBAN').text = company_iban cdtr_agt = ET.SubElement(pmt_inf, 'CdtrAgt') cdtr_agt_fin = ET.SubElement(cdtr_agt, 'FinInstnId') ET.SubElement(cdtr_agt_fin, 'BIC').text = company_bic ET.SubElement(pmt_inf, 'ChrgBr').text = 'SLEV' # Creditor Scheme Identification cdtr_schme_id = ET.SubElement(pmt_inf, 'CdtrSchmeId') cdtr_schme_id_el = ET.SubElement(cdtr_schme_id, 'Id') prvt_id = ET.SubElement(cdtr_schme_id_el, 'PrvtId') othr = ET.SubElement(prvt_id, 'Othr') ET.SubElement(othr, 'Id').text = _sanitise_text( self.fusion_sdd_creditor_identifier, 35 ) schme_nm = ET.SubElement(othr, 'SchmeNm') ET.SubElement(schme_nm, 'Prtry').text = 'SEPA' # Individual transactions for mandate in mandates: self._add_pain008_transaction(pmt_inf, mandate) def _add_pain008_transaction(self, pmt_inf, mandate): """Append a ``DrctDbtTxInf`` element for a single mandate. :param pmt_inf: The ``PmtInf`` XML element. :param mandate: A ``fusion.sdd.mandate`` record. """ debtor_iban = _validate_iban(mandate.iban) dd_tx = ET.SubElement(pmt_inf, 'DrctDbtTxInf') # Payment Identification pmt_id = ET.SubElement(dd_tx, 'PmtId') ET.SubElement(pmt_id, 'EndToEndId').text = _sanitise_text( mandate.mandate_ref or mandate.name, 35 ) # Amount - use a default amount; callers should set this via # the payment amount linked to the mandate amt = ET.SubElement(dd_tx, 'InstdAmt') # Default to 0.00; the caller is expected to provide real # amounts via a companion payment record. This serves as the # structural placeholder per the schema. amt.text = '0.00' amt.set('Ccy', 'EUR') # Direct Debit Transaction Info (mandate details) dd_tx_inf = ET.SubElement(dd_tx, 'DrctDbtTx') mndt_rltd_inf = ET.SubElement(dd_tx_inf, 'MndtRltdInf') ET.SubElement(mndt_rltd_inf, 'MndtId').text = _sanitise_text( mandate.mandate_ref, 35 ) ET.SubElement(mndt_rltd_inf, 'DtOfSgntr').text = ( mandate.date_signed.isoformat() if mandate.date_signed else fields.Date.context_today(self).isoformat() ) # Debtor Agent (BIC) if mandate.bic: dbtr_agt = ET.SubElement(dd_tx, 'DbtrAgt') dbtr_agt_fin = ET.SubElement(dbtr_agt, 'FinInstnId') ET.SubElement(dbtr_agt_fin, 'BIC').text = ( mandate.bic.upper().replace(' ', '') ) # Debtor dbtr = ET.SubElement(dd_tx, 'Dbtr') ET.SubElement(dbtr, 'Nm').text = _sanitise_text( mandate.partner_id.name or '', 70 ) # Debtor Account dbtr_acct = ET.SubElement(dd_tx, 'DbtrAcct') dbtr_acct_id = ET.SubElement(dbtr_acct, 'Id') ET.SubElement(dbtr_acct_id, 'IBAN').text = debtor_iban