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
+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