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:
+316
@@ -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}",
|
||||
}
|
||||
Reference in New Issue
Block a user