392 lines
18 KiB
Python
392 lines
18 KiB
Python
# -*- 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 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):
|
|
# 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
|
|
|
|
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
|
|
)
|
|
|
|
@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.
|
|
|
|
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)
|
|
# 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))
|
|
|
|
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)
|
|
|
|
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()
|
|
# 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
|
|
|
|
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
|
|
|
|
@api.model
|
|
def space_scor_reference(self, iso11649_ref):
|
|
""" 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):
|
|
'''
|
|
Checks that all invoices can be printed in the QR format.
|
|
If so, launches the printing action.
|
|
Else, triggers the l10n_ch wizard that will display the informations.
|
|
'''
|
|
if any(x.move_type != 'out_invoice' for x in self):
|
|
raise UserError(_("Only customers invoices can be QR-printed."))
|
|
if False in self.mapped('l10n_ch_is_qr_valid'):
|
|
return {
|
|
'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},
|
|
}
|
|
return self.env.ref('account.account_invoices').report_action(self)
|
|
|
|
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,
|
|
}
|