mirror of
https://github.com/bringout/oca-ocb-l10n_europe.git
synced 2026-04-27 17:42:05 +02:00
Initial commit: L10N_Europe packages
This commit is contained in:
commit
9803722600
2377 changed files with 380711 additions and 0 deletions
388
odoo-bringout-oca-ocb-l10n_ch/l10n_ch/models/res_bank.py
Normal file
388
odoo-bringout-oca-ocb-l10n_ch/l10n_ch/models/res_bank.py
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import re
|
||||
from stdnum.util import clean
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.addons.base.models.res_bank import sanitize_account_number
|
||||
from odoo.addons.base_iban.models.res_partner_bank import normalize_iban, pretty_iban, validate_iban
|
||||
from odoo.exceptions import ValidationError, UserError
|
||||
from odoo.tools.misc import mod10r
|
||||
|
||||
ISR_SUBSCRIPTION_CODE = {'CHF': '01', 'EUR': '03'}
|
||||
CLEARING = "09000"
|
||||
_re_postal = re.compile('^[0-9]{2}-[0-9]{1,6}-[0-9]$')
|
||||
|
||||
|
||||
def _is_l10n_ch_postal(account_ref):
|
||||
""" Returns True if the string account_ref is a valid postal account number,
|
||||
i.e. it only contains ciphers and is last cipher is the result of a recursive
|
||||
modulo 10 operation ran over the rest of it. Shorten form with - is also accepted.
|
||||
"""
|
||||
if _re_postal.match(account_ref or ''):
|
||||
ref_subparts = account_ref.split('-')
|
||||
account_ref = ref_subparts[0] + ref_subparts[1].rjust(6, '0') + ref_subparts[2]
|
||||
|
||||
if re.match(r'\d+$', account_ref or ''):
|
||||
account_ref_without_check = account_ref[:-1]
|
||||
return mod10r(account_ref_without_check) == account_ref
|
||||
return False
|
||||
|
||||
def _is_l10n_ch_isr_issuer(account_ref, currency_code):
|
||||
""" Returns True if the string account_ref is a valid a valid ISR issuer
|
||||
An ISR issuer is postal account number that starts by 01 (CHF) or 03 (EUR),
|
||||
"""
|
||||
if (account_ref or '').startswith(ISR_SUBSCRIPTION_CODE[currency_code]):
|
||||
return _is_l10n_ch_postal(account_ref)
|
||||
return False
|
||||
|
||||
def validate_qr_iban(qr_iban):
|
||||
# Check first if it's a valid IBAN.
|
||||
validate_iban(qr_iban)
|
||||
|
||||
# We sanitize first so that _check_qr_iban_range() can extract correct IID from IBAN to validate it.
|
||||
sanitized_qr_iban = sanitize_account_number(qr_iban)
|
||||
|
||||
if sanitized_qr_iban[:2] not in ['CH', 'LI']:
|
||||
raise ValidationError(_("QR-IBAN numbers are only available in Switzerland."))
|
||||
|
||||
# Now, check if it's valid QR-IBAN (based on its IID).
|
||||
if not check_qr_iban_range(sanitized_qr_iban):
|
||||
raise ValidationError(_("QR-IBAN '%s' is invalid.") % qr_iban)
|
||||
|
||||
return True
|
||||
|
||||
def check_qr_iban_range(iban):
|
||||
if not iban 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(r'\d+', iid) and 30000 <= int(iid) <= 31999 # Those values for iid are reserved for QR-IBANs only
|
||||
|
||||
|
||||
class ResPartnerBank(models.Model):
|
||||
_inherit = 'res.partner.bank'
|
||||
|
||||
l10n_ch_postal = fields.Char(
|
||||
string="Swiss Postal Account",
|
||||
readonly=False, store=True,
|
||||
compute='_compute_l10n_ch_postal',
|
||||
help="This field is used for the Swiss postal account number on a vendor account and for the client number on "
|
||||
"your own account. The client number is mostly 6 numbers without -, while the postal account number can "
|
||||
"be e.g. 01-162-8")
|
||||
|
||||
l10n_ch_qr_iban = fields.Char(string='QR-IBAN',
|
||||
compute='_compute_l10n_ch_qr_iban',
|
||||
store=True,
|
||||
readonly=False,
|
||||
help="Put the QR-IBAN here for your own bank accounts. That way, you can "
|
||||
"still use the main IBAN in the Account Number while you will see the "
|
||||
"QR-IBAN for the barcode. ")
|
||||
|
||||
# fields to configure ISR payment slip generation
|
||||
l10n_ch_isr_subscription_chf = fields.Char(string='CHF ISR Subscription Number', help='The subscription number provided by the bank or Postfinance to identify the bank, used to generate ISR in CHF. eg. 01-162-8')
|
||||
l10n_ch_isr_subscription_eur = fields.Char(string='EUR ISR Subscription Number', help='The subscription number provided by the bank or Postfinance to identify the bank, used to generate ISR in EUR. eg. 03-162-5')
|
||||
l10n_ch_show_subscription = fields.Boolean(compute='_compute_l10n_ch_show_subscription', default=lambda self: self.env.company.account_fiscal_country_id.code == 'CH')
|
||||
|
||||
def _is_isr_issuer(self):
|
||||
return (_is_l10n_ch_isr_issuer(self.l10n_ch_postal, 'CHF')
|
||||
or _is_l10n_ch_isr_issuer(self.l10n_ch_postal, 'EUR'))
|
||||
|
||||
@api.constrains("l10n_ch_postal", "partner_id")
|
||||
def _check_postal_num(self):
|
||||
"""Validate postal number format"""
|
||||
for rec in self:
|
||||
if rec.l10n_ch_postal and not _is_l10n_ch_postal(rec.l10n_ch_postal):
|
||||
# l10n_ch_postal is used for the purpose of Client Number on your own accounts, so don't do the check there
|
||||
if rec.partner_id and not rec.partner_id.ref_company_ids:
|
||||
raise ValidationError(
|
||||
_("The postal number {} is not valid.\n"
|
||||
"It must be a valid postal number format. eg. 10-8060-7").format(rec.l10n_ch_postal))
|
||||
return True
|
||||
|
||||
@api.constrains("l10n_ch_isr_subscription_chf", "l10n_ch_isr_subscription_eur")
|
||||
def _check_subscription_num(self):
|
||||
"""Validate ISR subscription number format
|
||||
Subscription number can only starts with 01 or 03
|
||||
"""
|
||||
for rec in self:
|
||||
for currency in ["CHF", "EUR"]:
|
||||
subscrip = rec.l10n_ch_isr_subscription_chf if currency == "CHF" else rec.l10n_ch_isr_subscription_eur
|
||||
if subscrip and not _is_l10n_ch_isr_issuer(subscrip, currency):
|
||||
example = "01-162-8" if currency == "CHF" else "03-162-5"
|
||||
raise ValidationError(
|
||||
_("The ISR subcription {} for {} number is not valid.\n"
|
||||
"It must starts with {} and we a valid postal number format. eg. {}"
|
||||
).format(subscrip, currency, ISR_SUBSCRIPTION_CODE[currency], example))
|
||||
return True
|
||||
|
||||
@api.depends('partner_id', 'company_id')
|
||||
def _compute_l10n_ch_show_subscription(self):
|
||||
for bank in self:
|
||||
if bank.partner_id:
|
||||
bank.l10n_ch_show_subscription = bank.partner_id.ref_company_ids.country_id.code in ('CH', 'LI')
|
||||
elif bank.company_id:
|
||||
bank.l10n_ch_show_subscription = bank.company_id.account_fiscal_country_id.code in ('CH', 'LI')
|
||||
else:
|
||||
bank.l10n_ch_show_subscription = self.env.company.account_fiscal_country_id.code in ('CH', 'LI')
|
||||
|
||||
@api.depends('acc_number', 'acc_type')
|
||||
def _compute_sanitized_acc_number(self):
|
||||
#Only remove spaces in case it is not postal
|
||||
postal_banks = self.filtered(lambda b: b.acc_type == "postal")
|
||||
for bank in postal_banks:
|
||||
bank.sanitized_acc_number = bank.acc_number
|
||||
super(ResPartnerBank, self - postal_banks)._compute_sanitized_acc_number()
|
||||
|
||||
@api.depends('acc_number')
|
||||
def _compute_l10n_ch_qr_iban(self):
|
||||
for record in self:
|
||||
try:
|
||||
validate_qr_iban(record.acc_number)
|
||||
valid_qr_iban = True
|
||||
except ValidationError:
|
||||
valid_qr_iban = False
|
||||
|
||||
if valid_qr_iban:
|
||||
record.l10n_ch_qr_iban = record.sanitized_acc_number
|
||||
else:
|
||||
record.l10n_ch_qr_iban = None
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('l10n_ch_qr_iban'):
|
||||
validate_qr_iban(vals['l10n_ch_qr_iban'])
|
||||
vals['l10n_ch_qr_iban'] = pretty_iban(normalize_iban(vals['l10n_ch_qr_iban']))
|
||||
return super().create(vals_list)
|
||||
|
||||
def write(self, vals):
|
||||
if vals.get('l10n_ch_qr_iban'):
|
||||
validate_qr_iban(vals['l10n_ch_qr_iban'])
|
||||
vals['l10n_ch_qr_iban'] = pretty_iban(normalize_iban(vals['l10n_ch_qr_iban']))
|
||||
return super().write(vals)
|
||||
|
||||
@api.model
|
||||
def _get_supported_account_types(self):
|
||||
rslt = super(ResPartnerBank, self)._get_supported_account_types()
|
||||
rslt.append(('postal', _('Postal')))
|
||||
return rslt
|
||||
|
||||
@api.model
|
||||
def retrieve_acc_type(self, acc_number):
|
||||
""" Overridden method enabling the recognition of swiss postal bank
|
||||
account numbers.
|
||||
"""
|
||||
acc_number_split = ""
|
||||
# acc_number_split is needed to continue to recognize the account
|
||||
# as a postal account even if the difference
|
||||
if acc_number and " " in acc_number:
|
||||
acc_number_split = acc_number.split(" ")[0]
|
||||
if _is_l10n_ch_postal(acc_number) or (acc_number_split and _is_l10n_ch_postal(acc_number_split)):
|
||||
return 'postal'
|
||||
else:
|
||||
return super(ResPartnerBank, self).retrieve_acc_type(acc_number)
|
||||
|
||||
@api.depends('acc_number', 'partner_id', 'acc_type')
|
||||
def _compute_l10n_ch_postal(self):
|
||||
for record in self:
|
||||
if record.acc_type == 'iban':
|
||||
record.l10n_ch_postal = self._retrieve_l10n_ch_postal(record.sanitized_acc_number)
|
||||
elif record.acc_type == 'postal':
|
||||
if record.acc_number and " " in record.acc_number:
|
||||
record.l10n_ch_postal = record.acc_number.split(" ")[0]
|
||||
else:
|
||||
record.l10n_ch_postal = record.acc_number
|
||||
# In case of ISR issuer, this number is not
|
||||
# unique and we fill acc_number with partner
|
||||
# name to give proper information to the user
|
||||
if record.partner_id and record.acc_number[:2] in ["01", "03"]:
|
||||
record.acc_number = ("{} {}").format(record.acc_number, record.partner_id.name)
|
||||
|
||||
@api.model
|
||||
def _is_postfinance_iban(self, iban):
|
||||
"""Postfinance IBAN have format
|
||||
CHXX 0900 0XXX XXXX XXXX K
|
||||
Where 09000 is the clearing number
|
||||
"""
|
||||
return iban.startswith(('CH', 'LI')) and iban[4:9] == CLEARING
|
||||
|
||||
@api.model
|
||||
def _pretty_postal_num(self, number):
|
||||
"""format a postal account number or an ISR subscription number
|
||||
as per specifications with '-' separators.
|
||||
eg. 010001628 -> 01-162-8
|
||||
"""
|
||||
if re.match('^[0-9]{2}-[0-9]{1,6}-[0-9]$', number or ''):
|
||||
return number
|
||||
currency_code = number[:2]
|
||||
middle_part = number[2:-1]
|
||||
trailing_cipher = number[-1]
|
||||
middle_part = middle_part.lstrip("0")
|
||||
return currency_code + '-' + middle_part + '-' + trailing_cipher
|
||||
|
||||
@api.model
|
||||
def _retrieve_l10n_ch_postal(self, iban):
|
||||
"""Reads a swiss postal account number from a an IBAN and returns it as
|
||||
a string. Returns None if no valid postal account number was found, or
|
||||
the given iban was not from Swiss Postfinance.
|
||||
|
||||
CH09 0900 0000 1000 8060 7 -> 10-8060-7
|
||||
"""
|
||||
if self._is_postfinance_iban(iban):
|
||||
# the IBAN corresponds to a swiss account
|
||||
return self._pretty_postal_num(iban[-9:])
|
||||
return None
|
||||
|
||||
def _l10n_ch_get_qr_vals(self, amount, currency, debtor_partner, free_communication, structured_communication):
|
||||
comment = ""
|
||||
if free_communication:
|
||||
comment = (free_communication[:137] + '...') if len(free_communication) > 140 else free_communication
|
||||
|
||||
creditor_addr_1, creditor_addr_2 = self._get_partner_address_lines(self.partner_id)
|
||||
debtor_addr_1, debtor_addr_2 = self._get_partner_address_lines(debtor_partner)
|
||||
|
||||
# Compute reference type (empty by default, only mandatory for QR-IBAN,
|
||||
# and must then be 27 characters-long, with mod10r check digit as the 27th one,
|
||||
# just like ISR number for invoices)
|
||||
reference_type = 'NON'
|
||||
reference = ''
|
||||
acc_number = self.sanitized_acc_number
|
||||
|
||||
if self.l10n_ch_qr_iban:
|
||||
# _check_for_qr_code_errors ensures we can't have a QR-IBAN without a QR-reference here
|
||||
reference_type = 'QRR'
|
||||
reference = structured_communication
|
||||
acc_number = sanitize_account_number(self.l10n_ch_qr_iban)
|
||||
elif self._is_iso11649_reference(structured_communication):
|
||||
reference_type = 'SCOR'
|
||||
reference = structured_communication.replace(' ', '')
|
||||
|
||||
currency = currency or self.currency_id or self.company_id.currency_id
|
||||
|
||||
return [
|
||||
'SPC', # QR Type
|
||||
'0200', # Version
|
||||
'1', # Coding Type
|
||||
acc_number, # IBAN / QR-IBAN
|
||||
'K', # Creditor Address Type
|
||||
(self.acc_holder_name or self.partner_id.name)[:70], # Creditor Name
|
||||
creditor_addr_1, # Creditor Address Line 1
|
||||
creditor_addr_2, # Creditor Address Line 2
|
||||
'', # Creditor Postal Code (empty, since we're using combined addres elements)
|
||||
'', # Creditor Town (empty, since we're using combined addres elements)
|
||||
self.partner_id.country_id.code, # Creditor Country
|
||||
'', # Ultimate Creditor Address Type
|
||||
'', # Name
|
||||
'', # Ultimate Creditor Address Line 1
|
||||
'', # Ultimate Creditor Address Line 2
|
||||
'', # Ultimate Creditor Postal Code
|
||||
'', # Ultimate Creditor Town
|
||||
'', # Ultimate Creditor Country
|
||||
'{:.2f}'.format(amount), # Amount
|
||||
currency.name, # Currency
|
||||
'K', # Ultimate Debtor Address Type
|
||||
debtor_partner.commercial_partner_id.name[:70], # Ultimate Debtor Name
|
||||
debtor_addr_1, # Ultimate Debtor Address Line 1
|
||||
debtor_addr_2, # Ultimate Debtor Address Line 2
|
||||
'', # Ultimate Debtor Postal Code (not to be provided for address type K)
|
||||
'', # Ultimate Debtor Postal City (not to be provided for address type K)
|
||||
debtor_partner.country_id.code, # Ultimate Debtor Postal Country
|
||||
reference_type, # Reference Type
|
||||
reference, # Reference
|
||||
comment, # Unstructured Message
|
||||
'EPD', # Mandatory trailer part
|
||||
]
|
||||
|
||||
def _get_qr_vals(self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication):
|
||||
if qr_method == 'ch_qr':
|
||||
return self._l10n_ch_get_qr_vals(amount, currency, debtor_partner, free_communication, structured_communication)
|
||||
return super()._get_qr_vals(qr_method, amount, currency, debtor_partner, free_communication, structured_communication)
|
||||
|
||||
def _get_qr_code_generation_params(self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication):
|
||||
if qr_method == 'ch_qr':
|
||||
return {
|
||||
'barcode_type': 'QR',
|
||||
'width': 256,
|
||||
'height': 256,
|
||||
'quiet': 1,
|
||||
'mask': 'ch_cross',
|
||||
'value': '\n'.join(self._get_qr_vals(qr_method, amount, currency, debtor_partner, free_communication, structured_communication)),
|
||||
# Swiss QR code requires Error Correction Level = 'M' by specification
|
||||
'barLevel': 'M',
|
||||
}
|
||||
return super()._get_qr_code_generation_params(qr_method, amount, currency, debtor_partner, free_communication, structured_communication)
|
||||
|
||||
def _get_partner_address_lines(self, partner):
|
||||
""" Returns a tuple of two elements containing the address lines to use
|
||||
for this partner. Line 1 contains the street and number, line 2 contains
|
||||
zip and city. Those two lines are limited to 70 characters
|
||||
"""
|
||||
streets = [partner.street, partner.street2]
|
||||
line_1 = ' '.join(filter(None, streets))
|
||||
line_2 = partner.zip + ' ' + partner.city
|
||||
return line_1[:70], line_2[:70]
|
||||
|
||||
@api.model
|
||||
def _is_qr_reference(self, reference):
|
||||
""" Checks whether the given reference is a QR-reference, i.e. it is
|
||||
made of 27 digits, the 27th being a mod10r check on the 26 previous ones.
|
||||
"""
|
||||
return reference \
|
||||
and len(reference) == 27 \
|
||||
and re.match(r'\d+$', reference) \
|
||||
and reference == mod10r(reference[:-1])
|
||||
|
||||
@api.model
|
||||
def _is_iso11649_reference(self, reference):
|
||||
""" Checks whether the given reference is a ISO11649 (SCOR) reference.
|
||||
"""
|
||||
return reference \
|
||||
and len(reference) >= 5 \
|
||||
and len(reference) <= 25 \
|
||||
and reference.startswith('RF') \
|
||||
and int(''.join(str(int(x, 36)) for x in clean(reference[4:] + reference[:4], ' -.,/:').upper().strip())) % 97 == 1
|
||||
# see https://github.com/arthurdejong/python-stdnum/blob/master/stdnum/iso11649.py
|
||||
|
||||
def _eligible_for_qr_code(self, qr_method, debtor_partner, currency, raises_error=True):
|
||||
if qr_method == 'ch_qr':
|
||||
error_messages = [_("The QR code could not be generated for the following reason(s):")]
|
||||
if self.acc_type != 'iban':
|
||||
error_messages.append(_("The account type isn't QR-IBAN or IBAN."))
|
||||
if not debtor_partner or debtor_partner.country_id.code not in ('CH', 'LI'):
|
||||
error_messages.append(_("The debtor partner's address isn't located in Switzerland."))
|
||||
if currency.id not in (self.env.ref('base.EUR').id, self.env.ref('base.CHF').id):
|
||||
error_messages.append(_("The currency isn't EUR nor CHF. \r\n"))
|
||||
if len(error_messages) != 1:
|
||||
if raises_error:
|
||||
raise UserError(' '.join(error_messages))
|
||||
return False
|
||||
return True
|
||||
return super()._eligible_for_qr_code(qr_method, debtor_partner, currency, raises_error)
|
||||
|
||||
def _check_for_qr_code_errors(self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication):
|
||||
def _partner_fields_set(partner):
|
||||
return partner.zip and \
|
||||
partner.city and \
|
||||
partner.country_id.code and \
|
||||
(partner.street or partner.street2)
|
||||
|
||||
if qr_method == 'ch_qr':
|
||||
if not _partner_fields_set(self.partner_id):
|
||||
return _("The partner set on the bank account meant to receive the payment (%s) must have a complete postal address (street, zip, city and country).", self.acc_number)
|
||||
|
||||
if debtor_partner and not _partner_fields_set(debtor_partner):
|
||||
return _("The partner must have a complete postal address (street, zip, city and country).")
|
||||
|
||||
if self.l10n_ch_qr_iban and not self._is_qr_reference(structured_communication):
|
||||
return _("When using a QR-IBAN as the destination account of a QR-code, the payment reference must be a QR-reference.")
|
||||
|
||||
return super()._check_for_qr_code_errors(qr_method, amount, currency, debtor_partner, free_communication, structured_communication)
|
||||
|
||||
@api.model
|
||||
def _get_available_qr_methods(self):
|
||||
rslt = super()._get_available_qr_methods()
|
||||
rslt.append(('ch_qr', _("Swiss QR bill"), 10))
|
||||
return rslt
|
||||
Loading…
Add table
Add a link
Reference in a new issue