222 lines
8.2 KiB
Python
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()
|