M7 CRM Članarine: CRUD + dug + uplata + HUB-3 PDF + EPC QR

- /api/crm/clanarine[CRUD] s filterima (godina/klub/clan/status), summary
- /api/crm/clanarine/dug — dužnici (z opcionim days_overdue)
- /api/crm/clanarine/{id}/uplata — registracija parcijalne/cijele uplate
- /api/crm/clanarine/notify-bulk — mock e-mail kampanja (lista primatelja)
- /api/crm/clanarine/{id}/uplatnica.pdf — HUB-3 A4 PDF s ugrađenim EPC QR
- /api/crm/clanarine/{id}/qr.png — samo EPC BCD/002 SCT QR PNG
- /api/crm/clanarine/{id}/payment-info — JSON za UI gumbe + bank deep linkovi

crm/payments.py — HUB-3 PDF generator (ReportLab) + EPC QR (qrcode lib),
poziv-na-broj model HR00 = OIB-godina-id, format_eur HR notation.
This commit is contained in:
Damir Radulić
2026-05-04 23:54:26 +02:00
parent 834b7bf89f
commit 1bd34ed678
4 changed files with 860 additions and 0 deletions
View File
+316
View File
@@ -0,0 +1,316 @@
#!/usr/bin/env python3
# ═══════════════════════════════════════════════════════════════════
# Fajl: crm/payments.py | v1.0.0 | 04.05.2026
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
# Lokacija: /opt/pgz-sport/crm/payments.py
# Svrha: HUB-3 uplatnica PDF + EPC QR (BCD/002) generatori za HR mobilno banking
# ═══════════════════════════════════════════════════════════════════
"""HUB-3 + EPC QR helpers.
HUB-3: standardna hrvatska uplatnica (HR pravilnik o izgledu).
EPC QR: BCD/002/SCT (SEPA Credit Transfer) — čita Zaba, PBZ, Erste, OTP, RBA mobilne aplikacije.
Format reference: https://www.europeanpaymentscouncil.eu (EPC069-12)
"""
from __future__ import annotations
import io
import re
from datetime import date, datetime
from typing import Optional
import qrcode
from qrcode.constants import ERROR_CORRECT_M
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen import canvas
from reportlab.lib.utils import ImageReader
# ─────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────
def normalize_iban(iban: str) -> str:
"""Strip whitespace + uppercase. HR IBAN = 21 chars (HR + 19 digits)."""
return re.sub(r"\s+", "", (iban or "").upper())
def format_iban(iban: str) -> str:
"""Pretty-print: HR12 3456 7890 1234 5678 9."""
s = normalize_iban(iban)
return " ".join(s[i:i + 4] for i in range(0, len(s), 4))
def format_eur(amount: float) -> str:
"""1234.5 → '1.234,50' (HR notation)."""
s = f"{float(amount):,.2f}"
return s.replace(",", "X").replace(".", ",").replace("X", ".")
def make_poziv_na_broj(klub_oib: Optional[str], godina: int, clanarina_id: int,
model: str = "HR00") -> str:
"""
Model HR00 (slobodni format) — koristimo: KLUB_OIB-GODINA-ID
Ako nema OIB-a, samo GODINA-ID.
Vraća (model, poziv) zaista nije separiran u 1 stringu — model je odvojen.
"""
parts = []
if klub_oib:
# samo digiti
oib_digits = re.sub(r"\D", "", str(klub_oib))[:11]
if oib_digits:
parts.append(oib_digits)
parts.append(str(godina))
parts.append(str(clanarina_id))
return "-".join(parts)
# ─────────────────────────────────────────────────────────────────────
# EPC QR (BCD/002) — readable by HR mobile banking apps
# ─────────────────────────────────────────────────────────────────────
def build_epc_payload(*, primatelj: str, iban: str, amount_eur: float,
opis: str, model: str = "HR00",
poziv_na_broj: str = "",
bic: str = "",
purpose: str = "OTHR") -> str:
"""
EPC069-12 SCT QR payload (version 002):
BCD ← service tag
002 ← version
1 ← character set (1 = UTF-8)
SCT ← SEPA Credit Transfer
[BIC] ← optional in v002
[Beneficiary] ← max 70
[IBAN] ← max 34
EUR{amount} ← e.g. EUR12.50
[Purpose] ← max 4 (e.g. OTHR)
[Reference] ← max 25 (structured)
[Remittance] ← max 140 (unstructured) — used if no structured ref
[Hint] ← optional
Ne smije premašiti ~331 byte ukupno.
"""
iban = normalize_iban(iban)
amount = f"EUR{float(amount_eur):.2f}"
primatelj = (primatelj or "")[:70]
# structured ref ima prednost; ako nemamo, koristimo opis kao remittance
structured_ref = ""
remittance = ""
if poziv_na_broj:
structured_ref = f"{model} {poziv_na_broj}"[:35]
remittance = (opis or "")[:140]
else:
remittance = (opis or "")[:140]
lines = [
"BCD",
"002",
"1",
"SCT",
bic or "",
primatelj,
iban,
amount,
purpose,
structured_ref,
remittance,
"", # hint
]
return "\n".join(lines)
def build_epc_qr_png(payload: str, box_size: int = 8) -> bytes:
"""Vraća PNG bytes (Pillow) za EPC payload."""
qr = qrcode.QRCode(
version=None,
error_correction=ERROR_CORRECT_M,
box_size=box_size,
border=2,
)
qr.add_data(payload)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
# ─────────────────────────────────────────────────────────────────────
# HUB-3 PDF (A4, jedan list, gornji dio = barkod stripa, donji = pregled)
# ─────────────────────────────────────────────────────────────────────
# Font ćemo koristiti Helvetica (default ReportLab) — sigurno dostupan.
# Ako želiš proper HR diakritike, registriraj DejaVu:
def _ensure_font():
try:
if "DejaVu" not in pdfmetrics.getRegisteredFontNames():
for path in (
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
):
try:
pdfmetrics.registerFont(TTFont("DejaVu", path))
pdfmetrics.registerFont(TTFont("DejaVu-Bold",
path.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf")))
return "DejaVu", "DejaVu-Bold"
except Exception:
continue
except Exception:
pass
return "Helvetica", "Helvetica-Bold"
def build_hub3_pdf(*, platitelj_naziv: str, platitelj_adresa: str,
primatelj_naziv: str, primatelj_adresa: str,
iban: str, amount_eur: float, model: str,
poziv_na_broj: str, opis: str,
sifra_namjene: str = "OTHR",
datum: Optional[date] = None,
epc_payload: Optional[str] = None) -> bytes:
"""
Generira A4 HUB-3 uplatnicu (jednostavna verzija, čitljiva, s EPC QR-om).
Layout otprilike kao standardna HR uplatnica iz banaka — gornji dio je
duplikat za platitelja, donji za banku, oba s istim podacima i QR kodom
desno.
"""
font_reg, font_bold = _ensure_font()
buf = io.BytesIO()
c = canvas.Canvas(buf, pagesize=A4)
W, H = A4
datum = datum or date.today()
iban_fmt = format_iban(iban)
if epc_payload is None:
epc_payload = build_epc_payload(
primatelj=primatelj_naziv, iban=iban, amount_eur=amount_eur,
opis=opis, model=model, poziv_na_broj=poziv_na_broj,
)
qr_png = build_epc_qr_png(epc_payload, box_size=6)
qr_img = ImageReader(io.BytesIO(qr_png))
def section(top_y: float, title: str):
# Title bar
c.setFillColorRGB(0.13, 0.20, 0.32)
c.rect(15 * mm, top_y - 8 * mm, 180 * mm, 8 * mm, fill=1, stroke=0)
c.setFillColorRGB(1, 1, 1)
c.setFont(font_bold, 10)
c.drawString(20 * mm, top_y - 5.5 * mm, title)
# Frame
c.setFillColorRGB(1, 1, 1)
c.setStrokeColorRGB(0.13, 0.20, 0.32)
c.setLineWidth(0.6)
c.rect(15 * mm, top_y - 8 * mm - 100 * mm, 180 * mm, 100 * mm, fill=0, stroke=1)
# Inner labels + values (left column = data, right column = QR)
c.setFillColorRGB(0, 0, 0)
def label(x_mm, y_offset_mm, txt):
c.setFont(font_reg, 7)
c.setFillColorRGB(0.45, 0.45, 0.45)
c.drawString(x_mm * mm, top_y - 8 * mm - y_offset_mm * mm, txt)
def value(x_mm, y_offset_mm, txt, bold=False, size=10, max_w_mm=None):
c.setFont(font_bold if bold else font_reg, size)
c.setFillColorRGB(0, 0, 0)
v = str(txt or "")
if max_w_mm:
w_pt = pdfmetrics.stringWidth(v, font_bold if bold else font_reg, size)
while w_pt > max_w_mm * mm and len(v) > 5:
v = v[:-2]
w_pt = pdfmetrics.stringWidth(v, font_bold if bold else font_reg, size)
c.drawString(x_mm * mm, top_y - 8 * mm - y_offset_mm * mm, v)
# Platitelj
label(20, 7, "PLATITELJ")
value(20, 12, platitelj_naziv, bold=True, size=10, max_w_mm=85)
value(20, 17, platitelj_adresa, size=9, max_w_mm=85)
# Primatelj
label(20, 26, "PRIMATELJ")
value(20, 31, primatelj_naziv, bold=True, size=10, max_w_mm=85)
value(20, 36, primatelj_adresa, size=9, max_w_mm=85)
# IBAN
label(20, 45, "IBAN PRIMATELJA")
value(20, 50, iban_fmt, bold=True, size=11, max_w_mm=85)
# Iznos
label(20, 59, "IZNOS")
c.setFont(font_bold, 16)
c.setFillColorRGB(0.13, 0.20, 0.32)
c.drawString(20 * mm, top_y - 8 * mm - 67 * mm, f"{format_eur(amount_eur)} EUR")
c.setFillColorRGB(0, 0, 0)
# Model + poziv
label(20, 75, "MODEL")
value(20, 80, model, bold=True, size=10)
label(40, 75, "POZIV NA BROJ")
value(40, 80, poziv_na_broj, bold=True, size=10, max_w_mm=65)
# Šifra namjene + datum
label(20, 88, "ŠIFRA NAMJENE")
value(20, 93, sifra_namjene, size=9)
label(50, 88, "DATUM IZVRŠENJA")
value(50, 93, datum.strftime("%d.%m.%Y."), size=9)
# Opis plaćanja
label(75, 88, "OPIS PLAĆANJA")
value(75, 93, opis[:60], size=9, max_w_mm=50)
# QR (right side)
c.drawImage(qr_img, 140 * mm, top_y - 8 * mm - 65 * mm,
width=45 * mm, height=45 * mm, mask='auto')
c.setFont(font_reg, 6)
c.setFillColorRGB(0.45, 0.45, 0.45)
c.drawString(140 * mm, top_y - 8 * mm - 70 * mm,
"Skenirajte QR mobilnom bankom (Zaba/PBZ/Erste/OTP/RBA)")
# Page header
c.setFont(font_bold, 14)
c.setFillColorRGB(0.13, 0.20, 0.32)
c.drawString(15 * mm, H - 18 * mm, "HUB-3 UPLATNICA")
c.setFont(font_reg, 9)
c.setFillColorRGB(0.45, 0.45, 0.45)
c.drawString(15 * mm, H - 23 * mm,
f"Generirao: PGŽ Sport platforma • {datetime.now().strftime('%d.%m.%Y. %H:%M')}")
# Two identical copies (kopija za platitelja + kopija za banku)
section(H - 30 * mm, "KOPIJA ZA PLATITELJA")
section(H - 150 * mm, "KOPIJA ZA BANKU")
# Footer
c.setFont(font_reg, 7)
c.setFillColorRGB(0.55, 0.55, 0.55)
c.drawString(15 * mm, 12 * mm,
"PGŽ Sport ERP/CRM • Plaćanje: skeniraj QR kod ili unesi IBAN/poziv na broj ručno • EPC QR (BCD/002 SCT)")
c.save()
return buf.getvalue()
# ─────────────────────────────────────────────────────────────────────
# Mobile-banking deep links (HR best-effort)
# ─────────────────────────────────────────────────────────────────────
def build_bank_deep_links(iban: str, amount_eur: float, opis: str,
model: str = "HR00", poziv_na_broj: str = "") -> dict:
"""
Best-effort deep linkovi za HR mobilne banking aplikacije.
Pravi standard je EPC QR — ovi linkovi su dodatak.
"""
iban = normalize_iban(iban)
from urllib.parse import quote
opis_q = quote(opis or "")
return {
"epc_qr": "ugrađen u PDF",
"zaba": f"https://m.zaba.hr/pay?iban={iban}&amount={amount_eur:.2f}&ref={poziv_na_broj}&desc={opis_q}",
"pbz": f"https://pbz.hr/mtoken/pay?iban={iban}&amt={amount_eur:.2f}&ref={poziv_na_broj}",
"erste": f"https://erstebank.hr/mbanking/pay?iban={iban}&amount={amount_eur:.2f}&ref={poziv_na_broj}",
"otp": f"https://otpbanka.hr/mbanking/pay?iban={iban}&amount={amount_eur:.2f}&ref={poziv_na_broj}",
"rba": f"https://rba.hr/mtoken/pay?iban={iban}&amount={amount_eur:.2f}&ref={poziv_na_broj}",
}