1bd34ed678
- /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.
317 lines
13 KiB
Python
317 lines
13 KiB
Python
#!/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}",
|
|
}
|