diff --git a/routers/erp_full_router.py b/routers/erp_full_router.py
index b5563c6..c64422f 100644
--- a/routers/erp_full_router.py
+++ b/routers/erp_full_router.py
@@ -12,21 +12,25 @@
# ═══════════════════════════════════════════════════════════════════
from __future__ import annotations
+import csv
import hashlib
+import io
import os
import re
+import uuid
from datetime import date, datetime
-from decimal import Decimal
-from io import BytesIO
+from decimal import Decimal, InvalidOperation
+from io import BytesIO, StringIO
from pathlib import Path
from typing import Optional, List
from xml.etree import ElementTree as ET
+from xml.sax.saxutils import escape as xml_escape
import psycopg2
import psycopg2.extras
from fastapi import APIRouter, HTTPException, Query, Body, UploadFile, File, Depends, Header, Form
-from fastapi.responses import StreamingResponse
-from pydantic import BaseModel
+from fastapi.responses import StreamingResponse, Response
+from pydantic import BaseModel, Field
# ── Upload destination (relative to web root /uploads/...) ──────────
UPLOAD_BASE = Path("/opt/pgz-sport/uploads")
@@ -1308,6 +1312,410 @@ def payments_list(klub_id: Optional[int] = None,
return {"count": len(rows), "rows": rows}
+# ── GET /payments/sepa-export — must be before /payments/{id} ───────
+def _parse_ids_param(ids: Optional[str]) -> List[int]:
+ if not ids:
+ return []
+ out: List[int] = []
+ for chunk in ids.split(","):
+ chunk = chunk.strip()
+ if not chunk:
+ continue
+ try:
+ out.append(int(chunk))
+ except ValueError:
+ raise HTTPException(400, f"Bad id in 'ids': {chunk!r}")
+ return out
+
+
+@router.get("/payments/sepa-export")
+def payments_sepa_export(
+ ids: Optional[str] = Query(None, description="CSV id list, e.g. 1,2,3"),
+ godina: Optional[int] = None,
+ klub_id: Optional[int] = None,
+ matched_status: Optional[str] = None,
+ payment_method: Optional[str] = None,
+):
+ """Generate pain.001.001.03 SEPA Credit Transfer XML — MOCK / placeholder.
+ Either pass ?ids=1,2,3 OR filter (godina/klub_id/matched_status/payment_method)."""
+ where = ["1=1"]
+ params: list = []
+ id_list = _parse_ids_param(ids)
+ if id_list:
+ where.append("p.id = ANY(%s)")
+ params.append(id_list)
+ else:
+ if godina:
+ where.append("EXTRACT(YEAR FROM p.payment_date)=%s"); params.append(godina)
+ if klub_id:
+ where.append("p.klub_id=%s"); params.append(klub_id)
+ if matched_status:
+ where.append("p.matched_status=%s"); params.append(matched_status)
+ if payment_method:
+ where.append("p.payment_method=%s"); params.append(payment_method)
+
+ rows = db_query(
+ "SELECT p.id, p.klub_id, k.naziv AS klub_naziv, p.payment_date, p.amount, "
+ "p.currency, p.iban_from, p.iban_to, p.reference, p.description "
+ "FROM pgz_sport.payments p "
+ "LEFT JOIN pgz_sport.klubovi k ON k.id=p.klub_id "
+ f"WHERE {' AND '.join(where)} ORDER BY p.payment_date, p.id LIMIT 5000",
+ tuple(params))
+
+ debtor_name = os.environ.get("PGZ_SEPA_DEBTOR_NAME", "PGŽ Sportski savez")
+ debtor_iban = os.environ.get("PGZ_SEPA_DEBTOR_IBAN", "HR0000000000000000000")
+ debtor_bic = os.environ.get("PGZ_SEPA_DEBTOR_BIC", "NOTPROVIDED")
+
+ now = datetime.now()
+ msg_id = f"PGZ-{now.strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:8].upper()}"
+ pmt_inf_id = f"{msg_id}-PMT01"
+ creation_dt = now.strftime("%Y-%m-%dT%H:%M:%S")
+ nb_of_txs = len(rows)
+ ctrl_sum = sum((Decimal(str(r["amount"])) for r in rows if r["amount"] is not None), Decimal("0"))
+ ctrl_sum_str = f"{ctrl_sum:.2f}"
+
+ requested_exec_date = (rows[0]["payment_date"] if rows and rows[0]["payment_date"] else now.date()).isoformat()
+
+ parts: List[str] = []
+ parts.append('')
+ parts.append('')
+ parts.append('')
+ parts.append(' ')
+ # Group Header
+ parts.append(' ')
+ parts.append(f' {xml_escape(msg_id)}')
+ parts.append(f' {creation_dt}')
+ parts.append(f' {nb_of_txs}')
+ parts.append(f' {ctrl_sum_str}')
+ parts.append(' ')
+ parts.append(f' {xml_escape(debtor_name)}')
+ parts.append(' ')
+ parts.append(' ')
+ # Payment Information
+ parts.append(' ')
+ parts.append(f' {xml_escape(pmt_inf_id)}')
+ parts.append(' TRF')
+ parts.append(f' {nb_of_txs}')
+ parts.append(f' {ctrl_sum_str}')
+ parts.append(' SEPA')
+ parts.append(f' {requested_exec_date}')
+ parts.append(' ')
+ parts.append(f' {xml_escape(debtor_name)}')
+ parts.append(' ')
+ parts.append(' ')
+ parts.append(f' {xml_escape(debtor_iban)}')
+ parts.append(' ')
+ parts.append(' ')
+ parts.append(f' {xml_escape(debtor_bic)}')
+ parts.append(' ')
+ parts.append(' SLEV')
+
+ for r in rows:
+ amt = r["amount"] if r["amount"] is not None else Decimal("0")
+ try:
+ amt_str = f"{Decimal(str(amt)):.2f}"
+ except (InvalidOperation, TypeError):
+ amt_str = "0.00"
+ ccy = (r.get("currency") or "EUR")[:3]
+ cdtr_name = r.get("klub_naziv") or (f"Klub #{r['klub_id']}" if r.get("klub_id") else "Beneficiary")
+ cdtr_iban = (r.get("iban_to") or "").strip() or "HR0000000000000000000"
+ end_to_end = (r.get("reference") or f"PGZ-PAY-{r['id']}")[:35]
+ rmt = (r.get("description") or r.get("reference") or f"Payment #{r['id']}")[:140]
+
+ parts.append(' ')
+ parts.append(' ')
+ parts.append(f' {xml_escape(end_to_end)}')
+ parts.append(' ')
+ parts.append(' ')
+ parts.append(f' {amt_str}')
+ parts.append(' ')
+ parts.append(' ')
+ parts.append(f' {xml_escape(cdtr_name)}')
+ parts.append(' ')
+ parts.append(' ')
+ parts.append(f' {xml_escape(cdtr_iban)}')
+ parts.append(' ')
+ parts.append(' ')
+ parts.append(f' {xml_escape(rmt)}')
+ parts.append(' ')
+ parts.append(' ')
+
+ parts.append(' ')
+ parts.append(' ')
+ parts.append('')
+
+ xml_body = "\n".join(parts).encode("utf-8")
+ fname = f"sepa_export_{now.strftime('%Y%m%d_%H%M%S')}.xml"
+ return Response(
+ content=xml_body,
+ media_type="application/xml",
+ headers={"Content-Disposition": f'attachment; filename="{fname}"'},
+ )
+
+
+# ── GET /payments/{id} — single record + linked info ────────────────
+@router.get("/payments/{rid}")
+def payments_get(rid: int):
+ row = db_one(
+ "SELECT p.id, p.klub_id, k.naziv AS klub_naziv, p.invoice_id, "
+ "p.expense_report_id, p.clanarina_id, p.payment_date, p.amount, p.currency, "
+ "p.payment_method, p.iban_from, p.iban_to, p.reference, p.description, "
+ "p.bank_statement_no, p.bank_transaction_id, p.matched_status, "
+ "p.matched_by, p.matched_at, p.created_at, "
+ "i.invoice_no, i.vendor_name, i.amount_gross AS invoice_amount, "
+ "er.report_no, er.purpose AS expense_purpose, er.cost_total AS expense_total "
+ "FROM pgz_sport.payments p "
+ "LEFT JOIN pgz_sport.klubovi k ON k.id=p.klub_id "
+ "LEFT JOIN pgz_sport.invoices i ON i.id=p.invoice_id "
+ "LEFT JOIN pgz_sport.expense_reports er ON er.id=p.expense_report_id "
+ "WHERE p.id=%s",
+ (rid,))
+ if not row:
+ raise HTTPException(404, f"Payment id={rid} not found")
+ # Try clanarina (table may exist optionally)
+ if row.get("clanarina_id"):
+ try:
+ cl = db_one(
+ "SELECT id, godina, iznos, status FROM pgz_sport.clanarine WHERE id=%s",
+ (row["clanarina_id"],))
+ if cl:
+ row["clanarina"] = cl
+ except Exception:
+ row["clanarina"] = None
+ return row
+
+
+# ── POST /payments — create ─────────────────────────────────────────
+class PaymentIn(BaseModel):
+ klub_id: Optional[int] = None
+ invoice_id: Optional[int] = None
+ expense_report_id: Optional[int] = None
+ clanarina_id: Optional[int] = None
+ payment_date: date
+ amount: Decimal
+ currency: str = "EUR"
+ payment_method: Optional[str] = None
+ iban_from: Optional[str] = None
+ iban_to: Optional[str] = None
+ reference: Optional[str] = None
+ description: Optional[str] = None
+ bank_statement_no: Optional[str] = None
+ bank_transaction_id: Optional[str] = None
+
+
+_ALLOWED_METHODS = {"iban", "cash", "card", "sepa", "transfer"}
+
+
+@router.post("/payments")
+def payments_create(body: PaymentIn):
+ if body.amount is None or Decimal(str(body.amount)) <= 0:
+ raise HTTPException(400, "amount must be > 0")
+ if body.payment_method and body.payment_method not in _ALLOWED_METHODS:
+ raise HTTPException(400, f"payment_method must be one of {sorted(_ALLOWED_METHODS)}")
+ new_id = db_exec(
+ "INSERT INTO pgz_sport.payments "
+ "(klub_id, invoice_id, expense_report_id, clanarina_id, payment_date, amount, "
+ " currency, payment_method, iban_from, iban_to, reference, description, "
+ " bank_statement_no, bank_transaction_id, matched_status, created_at) "
+ "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'unmatched',now()) "
+ "RETURNING id",
+ (body.klub_id, body.invoice_id, body.expense_report_id, body.clanarina_id,
+ body.payment_date, body.amount, body.currency or "EUR",
+ body.payment_method, body.iban_from, body.iban_to,
+ body.reference, body.description,
+ body.bank_statement_no, body.bank_transaction_id),
+ returning=True,
+ )
+ return payments_get(new_id)
+
+
+# ── PATCH /payments/{id} — partial update + manual matching ─────────
+class PaymentPatch(BaseModel):
+ klub_id: Optional[int] = None
+ invoice_id: Optional[int] = None
+ expense_report_id: Optional[int] = None
+ clanarina_id: Optional[int] = None
+ payment_date: Optional[date] = None
+ amount: Optional[Decimal] = None
+ currency: Optional[str] = None
+ payment_method: Optional[str] = None
+ iban_from: Optional[str] = None
+ iban_to: Optional[str] = None
+ reference: Optional[str] = None
+ description: Optional[str] = None
+ bank_statement_no: Optional[str] = None
+ bank_transaction_id: Optional[str] = None
+ matched_status: Optional[str] = None
+ matched_by: Optional[str] = None
+
+
+@router.patch("/payments/{rid}")
+def payments_patch(rid: int, body: PaymentPatch):
+ existing = db_one("SELECT id FROM pgz_sport.payments WHERE id=%s", (rid,))
+ if not existing:
+ raise HTTPException(404, f"Payment id={rid} not found")
+ data = body.model_dump(exclude_unset=True) if hasattr(body, "model_dump") else body.dict(exclude_unset=True)
+ if "amount" in data and data["amount"] is not None and Decimal(str(data["amount"])) <= 0:
+ raise HTTPException(400, "amount must be > 0")
+ if "payment_method" in data and data["payment_method"] and data["payment_method"] not in _ALLOWED_METHODS:
+ raise HTTPException(400, f"payment_method must be one of {sorted(_ALLOWED_METHODS)}")
+ if not data:
+ return payments_get(rid)
+
+ set_parts: List[str] = []
+ params: list = []
+ for col, val in data.items():
+ set_parts.append(f"{col}=%s")
+ params.append(val)
+
+ # Manual matching: when matched_status flips to 'matched', stamp matched_at
+ if data.get("matched_status") == "matched":
+ set_parts.append("matched_at=now()")
+
+ params.append(rid)
+ db_exec(
+ f"UPDATE pgz_sport.payments SET {', '.join(set_parts)} WHERE id=%s",
+ tuple(params),
+ )
+ return payments_get(rid)
+
+
+# ── POST /payments/import-csv — batch import ────────────────────────
+_CSV_REQUIRED = ["payment_date", "amount", "currency", "payment_method",
+ "iban_from", "iban_to", "reference", "description"]
+_CSV_OPTIONAL = ["klub_id", "invoice_id", "expense_report_id",
+ "clanarina_id", "bank_statement_no", "bank_transaction_id"]
+
+
+def _parse_csv_date(s: str) -> Optional[date]:
+ if not s or not s.strip():
+ return None
+ s = s.strip()
+ for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%d.%m.%Y.", "%d/%m/%Y", "%Y/%m/%d"):
+ try:
+ return datetime.strptime(s, fmt).date()
+ except ValueError:
+ continue
+ return None
+
+
+def _parse_csv_decimal(s: str) -> Optional[Decimal]:
+ if s is None or not str(s).strip():
+ return None
+ raw = str(s).strip().replace(" ", "")
+ # Croatian-style: 1.234,56 → 1234.56
+ if "," in raw and "." in raw:
+ raw = raw.replace(".", "").replace(",", ".")
+ elif "," in raw:
+ raw = raw.replace(",", ".")
+ try:
+ return Decimal(raw)
+ except (InvalidOperation, ValueError):
+ return None
+
+
+def _parse_csv_int(s) -> Optional[int]:
+ if s is None or not str(s).strip():
+ return None
+ try:
+ return int(str(s).strip())
+ except ValueError:
+ return None
+
+
+@router.post("/payments/import-csv")
+async def payments_import_csv(file: UploadFile = File(...)):
+ """Accepts CSV (UTF-8, header row). Required columns: payment_date,amount,currency,
+ payment_method,iban_from,iban_to,reference,description.
+ Optional: klub_id,invoice_id,expense_report_id,clanarina_id,bank_statement_no,
+ bank_transaction_id. All rows imported in a single transaction; bad rows are
+ skipped with an error entry."""
+ raw = await file.read()
+ if not raw:
+ raise HTTPException(400, "Empty file")
+ if len(raw) > 10 * 1024 * 1024:
+ raise HTTPException(413, "File > 10 MB")
+
+ text: Optional[str] = None
+ for enc in ("utf-8-sig", "utf-8", "cp1250", "iso-8859-2", "latin-1"):
+ try:
+ text = raw.decode(enc)
+ break
+ except UnicodeDecodeError:
+ continue
+ if text is None:
+ raise HTTPException(400, "Cannot decode CSV (try UTF-8)")
+
+ # Auto-detect delimiter (, vs ;)
+ sample = text[:4096]
+ try:
+ dialect = csv.Sniffer().sniff(sample, delimiters=",;|\t")
+ except csv.Error:
+ dialect = csv.excel
+ reader = csv.DictReader(StringIO(text), dialect=dialect)
+
+ if not reader.fieldnames:
+ raise HTTPException(400, "CSV has no header row")
+ missing = [c for c in _CSV_REQUIRED if c not in reader.fieldnames]
+ if missing:
+ raise HTTPException(400, f"CSV missing columns: {missing}")
+
+ errors: List[dict] = []
+ valid_rows: List[tuple] = []
+
+ for idx, row in enumerate(reader, start=2): # header is row 1
+ pd = _parse_csv_date(row.get("payment_date", ""))
+ if pd is None:
+ errors.append({"row": idx, "msg": f"bad payment_date: {row.get('payment_date')!r}"})
+ continue
+ amt = _parse_csv_decimal(row.get("amount", ""))
+ if amt is None or amt <= 0:
+ errors.append({"row": idx, "msg": f"bad amount: {row.get('amount')!r}"})
+ continue
+ method = (row.get("payment_method") or "").strip() or None
+ if method and method not in _ALLOWED_METHODS:
+ errors.append({"row": idx, "msg": f"bad payment_method: {method!r}"})
+ continue
+ valid_rows.append((
+ _parse_csv_int(row.get("klub_id")),
+ _parse_csv_int(row.get("invoice_id")),
+ _parse_csv_int(row.get("expense_report_id")),
+ _parse_csv_int(row.get("clanarina_id")),
+ pd,
+ amt,
+ (row.get("currency") or "EUR").strip() or "EUR",
+ method,
+ (row.get("iban_from") or "").strip() or None,
+ (row.get("iban_to") or "").strip() or None,
+ (row.get("reference") or "").strip() or None,
+ (row.get("description") or "").strip() or None,
+ (row.get("bank_statement_no") or "").strip() or None,
+ (row.get("bank_transaction_id") or "").strip() or None,
+ ))
+
+ inserted = 0
+ if valid_rows:
+ def _do(cur):
+ nonlocal inserted
+ for tup in valid_rows:
+ cur.execute(
+ "INSERT INTO pgz_sport.payments "
+ "(klub_id, invoice_id, expense_report_id, clanarina_id, payment_date, "
+ " amount, currency, payment_method, iban_from, iban_to, reference, "
+ " description, bank_statement_no, bank_transaction_id, matched_status, "
+ " created_at) "
+ "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'unmatched',now())",
+ tup,
+ )
+ inserted += 1
+ return inserted
+ db_tx(_do)
+
+ return {"ok": True, "inserted": inserted, "errors": errors,
+ "total_rows": inserted + len(errors)}
+
+
# ═══════════════════════════════════════════════════════════════════
# 14) HEALTH/DEBUG
# ═══════════════════════════════════════════════════════════════════
diff --git a/static/erp_full.html b/static/erp_full.html
index ab32bfd..bc7a6fd 100644
--- a/static/erp_full.html
+++ b/static/erp_full.html
@@ -272,15 +272,26 @@ table tbody tr:hover{background:var(--bg3)}
-
Plaćanja / Bank Reconciliation (payments)
+
+
Plaćanja / Bank Reconciliation (payments)
+
+
+
+
+
+
+
-
-
+
+
+
+
+
-
| # | Datum | Klub | Iznos | Valuta | Način | IBAN OD | IBAN ZA | Referenca | Račun | Putni nalog | Match |
|---|
| Klikni "Osvježi"… |
+
| # | Datum | Klub | Iznos | Valuta | Metoda | IBAN→IBAN | Reference | Status | Akcije |
|---|
| Klikni "Osvježi"… |
@@ -492,6 +503,30 @@ table tbody tr:hover{background:var(--bg3)}
+
+