Files
Odoo-Modules/Fusion Accounting/models/sepa_credit_transfer.py
2026-02-22 01:22:18 -05:00

276 lines
9.5 KiB
Python

"""
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
<https://www.iso20022.org/catalogue-messages/iso-20022-messages-archive>`_
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
)