#!/usr/bin/env python3 # ═══════════════════════════════════════════════════════════════════ # Fajl: crm/payments.py | v1.0.0 | 04.05.2026 # Autor: Damir Radulić / 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}", }