Files
Odoo-Modules/Fusion Accounting/models/payment_qr_code.py
2026-02-22 01:22:18 -05:00

222 lines
8.2 KiB
Python

"""
Fusion Accounting - Payment QR Codes
Extends ``account.move`` to generate **EPC QR codes** (European Payments
Council Quick Response Code) on invoices, allowing customers to scan
and pay directly from their banking app.
The EPC QR format is defined by the
`European Payments Council — Guidelines for QR Code
<https://www.europeanpaymentscouncil.eu/document-library/guidance-documents/quick-response-code-guidelines-enable-data-capture-initiation>`_.
Dependencies
------------
Requires the ``qrcode`` Python library (declared in the module manifest
under ``external_dependencies``).
"""
import base64
import io
import logging
import re
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
_log = logging.getLogger(__name__)
try:
import qrcode
from qrcode.constants import ERROR_CORRECT_M
except ImportError:
qrcode = None
_log.warning(
"The 'qrcode' Python library is not installed. "
"EPC QR code generation will be unavailable."
)
# IBAN validation (basic structural check)
_IBAN_RE = re.compile(r'^[A-Z]{2}\d{2}[A-Z0-9]{4,30}$')
class FusionPaymentQR(models.Model):
"""Adds EPC QR code generation to customer invoices.
The EPC QR standard encodes payment instructions into a QR code
that European banking apps can read to pre-fill a SEPA Credit
Transfer.
EPC QR Data Format
------------------
The payload is a UTF-8 string with fields separated by newlines::
BCD # Service Tag (fixed)
002 # Version
1 # Character Set (1 = UTF-8)
SCT # Identification (SEPA Credit Transfer)
<BIC> # BIC of the beneficiary bank
<Name> # Beneficiary name (max 70 chars)
<IBAN> # Beneficiary IBAN
EUR<Amount> # Amount with currency prefix
<Purpose> # Purpose code (optional, max 4 chars)
<Reference> # Remittance reference (max 35 chars)
<Unstructured Reference> # Unstructured remittance info (max 140)
<Information> # Beneficiary to originator info (optional)
"""
_inherit = 'account.move'
# ------------------------------------------------------------------
# Fields
# ------------------------------------------------------------------
fusion_qr_code_image = fields.Binary(
string='Payment QR Code',
compute='_compute_fusion_qr_code',
help="EPC QR code for this invoice. Customers can scan this "
"with their banking app to initiate payment.",
)
fusion_qr_code_available = fields.Boolean(
string='QR Code Available',
compute='_compute_fusion_qr_code',
help="Indicates whether a QR code can be generated for this "
"invoice (depends on IBAN / BIC configuration).",
)
# ------------------------------------------------------------------
# Computed fields
# ------------------------------------------------------------------
@api.depends(
'state', 'move_type', 'amount_residual',
'company_id', 'partner_id', 'currency_id',
)
def _compute_fusion_qr_code(self):
"""Compute the QR code image for eligible invoices."""
for move in self:
if (
move.move_type in ('out_invoice', 'out_refund')
and move.state == 'posted'
and move.amount_residual > 0
and qrcode is not None
):
try:
qr_bytes = move.generate_epc_qr()
move.fusion_qr_code_image = base64.b64encode(qr_bytes)
move.fusion_qr_code_available = True
except (UserError, ValidationError):
move.fusion_qr_code_image = False
move.fusion_qr_code_available = False
else:
move.fusion_qr_code_image = False
move.fusion_qr_code_available = False
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def generate_epc_qr(self):
"""Generate an EPC QR code image for this invoice.
The QR code encodes payment instructions following the
European Payments Council standard so that banking apps
can pre-fill a SEPA Credit Transfer form.
:return: PNG image bytes of the QR code.
:rtype: bytes
:raises UserError: if the ``qrcode`` library is not installed
or required bank details are missing.
"""
self.ensure_one()
if qrcode is None:
raise UserError(_(
"The 'qrcode' Python library is required to generate "
"payment QR codes. Install it with: pip install qrcode[pil]"
))
# Gather bank details
company = self.company_id
company_bank = company.partner_id.bank_ids[:1]
if not company_bank:
raise UserError(_(
"No bank account is configured for company '%(company)s'. "
"Please add a bank account with an IBAN in the company "
"settings.",
company=company.name,
))
iban = (company_bank.acc_number or '').upper().replace(' ', '')
if not _IBAN_RE.match(iban):
raise UserError(_(
"The company bank account '%(acc)s' does not appear to "
"be a valid IBAN.",
acc=company_bank.acc_number,
))
bic = (company_bank.bank_bic or '').upper().replace(' ', '')
# Amount and currency
if self.currency_id.name != 'EUR':
raise UserError(_(
"EPC QR codes are only supported for invoices in EUR. "
"This invoice uses %(currency)s.",
currency=self.currency_id.name,
))
amount = self.amount_residual
if amount <= 0 or amount > 999999999.99:
raise UserError(_(
"Amount must be between 0.01 and 999,999,999.99 for "
"EPC QR codes."
))
# Build EPC QR payload
beneficiary_name = (company.name or '')[:70]
reference = (self.payment_reference or self.name or '')[:35]
unstructured = ''
if not reference:
unstructured = (
f'Invoice {self.name}' if self.name else ''
)[:140]
# EPC QR payload lines (Version 002)
lines = [
'BCD', # Service Tag
'002', # Version
'1', # Character set (UTF-8)
'SCT', # Identification code
bic, # BIC (may be empty)
beneficiary_name, # Beneficiary name
iban, # Beneficiary IBAN
f'EUR{amount:.2f}', # Amount
'', # Purpose (optional)
reference if reference else '', # Structured reference
unstructured if not reference else '', # Unstructured ref
'', # Beneficiary info (optional)
]
payload = '\n'.join(lines)
# Validate payload size (EPC QR max 331 bytes)
if len(payload.encode('utf-8')) > 331:
raise UserError(_(
"The QR code payload exceeds the EPC maximum of 331 "
"bytes. Please shorten the payment reference."
))
# Generate QR code image
qr = qrcode.QRCode(
version=None, # auto-size
error_correction=ERROR_CORRECT_M,
box_size=10,
border=4,
)
qr.add_data(payload)
qr.make(fit=True)
img = qr.make_image(fill_color='black', back_color='white')
# Export to PNG bytes
buffer = io.BytesIO()
img.save(buffer, format='PNG')
return buffer.getvalue()