Merge agent3-payments: SEPA + CSV import + match workflow

This commit is contained in:
Damir Radulić
2026-05-05 18:35:01 +02:00
2 changed files with 615 additions and 27 deletions
+412 -4
View File
@@ -16,21 +16,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")
@@ -1549,6 +1553,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('<?xml version="1.0" encoding="UTF-8"?>')
parts.append('<!-- MOCK / placeholder — for future banking integration -->')
parts.append('<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.03" '
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">')
parts.append(' <CstmrCdtTrfInitn>')
# Group Header
parts.append(' <GrpHdr>')
parts.append(f' <MsgId>{xml_escape(msg_id)}</MsgId>')
parts.append(f' <CreDtTm>{creation_dt}</CreDtTm>')
parts.append(f' <NbOfTxs>{nb_of_txs}</NbOfTxs>')
parts.append(f' <CtrlSum>{ctrl_sum_str}</CtrlSum>')
parts.append(' <InitgPty>')
parts.append(f' <Nm>{xml_escape(debtor_name)}</Nm>')
parts.append(' </InitgPty>')
parts.append(' </GrpHdr>')
# Payment Information
parts.append(' <PmtInf>')
parts.append(f' <PmtInfId>{xml_escape(pmt_inf_id)}</PmtInfId>')
parts.append(' <PmtMtd>TRF</PmtMtd>')
parts.append(f' <NbOfTxs>{nb_of_txs}</NbOfTxs>')
parts.append(f' <CtrlSum>{ctrl_sum_str}</CtrlSum>')
parts.append(' <PmtTpInf><SvcLvl><Cd>SEPA</Cd></SvcLvl></PmtTpInf>')
parts.append(f' <ReqdExctnDt>{requested_exec_date}</ReqdExctnDt>')
parts.append(' <Dbtr>')
parts.append(f' <Nm>{xml_escape(debtor_name)}</Nm>')
parts.append(' </Dbtr>')
parts.append(' <DbtrAcct>')
parts.append(f' <Id><IBAN>{xml_escape(debtor_iban)}</IBAN></Id>')
parts.append(' </DbtrAcct>')
parts.append(' <DbtrAgt>')
parts.append(f' <FinInstnId><BIC>{xml_escape(debtor_bic)}</BIC></FinInstnId>')
parts.append(' </DbtrAgt>')
parts.append(' <ChrgBr>SLEV</ChrgBr>')
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(' <CdtTrfTxInf>')
parts.append(' <PmtId>')
parts.append(f' <EndToEndId>{xml_escape(end_to_end)}</EndToEndId>')
parts.append(' </PmtId>')
parts.append(' <Amt>')
parts.append(f' <InstdAmt Ccy="{xml_escape(ccy)}">{amt_str}</InstdAmt>')
parts.append(' </Amt>')
parts.append(' <Cdtr>')
parts.append(f' <Nm>{xml_escape(cdtr_name)}</Nm>')
parts.append(' </Cdtr>')
parts.append(' <CdtrAcct>')
parts.append(f' <Id><IBAN>{xml_escape(cdtr_iban)}</IBAN></Id>')
parts.append(' </CdtrAcct>')
parts.append(' <RmtInf>')
parts.append(f' <Ustrd>{xml_escape(rmt)}</Ustrd>')
parts.append(' </RmtInf>')
parts.append(' </CdtTrfTxInf>')
parts.append(' </PmtInf>')
parts.append(' </CstmrCdtTrfInitn>')
parts.append('</Document>')
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
# ═══════════════════════════════════════════════════════════════════
+203 -23
View File
@@ -332,16 +332,27 @@ table tbody tr:hover{background:var(--bg3)}
<!-- ============ PAYMENTS ============ -->
<section class="panel" id="panel-payments">
<div class="card">
<div class="card-h"><div class="card-t">Plaćanja / Bank Reconciliation (payments)</div></div>
<div class="card-h">
<div class="card-t">Plaćanja / Bank Reconciliation (payments)</div>
<div style="display:flex;gap:6px">
<button class="btn gold" onclick="openPaymentModal()">+ Novo plaćanje</button>
<button class="btn sec" onclick="document.getElementById('py-csv-file').click()">📥 Import CSV</button>
<input type="file" id="py-csv-file" accept=".csv,text/csv" style="display:none" onchange="importPaymentsCSV(this)">
<button class="btn sec" onclick="exportPaymentsSepa()">📤 SEPA XML</button>
</div>
</div>
<div class="toolbar">
<label>Status <select id="py-status"><option value="">— svi —</option><option value="unmatched">unmatched</option><option value="matched">matched</option><option value="manual">manual</option></select></label>
<label>Način <select id="py-method"><option value="">— svi —</option><option value="transfer">transfer</option><option value="cash">cash</option><option value="card">card</option></select></label>
<label>Godina <input type="number" id="py-godina" placeholder="2026" style="width:90px"></label>
<label>Status <select id="py-status"><option value="">— svi —</option><option value="unmatched">unmatched</option><option value="matched">matched</option><option value="manual">manual</option></select></label>
<label>Način <select id="py-method"><option value="">— svi —</option><option value="iban">iban</option><option value="sepa">sepa</option><option value="transfer">transfer</option><option value="cash">cash</option><option value="card">card</option></select></label>
<label>Klub ID <input type="number" id="py-klub" placeholder="—" style="width:90px"></label>
<button class="btn" onclick="loadPayments()">Osvježi</button>
<button id="py-export-btn" class="export-btn" type="button">Export ▾</button>
</div>
<div id="py-summary" class="kpi-grid"></div>
<div id="py-csv-result"></div>
<div class="tbl-wrap">
<table id="py-tbl"><thead><tr><th>#</th><th>Datum</th><th>Klub</th><th class="num">Iznos</th><th>Valuta</th><th>Način</th><th>IBAN OD</th><th>IBAN ZA</th><th>Referenca</th><th>Račun</th><th>Putni nalog</th><th>Match</th></tr></thead><tbody><tr><td colspan="12" style="color:var(--t2);text-align:center;padding:18px">Klikni "Osvježi"…</td></tr></tbody></table>
<table id="py-tbl"><thead><tr><th>#</th><th>Datum</th><th>Klub</th><th class="num">Iznos</th><th>Valuta</th><th>Metoda</th><th>IBANIBAN</th><th>Reference</th><th>Status</th><th>Akcije</th></tr></thead><tbody><tr><td colspan="10" style="color:var(--t2);text-align:center;padding:18px">Klikni "Osvježi"…</td></tr></tbody></table>
</div>
</div>
</section>
@@ -586,6 +597,30 @@ table tbody tr:hover{background:var(--bg3)}
</div>
</div>
<div class="modal-bg" id="m-py" onclick="if(event.target===this)closeModal('m-py')">
<div class="modal" style="width:min(820px,96vw)">
<h3 id="m-py-title">Novo plaćanje</h3>
<div class="form-row"><label>Datum *</label><input type="date" id="py-date"></div>
<div class="form-row"><label>Iznos *</label><input type="number" step="0.01" id="py-amount"></div>
<div class="form-row"><label>Valuta</label><input id="py-currency" value="EUR" maxlength="3"></div>
<div class="form-row"><label>Metoda</label><select id="py-method-in"><option value=""></option><option value="iban">iban</option><option value="sepa">sepa</option><option value="transfer">transfer</option><option value="cash">cash</option><option value="card">card</option></select></div>
<div class="form-row"><label>Klub ID</label><input type="number" id="py-klub-in" placeholder="opcionalno"></div>
<div class="form-row"><label>Račun ID</label><input type="number" id="py-invoice-in" placeholder="opcionalno"></div>
<div class="form-row"><label>Putni nalog ID</label><input type="number" id="py-er-in" placeholder="opcionalno"></div>
<div class="form-row"><label>Članarina ID</label><input type="number" id="py-cl-in" placeholder="opcionalno"></div>
<div class="form-row"><label>IBAN OD</label><input id="py-iban-from" placeholder="HR..."></div>
<div class="form-row"><label>IBAN ZA</label><input id="py-iban-to" placeholder="HR..."></div>
<div class="form-row"><label>Reference</label><input id="py-ref"></div>
<div class="form-row"><label>Opis</label><input id="py-desc"></div>
<div class="form-row"><label>Bank statement #</label><input id="py-bs"></div>
<div class="form-row"><label>Bank txn ID</label><input id="py-btx"></div>
<div class="form-actions">
<button class="btn sec" onclick="closeModal('m-py')">Odustani</button>
<button class="btn gold" onclick="savePayment()">Spremi</button>
</div>
</div>
</div>
<script>
const API = '/api/v2/erp';
const AUTH = () => ({ 'Authorization': 'Bearer ' + (localStorage.getItem('jwt') || localStorage.getItem('access_token') || 'admin-pgz-2026') });
@@ -1331,39 +1366,184 @@ async function deletePutni(id){
}
// ===== PAYMENTS =====
function _pyFilterParams(){
const p = new URLSearchParams();
const s = document.getElementById('py-status').value;
const m = document.getElementById('py-method').value;
const g = document.getElementById('py-godina').value;
const k = document.getElementById('py-klub').value;
if(s) p.set('matched_status', s);
if(m) p.set('payment_method', m);
if(g) p.set('godina', g);
if(k) p.set('klub_id', k);
return p;
}
async function loadPayments(){
const tbody = document.querySelector('#py-tbl tbody');
tbody.innerHTML = `<tr><td colspan="12" style="color:var(--t2);text-align:center;padding:14px">Učitavam…</td></tr>`;
tbody.innerHTML = `<tr><td colspan="10" style="color:var(--t2);text-align:center;padding:14px">Učitavam…</td></tr>`;
const sumDiv = document.getElementById('py-summary');
try {
const s = document.getElementById('py-status').value;
const m = document.getElementById('py-method').value;
const g = document.getElementById('py-godina').value;
const p = new URLSearchParams();
if(s) p.set('matched_status', s);
if(m) p.set('payment_method', m);
if(g) p.set('godina', g);
const d = await api('/payments?'+p.toString());
tbody.innerHTML = (d.rows||[]).length
? d.rows.map(r=>`<tr>
const p = _pyFilterParams();
if(!p.has('godina')){
// for summary tile we still want "this year" total
}
const d = await api('/payments?'+p.toString()+'&limit=500');
const rows = d.rows || [];
const yearNow = new Date().getFullYear();
let totalYear = 0, cMatched = 0, cUnmatched = 0;
for(const r of rows){
const py = r.payment_date ? Number(r.payment_date.slice(0,4)) : null;
if(py === yearNow) totalYear += Number(r.amount||0);
if(r.matched_status === 'matched') cMatched++;
else if(r.matched_status === 'unmatched' || !r.matched_status) cUnmatched++;
}
sumDiv.innerHTML = `
<div class="kpi g"><div class="kpi-l">Ukupno ${yearNow}</div><div class="kpi-v">${fmt(totalYear)} €</div></div>
<div class="kpi r"><div class="kpi-l">Unmatched</div><div class="kpi-v">${cUnmatched}</div></div>
<div class="kpi"><div class="kpi-l">Matched</div><div class="kpi-v">${cMatched}</div></div>
<div class="kpi"><div class="kpi-l">Total rows</div><div class="kpi-v">${rows.length}</div></div>`;
tbody.innerHTML = rows.length
? rows.map(r=>`<tr>
<td>${r.id}</td>
<td>${r.payment_date||''}</td>
<td>${r.klub_naziv||r.klub_id||''}</td>
<td>${(r.klub_naziv||r.klub_id||'').toString().replace(/</g,'&lt;')}</td>
<td class="num"><b>${fmt(r.amount)}</b></td>
<td>${r.currency||''}</td>
<td>${r.payment_method||''}</td>
<td>${r.iban_from||''}</td>
<td>${r.iban_to||''}</td>
<td>${r.reference||''}</td>
<td>${r.invoice_id?('#'+r.invoice_id):'—'}</td>
<td>${r.expense_report_id?('#'+r.expense_report_id):'—'}</td>
<td style="font-family:var(--mono);font-size:10.5px">${(r.iban_from||'')}${(r.iban_to||'—')}</td>
<td>${(r.reference||'').replace(/</g,'&lt;')}</td>
<td><span class="badge ${r.matched_status||''}">${r.matched_status||''}</span></td>
<td style="white-space:nowrap">
<button class="btn sec" style="padding:3px 8px" onclick="event.stopPropagation();viewPayment(${r.id})">👁</button>
${r.matched_status==='matched' ? '' : `<button class="btn green" style="padding:3px 8px" onclick="event.stopPropagation();matchPayment(${r.id})">✓ Match</button>`}
</td>
</tr>`).join('')
: `<tr><td colspan="12" style="color:var(--t2);text-align:center;padding:14px">Nema plaćanja.</td></tr>`;
: `<tr><td colspan="10" style="color:var(--t2);text-align:center;padding:14px">Nema plaćanja.</td></tr>`;
} catch(e) {
tbody.innerHTML = `<tr><td colspan="12" style="color:var(--red)">Greška: ${e.message}</td></tr>`;
sumDiv.innerHTML = '';
tbody.innerHTML = `<tr><td colspan="10" style="color:var(--red)">Greška: ${e.message}</td></tr>`;
}
}
function openPaymentModal(){
['py-date','py-amount','py-klub-in','py-invoice-in','py-er-in','py-cl-in',
'py-iban-from','py-iban-to','py-ref','py-desc','py-bs','py-btx']
.forEach(i=>{ const el=document.getElementById(i); if(el) el.value=''; });
document.getElementById('py-currency').value='EUR';
document.getElementById('py-method-in').value='';
// Pre-fill today
document.getElementById('py-date').value = new Date().toISOString().slice(0,10);
openModal('m-py');
}
async function savePayment(){
const v = id => { const el=document.getElementById(id); return el?el.value.trim():''; };
const numOrNull = s => s==='' ? null : Number(s);
const strOrNull = s => s==='' ? null : s;
const body = {
payment_date: v('py-date'),
amount: v('py-amount'),
currency: v('py-currency') || 'EUR',
payment_method: strOrNull(v('py-method-in')),
klub_id: numOrNull(v('py-klub-in')),
invoice_id: numOrNull(v('py-invoice-in')),
expense_report_id: numOrNull(v('py-er-in')),
clanarina_id: numOrNull(v('py-cl-in')),
iban_from: strOrNull(v('py-iban-from')),
iban_to: strOrNull(v('py-iban-to')),
reference: strOrNull(v('py-ref')),
description: strOrNull(v('py-desc')),
bank_statement_no: strOrNull(v('py-bs')),
bank_transaction_id: strOrNull(v('py-btx')),
};
if(!body.payment_date){ alert('Datum je obavezan'); return; }
if(!body.amount || Number(body.amount)<=0){ alert('Iznos mora biti > 0'); return; }
try {
await api('/payments', { method:'POST', body: JSON.stringify(body) });
closeModal('m-py');
loadPayments();
} catch(e) {
alert('Greška: '+e.message);
}
}
async function viewPayment(id){
try {
const r = await api('/payments/'+id);
const lines = [
`ID: ${r.id}`,
`Datum: ${r.payment_date||''}`,
`Klub: ${r.klub_naziv||r.klub_id||'—'}`,
`Iznos: ${fmt(r.amount)} ${r.currency||''}`,
`Metoda: ${r.payment_method||'—'}`,
`IBAN: ${r.iban_from||'—'}${r.iban_to||'—'}`,
`Reference: ${r.reference||'—'}`,
`Opis: ${r.description||'—'}`,
`Status: ${r.matched_status||''}${r.matched_at?(' @ '+r.matched_at):''}`,
r.invoice_no ? `Račun: ${r.invoice_no} (${r.vendor_name||''}, ${fmt(r.invoice_amount)})` : null,
r.report_no ? `Putni nalog: ${r.report_no} (${r.expense_purpose||''})` : null,
r.bank_statement_no ? `Izvod: ${r.bank_statement_no}` : null,
r.bank_transaction_id ? `Bank txn: ${r.bank_transaction_id}` : null,
].filter(Boolean);
alert(lines.join('\n'));
} catch(e) {
alert('Greška: '+e.message);
}
}
async function matchPayment(id){
const inv = prompt('Invoice ID za match (ostavi prazno za skip):', '');
let er = '';
if(!inv) er = prompt('Expense report ID za match (ostavi prazno za skip):', '') || '';
const body = { matched_status: 'matched', matched_by: 'user' };
if(inv && inv.trim()) body.invoice_id = parseInt(inv.trim(),10);
if(er && er.trim()) body.expense_report_id = parseInt(er.trim(),10);
try {
await api('/payments/'+id, { method:'PATCH', body: JSON.stringify(body) });
loadPayments();
} catch(e) {
alert('Greška: '+e.message);
}
}
async function importPaymentsCSV(input){
const f = input.files && input.files[0];
if(!f) return;
const fd = new FormData();
fd.append('file', f);
const out = document.getElementById('py-csv-result');
out.innerHTML = `<div style="padding:8px;background:var(--bg3);border-radius:4px;margin:8px 0;color:var(--t2)">Importing ${f.name}…</div>`;
try {
const r = await fetch(API+'/payments/import-csv', {
method: 'POST',
headers: { ...AUTH() },
body: fd,
});
const j = await r.json();
if(!r.ok){
out.innerHTML = `<div style="padding:8px;background:var(--bg3);border-left:3px solid var(--red);margin:8px 0;color:var(--red)">Import error: ${j.detail||r.status}</div>`;
} else {
const errLines = (j.errors||[]).slice(0,20).map(e=>`<li>row ${e.row}: ${e.msg}</li>`).join('');
out.innerHTML = `<div style="padding:8px;background:var(--bg3);border-left:3px solid var(--green);margin:8px 0">
<b style="color:var(--green)">Inserted: ${j.inserted}</b> · Errors: ${(j.errors||[]).length} · Total: ${j.total_rows||(j.inserted+(j.errors||[]).length)}
${errLines?`<ul style="margin-top:6px;color:var(--amber);font-size:11px">${errLines}</ul>`:''}
</div>`;
loadPayments();
}
} catch(e) {
out.innerHTML = `<div style="padding:8px;background:var(--bg3);border-left:3px solid var(--red);margin:8px 0;color:var(--red)">Greška: ${e.message}</div>`;
} finally {
input.value = '';
}
}
function exportPaymentsSepa(){
const p = _pyFilterParams();
const url = API+'/payments/sepa-export'+(p.toString()?('?'+p.toString()):'');
window.open(url, '_blank');
}
// ===== IZVJEŠTAJI =====
async function loadIzvjestaj(){
const tip = document.getElementById('iz-tip').value;