276 lines
9.5 KiB
Python
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
|
|
)
|