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}",
|
||||||
|
}
|
||||||
@@ -1400,7 +1400,27 @@ try:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'[ERP/PUTNI] router fail: {e}')
|
print(f'[ERP/PUTNI] router fail: {e}')
|
||||||
|
|
||||||
|
# === Round 3 / CC5 — CRM (M7 Članarine, M8 Liječnički, M9 Obrasci) ===
|
||||||
|
try:
|
||||||
|
from clanarine_router import router as clanarine_router
|
||||||
|
app.include_router(clanarine_router)
|
||||||
|
print('[CRM/M7] clanarine router loaded')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[CRM/M7] clanarine router fail: {e}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
from lijecnicki_router import router as lijecnicki_router
|
||||||
|
app.include_router(lijecnicki_router)
|
||||||
|
print('[CRM/M8] lijecnicki router loaded')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[CRM/M8] lijecnicki router fail: {e}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
from obrasci_router import router as obrasci_router
|
||||||
|
app.include_router(obrasci_router)
|
||||||
|
print('[CRM/M9] obrasci router loaded')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[CRM/M9] obrasci router fail: {e}')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,524 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# Fajl: routers/clanarine_router.py | v1.0.0 | 04.05.2026
|
||||||
|
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
|
||||||
|
# Lokacija: /opt/pgz-sport/routers/clanarine_router.py
|
||||||
|
# Svrha: M7 — CRM Članarine: CRUD + dug + uplata + HUB-3 PDF + EPC QR + bulk notify
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
"""M7 Članarine router.
|
||||||
|
|
||||||
|
Endpointi (montirani s prefixom /api/crm):
|
||||||
|
GET /clanarine → lista (filteri)
|
||||||
|
POST /clanarine → kreiraj zaduženje
|
||||||
|
GET /clanarine/{id} → detalji
|
||||||
|
PUT /clanarine/{id} → update
|
||||||
|
DELETE /clanarine/{id} → soft delete (status=storno)
|
||||||
|
POST /clanarine/{id}/uplata → registriraj uplatu
|
||||||
|
GET /clanarine/dug → svi koji duguju
|
||||||
|
POST /clanarine/notify-bulk → mock e-mail notifikacija
|
||||||
|
GET /clanarine/{id}/uplatnica.pdf → HUB-3 PDF
|
||||||
|
GET /clanarine/{id}/qr.png → EPC QR PNG
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
from datetime import date
|
||||||
|
from typing import Optional, List
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, Body
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
sys.path.insert(0, "/opt/pgz-sport")
|
||||||
|
from crm.payments import (
|
||||||
|
build_hub3_pdf, build_epc_qr_png, build_epc_payload,
|
||||||
|
make_poziv_na_broj, build_bank_deep_links, normalize_iban,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/crm", tags=["crm-clanarine"])
|
||||||
|
|
||||||
|
DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7"
|
||||||
|
|
||||||
|
# Default IBAN PGŽ Sport (placeholder ako klub nema svoj). Realan IBAN PGŽ
|
||||||
|
# Odjela za sport stavi ovdje kad bude poznat — za sada koristimo neutralni
|
||||||
|
# format za demo svrhe.
|
||||||
|
DEFAULT_PRIMATELJ_IBAN = "HR0000000000000000000"
|
||||||
|
DEFAULT_PRIMATELJ_NAZIV = "PGŽ Odjel za sport"
|
||||||
|
DEFAULT_PRIMATELJ_ADRESA = "Adamićeva 10, 51000 Rijeka"
|
||||||
|
|
||||||
|
|
||||||
|
def _conn():
|
||||||
|
return psycopg2.connect(DSN, cursor_factory=RealDictCursor)
|
||||||
|
|
||||||
|
|
||||||
|
def _conv(v):
|
||||||
|
if isinstance(v, (date,)):
|
||||||
|
return v.isoformat()
|
||||||
|
if isinstance(v, Decimal):
|
||||||
|
return float(v)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def _row(d):
|
||||||
|
return {k: _conv(v) for k, v in dict(d).items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_status(propisan: float, placen: float) -> str:
|
||||||
|
p, pl = float(propisan or 0), float(placen or 0)
|
||||||
|
if pl <= 0:
|
||||||
|
return "nepodmireno"
|
||||||
|
if pl + 0.005 < p:
|
||||||
|
return "djelomicno"
|
||||||
|
return "podmireno"
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────── modeli ─────────────
|
||||||
|
|
||||||
|
class ClanarinaIn(BaseModel):
|
||||||
|
clan_id: int
|
||||||
|
klub_id: Optional[int] = None
|
||||||
|
godina: int
|
||||||
|
razdoblje: Optional[str] = "godišnja"
|
||||||
|
iznos_propisan: float
|
||||||
|
iznos_placen: Optional[float] = 0
|
||||||
|
datum_uplate: Optional[date] = None
|
||||||
|
nacin_uplate: Optional[str] = None
|
||||||
|
napomena: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ClanarinaPatch(BaseModel):
|
||||||
|
klub_id: Optional[int] = None
|
||||||
|
godina: Optional[int] = None
|
||||||
|
razdoblje: Optional[str] = None
|
||||||
|
iznos_propisan: Optional[float] = None
|
||||||
|
iznos_placen: Optional[float] = None
|
||||||
|
datum_uplate: Optional[date] = None
|
||||||
|
nacin_uplate: Optional[str] = None
|
||||||
|
napomena: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UplataIn(BaseModel):
|
||||||
|
iznos: float = Field(..., gt=0)
|
||||||
|
datum_uplate: Optional[date] = None
|
||||||
|
nacin_uplate: Optional[str] = "transakcijski"
|
||||||
|
referenca: Optional[str] = None
|
||||||
|
racun_broj: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class NotifyBulkIn(BaseModel):
|
||||||
|
klub_id: Optional[int] = None
|
||||||
|
godina: Optional[int] = None
|
||||||
|
template: Optional[str] = "Poštovani, podsjećamo na nepodmirenu članarinu."
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────── lista ─────────────
|
||||||
|
|
||||||
|
@router.get("/clanarine")
|
||||||
|
def list_clanarine(
|
||||||
|
godina: Optional[int] = Query(None),
|
||||||
|
klub_id: Optional[int] = Query(None),
|
||||||
|
clan_id: Optional[int] = Query(None),
|
||||||
|
status: Optional[str] = Query(None, description="nepodmireno|djelomicno|podmireno|storno"),
|
||||||
|
sort: str = Query("godina"),
|
||||||
|
order: str = Query("desc"),
|
||||||
|
limit: int = Query(500, le=2000),
|
||||||
|
):
|
||||||
|
where, params = [], []
|
||||||
|
if godina:
|
||||||
|
where.append("c.godina = %s"); params.append(godina)
|
||||||
|
if klub_id:
|
||||||
|
where.append("c.klub_id = %s"); params.append(klub_id)
|
||||||
|
if clan_id:
|
||||||
|
where.append("c.clan_id = %s"); params.append(clan_id)
|
||||||
|
if status:
|
||||||
|
where.append("c.status = %s"); params.append(status)
|
||||||
|
|
||||||
|
sort_map = {
|
||||||
|
"godina": "c.godina",
|
||||||
|
"iznos": "c.iznos_propisan",
|
||||||
|
"klub": "k.naziv",
|
||||||
|
"datum_uplate": "c.datum_uplate",
|
||||||
|
"status": "c.status",
|
||||||
|
"dug": "(c.iznos_propisan - COALESCE(c.iznos_placen,0))",
|
||||||
|
}
|
||||||
|
sort_col = sort_map.get(sort, "c.godina")
|
||||||
|
order_sql = "DESC" if order.lower() == "desc" else "ASC"
|
||||||
|
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
|
||||||
|
|
||||||
|
sql = f"""
|
||||||
|
SELECT c.id, c.clan_id, c.klub_id, c.godina, c.razdoblje,
|
||||||
|
c.iznos_propisan, c.iznos_placen,
|
||||||
|
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
|
||||||
|
c.datum_uplate, c.nacin_uplate, c.referenca, c.racun_broj,
|
||||||
|
c.status, c.napomena, c.created_at, c.updated_at,
|
||||||
|
cl.ime || ' ' || cl.prezime AS clan,
|
||||||
|
cl.oib AS clan_oib,
|
||||||
|
k.naziv AS klub, k.oib AS klub_oib, k.iban AS klub_iban
|
||||||
|
FROM pgz_sport.clanarine c
|
||||||
|
LEFT JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
|
||||||
|
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
|
||||||
|
{where_sql}
|
||||||
|
ORDER BY {sort_col} {order_sql}
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
params.append(limit)
|
||||||
|
|
||||||
|
sum_sql = f"""
|
||||||
|
SELECT COUNT(*) AS total,
|
||||||
|
COALESCE(SUM(c.iznos_propisan), 0)::numeric(10,2) AS total_propisan,
|
||||||
|
COALESCE(SUM(c.iznos_placen), 0)::numeric(10,2) AS total_placen,
|
||||||
|
COALESCE(SUM(c.iznos_propisan - COALESCE(c.iznos_placen,0)), 0)::numeric(10,2) AS total_dug,
|
||||||
|
COUNT(*) FILTER (WHERE c.status='nepodmireno') AS n_nepodmireno,
|
||||||
|
COUNT(*) FILTER (WHERE c.status='djelomicno') AS n_djelomicno,
|
||||||
|
COUNT(*) FILTER (WHERE c.status='podmireno') AS n_podmireno
|
||||||
|
FROM pgz_sport.clanarine c
|
||||||
|
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
|
||||||
|
{where_sql}
|
||||||
|
"""
|
||||||
|
|
||||||
|
with _conn() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute(sql, params)
|
||||||
|
rows = [_row(r) for r in cur.fetchall()]
|
||||||
|
cur.execute(sum_sql, params[:-1]) # bez LIMIT
|
||||||
|
summary = _row(cur.fetchone() or {})
|
||||||
|
|
||||||
|
return {"count": len(rows), "rows": rows, "summary": summary}
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────── dug (samo neplaćene) ─────────────
|
||||||
|
|
||||||
|
@router.get("/clanarine/dug")
|
||||||
|
def list_dug(
|
||||||
|
klub_id: Optional[int] = Query(None),
|
||||||
|
godina: Optional[int] = Query(None),
|
||||||
|
days_overdue: int = Query(0, ge=0,
|
||||||
|
description="koliko dana mora biti od kreiranja dužnosti (default 0)"),
|
||||||
|
limit: int = Query(500, le=2000),
|
||||||
|
):
|
||||||
|
where = ["c.status IN ('nepodmireno', 'djelomicno')"]
|
||||||
|
params: list = []
|
||||||
|
if klub_id:
|
||||||
|
where.append("c.klub_id = %s"); params.append(klub_id)
|
||||||
|
if godina:
|
||||||
|
where.append("c.godina = %s"); params.append(godina)
|
||||||
|
if days_overdue > 0:
|
||||||
|
where.append("c.created_at < (now() - (%s || ' days')::interval)")
|
||||||
|
params.append(str(days_overdue))
|
||||||
|
|
||||||
|
where_sql = "WHERE " + " AND ".join(where)
|
||||||
|
params.append(limit)
|
||||||
|
sql = f"""
|
||||||
|
SELECT c.id, c.clan_id, c.klub_id, c.godina, c.razdoblje,
|
||||||
|
c.iznos_propisan, c.iznos_placen,
|
||||||
|
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
|
||||||
|
c.status, c.created_at,
|
||||||
|
cl.ime || ' ' || cl.prezime AS clan,
|
||||||
|
cl.email AS clan_email, cl.telefon AS clan_telefon,
|
||||||
|
k.naziv AS klub, k.oib AS klub_oib, k.iban AS klub_iban
|
||||||
|
FROM pgz_sport.clanarine c
|
||||||
|
LEFT JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
|
||||||
|
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
|
||||||
|
{where_sql}
|
||||||
|
ORDER BY (c.iznos_propisan - COALESCE(c.iznos_placen,0)) DESC
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
with _conn() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute(sql, params)
|
||||||
|
rows = [_row(r) for r in cur.fetchall()]
|
||||||
|
total_dug = sum(float(r["dug"] or 0) for r in rows)
|
||||||
|
return {"count": len(rows), "total_dug": round(total_dug, 2), "rows": rows}
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────── detalji ─────────────
|
||||||
|
|
||||||
|
@router.get("/clanarine/{cid}")
|
||||||
|
def get_clanarina(cid: int):
|
||||||
|
with _conn() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT c.*,
|
||||||
|
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
|
||||||
|
cl.ime || ' ' || cl.prezime AS clan,
|
||||||
|
cl.oib AS clan_oib, cl.email AS clan_email,
|
||||||
|
k.naziv AS klub, k.oib AS klub_oib, k.iban AS klub_iban,
|
||||||
|
k.adresa AS klub_adresa, k.grad AS klub_grad
|
||||||
|
FROM pgz_sport.clanarine c
|
||||||
|
LEFT JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
|
||||||
|
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
|
||||||
|
WHERE c.id = %s
|
||||||
|
""", (cid,))
|
||||||
|
r = cur.fetchone()
|
||||||
|
if not r:
|
||||||
|
raise HTTPException(404, "Članarina ne postoji")
|
||||||
|
return _row(r)
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────── kreiraj ─────────────
|
||||||
|
|
||||||
|
@router.post("/clanarine")
|
||||||
|
def create_clanarina(body: ClanarinaIn):
|
||||||
|
klub_id = body.klub_id
|
||||||
|
with _conn() as conn, conn.cursor() as cur:
|
||||||
|
if not klub_id:
|
||||||
|
cur.execute("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", (body.clan_id,))
|
||||||
|
r = cur.fetchone()
|
||||||
|
klub_id = r["klub_id"] if r else None
|
||||||
|
status = _compute_status(body.iznos_propisan, body.iznos_placen or 0)
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO pgz_sport.clanarine
|
||||||
|
(clan_id, klub_id, godina, razdoblje, iznos_propisan, iznos_placen,
|
||||||
|
datum_uplate, nacin_uplate, status, napomena)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
RETURNING *
|
||||||
|
""", (body.clan_id, klub_id, body.godina, body.razdoblje,
|
||||||
|
body.iznos_propisan, body.iznos_placen or 0,
|
||||||
|
body.datum_uplate, body.nacin_uplate, status, body.napomena))
|
||||||
|
r = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return _row(r)
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────── update / delete ─────────────
|
||||||
|
|
||||||
|
@router.put("/clanarine/{cid}")
|
||||||
|
def update_clanarina(cid: int, patch: ClanarinaPatch):
|
||||||
|
fields, params = [], []
|
||||||
|
for f in ("klub_id", "godina", "razdoblje", "iznos_propisan", "iznos_placen",
|
||||||
|
"datum_uplate", "nacin_uplate", "napomena", "status"):
|
||||||
|
v = getattr(patch, f)
|
||||||
|
if v is not None:
|
||||||
|
fields.append(f"{f} = %s"); params.append(v)
|
||||||
|
if not fields:
|
||||||
|
raise HTTPException(400, "Nema polja za izmjenu")
|
||||||
|
fields.append("updated_at = now()")
|
||||||
|
params.append(cid)
|
||||||
|
with _conn() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute(f"UPDATE pgz_sport.clanarine SET {', '.join(fields)} WHERE id=%s RETURNING *",
|
||||||
|
params)
|
||||||
|
r = cur.fetchone()
|
||||||
|
if not r:
|
||||||
|
raise HTTPException(404, "Članarina ne postoji")
|
||||||
|
# ako nije eksplicitno postavljen status, izračunaj iz iznos_*
|
||||||
|
if patch.status is None:
|
||||||
|
new_status = _compute_status(r["iznos_propisan"], r["iznos_placen"] or 0)
|
||||||
|
if new_status != r["status"]:
|
||||||
|
cur.execute("UPDATE pgz_sport.clanarine SET status=%s WHERE id=%s RETURNING *",
|
||||||
|
(new_status, cid))
|
||||||
|
r = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return _row(r)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/clanarine/{cid}")
|
||||||
|
def delete_clanarina(cid: int):
|
||||||
|
with _conn() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute("UPDATE pgz_sport.clanarine SET status='storno', updated_at=now() WHERE id=%s RETURNING id",
|
||||||
|
(cid,))
|
||||||
|
r = cur.fetchone()
|
||||||
|
if not r:
|
||||||
|
raise HTTPException(404, "Članarina ne postoji")
|
||||||
|
return {"ok": True, "id": cid, "status": "storno"}
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────── uplata ─────────────
|
||||||
|
|
||||||
|
@router.post("/clanarine/{cid}/uplata")
|
||||||
|
def register_uplata(cid: int, body: UplataIn):
|
||||||
|
with _conn() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT * FROM pgz_sport.clanarine WHERE id=%s", (cid,))
|
||||||
|
cur_row = cur.fetchone()
|
||||||
|
if not cur_row:
|
||||||
|
raise HTTPException(404, "Članarina ne postoji")
|
||||||
|
novi_iznos = float(cur_row["iznos_placen"] or 0) + float(body.iznos)
|
||||||
|
novi_status = _compute_status(cur_row["iznos_propisan"], novi_iznos)
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE pgz_sport.clanarine
|
||||||
|
SET iznos_placen = %s,
|
||||||
|
datum_uplate = COALESCE(%s, datum_uplate, now()::date),
|
||||||
|
nacin_uplate = COALESCE(%s, nacin_uplate),
|
||||||
|
referenca = COALESCE(%s, referenca),
|
||||||
|
racun_broj = COALESCE(%s, racun_broj),
|
||||||
|
status = %s,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING *
|
||||||
|
""", (novi_iznos, body.datum_uplate, body.nacin_uplate,
|
||||||
|
body.referenca, body.racun_broj, novi_status, cid))
|
||||||
|
r = cur.fetchone()
|
||||||
|
# log u audit_feed (ako postoji); nepoznata schema → silent skip
|
||||||
|
try:
|
||||||
|
cur.execute("""INSERT INTO pgz_sport.audit_feed (entity_type, entity_id, action, payload)
|
||||||
|
VALUES (%s,%s,%s,%s::jsonb)""",
|
||||||
|
("clanarina", cid, "uplata",
|
||||||
|
f'{{"iznos":{body.iznos}, "novi_status":"{novi_status}"}}'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True, "id": cid, "iznos_uplata": body.iznos,
|
||||||
|
"iznos_placen_total": novi_iznos, "status": novi_status,
|
||||||
|
"clanarina": _row(r)}
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────── notify-bulk (mock email) ─────────────
|
||||||
|
|
||||||
|
@router.post("/clanarine/notify-bulk")
|
||||||
|
def notify_bulk(body: NotifyBulkIn):
|
||||||
|
"""
|
||||||
|
Stvarno slanje pošte se još ne implementira (M9 SMTP integration TODO),
|
||||||
|
ali endpoint vraća listu primatelja koji bi bili kontaktirani.
|
||||||
|
"""
|
||||||
|
where = ["c.status IN ('nepodmireno','djelomicno')", "cl.email IS NOT NULL"]
|
||||||
|
params = []
|
||||||
|
if body.klub_id:
|
||||||
|
where.append("c.klub_id=%s"); params.append(body.klub_id)
|
||||||
|
if body.godina:
|
||||||
|
where.append("c.godina =%s"); params.append(body.godina)
|
||||||
|
where_sql = "WHERE " + " AND ".join(where)
|
||||||
|
sql = f"""
|
||||||
|
SELECT c.id, c.godina, c.iznos_propisan,
|
||||||
|
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
|
||||||
|
cl.ime || ' ' || cl.prezime AS clan, cl.email,
|
||||||
|
k.naziv AS klub
|
||||||
|
FROM pgz_sport.clanarine c
|
||||||
|
JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
|
||||||
|
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
|
||||||
|
{where_sql}
|
||||||
|
ORDER BY dug DESC
|
||||||
|
LIMIT 500
|
||||||
|
"""
|
||||||
|
with _conn() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute(sql, params)
|
||||||
|
recipients = [_row(r) for r in cur.fetchall()]
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"queued": len(recipients),
|
||||||
|
"template": body.template,
|
||||||
|
"note": "Mock — SMTP nije konfiguriran. Lista primatelja vraćena za pregled.",
|
||||||
|
"recipients": recipients,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────── HUB-3 PDF + EPC QR ─────────────
|
||||||
|
|
||||||
|
@router.get("/clanarine/{cid}/uplatnica.pdf")
|
||||||
|
def uplatnica_pdf(cid: int):
|
||||||
|
with _conn() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT c.id, c.godina, c.razdoblje, c.iznos_propisan, c.iznos_placen,
|
||||||
|
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
|
||||||
|
cl.ime || ' ' || cl.prezime AS clan,
|
||||||
|
cl.adresa AS clan_adresa, cl.grad AS clan_grad,
|
||||||
|
k.naziv AS klub, k.oib AS klub_oib, k.iban AS klub_iban,
|
||||||
|
k.adresa AS klub_adresa, k.grad AS klub_grad
|
||||||
|
FROM pgz_sport.clanarine c
|
||||||
|
LEFT JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
|
||||||
|
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
|
||||||
|
WHERE c.id=%s
|
||||||
|
""", (cid,))
|
||||||
|
r = cur.fetchone()
|
||||||
|
if not r:
|
||||||
|
raise HTTPException(404, "Članarina ne postoji")
|
||||||
|
|
||||||
|
dug = float(r["dug"] or 0)
|
||||||
|
if dug <= 0:
|
||||||
|
# podmireno — uplatnicu generiramo na cijeli iznos kao podsjetnik
|
||||||
|
dug = float(r["iznos_propisan"] or 0)
|
||||||
|
|
||||||
|
iban = normalize_iban(r["klub_iban"] or DEFAULT_PRIMATELJ_IBAN)
|
||||||
|
primatelj_naziv = r["klub"] or DEFAULT_PRIMATELJ_NAZIV
|
||||||
|
primatelj_adresa = ", ".join(filter(None, [r.get("klub_adresa"), r.get("klub_grad")])) \
|
||||||
|
or DEFAULT_PRIMATELJ_ADRESA
|
||||||
|
platitelj_naziv = r["clan"] or "Član"
|
||||||
|
platitelj_adresa = ", ".join(filter(None, [r.get("clan_adresa"), r.get("clan_grad")])) or "—"
|
||||||
|
poziv = make_poziv_na_broj(r.get("klub_oib"), int(r["godina"]), int(r["id"]))
|
||||||
|
|
||||||
|
pdf = build_hub3_pdf(
|
||||||
|
platitelj_naziv=platitelj_naziv,
|
||||||
|
platitelj_adresa=platitelj_adresa,
|
||||||
|
primatelj_naziv=primatelj_naziv,
|
||||||
|
primatelj_adresa=primatelj_adresa,
|
||||||
|
iban=iban,
|
||||||
|
amount_eur=dug,
|
||||||
|
model="HR00",
|
||||||
|
poziv_na_broj=poziv,
|
||||||
|
opis=f"Članarina {r['godina']} — {r['razdoblje'] or 'godišnja'}",
|
||||||
|
sifra_namjene="OTHR",
|
||||||
|
)
|
||||||
|
return Response(content=pdf, media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition":
|
||||||
|
f"inline; filename=uplatnica-clanarina-{cid}.pdf"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/clanarine/{cid}/qr.png")
|
||||||
|
def uplatnica_qr(cid: int):
|
||||||
|
with _conn() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT c.id, c.godina, c.razdoblje, c.iznos_propisan, c.iznos_placen,
|
||||||
|
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
|
||||||
|
k.naziv AS klub, k.oib AS klub_oib, k.iban AS klub_iban
|
||||||
|
FROM pgz_sport.clanarine c
|
||||||
|
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
|
||||||
|
WHERE c.id=%s
|
||||||
|
""", (cid,))
|
||||||
|
r = cur.fetchone()
|
||||||
|
if not r:
|
||||||
|
raise HTTPException(404, "Članarina ne postoji")
|
||||||
|
dug = float(r["dug"] or 0)
|
||||||
|
if dug <= 0:
|
||||||
|
dug = float(r["iznos_propisan"] or 0)
|
||||||
|
iban = normalize_iban(r["klub_iban"] or DEFAULT_PRIMATELJ_IBAN)
|
||||||
|
primatelj = r["klub"] or DEFAULT_PRIMATELJ_NAZIV
|
||||||
|
poziv = make_poziv_na_broj(r.get("klub_oib"), int(r["godina"]), int(r["id"]))
|
||||||
|
payload = build_epc_payload(
|
||||||
|
primatelj=primatelj, iban=iban, amount_eur=dug,
|
||||||
|
opis=f"Članarina {r['godina']}", model="HR00", poziv_na_broj=poziv,
|
||||||
|
)
|
||||||
|
png = build_epc_qr_png(payload, box_size=10)
|
||||||
|
return Response(content=png, media_type="image/png",
|
||||||
|
headers={"Cache-Control": "public, max-age=300"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/clanarine/{cid}/payment-info")
|
||||||
|
def payment_info(cid: int):
|
||||||
|
"""Vraća JSON s IBAN, poziv-na-broj, EPC payload i deep linkovima — za UI gumbe."""
|
||||||
|
with _conn() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT c.id, c.godina, c.razdoblje,
|
||||||
|
c.iznos_propisan, c.iznos_placen,
|
||||||
|
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
|
||||||
|
k.naziv AS klub, k.oib AS klub_oib, k.iban AS klub_iban
|
||||||
|
FROM pgz_sport.clanarine c
|
||||||
|
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
|
||||||
|
WHERE c.id=%s
|
||||||
|
""", (cid,))
|
||||||
|
r = cur.fetchone()
|
||||||
|
if not r:
|
||||||
|
raise HTTPException(404, "Članarina ne postoji")
|
||||||
|
dug = float(r["dug"] or 0)
|
||||||
|
if dug <= 0:
|
||||||
|
dug = float(r["iznos_propisan"] or 0)
|
||||||
|
iban = normalize_iban(r["klub_iban"] or DEFAULT_PRIMATELJ_IBAN)
|
||||||
|
primatelj = r["klub"] or DEFAULT_PRIMATELJ_NAZIV
|
||||||
|
poziv = make_poziv_na_broj(r.get("klub_oib"), int(r["godina"]), int(r["id"]))
|
||||||
|
opis = f"Članarina {r['godina']} — {r['razdoblje'] or 'godišnja'}"
|
||||||
|
payload = build_epc_payload(primatelj=primatelj, iban=iban, amount_eur=dug,
|
||||||
|
opis=opis, model="HR00", poziv_na_broj=poziv)
|
||||||
|
return {
|
||||||
|
"id": cid,
|
||||||
|
"iznos_eur": round(dug, 2),
|
||||||
|
"primatelj": primatelj,
|
||||||
|
"iban": iban,
|
||||||
|
"model": "HR00",
|
||||||
|
"poziv_na_broj": poziv,
|
||||||
|
"opis": opis,
|
||||||
|
"epc_payload": payload,
|
||||||
|
"qr_url": f"/sport/api/crm/clanarine/{cid}/qr.png",
|
||||||
|
"pdf_url": f"/sport/api/crm/clanarine/{cid}/uplatnica.pdf",
|
||||||
|
"deep_links": build_bank_deep_links(iban, dug, opis,
|
||||||
|
model="HR00", poziv_na_broj=poziv),
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user