Rework: use native res.partner.bank qr_code_method instead of report xpath

Register 'spayd' as a proper qr_code_method on res.partner.bank (same
extension point account_qr_code_sepa uses for EPC/'sct_qr'), sequence=6
so it's tried before EPC (sequence 20). Eligibility now requires CZK/EUR
currency AND a Czech customer country - previously the report template
replaced EPC for *any* customer as long as the invoice currency was CZK
or EUR, which would incorrectly show a Czech SPAYD code to e.g. a German
customer being invoiced in EUR.

account.move._generate_qr_code() already tries all registered methods in
priority order and the existing div#qrcode block in
account.report_invoice_document already renders whatever comes back, so
this needs no report template override at all.

- Move get_cz_qr_code_base64()/cz_to_iban() from account_move.py into
  res_partner_bank.py (as _get_qr_code_generation_params/_spayd_to_iban);
  fix bank_code not being zero-padded to 4 digits, which could throw off
  the mod-97 checksum for short local bank codes. Verified the checksum
  against the standard 19-2000145399/0800 -> CZ6508000000192000145399
  reference example and general IBAN mod-97 validity.
- Remove views/report_invoice.xml (nothing left to override) and the now
  unnecessary qrcode Python dependency (Odoo's own barcode report action
  renders the image; this module only supplies the SPAYD string).
- Update README FAQ to match the new eligibility rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Tomas
2026-08-25 02:08:03 +02:00
co-authored by Claude Sonnet 5
parent cbf5fe3450
commit d381249225
8 changed files with 98 additions and 136 deletions
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+3 -2
View File
@@ -46,14 +46,15 @@ If you are a **Slovak company** invoicing **Czech customers in EUR**:
**Q: The QR code is not generated on the invoice**
A: Make sure you have:
- The **QR Codes** option enabled in the **Invoicing** app configuration
- A bank account set up for your company with the **same currency as the invoice** (EUR or CZK)
- The invoice's bank account (**Payment QR-code** section) is in **CZK or EUR**
- The **customer's country is set to Czech Republic** - SPAYD is only offered
automatically for Czech customers; for anyone else Odoo falls back to EPC
- The invoice is **confirmed/posted**
![QR Code Settings](static/description/qr_payment_settings_en.png)
**Q: The QR code is generated on the invoice, but scanning it results in an error**
A: Check whether:
- A bank account for the **invoice currency** (EUR or CZK) is set up for the company
- The bank account number is in **valid format** (Czech format `123-456/0800` or IBAN)
- The customer's banking app supports **SPAYD QR codes**
+7 -8
View File
@@ -20,8 +20,12 @@ Key Features:
-------------
* **Standard SPAYD Support:** Fully compatible with all major Czech banking apps (ČSOB, Komerční banka, Česká spořitelna, etc.)
* **Smart IBAN Conversion:** Automatically converts local account numbers (e.g. 123-456/0800) to IBAN format.
* **Multi-country IBAN Support:** Works with Czech (CZ), Slovak (SK), and other SEPA IBANs.
* **Integration:** Works with standard Odoo invoice templates.
* **Multi-country IBAN Support:** Works with Czech (CZ), Slovak (SK), and other IBANs.
* **Integration:** Registers as a native Odoo QR-code method (res.partner.bank
qr_code_method='spayd', same mechanism account_qr_code_sepa uses for EPC) -
no changes to the invoice print template are needed, and it's automatically
preferred over EPC for Czech customers, falling back to EPC otherwise.
Users can still switch manually via the "Payment QR-code" field.
* **Currency Support:** CZK and EUR (for cross-border payments).
Usage for Slovak Companies:
@@ -37,12 +41,7 @@ Note: Czech banks will process EUR payments if the recipient has a EUR account.
'author': 'Ash technology s.r.o.',
'website': 'https://github.com/ash-technology/l10n_cz_extra',
'license': 'LGPL-3',
'external_dependencies': {
'python': ['qrcode'],
},
'data': [
'views/report_invoice.xml',
],
'data': [],
'installable': True,
'auto_install': False,
'application': False,
+1 -1
View File
@@ -1 +1 @@
from . import account_move
from . import res_partner_bank
-90
View File
@@ -1,90 +0,0 @@
import qrcode
import base64
import io
from odoo import models, api
class AccountMove(models.Model):
_inherit = 'account.move'
def get_cz_qr_code_base64(self):
""" Returns Base64 PNG image for the invoice PDF """
self.ensure_one()
# Support both EUR and CZK currencies
# (for Slovak companies invoicing Czech customers in EUR)
allowed_currencies = ['CZK', 'EUR']
if self.currency_id.name not in allowed_currencies:
return False
# Find bank: 1. Matching currency, 2. No currency set (default)
bank = self.company_id.partner_id.bank_ids.filtered(lambda b: b.currency_id == self.currency_id)[:1]
if not bank:
bank = self.company_id.partner_id.bank_ids.filtered(lambda b: not b.currency_id)[:1]
if not bank or not bank.acc_number:
return False
# Build SPAYD string
# Note: cz_to_iban handles both CZ format (123-456/0800) and IBAN format (SK..., CZ..., etc.)
iban = self.cz_to_iban(bank.acc_number)
amount = '%.2f' % self.amount_residual
vs = ''.join(filter(str.isdigit, self.payment_reference or ''))[-10:]
currency = self.currency_id.name or 'EUR'
spayd = f"SPD*1.0*ACC:{iban}*AM:{amount}*CC:{currency}*X-VS:{vs}"
# Generate QR Image
qr = qrcode.make(spayd)
buf = io.BytesIO()
qr.save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode('utf-8')
def cz_to_iban(self, acc_number):
"""
Converts Czech account number formats to IBAN.
Supports:
- Czech local format: '123-456/0800' or '456/0800'
- IBAN format: 'CZ6508000000001234567890', 'SK6509000000005123456789', etc.
Returns:
str: Valid IBAN (uppercase) or original input if conversion fails
"""
if not acc_number:
return ""
# Clean the input
clean_acc = acc_number.replace(" ", "").upper()
# If already an IBAN (any country), return it
# IBAN format: 2-letter country code + 2-digit checksum + account number
if len(clean_acc) >= 4 and clean_acc[:2].isalpha() and clean_acc[2:4].isdigit():
return clean_acc
try:
# Split into [prefix-main, bank_code]
parts = clean_acc.split('/')
if len(parts) != 2:
return clean_acc # Fallback for non-CZ format
bank_code = parts[1]
account_parts = parts[0].split('-')
if len(account_parts) == 2:
prefix = account_parts[0].zfill(6)
main = account_parts[1].zfill(10)
else:
prefix = "000000"
main = account_parts[0].zfill(10)
# 1. Start with Bank Code + Prefix + Main + Country Number (CZ=12, 35=Z) + 00
# Czech Republic code is 123500 (12 for C, 35 for Z)
check_string = f"{bank_code}{prefix}{main}123500"
# 2. Calculate checksum
checksum = 98 - (int(check_string) % 97)
checksum_str = str(checksum).zfill(2)
return f"CZ{checksum_str}{bank_code}{prefix}{main}"
except Exception:
return clean_acc
+83
View File
@@ -0,0 +1,83 @@
from odoo import api, models, _
class ResPartnerBank(models.Model):
_inherit = 'res.partner.bank'
@api.model
def _get_available_qr_methods(self):
rslt = super()._get_available_qr_methods()
# Nižšia sequence ako natívny EPC/SCT QR (sequence 20 v
# account_qr_code_sepa) - SPAYD sa uprednostní, keď je eligible.
rslt.append(('spayd', _("SPAYD (Czech QR Platba)"), 6))
return rslt
def _get_error_messages_for_qr(self, qr_method, debtor_partner, currency):
if qr_method == 'spayd':
if currency.name not in ('CZK', 'EUR'):
return _("SPAYD podporuje len menu CZK alebo EUR.")
if not self._spayd_to_iban():
return _("Bankový účet nemá platné číslo účtu ani IBAN pre SPAYD.")
if debtor_partner and debtor_partner.country_id and debtor_partner.country_id.code != 'CZ':
return _("SPAYD je určený pre zákazníkov z Českej republiky.")
return None
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):
if qr_method == 'spayd':
return None
return super()._check_for_qr_code_errors(qr_method, amount, currency, debtor_partner, free_communication, structured_communication)
def _get_qr_code_generation_params(self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication):
if qr_method == 'spayd':
iban = self._spayd_to_iban()
vs_source = structured_communication or free_communication or ''
vs = ''.join(filter(str.isdigit, vs_source))[-10:]
spayd = f"SPD*1.0*ACC:{iban}*AM:{amount:.2f}*CC:{currency.name}*X-VS:{vs}"
return {
'barcode_type': 'QR',
'quiet': 0,
'width': 128,
'height': 128,
'humanreadable': 0,
'value': spayd,
}
return super()._get_qr_code_generation_params(qr_method, amount, currency, debtor_partner, free_communication, structured_communication)
def _spayd_to_iban(self):
"""Vráti IBAN pre tento bankový účet, vhodný pre SPAYD QR (ACC pole).
Podporuje český lokálny formát ('123-456/0800' alebo '456/0800') aj
priamo zadaný IBAN (akejkoľvek krajiny).
"""
self.ensure_one()
acc_number = self.acc_number
if not acc_number:
return ''
clean_acc = acc_number.replace(' ', '').upper()
# Už IBAN (ľubovoľná krajina): 2 písmená + 2-číslicový kontrolný súčet.
if len(clean_acc) >= 4 and clean_acc[:2].isalpha() and clean_acc[2:4].isdigit():
return clean_acc
try:
parts = clean_acc.split('/')
if len(parts) != 2:
return clean_acc # nie je český formát ani IBAN - vrátime pôvodné
bank_code = parts[1].zfill(4)
account_parts = parts[0].split('-')
if len(account_parts) == 2:
prefix = account_parts[0].zfill(6)
main = account_parts[1].zfill(10)
else:
prefix = '000000'
main = account_parts[0].zfill(10)
# ISO 7064 mod 97-10: BBAN + krajina ako čísla (C=12, Z=35) + '00'.
check_string = f"{bank_code}{prefix}{main}123500"
checksum = 98 - (int(check_string) % 97)
return f"CZ{str(checksum).zfill(2)}{bank_code}{prefix}{main}"
except (ValueError, IndexError):
return clean_acc
+2 -2
View File
@@ -1,2 +1,2 @@
qrcode
# Žiadne - QR obrázok generuje natívny Odoo report barcode mechanizmus
# (ir.actions.report.barcode), tento modul mu len dodáva SPAYD reťazec.
-33
View File
@@ -1,33 +0,0 @@
<odoo>
<template id="report_invoice_document_cz" inherit_id="account.report_invoice_document">
<xpath expr="//div[@id='qrcode']" position="replace">
<t t-set="cz_qr_base64" t-value="o.get_cz_qr_code_base64()"/>
<t t-if="cz_qr_base64">
<div id="qrcode" class="d-flex mb-3 avoid-page-break-inside">
<div class="qrcode me-3">
<img t-att-src="'data:image/png;base64,%s' % cz_qr_base64" style="width:140px;height:140px;"/>
</div>
<div class="d-inline text-muted lh-sm fst-italic">
<p class="fw-bold mb-0" style="color: black;">QR Platba</p>
<p>Scan this QR Code with<br/>your banking application</p>
</div>
</div>
</t>
<t t-else="">
<div id="qrcode" class="d-flex mb-3 avoid-page-break-inside" t-if="o.display_qr_code and o.amount_residual">
<div class="qrcode me-3" id="qrcode_image">
<t t-set="qr_code_url" t-value="o._generate_qr_code(silent_errors=True)"/>
<p t-if="qr_code_url" class="position-relative mb-0">
<img t-att-src="qr_code_url"/>
<img src="/account/static/src/img/Odoo_logo_O.svg" id="qrcode_odoo_logo" class="top-50 start-50 position-absolute bg-white border border-3 rounded-circle"/>
</p>
</div>
<div class="d-inline text-muted lh-sm fst-italic" id="qrcode_info" t-if="qr_code_url">
<p>Scan this QR Code with<br/>your banking application</p>
</div>
</div>
</t>
</xpath>
</template>
</odoo>