diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/l10n_ch/models | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/l10n_ch/models')
| -rw-r--r-- | addons/l10n_ch/models/__init__.py | 11 | ||||
| -rw-r--r-- | addons/l10n_ch/models/account_bank_statement.py | 25 | ||||
| -rw-r--r-- | addons/l10n_ch/models/account_invoice.py | 352 | ||||
| -rw-r--r-- | addons/l10n_ch/models/account_journal.py | 50 | ||||
| -rw-r--r-- | addons/l10n_ch/models/ir_actions_report.py | 28 | ||||
| -rw-r--r-- | addons/l10n_ch/models/mail_template.py | 54 | ||||
| -rw-r--r-- | addons/l10n_ch/models/res_bank.py | 315 | ||||
| -rw-r--r-- | addons/l10n_ch/models/res_company.py | 29 | ||||
| -rw-r--r-- | addons/l10n_ch/models/res_config_settings.py | 20 |
9 files changed, 884 insertions, 0 deletions
diff --git a/addons/l10n_ch/models/__init__.py b/addons/l10n_ch/models/__init__.py new file mode 100644 index 00000000..b4ae72a5 --- /dev/null +++ b/addons/l10n_ch/models/__init__.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import res_config_settings +from . import account_invoice +from . import account_journal +from . import mail_template +from . import res_bank +from . import res_company +from . import account_bank_statement +from . import ir_actions_report diff --git a/addons/l10n_ch/models/account_bank_statement.py b/addons/l10n_ch/models/account_bank_statement.py new file mode 100644 index 00000000..53c144f3 --- /dev/null +++ b/addons/l10n_ch/models/account_bank_statement.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, fields, api, _ +from odoo.addons.l10n_ch.models.res_bank import _is_l10n_ch_postal + +class AccountBankStatementLine(models.Model): + + _inherit = "account.bank.statement.line" + + def _find_or_create_bank_account(self): + if self.company_id.country_id.code == 'CH' and _is_l10n_ch_postal(self.account_number): + bank_account = self.env['res.partner.bank'].search( + [('company_id', '=', self.company_id.id), + ('sanitized_acc_number', 'like', self.account_number + '%'), + ('partner_id', '=', self.partner_id.id)]) + if not bank_account: + bank_account = self.env['res.partner.bank'].create({ + 'company_id': self.company_id.id, + 'acc_number': self.account_number + " " + self.partner_id.name, + 'partner_id': self.partner_id.id + }) + return bank_account + else: + super(AccountBankStatementLine, self)._find_or_create_bank_account()
\ No newline at end of file diff --git a/addons/l10n_ch/models/account_invoice.py b/addons/l10n_ch/models/account_invoice.py new file mode 100644 index 00000000..6354167c --- /dev/null +++ b/addons/l10n_ch/models/account_invoice.py @@ -0,0 +1,352 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import re + +from odoo import models, fields, api, _ +from odoo.exceptions import ValidationError, UserError +from odoo.tools.float_utils import float_split_str +from odoo.tools.misc import mod10r + + +l10n_ch_ISR_NUMBER_LENGTH = 27 +l10n_ch_ISR_ID_NUM_LENGTH = 6 + +class AccountMove(models.Model): + _inherit = 'account.move' + + l10n_ch_isr_subscription = fields.Char(compute='_compute_l10n_ch_isr_subscription', help='ISR subscription number identifying your company or your bank to generate ISR.') + l10n_ch_isr_subscription_formatted = fields.Char(compute='_compute_l10n_ch_isr_subscription', help="ISR subscription number your company or your bank, formated with '-' and without the padding zeros, to generate ISR report.") + + l10n_ch_isr_number = fields.Char(compute='_compute_l10n_ch_isr_number', store=True, help='The reference number associated with this invoice') + l10n_ch_isr_number_spaced = fields.Char(compute='_compute_l10n_ch_isr_number_spaced', help="ISR number split in blocks of 5 characters (right-justified), to generate ISR report.") + + l10n_ch_isr_optical_line = fields.Char(compute="_compute_l10n_ch_isr_optical_line", help='Optical reading line, as it will be printed on ISR') + + l10n_ch_isr_valid = fields.Boolean(compute='_compute_l10n_ch_isr_valid', help='Boolean value. True iff all the data required to generate the ISR are present') + + l10n_ch_isr_sent = fields.Boolean(default=False, help="Boolean value telling whether or not the ISR corresponding to this invoice has already been printed or sent by mail.") + l10n_ch_currency_name = fields.Char(related='currency_id.name', readonly=True, string="Currency Name", help="The name of this invoice's currency") #This field is used in the "invisible" condition field of the 'Print ISR' button. + l10n_ch_isr_needs_fixing = fields.Boolean(compute="_compute_l10n_ch_isr_needs_fixing", help="Used to show a warning banner when the vendor bill needs a correct ISR payment reference. ") + + @api.depends('partner_bank_id.l10n_ch_isr_subscription_eur', 'partner_bank_id.l10n_ch_isr_subscription_chf') + def _compute_l10n_ch_isr_subscription(self): + """ Computes the ISR subscription identifying your company or the bank that allows to generate ISR. And formats it accordingly""" + def _format_isr_subscription(isr_subscription): + #format the isr as per specifications + currency_code = isr_subscription[:2] + middle_part = isr_subscription[2:-1] + trailing_cipher = isr_subscription[-1] + middle_part = re.sub('^0*', '', middle_part) + return currency_code + '-' + middle_part + '-' + trailing_cipher + + def _format_isr_subscription_scanline(isr_subscription): + # format the isr for scanline + return isr_subscription[:2] + isr_subscription[2:-1].rjust(6, '0') + isr_subscription[-1:] + + for record in self: + record.l10n_ch_isr_subscription = False + record.l10n_ch_isr_subscription_formatted = False + if record.partner_bank_id: + if record.currency_id.name == 'EUR': + isr_subscription = record.partner_bank_id.l10n_ch_isr_subscription_eur + elif record.currency_id.name == 'CHF': + isr_subscription = record.partner_bank_id.l10n_ch_isr_subscription_chf + else: + #we don't format if in another currency as EUR or CHF + continue + + if isr_subscription: + isr_subscription = isr_subscription.replace("-", "") # In case the user put the - + record.l10n_ch_isr_subscription = _format_isr_subscription_scanline(isr_subscription) + record.l10n_ch_isr_subscription_formatted = _format_isr_subscription(isr_subscription) + + def _get_isrb_id_number(self): + """Hook to fix the lack of proper field for ISR-B Customer ID""" + # FIXME + # replace l10n_ch_postal by an other field to not mix ISR-B + # customer ID as it forbid the following validations on l10n_ch_postal + # number for Vendor bank accounts: + # - validation of format xx-yyyyy-c + # - validation of checksum + self.ensure_one() + return self.partner_bank_id.l10n_ch_postal or '' + + @api.depends('name', 'partner_bank_id.l10n_ch_postal') + def _compute_l10n_ch_isr_number(self): + """Generates the ISR or QRR reference + + An ISR references are 27 characters long. + QRR is a recycling of ISR for QR-bills. Thus works the same. + + The invoice sequence number is used, removing each of its non-digit characters, + and pad the unused spaces on the left of this number with zeros. + The last digit is a checksum (mod10r). + + There are 2 types of references: + + * ISR (Postfinance) + + The reference is free but for the last + digit which is a checksum. + If shorter than 27 digits, it is filled with zeros on the left. + + e.g. + + 120000000000234478943216899 + \________________________/| + 1 2 + (1) 12000000000023447894321689 | reference + (2) 9: control digit for identification number and reference + + * ISR-B (Indirect through a bank, requires a customer ID) + + In case of ISR-B The firsts digits (usually 6), contain the customer ID + at the Bank of this ISR's issuer. + The rest (usually 20 digits) is reserved for the reference plus the + control digit. + If the [customer ID] + [the reference] + [the control digit] is shorter + than 27 digits, it is filled with zeros between the customer ID till + the start of the reference. + + e.g. + + 150001123456789012345678901 + \____/\__________________/| + 1 2 3 + (1) 150001 | id number of the customer (size may vary) + (2) 12345678901234567890 | reference + (3) 1: control digit for identification number and reference + """ + for record in self: + has_qriban = record.partner_bank_id and record.partner_bank_id._is_qr_iban() or False + isr_subscription = record.l10n_ch_isr_subscription + if (has_qriban or isr_subscription) and record.name: + id_number = record._get_isrb_id_number() + if id_number: + id_number = id_number.zfill(l10n_ch_ISR_ID_NUM_LENGTH) + invoice_ref = re.sub('[^\d]', '', record.name) + # keep only the last digits if it exceed boundaries + full_len = len(id_number) + len(invoice_ref) + ref_payload_len = l10n_ch_ISR_NUMBER_LENGTH - 1 + extra = full_len - ref_payload_len + if extra > 0: + invoice_ref = invoice_ref[extra:] + internal_ref = invoice_ref.zfill(ref_payload_len - len(id_number)) + record.l10n_ch_isr_number = mod10r(id_number + internal_ref) + else: + record.l10n_ch_isr_number = False + + @api.depends('l10n_ch_isr_number') + def _compute_l10n_ch_isr_number_spaced(self): + def _space_isr_number(isr_number): + to_treat = isr_number + res = '' + while to_treat: + res = to_treat[-5:] + res + to_treat = to_treat[:-5] + if to_treat: + res = ' ' + res + return res + + for record in self: + if record.l10n_ch_isr_number: + record.l10n_ch_isr_number_spaced = _space_isr_number(record.l10n_ch_isr_number) + else: + record.l10n_ch_isr_number_spaced = False + + def _get_l10n_ch_isr_optical_amount(self): + """Prepare amount string for ISR optical line""" + self.ensure_one() + currency_code = None + if self.currency_id.name == 'CHF': + currency_code = '01' + elif self.currency_id.name == 'EUR': + currency_code = '03' + units, cents = float_split_str(self.amount_residual, 2) + amount_to_display = units + cents + amount_ref = amount_to_display.zfill(10) + optical_amount = currency_code + amount_ref + optical_amount = mod10r(optical_amount) + return optical_amount + + @api.depends( + 'currency_id.name', 'amount_residual', 'name', + 'partner_bank_id.l10n_ch_isr_subscription_eur', + 'partner_bank_id.l10n_ch_isr_subscription_chf') + def _compute_l10n_ch_isr_optical_line(self): + """ Compute the optical line to print on the bottom of the ISR. + + This line is read by an OCR. + It's format is: + + amount>reference+ creditor> + + Where: + + - amount: currency and invoice amount + - reference: ISR structured reference number + - in case of ISR-B contains the Customer ID number + - it can also contains a partner reference (of the debitor) + - creditor: Subscription number of the creditor + + An optical line can have the 2 following formats: + + * ISR (Postfinance) + + 0100003949753>120000000000234478943216899+ 010001628> + |/\________/| \________________________/| \_______/ + 1 2 3 4 5 6 + + (1) 01 | currency + (2) 0000394975 | amount 3949.75 + (3) 4 | control digit for amount + (5) 12000000000023447894321689 | reference + (6) 9: control digit for identification number and reference + (7) 010001628: subscription number (01-162-8) + + * ISR-B (Indirect through a bank, requires a customer ID) + + 0100000494004>150001123456789012345678901+ 010234567> + |/\________/| \____/\__________________/| \_______/ + 1 2 3 4 5 6 7 + + (1) 01 | currency + (2) 0000049400 | amount 494.00 + (3) 4 | control digit for amount + (4) 150001 | id number of the customer (size may vary, usually 6 chars) + (5) 12345678901234567890 | reference + (6) 1: control digit for identification number and reference + (7) 010234567: subscription number (01-23456-7) + """ + for record in self: + record.l10n_ch_isr_optical_line = '' + if record.l10n_ch_isr_number and record.l10n_ch_isr_subscription and record.currency_id.name: + # Final assembly (the space after the '+' is no typo, it stands in the specs.) + record.l10n_ch_isr_optical_line = '{amount}>{reference}+ {creditor}>'.format( + amount=record._get_l10n_ch_isr_optical_amount(), + reference=record.l10n_ch_isr_number, + creditor=record.l10n_ch_isr_subscription, + ) + + @api.depends( + 'move_type', 'name', 'currency_id.name', + 'partner_bank_id.l10n_ch_isr_subscription_eur', + 'partner_bank_id.l10n_ch_isr_subscription_chf') + def _compute_l10n_ch_isr_valid(self): + """Returns True if all the data required to generate the ISR are present""" + for record in self: + record.l10n_ch_isr_valid = record.move_type == 'out_invoice' and\ + record.name and \ + record.l10n_ch_isr_subscription and \ + record.l10n_ch_currency_name in ['EUR', 'CHF'] + + @api.depends('move_type', 'partner_bank_id', 'payment_reference') + def _compute_l10n_ch_isr_needs_fixing(self): + for inv in self: + if inv.move_type == 'in_invoice' and inv.company_id.country_id.code == "CH": + partner_bank = inv.partner_bank_id + if partner_bank: + needs_isr_ref = partner_bank._is_qr_iban() or partner_bank._is_isr_issuer() + else: + needs_isr_ref = False + if needs_isr_ref and not inv._has_isr_ref(): + inv.l10n_ch_isr_needs_fixing = True + continue + inv.l10n_ch_isr_needs_fixing = False + + def _has_isr_ref(self): + """Check if this invoice has a valid ISR reference (for Switzerland) + e.g. + 12371 + 000000000000000000000012371 + 210000000003139471430009017 + 21 00000 00003 13947 14300 09017 + """ + self.ensure_one() + ref = self.payment_reference or self.ref + if not ref: + return False + ref = ref.replace(' ', '') + if re.match(r'^(\d{2,27})$', ref): + return ref == mod10r(ref[:-1]) + return False + + def split_total_amount(self): + """ Splits the total amount of this invoice in two parts, using the dot as + a separator, and taking two precision digits (always displayed). + These two parts are returned as the two elements of a tuple, as strings + to print in the report. + + This function is needed on the model, as it must be called in the report + template, which cannot reference static functions + """ + return float_split_str(self.amount_residual, 2) + + def isr_print(self): + """ Triggered by the 'Print ISR' button. + """ + self.ensure_one() + if self.l10n_ch_isr_valid: + self.l10n_ch_isr_sent = True + return self.env.ref('l10n_ch.l10n_ch_isr_report').report_action(self) + else: + raise ValidationError(_("""You cannot generate an ISR yet.\n + For this, you need to :\n + - set a valid postal account number (or an IBAN referencing one) for your company\n + - define its bank\n + - associate this bank with a postal reference for the currency used in this invoice\n + - fill the 'bank account' field of the invoice with the postal to be used to receive the related payment. A default account will be automatically set for all invoices created after you defined a postal account for your company.""")) + + def print_ch_qr_bill(self): + """ Triggered by the 'Print QR-bill' button. + """ + self.ensure_one() + + if not self.partner_bank_id._eligible_for_qr_code('ch_qr', self.partner_id, self.currency_id): + raise UserError(_("Cannot generate the QR-bill. Please check you have configured the address of your company and debtor. If you are using a QR-IBAN, also check the invoice's payment reference is a QR reference.")) + + self.l10n_ch_isr_sent = True + return self.env.ref('l10n_ch.l10n_ch_qr_report').report_action(self) + + def action_invoice_sent(self): + # OVERRIDE + rslt = super(AccountMove, self).action_invoice_sent() + + if self.l10n_ch_isr_valid: + rslt['context']['l10n_ch_mark_isr_as_sent'] = True + + return rslt + + @api.returns('mail.message', lambda value: value.id) + def message_post(self, **kwargs): + if self.env.context.get('l10n_ch_mark_isr_as_sent'): + self.filtered(lambda inv: not inv.l10n_ch_isr_sent).write({'l10n_ch_isr_sent': True}) + return super(AccountMove, self.with_context(mail_post_autofollow=True)).message_post(**kwargs) + + def _get_invoice_reference_ch_invoice(self): + """ This sets ISR reference number which is generated based on customer's `Bank Account` and set it as + `Payment Reference` of the invoice when invoice's journal is using Switzerland's communication standard + """ + self.ensure_one() + return self.l10n_ch_isr_number + + def _get_invoice_reference_ch_partner(self): + """ This sets ISR reference number which is generated based on customer's `Bank Account` and set it as + `Payment Reference` of the invoice when invoice's journal is using Switzerland's communication standard + """ + self.ensure_one() + return self.l10n_ch_isr_number + + @api.model + def space_qrr_reference(self, qrr_ref): + """ Makes the provided QRR reference human-friendly, spacing its elements + by blocks of 5 from right to left. + """ + spaced_qrr_ref = '' + i = len(qrr_ref) # i is the index after the last index to consider in substrings + while i > 0: + spaced_qrr_ref = qrr_ref[max(i-5, 0) : i] + ' ' + spaced_qrr_ref + i -= 5 + + return spaced_qrr_ref diff --git a/addons/l10n_ch/models/account_journal.py b/addons/l10n_ch/models/account_journal.py new file mode 100644 index 00000000..f1bbb1bf --- /dev/null +++ b/addons/l10n_ch/models/account_journal.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, fields, api + +from odoo.exceptions import ValidationError + +from odoo.addons.base_iban.models.res_partner_bank import validate_iban +from odoo.addons.base.models.res_bank import sanitize_account_number + + +class AccountJournal(models.Model): + _inherit = 'account.journal' + + # creation of bank journals by giving the account number, allow craetion of the + l10n_ch_postal = fields.Char('Client Number', related='bank_account_id.l10n_ch_postal', readonly=False) + invoice_reference_model = fields.Selection(selection_add=[ + ('ch', 'Switzerland') + ], ondelete={'ch': lambda recs: recs.write({'invoice_reference_model': 'odoo'})}) + + @api.model + def create(self, vals): + rslt = super(AccountJournal, self).create(vals) + + # The call to super() creates the related bank_account_id field + if 'l10n_ch_postal' in vals: + rslt.l10n_ch_postal = vals['l10n_ch_postal'] + return rslt + + def write(self, vals): + rslt = super(AccountJournal, self).write(vals) + + # The call to super() creates the related bank_account_id field if necessary + if 'l10n_ch_postal' in vals: + for record in self.filtered('bank_account_id'): + record.bank_account_id.l10n_ch_postal = vals['l10n_ch_postal'] + return rslt + + @api.onchange('bank_acc_number') + def _onchange_set_l10n_ch_postal(self): + try: + validate_iban(self.bank_acc_number) + is_iban = True + except ValidationError: + is_iban = False + + if is_iban: + self.l10n_ch_postal = self.env['res.partner.bank']._retrieve_l10n_ch_postal(sanitize_account_number(self.bank_acc_number)) + else: + self.l10n_ch_postal = self.bank_acc_number diff --git a/addons/l10n_ch/models/ir_actions_report.py b/addons/l10n_ch/models/ir_actions_report.py new file mode 100644 index 00000000..7b09f53c --- /dev/null +++ b/addons/l10n_ch/models/ir_actions_report.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + +from pathlib import Path +from reportlab.graphics.shapes import Image as ReportLabImage +from reportlab.lib.units import mm + +CH_QR_CROSS_SIZE_RATIO = 0.1214 # Ratio between the side length of the Swiss QR-code cross image and the QR-code's +CH_QR_CROSS_FILE = Path('../static/src/img/CH-Cross_7mm.png') # Image file containing the Swiss QR-code cross to add on top of the QR-code + +class IrActionsReport(models.Model): + _inherit = 'ir.actions.report' + + @api.model + def get_available_barcode_masks(self): + rslt = super(IrActionsReport, self).get_available_barcode_masks() + rslt['ch_cross'] = self.apply_qr_code_ch_cross_mask + return rslt + + @api.model + def apply_qr_code_ch_cross_mask(self, width, height, barcode_drawing): + cross_width = CH_QR_CROSS_SIZE_RATIO * width + cross_height = CH_QR_CROSS_SIZE_RATIO * height + cross_path = Path(__file__).absolute().parent / CH_QR_CROSS_FILE + qr_cross = ReportLabImage((width/2 - cross_width/2) / mm, (height/2 - cross_height/2) / mm, cross_width / mm, cross_height / mm, cross_path.as_posix()) + barcode_drawing.add(qr_cross) diff --git a/addons/l10n_ch/models/mail_template.py b/addons/l10n_ch/models/mail_template.py new file mode 100644 index 00000000..7d50d1eb --- /dev/null +++ b/addons/l10n_ch/models/mail_template.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import base64 + +from odoo import api, models + + +class MailTemplate(models.Model): + _inherit = 'mail.template' + + def generate_email(self, res_ids, fields): + """ Method overridden in order to add an attachment containing the ISR + to the draft message when opening the 'send by mail' wizard on an invoice. + This attachment generation will only occur if all the required data are + present on the invoice. Otherwise, no ISR attachment will be created, and + the mail will only contain the invoice (as defined in the mother method). + """ + result = super(MailTemplate, self).generate_email(res_ids, fields) + if self.model != 'account.move': + return result + + multi_mode = True + if isinstance(res_ids, int): + res_ids = [res_ids] + multi_mode = False + + if self.model == 'account.move': + for record in self.env[self.model].browse(res_ids): + inv_print_name = self._render_field('report_name', record.ids, compute_lang=True)[record.id] + new_attachments = [] + + if record.l10n_ch_isr_valid: + # We add an attachment containing the ISR + isr_report_name = 'ISR-' + inv_print_name + '.pdf' + isr_pdf = self.env.ref('l10n_ch.l10n_ch_isr_report')._render_qweb_pdf(record.ids)[0] + isr_pdf = base64.b64encode(isr_pdf) + new_attachments.append((isr_report_name, isr_pdf)) + + if record.partner_bank_id._eligible_for_qr_code('ch_qr', record.partner_id, record.currency_id): + # We add an attachment containing the QR-bill + qr_report_name = 'QR-bill-' + inv_print_name + '.pdf' + qr_pdf = self.env.ref('l10n_ch.l10n_ch_qr_report')._render_qweb_pdf(record.ids)[0] + qr_pdf = base64.b64encode(qr_pdf) + new_attachments.append((qr_report_name, qr_pdf)) + + record_dict = multi_mode and result[record.id] or result + attachments_list = record_dict.get('attachments', False) + if attachments_list: + attachments_list.extend(new_attachments) + else: + record_dict['attachments'] = new_attachments + + return result diff --git a/addons/l10n_ch/models/res_bank.py b/addons/l10n_ch/models/res_bank.py new file mode 100644 index 00000000..10c337a4 --- /dev/null +++ b/addons/l10n_ch/models/res_bank.py @@ -0,0 +1,315 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import re + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError +from odoo.tools.misc import mod10r +from odoo.exceptions import UserError + +import werkzeug.urls + +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('\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 + + +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") + + # 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.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 =='CH' + elif bank.company_id: + bank.l10n_ch_show_subscription = bank.company_id.country_id.code == 'CH' + else: + bank.l10n_ch_show_subscription = self.env.company.country_id.code == 'CH' + + @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.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') 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 _get_qr_code_url(self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication): + if qr_method == 'ch_qr': + qr_code_vals = self._l10n_ch_get_qr_vals(amount, currency, debtor_partner, free_communication, structured_communication) + + return '/report/barcode/?type=%s&value=%s&width=%s&height=%s&quiet=1&mask=ch_cross' % ('QR', werkzeug.urls.url_quote_plus('\n'.join(qr_code_vals)), 256, 256) + + return super()._get_qr_code_url(qr_method, amount, currency, debtor_partner, free_communication, structured_communication) + + 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 = '' + if self._is_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 + + currency = currency or self.currency_id or self.company_id.currency_id + + return [ + 'SPC', # QR Type + '0200', # Version + '1', # Coding Type + self.sanitized_acc_number, # 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_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] + + def _check_qr_iban_range(self, 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('\d+', iid) \ + and 30000 <= int(iid) <= 31999 # Those values for iid are reserved for QR-IBANs only + + def _is_qr_iban(self): + """ Tells whether or not this bank account 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. + """ + self.ensure_one() + + return self.sanitized_acc_number.startswith('CH')\ + and self.acc_type == 'iban'\ + and self._check_qr_iban_range(self.sanitized_acc_number) + + @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('\d+$', reference) \ + and reference == mod10r(reference[:-1]) + + def _eligible_for_qr_code(self, qr_method, debtor_partner, currency): + if qr_method == 'ch_qr': + + return self.acc_type == 'iban' and \ + self.partner_id.country_id.code == 'CH' and \ + (not debtor_partner or debtor_partner.country_id.code == 'CH') \ + and currency.name in ('EUR', 'CHF') + + return super()._eligible_for_qr_code(qr_method, debtor_partner, currency) + + 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 the QR-code must have a complete postal address (street, zip, city and country).") + + if self._is_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 diff --git a/addons/l10n_ch/models/res_company.py b/addons/l10n_ch/models/res_company.py new file mode 100644 index 00000000..b30e8e13 --- /dev/null +++ b/addons/l10n_ch/models/res_company.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + +class Company(models.Model): + _inherit = "res.company" + + l10n_ch_isr_preprinted_account = fields.Boolean(string='Preprinted account', compute='_compute_l10n_ch_isr', inverse='_set_l10n_ch_isr') + l10n_ch_isr_preprinted_bank = fields.Boolean(string='Preprinted bank', compute='_compute_l10n_ch_isr', inverse='_set_l10n_ch_isr') + l10n_ch_isr_print_bank_location = fields.Boolean(string='Print bank location', default=False, help='Boolean option field indicating whether or not the alternate layout (the one printing bank name and address) must be used when generating an ISR.') + l10n_ch_isr_scan_line_left = fields.Float(string='Scan line horizontal offset (mm)', compute='_compute_l10n_ch_isr', inverse='_set_l10n_ch_isr') + l10n_ch_isr_scan_line_top = fields.Float(string='Scan line vertical offset (mm)', compute='_compute_l10n_ch_isr', inverse='_set_l10n_ch_isr') + + def _compute_l10n_ch_isr(self): + get_param = self.env['ir.config_parameter'].sudo().get_param + for company in self: + company.l10n_ch_isr_preprinted_account = bool(get_param('l10n_ch.isr_preprinted_account', default=False)) + company.l10n_ch_isr_preprinted_bank = bool(get_param('l10n_ch.isr_preprinted_bank', default=False)) + company.l10n_ch_isr_scan_line_top = float(get_param('l10n_ch.isr_scan_line_top', default=0)) + company.l10n_ch_isr_scan_line_left = float(get_param('l10n_ch.isr_scan_line_left', default=0)) + + def _set_l10n_ch_isr(self): + set_param = self.env['ir.config_parameter'].sudo().set_param + for company in self: + set_param("l10n_ch.isr_preprinted_account", company.l10n_ch_isr_preprinted_account) + set_param("l10n_ch.isr_preprinted_bank", company.l10n_ch_isr_preprinted_bank) + set_param("l10n_ch.isr_scan_line_top", company.l10n_ch_isr_scan_line_top) + set_param("l10n_ch.isr_scan_line_left", company.l10n_ch_isr_scan_line_left) diff --git a/addons/l10n_ch/models/res_config_settings.py b/addons/l10n_ch/models/res_config_settings.py new file mode 100644 index 00000000..0b972855 --- /dev/null +++ b/addons/l10n_ch/models/res_config_settings.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + l10n_ch_isr_preprinted_account = fields.Boolean(string='Preprinted account', + related="company_id.l10n_ch_isr_preprinted_account", readonly=False) + l10n_ch_isr_preprinted_bank = fields.Boolean(string='Preprinted bank', + related="company_id.l10n_ch_isr_preprinted_bank", readonly=False) + l10n_ch_isr_print_bank_location = fields.Boolean(string="Print bank on ISR", + related="company_id.l10n_ch_isr_print_bank_location", readonly=False, + required=True) + l10n_ch_isr_scan_line_left = fields.Float(string='Horizontal offset', + related="company_id.l10n_ch_isr_scan_line_left", readonly=False) + l10n_ch_isr_scan_line_top = fields.Float(string='Vertical offset', + related="company_id.l10n_ch_isr_scan_line_top", readonly=False) |
