summaryrefslogtreecommitdiff
path: root/addons/l10n_ch/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/l10n_ch/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/l10n_ch/models')
-rw-r--r--addons/l10n_ch/models/__init__.py11
-rw-r--r--addons/l10n_ch/models/account_bank_statement.py25
-rw-r--r--addons/l10n_ch/models/account_invoice.py352
-rw-r--r--addons/l10n_ch/models/account_journal.py50
-rw-r--r--addons/l10n_ch/models/ir_actions_report.py28
-rw-r--r--addons/l10n_ch/models/mail_template.py54
-rw-r--r--addons/l10n_ch/models/res_bank.py315
-rw-r--r--addons/l10n_ch/models/res_company.py29
-rw-r--r--addons/l10n_ch/models/res_config_settings.py20
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)