You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

509 lines
24 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models, fields, _
from odoo.exceptions import UserError, ValidationError
from odoo.tools import float_round, float_repr, DEFAULT_SERVER_DATE_FORMAT
from odoo.tools.misc import mod10r, remove_accents
from odoo.tools.xml_utils import create_xml_node, create_xml_node_chain
from odoo.addons.account_batch_payment.models.sepa_mapping import _replace_characters_SEPA
from collections import defaultdict
import random
import re
import time
from lxml import etree
def sanitize_communication(communication):
""" Returns a sanitized version of the communication given in parameter,
so that:
- it contains only latin characters
- it does not contain any //
- it does not start or end with /
- it is maximum 140 characters long
(these are the SEPA compliance criteria)
"""
communication = communication[:140]
while '//' in communication:
communication = communication.replace('//', '/')
if communication.startswith('/'):
communication = communication[1:]
if communication.endswith('/'):
communication = communication[:-1]
communication = _replace_characters_SEPA(communication)
return communication
class AccountJournal(models.Model):
_inherit = "account.journal"
sepa_pain_version = fields.Selection(
[
('pain.001.001.03', 'Generic'),
('pain.001.001.03.austrian.004', 'Austrian'),
('pain.001.003.03', 'German'),
('pain.001.001.03.se', 'Swedish'),
('pain.001.001.03.ch.02', 'Swiss'),
],
string='SEPA Pain Version',
readonly=False,
store=True,
compute='_compute_sepa_pain_version',
help='SEPA may be a generic format, some countries differ from the '
'SEPA recommendations made by the EPC (European Payment Council) '
'and thus the XML created need some tweaking.'
)
has_sepa_ct_payment_method = fields.Boolean(compute='_compute_has_sepa_ct_payment_method')
@api.depends('bank_acc_number', 'country_code', 'company_id.country_code')
def _compute_sepa_pain_version(self):
""" Set default value for the field sepa_pain_version"""
pains_by_country = {
'DE': 'pain.001.003.03',
'CH': 'pain.001.001.03.ch.02',
'SE': 'pain.001.001.03.se',
'AT': 'pain.001.001.03.austrian.004',
}
for rec in self:
# First try to retrieve the country_code from the IBAN
if rec.bank_acc_number and re.match('^[A-Z]{2}[0-9]{2}.*', rec.bank_acc_number):
country_code = rec.bank_acc_number[:2]
# Then try from the company's fiscal country, and finally from the company's country
else:
country_code = rec.country_code or rec.company_id.country_code or ""
rec.sepa_pain_version = pains_by_country.get(country_code, 'pain.001.001.03')
@api.depends('outbound_payment_method_line_ids.payment_method_id.code')
def _compute_has_sepa_ct_payment_method(self):
for rec in self:
rec.has_sepa_ct_payment_method = any(
payment_method.payment_method_id.code == 'sepa_ct'
for payment_method in rec.outbound_payment_method_line_ids
)
def _default_outbound_payment_methods(self):
res = super()._default_outbound_payment_methods()
if self._is_payment_method_available('sepa_ct'):
res |= self.env.ref('account_sepa.account_payment_method_sepa_ct')
return res
def create_iso20022_credit_transfer(self, payments, batch_booking=False, sct_generic=False):
"""
This method creates the body of the XML file for the SEPA document.
It returns the content of the XML file.
"""
pain_version = self.sepa_pain_version
Document = self._get_document(pain_version)
CstmrCdtTrfInitn = etree.SubElement(Document, "CstmrCdtTrfInitn")
# Create the GrpHdr XML block
GrpHdr = etree.SubElement(CstmrCdtTrfInitn, "GrpHdr")
MsgId = etree.SubElement(GrpHdr, "MsgId")
val_MsgId = str(int(time.time() * 100))[-10:]
if self.company_id.sepa_initiating_party_name:
company_name = self.company_id.sepa_initiating_party_name[:15]
else:
company_name = self.company_id.name[:15]
val_MsgId = sanitize_communication(company_name) + val_MsgId
val_MsgId = str(random.random()) + val_MsgId
val_MsgId = val_MsgId[-30:]
MsgId.text = val_MsgId
CreDtTm = etree.SubElement(GrpHdr, "CreDtTm")
CreDtTm.text = time.strftime("%Y-%m-%dT%H:%M:%S")
NbOfTxs = etree.SubElement(GrpHdr, "NbOfTxs")
val_NbOfTxs = str(len(payments))
if len(val_NbOfTxs) > 15:
raise ValidationError(_("Too many transactions for a single file."))
NbOfTxs.text = val_NbOfTxs
CtrlSum = etree.SubElement(GrpHdr, "CtrlSum")
CtrlSum.text = self._get_CtrlSum(payments)
GrpHdr.append(self._get_InitgPty(pain_version, sct_generic))
# Create one PmtInf XML block per execution date, per currency
payments_date_instr_wise = defaultdict(lambda: [])
today = fields.Date.today()
for payment in payments:
local_instrument = self._get_local_instrument(payment)
required_payment_date = payment['payment_date'] if payment['payment_date'] > today else today
currency = payment['currency_id'] or self.company_id.currency_id.id
payments_date_instr_wise[(required_payment_date, local_instrument, currency)].append(payment)
count = 0
for (payment_date, local_instrument, currency), payments_list in payments_date_instr_wise.items():
count += 1
PmtInf = etree.SubElement(CstmrCdtTrfInitn, "PmtInf")
PmtInfId = etree.SubElement(PmtInf, "PmtInfId")
PmtInfId.text = (val_MsgId + str(self.id) + str(count))[-30:]
PmtMtd = etree.SubElement(PmtInf, "PmtMtd")
PmtMtd.text = 'TRF'
BtchBookg = etree.SubElement(PmtInf, "BtchBookg")
BtchBookg.text = batch_booking and 'true' or 'false'
NbOfTxs = etree.SubElement(PmtInf, "NbOfTxs")
NbOfTxs.text = str(len(payments_list))
CtrlSum = etree.SubElement(PmtInf, "CtrlSum")
CtrlSum.text = self._get_CtrlSum(payments_list)
PmtTpInf = self._get_PmtTpInf(sct_generic, local_instrument)
if len(PmtTpInf) != 0: #Boolean conversion from etree element triggers a deprecation warning ; this is the proper way
PmtInf.append(PmtTpInf)
ReqdExctnDt = etree.SubElement(PmtInf, "ReqdExctnDt")
ReqdExctnDt.text = fields.Date.to_string(payment_date)
PmtInf.append(self._get_Dbtr(pain_version, sct_generic))
PmtInf.append(self._get_DbtrAcct())
DbtrAgt = etree.SubElement(PmtInf, "DbtrAgt")
FinInstnId = etree.SubElement(DbtrAgt, "FinInstnId")
bank_account = self.bank_account_id
bic_code = self._get_cleaned_bic_code(bank_account)
if pain_version in ['pain.001.001.03.se', 'pain.001.001.03.ch.02'] and not bic_code:
raise UserError(_("Bank account %s 's bank does not have any BIC number associated. Please define one.") % bank_account.sanitized_acc_number)
if bic_code:
BIC = etree.SubElement(FinInstnId, "BIC")
BIC.text = bic_code
else:
Othr = etree.SubElement(FinInstnId, "Othr")
Id = etree.SubElement(Othr, "Id")
Id.text = "NOTPROVIDED"
# One CdtTrfTxInf per transaction
for payment in payments_list:
PmtInf.append(self._get_CdtTrfTxInf(PmtInfId, payment, sct_generic, pain_version, local_instrument))
return etree.tostring(Document, pretty_print=True, xml_declaration=True, encoding='utf-8')
def _get_document(self, pain_version):
if pain_version == 'pain.001.001.03.ch.02':
Document = self._create_pain_001_001_03_ch_document()
else: #The German version will also use the create_pain_001_001_03_document since the version 001.003.03 is deprecated
Document = self._create_pain_001_001_03_document()
return Document
def _create_pain_001_001_03_document(self):
""" Create a sepa credit transfer file that follows the European Payment Councile generic guidelines (pain.001.001.03)
:param doc_payments: recordset of account.payment to be exported in the XML document returned
"""
Document = self._create_iso20022_document('pain.001.001.03')
return Document
def _create_pain_001_001_03_ch_document(self):
""" Create a sepa credit transfer file that follows the swiss specific guidelines, as established
by SIX Interbank Clearing (pain.001.001.03.ch.02)
:param doc_payments: recordset of account.payment to be exported in the XML document returned
"""
Document = etree.Element("Document", nsmap={
None: "http://www.six-interbank-clearing.com/de/pain.001.001.03.ch.02.xsd",
'xsi': "http://www.w3.org/2001/XMLSchema-instance"})
return Document
def _create_pain_001_003_03_document(self):
""" This funtion is now deprecated since pain.001.003.03 cannot be used anymore.
Create a sepa credit transfer file that follows the German specific guidelines, as established
by the German Bank Association (Deutsche Kreditwirtschaft) (pain.001.003.03)
:param doc_payments: recordset of account.payment to be exported in the XML document returned
"""
Document = self._create_iso20022_document('pain.001.003.03')
return Document
def _create_iso20022_document(self, pain_version):
return etree.Element("Document", nsmap={
None: "urn:iso:std:iso:20022:tech:xsd:%s" % (pain_version,),
'xsi': "http://www.w3.org/2001/XMLSchema-instance"})
def _get_CtrlSum(self, payments):
return float_repr(float_round(sum(payment['amount'] for payment in payments), 2), 2)
def _get_InitgPty(self, pain_version, sct_generic=False):
InitgPty = etree.Element("InitgPty")
if pain_version == 'pain.001.001.03.se':
InitgPty.extend(self._get_company_PartyIdentification32(sct_generic, org_id=True, postal_address=False, issr=False, nm=False, schme_nm='BANK'))
elif pain_version == 'pain.001.001.03.austrian.004':
InitgPty.extend(self._get_company_PartyIdentification32(sct_generic, org_id=True, postal_address=False, issr=False))
else:
InitgPty.extend(self._get_company_PartyIdentification32(sct_generic, org_id=True, postal_address=False, issr=True))
return InitgPty
def _get_company_PartyIdentification32(self, sct_generic=False, org_id=True, postal_address=True, nm=True, issr=True, schme_nm=False):
""" Returns a PartyIdentification32 element identifying the current journal's company
"""
ret = []
company = self.company_id
name_length = sct_generic and 35 or 70
if nm:
Nm = etree.Element("Nm")
if company.sepa_initiating_party_name:
company_name = company.sepa_initiating_party_name[:name_length]
else:
company_name = company.name[:name_length]
Nm.text = sanitize_communication(company_name)
ret.append(Nm)
if postal_address:
ret.append(self._get_PstlAdr(company.partner_id))
if org_id:
if not company.sepa_orgid_id:
raise UserError(_("Please first set a SEPA identification number in the accounting settings."))
Id = etree.Element("Id")
OrgId = etree.SubElement(Id, "OrgId")
Othr = etree.SubElement(OrgId, "Othr")
_Id = etree.SubElement(Othr, "Id")
_Id.text = sanitize_communication(company.sepa_orgid_id)
if issr and company.sepa_orgid_issr:
Issr = etree.SubElement(Othr, "Issr")
Issr.text = sanitize_communication(company.sepa_orgid_issr)
if schme_nm:
SchmeNm = etree.SubElement(Othr, "SchmeNm")
Cd = etree.SubElement(SchmeNm, "Cd")
Cd.text = schme_nm
ret.append(Id)
return ret
def _get_PmtTpInf(self, sct_generic=False, local_instrument=None):
PmtTpInf = etree.Element("PmtTpInf")
if not sct_generic and self.sepa_pain_version != 'pain.001.001.03.ch.02':
SvcLvl = etree.SubElement(PmtTpInf, "SvcLvl")
Cd = etree.SubElement(SvcLvl, "Cd")
Cd.text = 'SEPA'
if local_instrument:
create_xml_node_chain(PmtTpInf, ['LclInstrm', 'Prtry'], local_instrument)
return PmtTpInf
def _get_Dbtr(self, pain_version, sct_generic=False):
Dbtr = etree.Element("Dbtr")
if pain_version == "pain.001.001.03.se":
Dbtr.extend(self._get_company_PartyIdentification32(sct_generic, org_id=True, postal_address=True, issr=False, schme_nm="CUST"))
else:
Dbtr.extend(self._get_company_PartyIdentification32(sct_generic, org_id=not sct_generic, postal_address=True))
return Dbtr
def _get_DbtrAcct(self):
DbtrAcct = etree.Element("DbtrAcct")
Id = etree.SubElement(DbtrAcct, "Id")
IBAN = etree.SubElement(Id, "IBAN")
IBAN.text = self.bank_account_id.sanitized_acc_number
Ccy = etree.SubElement(DbtrAcct, "Ccy")
Ccy.text = self.currency_id and self.currency_id.name or self.company_id.currency_id.name
return DbtrAcct
def _get_PstlAdr(self, partner_id):
if not partner_id.country_id.code:
raise ValidationError(_('Partner %s has no country code defined.', partner_id.name))
PstlAdr = etree.Element("PstlAdr")
Ctry = etree.SubElement(PstlAdr, "Ctry")
Ctry.text = partner_id.country_id.code
if partner_id.street:
AdrLine = etree.SubElement(PstlAdr, "AdrLine")
AdrLine.text = sanitize_communication(partner_id.street[:70])
if partner_id.zip and partner_id.city:
AdrLine = etree.SubElement(PstlAdr, "AdrLine")
AdrLine.text = sanitize_communication((partner_id.zip + " " + partner_id.city)[:70])
return PstlAdr
def _skip_CdtrAgt(self, partner_bank, pain_version, local_instrument):
return (
self.env.context.get('skip_bic', False)
or not partner_bank.bank_id.bic
or (
# Creditor Agent can be omitted with IBAN and QR-IBAN accounts
pain_version == 'pain.001.001.03.ch.02'
and (self._is_qr_iban({'partner_bank_id' : partner_bank.id, 'journal_id' : self.id}) or local_instrument == 'CH01')
)
)
def _get_CdtTrfTxInf(self, PmtInfId, payment, sct_generic, pain_version, local_instrument=None):
CdtTrfTxInf = etree.Element("CdtTrfTxInf")
PmtId = etree.SubElement(CdtTrfTxInf, "PmtId")
if payment['name']:
InstrId = etree.SubElement(PmtId, "InstrId")
InstrId.text = sanitize_communication(payment['name'][:35])
EndToEndId = etree.SubElement(PmtId, "EndToEndId")
EndToEndId.text = (PmtInfId.text + str(payment['id']))[-30:].strip()
Amt = etree.SubElement(CdtTrfTxInf, "Amt")
currency_id = self.env['res.currency'].search([('id', '=', payment['currency_id'])], limit=1)
journal_id = self.env['account.journal'].search([('id', '=', payment['journal_id'])], limit=1)
val_Ccy = currency_id and currency_id.name or journal_id.company_id.currency_id.name
val_InstdAmt = float_repr(float_round(payment['amount'], 2), 2)
max_digits = val_Ccy == 'EUR' and 11 or 15
if len(re.sub('\.', '', val_InstdAmt)) > max_digits:
raise ValidationError(_(
"The amount of the payment '%(payment)s' is too high. The maximum permitted is %(limit)s.",
payment=payment['name'],
limit=str(9) * (max_digits - 2) + ".99",
))
InstdAmt = etree.SubElement(Amt, "InstdAmt", Ccy=val_Ccy)
InstdAmt.text = val_InstdAmt
CdtTrfTxInf.append(self._get_ChrgBr(sct_generic))
partner = self.env['res.partner'].sudo().browse(payment['partner_id'])
partner_bank_id = payment.get('partner_bank_id')
if not partner_bank_id:
raise UserError(_('Partner %s has not bank account defined.', partner.name))
partner_bank = self.env['res.partner.bank'].sudo().browse(partner_bank_id)
if not self._skip_CdtrAgt(partner_bank, pain_version, local_instrument):
CdtTrfTxInf.append(self._get_CdtrAgt(partner_bank, sct_generic, pain_version))
Cdtr = etree.SubElement(CdtTrfTxInf, "Cdtr")
Nm = etree.SubElement(Cdtr, "Nm")
Nm.text = sanitize_communication((
partner_bank.acc_holder_name or partner.name or partner.commercial_partner_id.name or '/'
)[:70]).strip() or '/'
if partner.country_id.code and (partner.city or pain_version == "pain.001.001.03.se"): # For Sweden, country is enough
Cdtr.append(self._get_PstlAdr(partner))
CdtTrfTxInf.append(self._get_CdtrAcct(partner_bank, sct_generic))
val_RmtInf = self._get_RmtInf(payment, local_instrument)
if val_RmtInf is not False:
CdtTrfTxInf.append(val_RmtInf)
return CdtTrfTxInf
def _get_ChrgBr(self, sct_generic):
ChrgBr = etree.Element("ChrgBr")
ChrgBr.text = sct_generic and "SHAR" or "SLEV"
return ChrgBr
def _get_CdtrAgt(self, bank_account, sct_generic, pain_version):
CdtrAgt = etree.Element("CdtrAgt")
FinInstnId = etree.SubElement(CdtrAgt, "FinInstnId")
bic_code = self._get_cleaned_bic_code(bank_account)
if bic_code:
BIC = etree.SubElement(FinInstnId, "BIC")
BIC.text = bic_code
else:
if pain_version in ['pain.001.001.03.austrian.004', 'pain.001.001.03.ch.02']:
# Othr and NOTPROVIDED are not supported in CdtrAgt by those flavours
raise UserError(_("The bank defined on account %s (from partner %s) has no BIC. Please first set one.", bank_account.acc_number, bank_account.partner_id.name))
Othr = etree.SubElement(FinInstnId, "Othr")
Id = etree.SubElement(Othr, "Id")
Id.text = "NOTPROVIDED"
return CdtrAgt
def _get_CdtrAcct(self, bank_account, sct_generic):
if not sct_generic and (not bank_account.acc_type or not bank_account.acc_type == 'iban'):
raise UserError(_("The account %s, linked to partner '%s', is not of type IBAN.\nA valid IBAN account is required to use SEPA features.") % (bank_account.acc_number, bank_account.partner_id.name))
CdtrAcct = etree.Element("CdtrAcct")
Id = etree.SubElement(CdtrAcct, "Id")
if sct_generic and bank_account.acc_type != 'iban':
Othr = etree.SubElement(Id, "Othr")
_Id = etree.SubElement(Othr, "Id")
acc_number = bank_account.acc_number
# CH case when when we have non-unique account numbers
if " " in bank_account.sanitized_acc_number and " " in bank_account.acc_number:
acc_number = bank_account.acc_number.split(" ")[0]
_Id.text = acc_number
else:
IBAN = etree.SubElement(Id, "IBAN")
IBAN.text = bank_account.sanitized_acc_number
return CdtrAcct
def _get_RmtInf(self, payment, local_instrument=None):
if not payment['ref']:
return False
RmtInf = etree.Element("RmtInf")
# In Switzerland, postal accounts and QR-IBAN accounts always require a structured communication with the ISR reference
qr_iban = self._is_qr_iban(payment)
if local_instrument == 'CH01' or qr_iban:
ref = payment['ref'].replace(' ', '')
ref = ref.rjust(27, '0')
CdtrRefInf = create_xml_node_chain(RmtInf, ['Strd', 'CdtrRefInf'])[1]
if qr_iban:
create_xml_node_chain(CdtrRefInf, ['Tp', 'CdOrPrtry', 'Prtry'], "QRR")
Ref = etree.SubElement(CdtrRefInf, "Ref")
Ref.text = ref
else:
Ustrd = etree.SubElement(RmtInf, "Ustrd")
Ustrd.text = sanitize_communication(payment['ref'])
return RmtInf
def _has_isr_ref(self, payment_comm):
"""Check if the communication is a valid ISR reference (for Switzerland)
e.g.
12371
000000000000000000000012371
210000000003139471430009017
21 00000 00003 13947 14300 09017
This is used to determine SEPA local instrument
"""
if not payment_comm:
return False
if re.match(r'^(\d{2,27}|\d{2}( \d{5}){5})$', payment_comm):
ref = payment_comm.replace(' ', '')
return ref == mod10r(ref[:-1])
return False
def _is_qr_iban(self, payment_dict):
""" Tells if the bank account linked to the payment has a QR-IBAN account number.
QR-IBANs are specific identifiers used in Switzerland as references in
QR-codes. They are formed like regular IBANs, but are actually something
different.
"""
partner_bank = self.env['res.partner.bank'].browse(payment_dict['partner_bank_id'])
company = self.env['account.journal'].browse(payment_dict['journal_id']).company_id
iban = partner_bank.sanitized_acc_number
if (
partner_bank.acc_type != 'iban'
or (partner_bank.sanitized_acc_number or '')[:2] not in ('CH', 'LI')
or partner_bank.company_id.id not in (False, company.id)
or len(iban) < 9
):
return False
iid_start_index = 4
iid_end_index = 8
iid = iban[iid_start_index : iid_end_index+1]
return re.match('\d+', iid) \
and 30000 <= int(iid) <= 31999 # Those values for iid are reserved for QR-IBANs only
def _get_local_instrument(self, payment_dict):
""" Local instrument node is used to indicate the use of some regional
variant, such as in Switzerland.
"""
partner_bank = self.env['res.partner.bank'].browse(payment_dict['partner_bank_id'])
company = self.env['account.journal'].browse(payment_dict['journal_id']).company_id
if (
partner_bank.acc_type == 'postal'
and partner_bank.company_id.id in (False, company.id)
and self._has_isr_ref(payment_dict['ref'])
):
return 'CH01'
return None
def _get_cleaned_bic_code(self, bank_account):
""" Checks if the BIC code is matching the pattern from the XSD to avoid
having files generated here that are refused by banks after.
It also returns a cleaned version of the BIC as a convenient use.
"""
if not bank_account.bank_bic:
return
if not re.match('[A-Z]{6,6}[A-Z2-9][A-NP-Z0-9]([A-Z0-9]{3,3}){0,1}', bank_account.bank_bic):
raise UserError(_("The BIC code '%s' associated to the bank '%s' of bank account '%s' "
"of partner '%s' does not respect the required convention.\n"
"It must contain 8 or 11 characters and match the following structure:\n"
"- 4 letters: institution code or bank code\n"
"- 2 letters: country code\n"
"- 2 letters or digits: location code\n"
"- 3 letters or digits: branch code, optional\n",
bank_account.bank_bic, bank_account.bank_id.name,
bank_account.sanitized_acc_number, bank_account.partner_id.name))
return bank_account.bank_bic.replace(' ', '').upper()