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}",
}
+20
View File
@@ -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}')
+524
View File
@@ -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),
}