[IMP] base, account, l10n_ch, website_sale, payment, web: make payment QR-code generation more generic
This new modelling makes it easier to add new QR-code formats, and allows using all of them in website_sale and account.payment's form view as well (so, Swiss QR codes are now available there, while they were restricted to only invoices in the past). All barcodes are now generated as reports, from a dedicated route. This was only partly the case before : Swiss QR added a cross on top of the QR-code directly in the template, it wasn't part of the image returned by the route; now it is. [ADD] base_qr_code_sepa: new module decoupling SEPA QR-codes generation from the base module Each new QR-code generation option should thus be done in a dedicated module (or added to a localization) in the future. [IMP] base_qr_code_sepa: update the generated QR codes to version 2 of the specification Version 1 is still supported, so no need to backport this. [IMP] l10n_ch: make Swiss QR-codes compatible with the new version of the specification (the old one is deprecated) This will be backported to 11.0 and 12.0, as these QR-codes will soon replace ISR. [IMP] account: make it possible to mark manual payments as sent with a button on the form view This way, when making them directly with a QR-code (or doing a more classical wire transfer), people can keep track of what they already have asked the bank to do, and what they still have to treat. closes odoo/odoo#44839 Related: odoo/enterprise#8262 Related: odoo/upgrade#992 Signed-off-by: Laurent Smet <smetl@users.noreply.github.com>
This commit is contained in:
@@ -23,3 +23,4 @@ from . import digest
|
||||
from . import res_users
|
||||
from . import ir_actions_report
|
||||
from . import res_currency
|
||||
from . import res_bank
|
||||
|
||||
@@ -242,6 +242,10 @@ class AccountMove(models.Model):
|
||||
invoice_incoterm_id = fields.Many2one('account.incoterms', string='Incoterm',
|
||||
default=_get_default_invoice_incoterm,
|
||||
help='International Commercial Terms are a series of predefined commercial terms used in international transactions.')
|
||||
display_qr_code = fields.Boolean(string="Display QR-code", related='company_id.qr_code')
|
||||
qr_code_method = fields.Selection(string="Payment QR-code",
|
||||
selection=lambda self: self.env['res.partner.bank'].get_available_qr_methods_in_sequence(),
|
||||
help="Type of QR-code to be generated for the payment of this invoice, when printing it. If left blank, the first available and usable method will be used.")
|
||||
|
||||
# ==== Payment widget fields ====
|
||||
invoice_outstanding_credits_debits_widget = fields.Text(groups="account.group_account_invoice,account.group_account_readonly",
|
||||
@@ -2469,6 +2473,45 @@ class AccountMove(models.Model):
|
||||
line[2]['credit'] = currency_id and formatLang(self.env, line[2]['credit'], currency_obj=currency_id) or line[2]['debit']
|
||||
return preview_vals
|
||||
|
||||
def generate_qr_code(self):
|
||||
""" Generates and returns a QR-code generation URL for this invoice,
|
||||
raising an error message if something is misconfigured.
|
||||
|
||||
The chosen QR generation method is the one set in qr_method field if there is one,
|
||||
or the first eligible one found. If this search had to be performed and
|
||||
and eligible method was found, qr_method field is set to this method before
|
||||
returning the URL. If no eligible QR method could be found, we return None.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.is_invoice():
|
||||
raise UserError(_("QR-codes can only be generated for invoice entries."))
|
||||
|
||||
qr_code_method = self.qr_code_method
|
||||
if qr_code_method:
|
||||
# If the user set a qr code generator manually, we check that we can use it
|
||||
if not self.invoice_partner_bank_id._eligible_for_qr_code(self.qr_code_method, self.partner_id, self.currency_id):
|
||||
raise UserError(_("The chosen QR-code type is not eligible for this invoice."))
|
||||
else:
|
||||
# Else we find one that's eligible and assign it to the invoice
|
||||
for candidate_method, candidate_name in self.env['res.partner.bank'].get_available_qr_methods_in_sequence():
|
||||
if self.invoice_partner_bank_id._eligible_for_qr_code(candidate_method, self.partner_id, self.currency_id):
|
||||
qr_code_method = candidate_method
|
||||
break
|
||||
|
||||
if not qr_code_method:
|
||||
# No eligible method could be found; we can't generate the QR-code
|
||||
return None
|
||||
|
||||
unstruct_ref = self.ref if self.ref else self.name
|
||||
rslt = self.invoice_partner_bank_id.build_qr_code_url(self.amount_residual, unstruct_ref, self.invoice_payment_ref, self.currency_id, self.partner_id, qr_code_method, silent_errors=False)
|
||||
|
||||
# We only set qr_code_method after generating the url; otherwise, it
|
||||
# could be set even in case of a failure in the QR code generation
|
||||
# (which would change the field, but not refresh UI, making the displayed data inconsistent with db)
|
||||
self.qr_code_method = qr_code_method
|
||||
|
||||
return rslt
|
||||
|
||||
class AccountMoveLine(models.Model):
|
||||
_name = "account.move.line"
|
||||
|
||||
@@ -94,6 +94,7 @@ class account_payment(models.Model):
|
||||
possible_bank_partner_ids = fields.Many2many('res.partner', compute='_compute_possible_bank_partners')
|
||||
show_partner_bank_account = fields.Boolean(compute='_compute_show_partner_bank', help='Technical field used to know whether the field `partner_bank_account_id` needs to be displayed or not in the payments form views')
|
||||
require_partner_bank_account = fields.Boolean(compute='_compute_show_partner_bank', help='Technical field used to know whether the field `partner_bank_account_id` needs to be required or not in the payments form views')
|
||||
qr_code = fields.Char(string="QR Code", compute="_compute_qr_code", help="QR-code report URL to use to generate the QR-code to scan with a banking app to perform this payment.")
|
||||
|
||||
@api.model
|
||||
def default_get(self, default_fields):
|
||||
@@ -171,6 +172,28 @@ class account_payment(models.Model):
|
||||
payment_methods = p.journal_id.outbound_payment_method_ids
|
||||
p._payment_methods = default | payment_methods
|
||||
|
||||
@api.depends('partner_bank_account_id', 'amount', 'communication', 'currency_id', 'journal_id', 'state', 'payment_method_id', 'payment_type')
|
||||
def _compute_qr_code(self):
|
||||
for record in self:
|
||||
if record.state in ('draft', 'posted') \
|
||||
and record.partner_bank_account_id \
|
||||
and record.payment_method_id.code == 'manual' \
|
||||
and record.payment_type == 'outbound':
|
||||
|
||||
currency = record.currency_id or record.company_id.currency_id
|
||||
qr_code = record.partner_bank_account_id and record.partner_bank_account_id.build_qr_code_url(record.amount, record.communication, None, currency, record.partner_id) or None
|
||||
if qr_code:
|
||||
record.qr_code = '''
|
||||
<br/>
|
||||
<img class="border border-dark rounded" src="{qr_code}"/>
|
||||
<br/>
|
||||
<strong class="text-center">{txt}</strong>
|
||||
'''.format(txt = _('Scan me with your banking app.'),
|
||||
qr_code = qr_code)
|
||||
continue
|
||||
|
||||
record.qr_code = None
|
||||
|
||||
@api.constrains('amount')
|
||||
def _check_amount(self):
|
||||
for payment in self:
|
||||
@@ -179,7 +202,7 @@ class account_payment(models.Model):
|
||||
|
||||
@api.model
|
||||
def _get_method_codes_using_bank_account(self):
|
||||
return []
|
||||
return ['manual']
|
||||
|
||||
@api.model
|
||||
def _get_method_codes_needing_bank_account(self):
|
||||
@@ -414,6 +437,12 @@ class account_payment(models.Model):
|
||||
'context': {'create': False},
|
||||
}
|
||||
|
||||
def mark_as_sent(self):
|
||||
self.write({'state': 'sent'})
|
||||
|
||||
def unmark_as_sent(self):
|
||||
self.write({'state': 'posted'})
|
||||
|
||||
def unreconcile(self):
|
||||
""" Set back the payments in 'posted' or 'sent' state, without deleting the journal entries.
|
||||
Called when cancelling a bank statement line linked to a pre-registered payment.
|
||||
|
||||
@@ -71,7 +71,7 @@ class ResCompany(models.Model):
|
||||
incoterm_id = fields.Many2one('account.incoterms', string='Default incoterm',
|
||||
help='International Commercial Terms are a series of predefined commercial terms used in international transactions.')
|
||||
|
||||
qr_code = fields.Boolean(string='Display SEPA QR code')
|
||||
qr_code = fields.Boolean(string='Display QR-code on invoices')
|
||||
|
||||
invoice_is_email = fields.Boolean('Email by default', default=True)
|
||||
invoice_is_print = fields.Boolean('Print by default', default=True)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, models, fields, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
class ResPartnerBank(models.Model):
|
||||
_inherit = 'res.partner.bank'
|
||||
|
||||
def build_qr_code_url(self, amount, free_communication, structured_communication, currency, debtor_partner, qr_method=None, silent_errors=True):
|
||||
""" Returns the QR-code report URL to pay to this account with the given parameters,
|
||||
or None if no QR-code could be generated.
|
||||
|
||||
:param amount: The amount to be paid
|
||||
:param free_communication: Free communication to add to the payment when generating one with the QR-code
|
||||
:param structured_communication: Structured communication to add to the payment when generating one with the QR-code
|
||||
:param currency: The currency in which amount is expressed
|
||||
:param debtor_partner: The partner to which this QR-code is aimed (so the one who will have to pay)
|
||||
:param qr_method: The QR generation method to be used to make the QR-code. If None, the first one giving a result will be used.
|
||||
:param silent_errors: If true, forbids errors to be raised if some tested QR-code format can't be generated because of incorrect data.
|
||||
"""
|
||||
if not self:
|
||||
return None
|
||||
|
||||
self.ensure_one()
|
||||
|
||||
if not currency:
|
||||
raise UserError(_("Currency must always be provided in order to generate a QR-code"))
|
||||
|
||||
available_qr_methods = self.get_available_qr_methods_in_sequence()
|
||||
candidate_methods = qr_method and [(qr_method, dict(available_qr_methods)[qr_method])] or available_qr_methods
|
||||
for candidate_method, candidate_name in candidate_methods:
|
||||
if self._eligible_for_qr_code(candidate_method, debtor_partner, currency):
|
||||
error_message = self._check_for_qr_code_errors(candidate_method, amount, currency, debtor_partner, free_communication, structured_communication)
|
||||
|
||||
if not error_message:
|
||||
return self._get_qr_code_url(candidate_method, amount, currency, debtor_partner, free_communication, structured_communication)
|
||||
|
||||
elif not silent_errors:
|
||||
error_header = _("The following error prevented '%s' QR-code to be generated though it was detected as eligible: ") % candidate_name
|
||||
raise UserError( error_header + error_message)
|
||||
|
||||
return None
|
||||
|
||||
def _get_qr_code_url(self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication):
|
||||
""" Hook for extension, to support the different QR generation methods.
|
||||
This function uses the provided qr_method to try generation a QR-code for
|
||||
the given data. It it succeeds, it returns the report URL to make this
|
||||
QR-code; else None.
|
||||
|
||||
:param qr_method: The QR generation method to be used to make the QR-code.
|
||||
:param amount: The amount to be paid
|
||||
:param currency: The currency in which amount is expressed
|
||||
:param debtor_partner: The partner to which this QR-code is aimed (so the one who will have to pay)
|
||||
:param free_communication: Free communication to add to the payment when generating one with the QR-code
|
||||
:param structured_communication: Structured communication to add to the payment when generating one with the QR-code
|
||||
"""
|
||||
return None
|
||||
|
||||
@api.model
|
||||
def _get_available_qr_methods(self):
|
||||
""" Returns the QR-code generation methods that are available on this db,
|
||||
in the form of a list of (code, name, sequence) elements, where
|
||||
'code' is a unique string identifier, 'name' the name to display
|
||||
to the user to designate the method, and 'sequence' is a positive integer
|
||||
indicating the order in which those mehtods need to be checked, to avoid
|
||||
shadowing between them (lower sequence means more prioritary).
|
||||
"""
|
||||
return []
|
||||
|
||||
@api.model
|
||||
def get_available_qr_methods_in_sequence(self):
|
||||
""" Same as _get_available_qr_methods but without returning the sequence,
|
||||
and using it directly to order the returned list.
|
||||
"""
|
||||
all_available = self._get_available_qr_methods()
|
||||
all_available.sort(key=lambda x: x[2])
|
||||
return [(code, name) for (code, name, sequence) in all_available]
|
||||
|
||||
|
||||
def _eligible_for_qr_code(self, qr_method, debtor_partner, currency):
|
||||
""" Tells whether or not the criteria to apply QR-generation
|
||||
method qr_method are met for a payment on this account, in the
|
||||
given currency, by debtor_partner. This does not impeach generation errors,
|
||||
it only checks that this type of QR-code *should be* possible to generate.
|
||||
Consistency of the required field needs then to be checked by _check_for_qr_code_errors().
|
||||
"""
|
||||
return False
|
||||
|
||||
def _check_for_qr_code_errors(self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication):
|
||||
""" Checks the data before generating a QR-code for the specified qr_method
|
||||
(this method must have been checked for eligbility by _eligible_for_qr_code() first).
|
||||
|
||||
Returns None if no error was found, or a string describing the first error encountered
|
||||
so that it can be reported to the user.
|
||||
"""
|
||||
return None
|
||||
@@ -80,7 +80,7 @@ class ResConfigSettings(models.TransientModel):
|
||||
This is useful if you install accounting after having used invoicing for some time and
|
||||
don't want to reconcile all the past payments with bank statements.""")
|
||||
|
||||
qr_code = fields.Boolean(string='Display SEPA QR code', related='company_id.qr_code', readonly=False)
|
||||
qr_code = fields.Boolean(string='Display SEPA QR-code', related='company_id.qr_code', readonly=False)
|
||||
invoice_is_print = fields.Boolean(string='Print', related='company_id.invoice_is_print', readonly=False)
|
||||
invoice_is_email = fields.Boolean(string='Send Email', related='company_id.invoice_is_email', readonly=False)
|
||||
incoterm_id = fields.Many2one('account.incoterms', string='Default incoterm', related='company_id.incoterm_id', help='International Commercial Terms are a series of predefined commercial terms used in international transactions.', readonly=False)
|
||||
|
||||
@@ -195,15 +195,35 @@ class AccountInvoiceReport(models.Model):
|
||||
))
|
||||
|
||||
|
||||
class ReportInvoiceWithPayment(models.AbstractModel):
|
||||
_name = 'report.account.report_invoice_with_payments'
|
||||
_description = 'Account report with payment lines'
|
||||
class ReportInvoiceWithoutPayment(models.AbstractModel):
|
||||
_name = 'report.account.report_invoice'
|
||||
_description = 'Account report without payment lines'
|
||||
|
||||
@api.model
|
||||
def _get_report_values(self, docids, data=None):
|
||||
docs = self.env['account.move'].browse(docids)
|
||||
|
||||
qr_code_urls = {}
|
||||
for invoice in docs:
|
||||
if invoice.display_qr_code:
|
||||
new_code_url = invoice.generate_qr_code()
|
||||
if new_code_url:
|
||||
qr_code_urls[invoice.id] = new_code_url
|
||||
|
||||
return {
|
||||
'doc_ids': docids,
|
||||
'doc_model': 'account.move',
|
||||
'docs': self.env['account.move'].browse(docids),
|
||||
'report_type': data.get('report_type') if data else '',
|
||||
'docs': docs,
|
||||
'qr_code_urls': qr_code_urls,
|
||||
}
|
||||
|
||||
class ReportInvoiceWithPayment(models.AbstractModel):
|
||||
_name = 'report.account.report_invoice_with_payments'
|
||||
_description = 'Account report with payment lines'
|
||||
_inherit = 'report.account.report_invoice'
|
||||
|
||||
@api.model
|
||||
def _get_report_values(self, docids, data=None):
|
||||
rslt = super()._get_report_values(docids, data)
|
||||
rslt['report_type'] = data.get('report_type') if data else ''
|
||||
return rslt
|
||||
|
||||
@@ -571,6 +571,7 @@
|
||||
<field name="company_currency_id" invisible="1"/>
|
||||
<field name="commercial_partner_id" invisible="1"/>
|
||||
<field name="bank_partner_id" invisible="1"/>
|
||||
<field name="display_qr_code" invisible="1"/>
|
||||
|
||||
<field name="invoice_has_outstanding" invisible="1"/>
|
||||
<field name="invoice_sent" invisible="1"/>
|
||||
@@ -1001,6 +1002,7 @@
|
||||
context="{'default_partner_id': commercial_partner_id}"
|
||||
domain="[('partner_id', '=', bank_partner_id)]"
|
||||
attrs="{'readonly': [('state', '!=', 'draft')]}"/>
|
||||
<field name="qr_code_method" attrs="{'invisible': [('display_qr_code', '=', False)]}"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
|
||||
@@ -128,6 +128,8 @@
|
||||
<button name="post" class="oe_highlight" states="draft" string="Confirm" type="object"/>
|
||||
<button name="action_draft" class="btn btn-secondary" attrs="{'invisible': [('state', '=', 'draft')]}" string="Reset To Draft" type="object" groups="account.group_account_invoice"/>
|
||||
<button name="cancel" attrs="{'invisible': [('state', 'in', ('cancelled', 'posted', 'invoicing_legacy'))]}" string="Cancel" type="object"/>
|
||||
<button name="mark_as_sent" string="Mark as Sent" type="object" attrs="{'invisible': ['|', ('state', '!=', 'posted'), ('payment_method_code', '!=', 'manual')]}"/>
|
||||
<button name="unmark_as_sent" string="Unmark as Sent" type="object" attrs="{'invisible': ['|', ('state', '!=', 'sent'), ('payment_method_code', '!=', 'manual')]}"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,posted,reconciled,cancelled"/>
|
||||
</header>
|
||||
|
||||
@@ -202,6 +204,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</group>
|
||||
|
||||
<group>
|
||||
<field name="qr_code" invisible="1"/>
|
||||
<div attrs="{'invisible': [('qr_code', '=', False)]}" colspan="2" class="text-center">
|
||||
<field name="qr_code" widget="html"/>
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
<div class="o_attachment_preview"/>
|
||||
|
||||
@@ -177,13 +177,10 @@
|
||||
<p t-if="o.invoice_incoterm_id" name="note">
|
||||
<strong>Incoterm: </strong><span t-field="o.invoice_incoterm_id.code"/> - <span t-field="o.invoice_incoterm_id.name"/>
|
||||
</p>
|
||||
<div id="qrcode" t-if="(o.company_id.qr_code) and (o.currency_id.name == 'EUR') and (o.invoice_partner_bank_id.acc_number != False)">
|
||||
<p t-if="(o.invoice_partner_bank_id.qr_code_valid)">
|
||||
<strong class="text-center">Scan me with your banking app.</strong><br /><br />
|
||||
<img class="border border-dark rounded" t-att-src="o.invoice_partner_bank_id.build_qr_code_url(o.amount_residual,(o.ref) if (o.ref) else o.name)"/>
|
||||
</p>
|
||||
<p t-if="(o.invoice_partner_bank_id.qr_code_valid == False)">
|
||||
<strong class="text-center">The SEPA QR Code informations are not set correctly.</strong><br />
|
||||
<div id="qrcode" t-if="o.display_qr_code">
|
||||
<p t-if="qr_code_urls.get(o.id)">
|
||||
<strong class="text-center">Scan me with your banking app.</strong><br/><br/>
|
||||
<img class="border border-dark rounded" t-att-src="qr_code_urls[o.id]"/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -356,15 +356,15 @@
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-6 o_setting_box"
|
||||
id="qr_code_invoices"
|
||||
title="Add an EPC QR code to your invoices so that your customers can pay instantly with their mobile banking application. EPC QR codes are used by many European banks to process SEPA payments.">
|
||||
title="Add a QR-code to your invoices so that your customers can pay instantly with their mobile banking application.">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="qr_code" class="oe_inline" widget="upgrade_boolean"/>
|
||||
<field name="qr_code" class="oe_inline"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane" name="qr_code_right_pane">
|
||||
<label string="SEPA QR Code" for="qr_code"/>
|
||||
<label string="QR Codes" for="qr_code"/>
|
||||
<span class="fa fa-lg fa-building-o" title="Values set here are company-specific." aria-label="Values set here are company-specific." groups="base.group_multi_company" role="img"/>
|
||||
<div class="text-muted">
|
||||
Add a payment QR code to your invoices
|
||||
Add a payment QR-code to your invoices
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
from . import tests
|
||||
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': "base_qr_code_sepa",
|
||||
|
||||
'description': """
|
||||
This module adds support for SEPA Credit Transfer QR-code generation.
|
||||
""",
|
||||
|
||||
'category': 'Accounting/Payment',
|
||||
'version': '0.1',
|
||||
|
||||
# any module necessary for this one to work correctly
|
||||
'depends': ['account'],
|
||||
|
||||
'data': [
|
||||
],
|
||||
|
||||
'auto_install': True,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import res_bank
|
||||
@@ -0,0 +1,59 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
|
||||
import werkzeug
|
||||
|
||||
|
||||
class ResPartnerBank(models.Model):
|
||||
_inherit = 'res.partner.bank'
|
||||
|
||||
def _get_qr_code_url(self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication):
|
||||
if qr_method == 'sct_qr':
|
||||
|
||||
comment = (free_communication or '') if not structured_communication else ''
|
||||
|
||||
qr_code_vals = [
|
||||
'BCD', # Service Tag
|
||||
'002', # Version
|
||||
'1', # Character Set
|
||||
'SCT', # Identification Code
|
||||
self.bank_bic or '', # BIC of the Beneficiary Bank
|
||||
(self.acc_holder_name or self.partner_id.name)[:71], # Name of the Beneficiary
|
||||
self.sanitized_acc_number, # Account Number of the Beneficiary
|
||||
str(amount), # Amount of the Transfer in EUR
|
||||
'', # Purpose of the Transfer
|
||||
(structured_communication or '')[:36], # Remittance Information (Structured)
|
||||
comment[:141], # Remittance Information (Unstructured) (can't be set if there is a structured one)
|
||||
'', # Beneficiary to Originator Information
|
||||
]
|
||||
|
||||
return '/report/barcode/?' + werkzeug.urls.url_encode({'type': 'QR', 'value': '\n'.join(qr_code_vals), 'width': 128, 'height': 128, 'humanreadable': 1})
|
||||
|
||||
return super()._get_qr_code_url(qr_method, amount, currency, debtor_partner, free_communication, structured_communication)
|
||||
|
||||
def _eligible_for_qr_code(self, qr_method, debtor_partner, currency):
|
||||
if qr_method == 'sct_qr':
|
||||
|
||||
# Some countries share the same IBAN country code
|
||||
# (e.g. Åland Islands and Finland IBANs are 'FI', but Åland Islands' code is 'AX').
|
||||
sepa_country_codes = self.env.ref('base.sepa_zone').country_ids.mapped('code')
|
||||
non_iban_codes = {'AX', 'NC', 'YT', 'TF', 'BL', 'RE', 'MF', 'GP', 'PM', 'PF', 'GF', 'MQ', 'JE', 'GG', 'IM'}
|
||||
sepa_iban_codes = {code for code in sepa_country_codes if code not in non_iban_codes}
|
||||
|
||||
return currency.name == 'EUR' and self.acc_type == 'iban' and self.sanitized_acc_number[:2] in sepa_iban_codes
|
||||
|
||||
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):
|
||||
if qr_method == 'sct_qr':
|
||||
if not self.acc_holder_name and not self.partner_id.name:
|
||||
return _("The account receiving the payment must have an account horder name or partner name set.")
|
||||
|
||||
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(('sct_qr', _("SEPA Credit Transfer QR"), 20))
|
||||
return rslt
|
||||
@@ -0,0 +1,3 @@
|
||||
# *-* coding:utf-8 *-*
|
||||
|
||||
from . import test_sepa_qr
|
||||
@@ -0,0 +1,62 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
|
||||
|
||||
class TestSEPAQRCode(AccountTestInvoicingCommon):
|
||||
""" Tests the generation of Swiss QR-codes on invoices
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(TestSEPAQRCode, cls).setUpClass()
|
||||
|
||||
cls.company_data['company'].qr_code = True
|
||||
cls.acc_sepa_iban = cls.env['res.partner.bank'].create({
|
||||
'acc_number': 'BE15001559627230',
|
||||
'partner_id': cls.company_data['company'].partner_id.id,
|
||||
})
|
||||
|
||||
cls.acc_non_sepa_iban = cls.env['res.partner.bank'].create({
|
||||
'acc_number': 'SA4420000001234567891234',
|
||||
'partner_id': cls.company_data['company'].partner_id.id,
|
||||
})
|
||||
|
||||
cls.sepa_qr_invoice = cls.env['account.move'].create({
|
||||
'move_type': 'out_invoice',
|
||||
'partner_id': cls.partner_a.id,
|
||||
'currency_id': cls.env.ref('base.EUR').id,
|
||||
'invoice_partner_bank_id': cls.acc_sepa_iban.id,
|
||||
'company_id': cls.company_data['company'].id,
|
||||
'invoice_line_ids': [
|
||||
(0, 0, {'quantity': 1, 'price_unit': 100})
|
||||
],
|
||||
})
|
||||
|
||||
def test_sepa_qr_code_generation(self):
|
||||
""" Check different cases of SEPA QR-code generation, when qr_method is
|
||||
specified beforehand.
|
||||
"""
|
||||
self.sepa_qr_invoice.qr_code_method = 'sct_qr'
|
||||
|
||||
# Using a SEPA IBAN should work
|
||||
self.sepa_qr_invoice.generate_qr_code()
|
||||
|
||||
# Using a non-SEPA IBAN shouldn't
|
||||
self.sepa_qr_invoice.invoice_partner_bank_id = self.acc_non_sepa_iban
|
||||
with self.assertRaises(UserError, msg="It shouldn't be possible to generate a SEPA QR-code for IBAN of countries outside SEPA zone."):
|
||||
self.sepa_qr_invoice.generate_qr_code()
|
||||
|
||||
# Changing the currency should break it as well
|
||||
self.sepa_qr_invoice.invoice_partner_bank_id = self.acc_sepa_iban
|
||||
self.sepa_qr_invoice.currency_id = self.env.ref('base.USD').id
|
||||
with self.assertRaises(UserError, msg="It shouldn't be possible to generate a SEPA QR-code for another currency as EUR."):
|
||||
self.sepa_qr_invoice.generate_qr_code()
|
||||
|
||||
def test_sepa_qr_code_detection(self):
|
||||
""" Checks SEPA QR-code auto-detection when no specific QR-method
|
||||
is given to the invoice.
|
||||
"""
|
||||
self.sepa_qr_invoice.generate_qr_code()
|
||||
self.assertEqual(self.sepa_qr_invoice.qr_code_method, 'sct_qr', "SEPA QR-code generator should have been chosen for this invoice.")
|
||||
|
||||
@@ -7,4 +7,5 @@ from . import account_journal
|
||||
from . import mail_template
|
||||
from . import res_bank
|
||||
from . import res_company
|
||||
from . import account_bank_statement
|
||||
from . import account_bank_statement
|
||||
from . import ir_actions_report
|
||||
|
||||
@@ -166,11 +166,12 @@ class AccountMove(models.Model):
|
||||
return float_split_str(self.amount_residual, 2)
|
||||
|
||||
def display_swiss_qr_code(self):
|
||||
""" Trigger the print of the Swiss QR code in the invoice report or not
|
||||
""" Returns whether or not to print the QR-bills additional page when
|
||||
generation an invoice pdf.
|
||||
"""
|
||||
self.ensure_one()
|
||||
qr_parameter = self.env['ir.config_parameter'].sudo().get_param('l10n_ch.print_qrcode')
|
||||
return self.partner_id.country_id.code == 'CH' and qr_parameter
|
||||
return self.display_qr_code and \
|
||||
self.invoice_partner_bank_id._eligible_for_qr_code('ch_qr', self.partner_id, self.currency_id)
|
||||
|
||||
def isr_print(self):
|
||||
""" Triggered by the 'Print ISR' button.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
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 = 'addons/l10n_ch/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
|
||||
qr_cross = ReportLabImage((width/2 - cross_width/2) / mm, (height/2 - cross_height/2) / mm, cross_width / mm, cross_height / mm, CH_QR_CROSS_FILE)
|
||||
barcode_drawing.add(qr_cross)
|
||||
@@ -5,6 +5,7 @@ import re
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.tools.misc import mod10r
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
import werkzeug.urls
|
||||
|
||||
@@ -98,91 +99,133 @@ class ResPartnerBank(models.Model):
|
||||
return iban[-12:]
|
||||
return None
|
||||
|
||||
def find_number(self, s):
|
||||
# this regex match numbers like 1bis 1a
|
||||
lmo = re.findall('([0-9]+[^ ]*)',s)
|
||||
# no number found
|
||||
if len(lmo) == 0:
|
||||
return ''
|
||||
# Only one number or starts with a number return the first one
|
||||
if len(lmo) == 1 or re.match(r'^\s*([0-9]+[^ ]*)',s):
|
||||
return lmo[0]
|
||||
# else return the last one
|
||||
if len(lmo) > 1:
|
||||
return lmo[-1]
|
||||
else:
|
||||
return ''
|
||||
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&humanreadable=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)[:71], # 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.name[:71], # 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 _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()
|
||||
|
||||
iid_start_index = 4
|
||||
iid_end_index = 8
|
||||
iid = self.sanitized_acc_number[iid_start_index : iid_end_index+1]
|
||||
return self.acc_type == 'iban' \
|
||||
and re.match('\d+', iid) \
|
||||
and 30000 <= int(iid) <= 31999 # Those values for iid are reserved for QR-IBANs only
|
||||
|
||||
@api.model
|
||||
def build_swiss_code_url(self, amount, currency, date_due, debitor, ref_type, reference, comment):
|
||||
communication = ""
|
||||
if comment:
|
||||
communication = (comment[:137] + '...') if len(comment) > 140 else comment
|
||||
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])
|
||||
|
||||
t_street_comp = '%s %s' % (self.company_id.street if (self.company_id.street != False) else '', self.company_id.street2 if (self.company_id.street2 != False) else '')
|
||||
t_street_deb = '%s %s' % (debitor.street if (debitor.street != False) else '', debitor.street2 if (debitor.street2 != False) else '')
|
||||
number = self.find_number(t_street_comp)
|
||||
number_deb = self.find_number(t_street_deb)
|
||||
if (t_street_comp == ' '):
|
||||
t_street_comp = False
|
||||
if (t_street_deb == ' '):
|
||||
t_street_deb = False
|
||||
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
|
||||
"""
|
||||
line_1 = (partner and partner.street or '') + ' ' + (partner and partner.street2 or '')
|
||||
line_2 = partner.zip + ' ' + partner.city
|
||||
return line_1[:71], line_2[:71]
|
||||
|
||||
qr_code_string = 'SPC\n0100\n1\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s' % (
|
||||
self.acc_number,
|
||||
self.company_id.name,
|
||||
t_street_comp,
|
||||
number,
|
||||
self.company_id.zip,
|
||||
self.company_id.city,
|
||||
self.company_id.country_id.code,
|
||||
amount,
|
||||
currency,
|
||||
date_due,
|
||||
debitor.name,
|
||||
t_street_deb,
|
||||
number_deb,
|
||||
debitor.zip,
|
||||
debitor.city,
|
||||
debitor.country_id.code,
|
||||
ref_type,
|
||||
reference,
|
||||
communication)
|
||||
qr_code_url = '/report/barcode/?type=%s&value=%s&width=%s&height=%s&humanreadable=1' % ('QR', werkzeug.urls.url_quote_plus(qr_code_string), 256, 256)
|
||||
return qr_code_url
|
||||
def _eligible_for_qr_code(self, qr_method, debtor_partner, currency):
|
||||
if qr_method == 'ch_qr':
|
||||
|
||||
def validate_swiss_code_arguments(self, currency, debitor):
|
||||
currency = currency or self.company_id.currency_id
|
||||
t_street_comp = '%s %s' % (self.company_id.street if (self.company_id.street != False) else '', self.company_id.street2 if (self.company_id.street2 != False) else '')
|
||||
t_street_deb = '%s %s' % (debitor.street if (debitor.street != False) else '', debitor.street2 if (debitor.street2 != False) else '')
|
||||
number = self.find_number(t_street_comp)
|
||||
number_deb = self.find_number(t_street_deb)
|
||||
if (t_street_comp == ' '):
|
||||
t_street_comp = False
|
||||
if (t_street_deb == ' '):
|
||||
t_street_deb = False
|
||||
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')
|
||||
|
||||
if(currency.name == 'EUR'):
|
||||
return (self.l10n_ch_isr_subscription_eur and
|
||||
self.company_id.zip and
|
||||
self.company_id.city and
|
||||
self.company_id.country_id.code and
|
||||
(t_street_comp != False) and
|
||||
(t_street_deb != False) and
|
||||
debitor.zip and
|
||||
debitor.city and
|
||||
debitor.country_id.code and
|
||||
(number != False) and (number_deb != False))
|
||||
elif(currency.name == 'CHF'):
|
||||
return (self.l10n_ch_isr_subscription_chf and
|
||||
self.company_id.zip and
|
||||
self.company_id.city and
|
||||
self.company_id.country_id.code and
|
||||
(t_street_comp != False) and
|
||||
(t_street_deb != False) and
|
||||
debitor.zip and
|
||||
debitor.city and
|
||||
debitor.country_id.code and
|
||||
(number != False) and (number_deb != False))
|
||||
else:
|
||||
return False
|
||||
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 \
|
||||
(self.partner_id.street or self.partner_id.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 is intended to 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): #TODO OCO ajouter une séquence (pour s'assurer que toujours checké avant SEPA)
|
||||
rslt = super()._get_available_qr_methods()
|
||||
rslt.append(('ch_qr', _("Swiss QR bill"), 10))
|
||||
return rslt
|
||||
|
||||
@@ -18,4 +18,3 @@ class ResConfigSettings(models.TransientModel):
|
||||
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)
|
||||
l10n_ch_print_qrcode = fields.Boolean("Print Swiss QR Code", config_parameter='l10n_ch.print_qrcode')
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
<odoo>
|
||||
<data>
|
||||
<template id="l10n_ch_swissqr_template" inherit_id="account.report_invoice_document">
|
||||
<xpath expr="//div[@id='qrcode']" position="attributes">
|
||||
<attribute name="t-if">not o.display_swiss_qr_code() and o.display_qr_code</attribute>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//div[@id='qrcode']" position="after">
|
||||
<link type="text/css" rel="stylesheet" href="/l10n_ch/static/src/scss/report_swissqr.css"/>
|
||||
<div class="swissqr_content" t-if="o.display_swiss_qr_code()">
|
||||
<div class="swissqr_body" t-if="o.invoice_partner_bank_id.validate_swiss_code_arguments(o.invoice_partner_bank_id.currency_id, o.partner_id) and ((o.currency_id.name == 'EUR') or (o.currency_id.name == 'CHF'))">
|
||||
<div class="swissqr_body" t-if="o.invoice_partner_bank_id._eligible_for_qr_code('ch_qr', o.partner_id, o.currency_id)">
|
||||
<div id="title_zone" class="swissqr_column_left title_zone">
|
||||
<span class="swissqr_text title title_zone">QR-bill payment part</span><br/>
|
||||
</div>
|
||||
@@ -15,10 +19,7 @@
|
||||
<span class="swissqr_text content">Credit transfer</span>
|
||||
</div>
|
||||
|
||||
<img class="swissqr" t-att-src="o.invoice_partner_bank_id.build_swiss_code_url(o.amount_residual, o.currency_id.name, o.invoice_date_due, o.partner_id, 'QRR',
|
||||
(o.invoice_partner_bank_id.l10n_ch_isr_subscription_chf) if (o.invoice_partner_bank_id.currency_id.name == 'CHF') else o.invoice_partner_bank_id.l10n_ch_isr_subscription_eur,
|
||||
(o.ref) if (o.ref) else o.name)"/>
|
||||
<img class="ch_cross" src="/l10n_ch/static/src/img/CH-Cross_7mm.png"/>
|
||||
<img class="swissqr" t-att-src="o.invoice_partner_bank_id.build_qr_code_url(o.amount_residual, o.ref or o.name, o.invoice_payment_ref, o.currency_id, o.partner_id, 'ch_qr')"/>
|
||||
|
||||
<div id="indications_zone" class="swissqr_column_right indication_zone">
|
||||
<span class="swissqr_text title">Account</span><br/>
|
||||
@@ -65,22 +66,6 @@
|
||||
<span class="swissqr_text content" t-esc="formated_amount"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swissqr_body" t-if="(o.invoice_partner_bank_id.validate_swiss_code_arguments(o.invoice_partner_bank_id.currency_id, o.partner_id) == False) and ((o.currency_id.name == 'EUR') or (o.currency_id.name == 'CHF'))">
|
||||
<div class="swissqr_column_left procedure_zone">
|
||||
<span>Some pieces of this information are not set correctly to display the Swiss QR Code.<br/>
|
||||
-Bank > CHF ISR subscription<br/>
|
||||
-Bank > EUR ISR subscription<br/>
|
||||
-Your company > Street with number<br/>
|
||||
-Your company > City<br/>
|
||||
-Your company > Zip<br/>
|
||||
-Your company > Country<br/>
|
||||
-Debtor > Street with number<br/>
|
||||
-Debtor > City<br/>
|
||||
-Debtor > Zip<br/>
|
||||
-Debtor > Country
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import test_ch_qr_code
|
||||
from . import test_l10n_ch_isr
|
||||
from . import test_l10n_ch_swissqr
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
|
||||
|
||||
class TestSwissQRCode(AccountTestInvoicingCommon):
|
||||
""" Tests the generation of Swiss QR-codes on invoices
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(TestSwissQRCode, cls).setUpClass()
|
||||
|
||||
cls.company_data['company'].qr_code = True
|
||||
cls.company_data['company'].country_id = None
|
||||
|
||||
cls.swiss_iban = cls.env['res.partner.bank'].create({
|
||||
'acc_number': 'CH15 3881 5158 3845 3843 7',
|
||||
'partner_id': cls.company_data['company'].partner_id.id,
|
||||
})
|
||||
|
||||
cls.swiss_qr_iban = cls.env['res.partner.bank'].create({
|
||||
'acc_number': 'CH21 3080 8001 2345 6782 7',
|
||||
'partner_id': cls.company_data['company'].partner_id.id,
|
||||
})
|
||||
|
||||
cls.ch_qr_invoice = cls.env['account.move'].create({
|
||||
'move_type': 'out_invoice',
|
||||
'partner_id': cls.partner_a.id,
|
||||
'currency_id': cls.env.ref('base.CHF').id,
|
||||
'invoice_partner_bank_id': cls.swiss_iban.id,
|
||||
'company_id': cls.company_data['company'].id,
|
||||
'invoice_payment_ref': "Papa a vu le fifi de lolo",
|
||||
'invoice_line_ids': [
|
||||
(0, 0, {'quantity': 1, 'price_unit': 100})
|
||||
],
|
||||
})
|
||||
|
||||
def _assign_partner_address(self, partner):
|
||||
partner.write({
|
||||
'country_id': self.env.ref('base.ch').id,
|
||||
'street': "Crab street, 11",
|
||||
'city': "Crab City",
|
||||
'zip': "4242",
|
||||
})
|
||||
|
||||
def test_swiss_qr_code_generation(self):
|
||||
""" Check different cases of Swiss QR-code generation, when qr_method is
|
||||
specified beforehand.
|
||||
"""
|
||||
self.ch_qr_invoice.qr_code_method = 'ch_qr'
|
||||
|
||||
# First check with a regular IBAN
|
||||
with self.assertRaises(UserError, msg="It shouldn't be possible to generate a Swiss QR-code for partners without a complete Swiss address."):
|
||||
self.ch_qr_invoice.generate_qr_code()
|
||||
|
||||
# Setting the address should make it work
|
||||
self._assign_partner_address(self.ch_qr_invoice.company_id.partner_id)
|
||||
self._assign_partner_address(self.ch_qr_invoice.partner_id)
|
||||
|
||||
self.ch_qr_invoice.generate_qr_code()
|
||||
|
||||
# Now, check with a QR-IBAN as the payment account
|
||||
self.ch_qr_invoice.invoice_partner_bank_id = self.swiss_qr_iban
|
||||
|
||||
with self.assertRaises(UserError, msg="It shouldn't be possible to generate a Swiss QR-cde for a QR-IBAN without giving it a valid QR-reference as payment reference."):
|
||||
self.ch_qr_invoice.generate_qr_code()
|
||||
|
||||
# Assigning a QR reference should fix it
|
||||
self.ch_qr_invoice.invoice_payment_ref = '210000000003139471430009017'
|
||||
|
||||
def test_ch_qr_code_detection(self):
|
||||
""" Checks Swiss QR-code auto-detection when no specific QR-method
|
||||
is given to the invoice.
|
||||
"""
|
||||
self._assign_partner_address(self.ch_qr_invoice.company_id.partner_id)
|
||||
self._assign_partner_address(self.ch_qr_invoice.partner_id)
|
||||
self.ch_qr_invoice.generate_qr_code()
|
||||
self.assertEqual(self.ch_qr_invoice.qr_code_method, 'ch_qr', "Swiss QR-code generator should have been chosen for this invoice.")
|
||||
@@ -1,20 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class SwissQRTest(TransactionCase):
|
||||
|
||||
def test_abc_1(self):
|
||||
assert self.env['res.partner.bank'].find_number('1 rue du bois communal') == '1'
|
||||
assert self.env['res.partner.bank'].find_number('1bis rue du bois communal') == '1bis'
|
||||
assert self.env['res.partner.bank'].find_number('rue du bois communal 1') == '1'
|
||||
assert self.env['res.partner.bank'].find_number('rue du bois communal 1bis') == '1bis'
|
||||
assert self.env['res.partner.bank'].find_number('1 rue du 9 mars 1962') == '1'
|
||||
assert self.env['res.partner.bank'].find_number('rue du 9 mars 1962 1') == '1'
|
||||
assert self.env['res.partner.bank'].find_number('1bis rue du 9 mars 1962') == '1bis'
|
||||
assert self.env['res.partner.bank'].find_number('rue du 9 mars 1962 1bis') == '1bis'
|
||||
assert self.env['res.partner.bank'].find_number('rue du 9 mars 1962') == '1962'
|
||||
assert self.env['res.partner.bank'].find_number('rue du 9 mars 1962 4 boite 2') == '2'
|
||||
@@ -8,15 +8,6 @@
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@id='invoicing_settings']" position="inside">
|
||||
<div class="col-12 col-lg-6 o_setting_box" id="l10n_ch-isr_print_bank" attrs="{'invisible': [('country_code', '!=', 'CH')]}">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="l10n_ch_print_qrcode"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="l10n_ch_print_qrcode"/>
|
||||
<div class="text-muted">
|
||||
Append the QR Code ISR at the end of customer invoices for Swiss customers
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="l10n_ch_isr_print_bank_location"/>
|
||||
</div>
|
||||
|
||||
@@ -149,7 +149,7 @@ class PaymentAcquirer(models.Model):
|
||||
fees_dom_var = fields.Float('Variable domestic fees (in percents)')
|
||||
fees_int_fixed = fields.Float('Fixed international fees')
|
||||
fees_int_var = fields.Float('Variable international fees (in percents)')
|
||||
qr_code = fields.Boolean('Use SEPA QR Code')
|
||||
qr_code = fields.Boolean('Enable QR Codes', help="Enable the use of QR-codes for payments made on this provider.")
|
||||
|
||||
# TDE FIXME: remove that brol
|
||||
module_id = fields.Many2one('ir.module.module', string='Corresponding Module')
|
||||
|
||||
@@ -93,13 +93,12 @@
|
||||
<span name="form_amount" class="form-control" t-esc="tx.amount" t-options="{'widget': 'monetary', 'display_currency': tx.currency_id}"/>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="(tx.acquirer_id.qr_code == True) and (tx.currency_id.name == 'EUR')">
|
||||
<div t-if="tx.acquirer_id.journal_id.bank_account_id.qr_code_valid">
|
||||
|
||||
<div t-if="tx.acquirer_id.qr_code">
|
||||
<t t-set="qr_code" t-value="tx.acquirer_id.journal_id.bank_account_id.build_qr_code_url(tx.amount, payment_tx_id.reference, None, tx.currency_id, tx.partner_id)"/>
|
||||
<div class="card-body" t-if="qr_code">
|
||||
<h3>Or scan me with your banking app.</h3>
|
||||
<img class="border border-dark rounded" t-att-src="tx.acquirer_id.journal_id.bank_account_id.build_qr_code_url(tx.amount,tx.reference)"/>
|
||||
</div>
|
||||
<div t-if="(tx.acquirer_id.journal_id.bank_account_id.qr_code_valid == False)">
|
||||
<h3>The SEPA QR Code informations are not set correctly.</h3>
|
||||
<img class="border border-dark rounded" t-att-src="qr_code"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -130,13 +129,12 @@
|
||||
<div t-if="payment_tx_id.acquirer_id.provider == 'transfer' and reference">
|
||||
<b>Communication: </b><span t-esc='reference'/>
|
||||
</div>
|
||||
<div t-if="(payment_tx_id.acquirer_id.qr_code == True) and (payment_tx_id.currency_id.name == 'EUR')">
|
||||
<div t-if="payment_tx_id.acquirer_id.journal_id.bank_account_id.qr_code_valid">
|
||||
|
||||
<div t-if="payment_tx_id.acquirer_id.qr_code and (payment_tx_id.acquirer_id.provider == 'transfer')">
|
||||
<t t-set="qr_code" t-value="payment_tx_id.acquirer_id.journal_id.bank_account_id.build_qr_code_url(payment_tx_id.amount,payment_tx_id.reference, None, payment_tx_id.currency_id, payment_tx_id.partner_id)"/>
|
||||
<div class="card-body" t-if="qr_code">
|
||||
<h3>Or scan me with your banking app.</h3>
|
||||
<img class="border border-dark rounded" t-att-src="payment_tx_id.acquirer_id.journal_id.bank_account_id.build_qr_code_url(payment_tx_id.amount,payment_tx_id.reference)"/>
|
||||
</div>
|
||||
<div t-if="(payment_tx_id.acquirer_id.journal_id.bank_account_id.qr_code_valid == False)">
|
||||
<h3>The SEPA QR Code informations are not set correctly.</h3>
|
||||
<img class="border border-dark rounded" t-att-src="qr_code"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1991,7 +1991,7 @@ class ReportController(http.Controller):
|
||||
# Misc. route utils
|
||||
#------------------------------------------------------
|
||||
@http.route(['/report/barcode', '/report/barcode/<type>/<path:value>'], type='http', auth="public")
|
||||
def report_barcode(self, type, value, width=600, height=100, humanreadable=0, quiet=1):
|
||||
def report_barcode(self, type, value, width=600, height=100, humanreadable=0, quiet=1, mask=None):
|
||||
"""Contoller able to render barcode images thanks to reportlab.
|
||||
Samples:
|
||||
<img t-att-src="'/report/barcode/QR/%s' % o.name"/>
|
||||
@@ -2005,10 +2005,13 @@ class ReportController(http.Controller):
|
||||
at the bottom of the output image
|
||||
:param quiet: Accepted values: 0 (default) or 1. 1 will display white
|
||||
margins on left and right.
|
||||
:param mask: The mask code to be used when rendering this QR-code.
|
||||
Masks allow adding elements on top of the generated image,
|
||||
such as the Swiss cross in the center of QR-bill codes.
|
||||
"""
|
||||
try:
|
||||
barcode = request.env['ir.actions.report'].barcode(type, value, width=width,
|
||||
height=height, humanreadable=humanreadable, quiet=quiet)
|
||||
height=height, humanreadable=humanreadable, quiet=quiet, mask=mask)
|
||||
except (ValueError, AttributeError):
|
||||
raise werkzeug.exceptions.HTTPException(description='Cannot convert into barcode.')
|
||||
|
||||
|
||||
@@ -1625,13 +1625,12 @@
|
||||
<div t-if="payment_tx_id.acquirer_id.provider == 'transfer' and order.reference" class="card-body">
|
||||
<b>Communication: </b><span t-esc='order.reference'/>
|
||||
</div>
|
||||
<div t-if="(payment_tx_id.acquirer_id.qr_code == True) and (payment_tx_id.acquirer_id.provider == 'transfer') and (payment_tx_id.currency_id.name == 'EUR')">
|
||||
<div class="card-body" t-if="payment_tx_id.acquirer_id.journal_id.bank_account_id.qr_code_valid">
|
||||
|
||||
<div t-if="payment_tx_id.acquirer_id.qr_code and (payment_tx_id.acquirer_id.provider == 'transfer')">
|
||||
<t t-set="qr_code" t-value="payment_tx_id.acquirer_id.journal_id.bank_account_id.build_qr_code_url(order.amount_total,payment_tx_id.reference, None, payment_tx_id.currency_id, payment_tx_id.partner_id)"/>
|
||||
<div class="card-body" t-if="qr_code">
|
||||
<h3>Or scan me with your banking app.</h3>
|
||||
<img class="border border-dark rounded" t-att-src="payment_tx_id.acquirer_id.journal_id.bank_account_id.build_qr_code_url(order.amount_total,payment_tx_id.reference)"/>
|
||||
</div>
|
||||
<div class="card-body" t-if="payment_tx_id.acquirer_id.journal_id.bank_account_id.qr_code_valid == False">
|
||||
<h3>The SEPA QR Code informations are not set correctly.</h3>
|
||||
<img class="border border-dark rounded" t-att-src="qr_code"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -491,7 +491,7 @@ class IrActionsReport(models.Model):
|
||||
return report_obj.with_context(context).search(conditions, limit=1)
|
||||
|
||||
@api.model
|
||||
def barcode(self, barcode_type, value, width=600, height=100, humanreadable=0, quiet=1):
|
||||
def barcode(self, barcode_type, value, width=600, height=100, humanreadable=0, quiet=1, mask=None):
|
||||
if barcode_type == 'UPCA' and len(value) in (11, 12, 13):
|
||||
barcode_type = 'EAN13'
|
||||
if len(value) in (11, 12):
|
||||
@@ -502,6 +502,15 @@ class IrActionsReport(models.Model):
|
||||
barcode_type, value=value, format='png', width=width, height=height,
|
||||
humanReadable=humanreadable, quiet=quiet
|
||||
)
|
||||
|
||||
# If a mask is asked and it is available, call its function to
|
||||
# post-process the generated QR-code image
|
||||
if mask:
|
||||
available_masks = self.get_available_barcode_masks()
|
||||
mask_to_apply = available_masks.get(mask)
|
||||
if mask_to_apply:
|
||||
mask_to_apply(width, height, barcode)
|
||||
|
||||
return barcode.asString('png')
|
||||
except (ValueError, AttributeError):
|
||||
if barcode_type == 'Code128':
|
||||
@@ -510,6 +519,19 @@ class IrActionsReport(models.Model):
|
||||
return self.barcode('Code128', value, width=width, height=height,
|
||||
humanreadable=humanreadable, quiet=quiet)
|
||||
|
||||
@api.model
|
||||
def get_available_barcode_masks(self):
|
||||
""" Hook for extension.
|
||||
This function returns the available QR-code masks, in the form of a
|
||||
list of (code, mask_function) elements, where code is a string identifying
|
||||
the mask uniquely, and mask_function is a function returning a reportlab
|
||||
Drawing object with the result of the mask, and taking as parameters:
|
||||
- width of the QR-code, in pixels
|
||||
- height of the QR-code, in pixels
|
||||
- reportlab Drawing object containing the barcode to apply the mask on
|
||||
"""
|
||||
return {}
|
||||
|
||||
def render_template(self, template, values=None):
|
||||
"""Allow to render a QWeb template python-side. This function returns the 'ir.ui.view'
|
||||
render but embellish it with some variables/methods used in reports.
|
||||
|
||||
@@ -6,6 +6,7 @@ import collections
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.osv import expression
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
import werkzeug.urls
|
||||
|
||||
@@ -49,12 +50,12 @@ class Bank(models.Model):
|
||||
domain = ['&'] + domain
|
||||
bank_ids = self._search(domain + args, limit=limit, access_rights_uid=name_get_uid)
|
||||
return models.lazy_name_get(self.browse(bank_ids).with_user(name_get_uid))
|
||||
|
||||
|
||||
@api.onchange('country')
|
||||
def _onchange_country_id(self):
|
||||
if self.country and self.country != self.state.country_id:
|
||||
self.state = False
|
||||
|
||||
|
||||
@api.onchange('state')
|
||||
def _onchange_state(self):
|
||||
if self.state.country_id:
|
||||
@@ -86,7 +87,6 @@ class ResPartnerBank(models.Model):
|
||||
sequence = fields.Integer(default=10)
|
||||
currency_id = fields.Many2one('res.currency', string='Currency')
|
||||
company_id = fields.Many2one('res.company', 'Company', default=lambda self: self.env.company, ondelete='cascade')
|
||||
qr_code_valid = fields.Boolean(string="Has all required arguments", compute="_validate_qr_code_arguments")
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_number', 'unique(sanitized_acc_number, company_id)', 'Account Number must be unique'),
|
||||
@@ -125,23 +125,3 @@ class ResPartnerBank(models.Model):
|
||||
args[pos] = ('sanitized_acc_number', op, value)
|
||||
pos += 1
|
||||
return super(ResPartnerBank, self)._search(args, offset, limit, order, count=count, access_rights_uid=access_rights_uid)
|
||||
|
||||
@api.model
|
||||
def build_qr_code_url(self, amount, comment):
|
||||
communication = ""
|
||||
if comment:
|
||||
communication = (comment[:137] + '...') if len(comment) > 140 else comment
|
||||
qr_code_string = 'BCD\n001\n1\nSCT\n%s\n%s\n%s\nEUR%s\n\n\n%s' % (self.bank_bic or "", self.company_id.name, self.acc_number, amount, communication)
|
||||
qr_code_url = '/report/barcode/?' + werkzeug.urls.url_encode({'type': 'QR', 'value': qr_code_string, 'width': 128, 'height': 128, 'humanreadable': 1})
|
||||
return qr_code_url
|
||||
|
||||
def _validate_qr_code_arguments(self):
|
||||
sepa_zones_codes = [c.code for c in self.env.ref('base.sepa_zone').country_ids]
|
||||
# Some country instances share the same IBAN country code (e.g. Åland Islands and Finland IBANs are "FI", but Åland Islands code is "AX").
|
||||
# Therefore sepa_zones_codes is too permissive, "AX" is not a valid IBAN country code.
|
||||
not_iban_codes = ("AX", "NC", "YT", "TF", "BL", "RE", "MF", "GP", "PM", "PF", "GF", "MQ", "JE", "GG", "IM")
|
||||
sepa_zones_codes = [code for code in sepa_zones_codes if code not in not_iban_codes]
|
||||
for bank in self:
|
||||
bank.qr_code_valid = (bank.company_id.name and
|
||||
bank.acc_number and
|
||||
bank.sanitized_acc_number[:2] in sepa_zones_codes)
|
||||
|
||||
Reference in New Issue
Block a user