19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:28 +01:00
parent ff721d030e
commit 7721452493
1826 changed files with 124775 additions and 274114 deletions

View file

@ -1,11 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import res_config_settings
from . import template_ch
from . import account_invoice
from . import account_journal
from . import res_bank
from . import res_company
from . import account_bank_statement
from . import ir_actions_report
from . import chart_template
from . import account_payment

View file

@ -1,29 +0,0 @@
# -*- 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
from odoo.tools.misc import str2bool
class AccountBankStatementLine(models.Model):
_inherit = "account.bank.statement.line"
def _find_or_create_bank_account(self):
if self.company_id.account_fiscal_country_id.code in ('CH', 'LI') 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 and not str2bool(
self.env['ir.config_parameter'].sudo().get_param("account.skip_create_bank_account_on_reconcile")
):
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:
return super()._find_or_create_bank_account()

View file

@ -4,342 +4,74 @@ import re
from odoo import models, fields, api, _
from odoo.exceptions import 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
L10N_CH_QRR_NUMBER_LENGTH = 27
class AccountMove(models.Model):
# NOTE
# The ISR system is kept and taken into account up to September 2022.
# After that, the transition to the QR system will be completed and the ISR system won't exist anymore.
# This means that Odoo v16 shouldn't support the ISR system and all the references to it should be cleaned up by then.
# In the versions leading to that change,
# although the functions related to the ISR are still taken into account and still exist,
# the QR billing is always preferred.
_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. ")
l10n_ch_is_qr_valid = fields.Boolean(compute='_compute_l10n_ch_qr_is_valid', help="Determines whether an invoice can be printed as a QR or not")
@api.depends('partner_id', 'currency_id')
def _compute_l10n_ch_qr_is_valid(self):
for move in self:
company_eligible = True
if(move.company_id.account_fiscal_country_id.code != 'CH'):
company_eligible = False
if move.partner_bank_id.acc_number and move.partner_bank_id.acc_type == 'iban':
iban = move.partner_bank_id.acc_number.replace(' ', '')
if iban.startswith('CH') and len(iban) >= 9:
bank_code = iban[4:9]
if bank_code.isdigit() and 30000 <= int(bank_code) <= 31999:
company_eligible = True
error_messages = move.partner_bank_id._get_error_messages_for_qr('ch_qr', move.partner_id, move.currency_id)
move.l10n_ch_is_qr_valid = (
move.move_type == 'out_invoice'
and move.partner_bank_id._eligible_for_qr_code('ch_qr', move.partner_id, move.currency_id, raises_error=False)
and company_eligible
move.move_type == 'out_invoice' and
not error_messages and
(
# QR codes must be printed on all Swiss transactions
move.company_id.account_fiscal_country_id.code == 'CH' or
(
# QR code is also printed if the fiscal country is not Switzerland but the receivale account is eligible
move.partner_bank_id.acc_type == 'iban' and
(iban := (move.partner_bank_id.acc_number or '').replace(' ', '')).startswith('CH') and
iban[4:9].isdigit() and
30000 <= int(iban[4:9]) <= 31999
)
)
)
@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
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):
for record in self:
if (record.partner_bank_id.l10n_ch_qr_iban or record.l10n_ch_isr_subscription) and record.name:
invoice_ref = re.sub(r'\D', '', record.name)
record.l10n_ch_isr_number = record._compute_isr_number(invoice_ref)
else:
record.l10n_ch_isr_number = False
@api.model
def _compute_isr_number(self, invoice_ref):
r"""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.
def get_l10n_ch_qrr_number(self):
"""Generates the QRR reference.
QRR references are 27 characters long.
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
"""
id_number = self._get_isrb_id_number()
if id_number:
id_number = id_number.zfill(l10n_ch_ISR_ID_NUM_LENGTH)
self.ensure_one()
if self.partner_bank_id.l10n_ch_qr_iban and self.l10n_ch_is_qr_valid and self.name:
invoice_ref = re.sub(r'[^\d]', '', self.name)
return self._compute_qrr_number(invoice_ref)
else:
return False
@api.model
def _compute_qrr_number(self, invoice_ref):
# 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
ref_payload_len = L10N_CH_QRR_NUMBER_LENGTH - 1
extra = len(invoice_ref) - ref_payload_len
if extra > 0:
invoice_ref = invoice_ref[extra:]
internal_ref = invoice_ref.zfill(ref_payload_len - len(id_number))
return mod10r(id_number + internal_ref)
@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):
r""" 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.account_fiscal_country_id.code in ('CH', 'LI'):
partner_bank = inv.partner_bank_id
needs_isr_ref = partner_bank.l10n_ch_qr_iban or partner_bank._is_isr_issuer()
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 action_invoice_sent(self):
# OVERRIDE
rslt = super(AccountMove, self).action_invoice_sent()
if self.l10n_ch_isr_valid or self.l10n_ch_is_qr_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=self.env.context.get('mail_post_autofollow', True))).message_post(**kwargs)
internal_ref = invoice_ref.zfill(ref_payload_len)
return mod10r(internal_ref)
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
""" This sets QRR 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()
# l10n_ch_isr_number is not always computed at this stage, and could change value when the invoice is posted.
# We manually compute here it to avoid this conflict.
self._compute_l10n_ch_isr_number()
return self.l10n_ch_isr_number
return self.get_l10n_ch_qrr_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
""" This sets QRR 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
return self.get_l10n_ch_qrr_number()
@api.model
def space_qrr_reference(self, qrr_ref):
@ -358,7 +90,6 @@ class AccountMove(models.Model):
""" Makes the provided SCOR reference human-friendly, spacing its elements
by blocks of 5 from right to left.
"""
return ' '.join(iso11649_ref[i:i + 4] for i in range(0, len(iso11649_ref), 4))
def l10n_ch_action_print_qr(self):
@ -374,7 +105,6 @@ class AccountMove(models.Model):
'name': (_("Some invoices could not be printed in the QR format")),
'type': 'ir.actions.act_window',
'res_model': 'l10n_ch.qr_invoice.wizard',
'view_type': 'form',
'view_mode': 'form',
'target': 'new',
'context': {'active_ids': self.ids},
@ -383,9 +113,7 @@ class AccountMove(models.Model):
def _l10n_ch_dispatch_invoices_to_print(self):
qr_invs = self.filtered('l10n_ch_is_qr_valid')
isr_invs = self.filtered('l10n_ch_isr_valid')
return {
'qr': qr_invs,
'isr': isr_invs,
'classic': self - qr_invs - isr_invs,
'classic': self - qr_invs,
}

View file

@ -13,18 +13,18 @@ class AccountJournal(models.Model):
_inherit = 'account.journal'
invoice_reference_model = fields.Selection(selection_add=[
('ch', 'Switzerland')
('ch', 'Switzerland (12 34560 00103 88500 1000 19188)')
], ondelete={'ch': lambda recs: recs.write({'invoice_reference_model': 'odoo'})})
def _process_reference_for_sale_order(self, order_reference):
'''
returns the order reference to be used for the payment respecting the ISR
Returns the order reference to be used for the payment, respecting the QRR standard.
'''
self.ensure_one()
if self.invoice_reference_model == 'ch':
# converting the sale order name into a unique number. Letters are converted to their base10 value
invoice_ref = "".join([a if a.isdigit() else str(ord(a)) for a in order_reference])
# id_number = self.company_id.bank_ids.l10n_ch_postal or ''
order_reference = self.env['account.move']._compute_isr_number(invoice_ref)
order_reference = self.env['account.move']._compute_qrr_number(invoice_ref)
return order_reference
return super()._process_reference_for_sale_order(order_reference)

View file

@ -0,0 +1,37 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
from odoo import _, models, fields, api
from odoo.tools import mod10r
class AccountPayment(models.Model):
_inherit = "account.payment"
l10n_ch_reference_warning_msg = fields.Char(compute='_compute_l10n_ch_reference_warning_msg')
@api.onchange('partner_id', 'memo', 'payment_type')
def _compute_l10n_ch_reference_warning_msg(self):
for payment in self:
if payment.payment_type == 'outbound' and\
payment.partner_id.country_code in ['CH', 'LI'] and\
payment.partner_bank_id.l10n_ch_qr_iban and\
not payment._l10n_ch_reference_is_valid(payment.memo):
payment.l10n_ch_reference_warning_msg = _("Please fill in a correct QRR reference in the payment reference. The banks will refuse your payment file otherwise.")
else:
payment.l10n_ch_reference_warning_msg = False
def _l10n_ch_reference_is_valid(self, payment_reference):
"""Check if this invoice has a valid reference (for Switzerland)
e.g.
000000000000000000000012371
210000000003139471430009017
21 00000 00003 13947 14300 09017
"""
self.ensure_one()
if not payment_reference:
return False
ref = payment_reference.replace(' ', '')
if re.match(r'^(\d{2,27})$', ref):
return ref == mod10r(ref[:-1])
return False

View file

@ -1,16 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models
class AccountChartTemplate(models.Model):
_inherit = 'account.chart.template'
# Write paperformat and report template used on company
def _load(self, company):
res = super(AccountChartTemplate, self)._load(company)
if self == self.env.ref('l10n_ch.l10nch_chart_template'):
company.write({
'external_report_layout_id': self.env.ref('l10n_din5008.external_layout_din5008').id,
'paperformat_id': self.env.ref('l10n_din5008.paperformat_euro_din').id
})
return res

View file

@ -10,6 +10,7 @@ from reportlab.lib.units import mm
CH_QR_CROSS_SIZE_RATIO = 0.1522 # 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'
@ -38,73 +39,39 @@ class IrActionsReport(models.Model):
report = self._get_report(report_ref)
if self._is_invoice_report(report_ref):
invoices = self.env[report.model].browse(res_ids)
# Determine which invoices need a QR/ISR.
qr_inv_ids = []
isr_inv_ids = []
for invoice in invoices:
# avoid duplicating existing streams
if report.attachment_use and report.retrieve_attachment(invoice):
continue
if invoice.l10n_ch_is_qr_valid:
qr_inv_ids.append(invoice.id)
elif invoice.company_id.country_code == 'CH' and invoice.l10n_ch_isr_valid:
isr_inv_ids.append(invoice.id)
# Render the additional reports.
streams_to_append = {}
# Determine which invoices need a QR.
qr_inv_ids = invoices.filtered('l10n_ch_is_qr_valid').ids
if qr_inv_ids:
qr_res = self._render_qweb_pdf_prepare_streams(
'l10n_ch.l10n_ch_qr_report',
{
**data,
'skip_headers': False,
},
data,
res_ids=qr_inv_ids,
)
header = self.env.ref('l10n_ch.l10n_ch_qr_header', raise_if_not_found=False)
if header:
# Make a separated rendering to get the a page containing the company header. Then, merge the qr bill with it.
header_res = self._render_qweb_pdf_prepare_streams(
'l10n_ch.l10n_ch_qr_header',
{
**data,
'skip_headers': True,
},
res_ids=qr_inv_ids,
)
for invoice_id, stream in qr_res.items():
qr_pdf = OdooPdfFileReader(stream['stream'], strict=False)
res_pdf = OdooPdfFileReader(res[invoice_id]['stream'], strict=False)
for invoice_id, stream in qr_res.items():
qr_pdf = OdooPdfFileReader(stream['stream'], strict=False)
header_pdf = OdooPdfFileReader(header_res[invoice_id]['stream'], strict=False)
last_page = res_pdf.getPage(-1)
last_page.mergePage(qr_pdf.getPage(0))
page = header_pdf.getPage(0)
page.mergePage(qr_pdf.getPage(0))
output_pdf = OdooPdfFileWriter()
output_pdf = OdooPdfFileWriter()
output_pdf.addPage(page)
new_pdf_stream = io.BytesIO()
output_pdf.write(new_pdf_stream)
streams_to_append[invoice_id] = {'stream': new_pdf_stream}
else:
for invoice_id, stream in qr_res.items():
streams_to_append[invoice_id] = stream
# Add all pages from the original PDF except the last one
for page_num in range(res_pdf.getNumPages() - 1):
output_pdf.addPage(res_pdf.getPage(page_num))
if isr_inv_ids:
isr_res = self._render_qweb_pdf_prepare_streams('l10n_ch.l10n_ch_isr_report', data, res_ids=isr_inv_ids)
for invoice_id, stream in isr_res.items():
streams_to_append[invoice_id] = stream
output_pdf.addPage(last_page) # Add the modified last page (with the QR code merged)
new_pdf_stream = io.BytesIO()
output_pdf.write(new_pdf_stream)
new_pdf_stream.seek(0)
res[invoice_id]['stream'].close()
res[invoice_id]['stream'] = new_pdf_stream
stream['stream'].close()
# Add to results
for invoice_id, additional_stream in streams_to_append.items():
invoice_stream = res[invoice_id]['stream']
writer = OdooPdfFileWriter()
writer.appendPagesFromReader(OdooPdfFileReader(invoice_stream, strict=False))
writer.appendPagesFromReader(OdooPdfFileReader(additional_stream['stream'], strict=False))
new_pdf_stream = io.BytesIO()
writer.write(new_pdf_stream)
res[invoice_id]['stream'] = new_pdf_stream
invoice_stream.close()
additional_stream['stream'].close()
return res
def get_paperformat(self):

View file

@ -6,37 +6,14 @@ 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.addons.base_iban.models.res_partner_bank import normalize_iban, pretty_iban, validate_iban, get_iban_part
from odoo.exceptions import ValidationError
from odoo.tools import LazyTranslate, street_split
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]$')
_lt = LazyTranslate(__name__)
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)
@ -45,34 +22,24 @@ def validate_qr_iban(qr_iban):
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."))
raise ValidationError(_lt("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)
raise ValidationError(_lt("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]
iid = get_iban_part(iban, 'bank')
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,
@ -81,60 +48,18 @@ class ResPartnerBank(models.Model):
"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
# fields to configure payment slip generation
l10n_ch_display_qr_bank_options = fields.Boolean(compute='_compute_l10n_ch_display_qr_bank_options')
@api.depends('partner_id', 'company_id')
def _compute_l10n_ch_show_subscription(self):
def _compute_l10n_ch_display_qr_bank_options(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')
bank.l10n_ch_display_qr_bank_options = 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')
bank.l10n_ch_display_qr_bank_options = 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()
bank.l10n_ch_display_qr_bank_options = self.env.company.account_fiscal_country_id.code in ('CH', 'LI')
@api.depends('acc_number')
def _compute_l10n_ch_qr_iban(self):
@ -144,7 +69,6 @@ class ResPartnerBank(models.Model):
valid_qr_iban = True
except ValidationError:
valid_qr_iban = False
if valid_qr_iban:
record.l10n_ch_qr_iban = record.sanitized_acc_number
else:
@ -164,89 +88,16 @@ class ResPartnerBank(models.Model):
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)
cred_street, cred_street_number, cred_zip, cred_city = self._get_partner_address_lines(self.partner_id)
debt_street, debt_street_number, debt_zip, debt_city = 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)
# and must then be 27 characters-long, with mod10r check digit as the 27th one)
reference_type = 'NON'
reference = ''
acc_number = self.sanitized_acc_number
@ -262,17 +113,17 @@ class ResPartnerBank(models.Model):
currency = currency or self.currency_id or self.company_id.currency_id
return [
result = [
'SPC', # QR Type
'0200', # Version
'1', # Coding Type
acc_number, # IBAN / QR-IBAN
'K', # Creditor Address Type
'S', # 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)
cred_street, # Creditor Street Name
cred_street_number, # Creditor Building Number
cred_zip, # Creditor Postal Code
cred_city, # Creditor Town
self.partner_id.country_id.code, # Creditor Country
'', # Ultimate Creditor Address Type
'', # Name
@ -283,19 +134,22 @@ class ResPartnerBank(models.Model):
'', # Ultimate Creditor Country
'{:.2f}'.format(amount), # Amount
currency.name, # Currency
'K', # Ultimate Debtor Address Type
'S', # 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
debt_street, # Ultimate Debtor Street Name
debt_street_number, # Ultimate Debtor Building Number
debt_zip, # Ultimate Debtor Postal Code
debt_city, # Ultimate Debtor Town
debtor_partner.country_id.code, # Ultimate Debtor Country
reference_type, # Reference Type
reference, # Reference
comment, # Unstructured Message
'EPD', # Mandatory trailer part
]
# newlines shift field content to a different line, causing the QR code to be rejected
return [line.replace('\n', ' ') for line in result]
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)
@ -307,7 +161,7 @@ class ResPartnerBank(models.Model):
'barcode_type': 'QR',
'width': 256,
'height': 256,
'quiet': 1,
'quiet': 0,
'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
@ -316,14 +170,28 @@ class ResPartnerBank(models.Model):
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
""" Retrieves the partner's address fields, truncated to respect the line specs.
:returns: tuple(street, street_number, zip, city)
"""
streets = [partner.street, partner.street2]
line_1 = ' '.join(filter(None, streets))
line_2 = partner.zip + ' ' + partner.city
return line_1[:70], line_2[:70]
street_1_split = street_split(partner.street or '')
street_name = street_1_split['street_name']
building_number = f"{street_1_split['street_number']} {street_1_split['street_number2']}".strip()
if building_number:
concatenated_building_number = f"{building_number} {partner.street2 or ''}".strip()
if len(concatenated_building_number) <= 16:
building_number = concatenated_building_number
else:
# Try to complete the address with street2
street_2_split = street_split(partner.street2 or '')
building_number = f"{street_2_split['street_number']} {street_2_split['street_number2']}".strip()
if building_number:
street_name = f"{street_name} {street_2_split['street_name']}".strip()
else:
building_number = (partner.street2 or '').strip()
return street_name[:70], building_number[:16], partner.zip[:16], partner.city[:35]
@api.model
def _is_qr_reference(self, reference):
@ -331,9 +199,9 @@ class ResPartnerBank(models.Model):
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])
and len(reference) == 27 \
and re.match(r'\d+$', reference) \
and reference == mod10r(reference[:-1])
@api.model
def _is_iso11649_reference(self, reference):
@ -346,21 +214,30 @@ class ResPartnerBank(models.Model):
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):")]
def _l10n_ch_qr_debtor_check(self, debtor_partner):
""" This method should be used in _get_error_messages_for_qr and _check_for_qr_code_errors
It allows is to permit to set this qr method if a partner is not yet provided when executing _get_error_messages_for_qr
while preventing to print qr code when executing _check_for_qr_code_errors if the partner is not provided
"""
if not debtor_partner or debtor_partner.country_id.code not in ('CH', 'LI'):
return _("The debtor partner's address isn't located in Switzerland.")
return False
def _get_error_messages_for_qr(self, qr_method, debtor_partner, currency):
def _get_error_for_ch_qr():
error_messages = [_("The Swiss 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."))
debtor_check = self._l10n_ch_qr_debtor_check(debtor_partner)
if debtor_partner and debtor_check:
error_messages.append(debtor_check)
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)
error_messages.append(_("The currency isn't EUR nor CHF."))
return '\r\n'.join(error_messages) if len(error_messages) > 1 else None
if qr_method == 'ch_qr':
return _get_error_for_ch_qr()
return super()._get_error_messages_for_qr(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):
@ -379,6 +256,10 @@ class ResPartnerBank(models.Model):
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.")
debtor_check = self._l10n_ch_qr_debtor_check(debtor_partner)
if debtor_check:
return debtor_check
return super()._check_for_qr_code_errors(qr_method, amount, currency, debtor_partner, free_communication, structured_communication)
@api.model

View file

@ -1,29 +0,0 @@
# -*- 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)

View file

@ -1,20 +0,0 @@
# -*- 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)

View file

@ -0,0 +1,50 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo.addons.account.models.chart_template import template
class AccountChartTemplate(models.AbstractModel):
_inherit = 'account.chart.template'
@template('ch')
def _get_ch_template_data(self):
return {
'code_digits': '4',
'property_account_receivable_id': 'ch_coa_1100',
'property_account_payable_id': 'ch_coa_2000',
}
@template('ch', 'res.company')
def _get_ch_res_company(self):
return {
self.env.company.id: {
'account_fiscal_country_id': 'base.ch',
'bank_account_code_prefix': '102',
'cash_account_code_prefix': '100',
'transfer_account_code_prefix': '1090',
'account_default_pos_receivable_account_id': 'ch_coa_1101',
'income_currency_exchange_account_id': 'ch_coa_3806',
'expense_currency_exchange_account_id': 'ch_coa_4906',
'account_journal_early_pay_discount_loss_account_id': 'ch_coa_4901',
'account_journal_early_pay_discount_gain_account_id': 'ch_coa_3801',
'default_cash_difference_expense_account_id': 'ch_coa_4991',
'default_cash_difference_income_account_id': 'ch_coa_4992',
'account_sale_tax_id': 'vat_sale_81',
'account_purchase_tax_id': 'vat_purchase_81',
'external_report_layout_id': 'l10n_din5008.external_layout_din5008',
'paperformat_id': 'l10n_din5008.paperformat_euro_din',
'expense_account_id': 'ch_coa_4200',
'income_account_id': 'ch_coa_3200',
'account_stock_journal_id': 'inventory_valuation',
'account_stock_valuation_id': 'ch_coa_1210',
},
}
@template('ch', 'account.account')
def _get_ch_account_account(self):
return {
'ch_coa_1210': {
'account_stock_expense_id': 'ch_coa_4000',
'account_stock_variation_id': 'ch_coa_4801',
},
}