l10n-switzerland-flectra/l10n_ch/models/account_invoice.py
2021-07-21 13:01:38 +02:00

353 lines
16 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details.
import re
from flectra import models, fields, api, _
from flectra.exceptions import ValidationError, UserError
from flectra.tools.float_utils import float_split_str
from flectra.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