CC2 R5: defense-in-depth JWT + invite/reset token flows + audit

#1 JWT middleware:
- pgz_sport_api.py: starlette middleware require_jwt_on_admin runs before
  every /api/admin/* route. Even routes that lack Depends(require_user)
  cannot be reached without a valid Bearer token (verifies signature,
  exp, typ='access', revocation via user_sessions). OPTIONS passes for CORS.

#2 Invitation flow:
- pgz_sport.user_action_tokens table (token_hash, user_id, kind, expires_at,
  used_at, created_by, ip, meta). Single-use, raw token never persisted.
- POST /api/admin/users/{id}/invite — issues 'invite' token (TTL 7d),
  marks must_change_pwd, revokes existing sessions, returns invite_link.
- GET  /api/auth/setup-password?token=X — preflight (no consume).
- POST /api/auth/setup-password — consumes token, sets password, sets
  email_verified=true.

#3 Password reset flow:
- POST /api/auth/forgot-password — generic 'ako račun postoji' response;
  issues 'reset' token (TTL 2h) only for active users. Token returned in
  response only on localhost or if PGZ_REVEAL_RESET_TOKEN=1.
- GET  /api/auth/reset-password?token=X — preflight.
- POST /api/auth/reset-password — consumes token, sets new password,
  revokes all active sessions.

#4 Audit coverage (auth events):
- login.ok, login.fail (with reason), login.locked, login.2fa_required,
  login.2fa_fail, logout, auth.refresh, password.change, password.reset.ok,
  password.reset.fail, password.forgot.issue, password.forgot.miss,
  invite.consume.ok, invite.consume.fail, user.invite, user.create,
  user.update, user.delete, user.role.change, user.suspend, user.unsuspend,
  user.password.reset, 2fa.verify.ok, 2fa.verify.fail, 2fa.disable.

#5 Live tests: 41/41 across 6 demo users (incl. fresh invited+deleted user).
   Phase 2 verifies 14 endpoints reject no-auth and accept valid Bearer.
This commit is contained in:
Damir Radulić
2026-05-05 01:28:29 +02:00
parent 8dce58c5f9
commit 0046b8d695
24 changed files with 15419 additions and 72 deletions
+291
View File
@@ -816,6 +816,297 @@ def invoices_pay(invoice_id: int, body: dict = Body(default={}),
return {"ok": True, "invoice": row, "payment_id": pay["id"] if pay else None}
# ── R5.3 BULK OPERATIONS ──────────────────────────────────────────────
@router.post("/invoices/bulk-pay")
def invoices_bulk_pay(body: dict = Body(...), authorization: Optional[str] = Header(None)):
"""Bulk označi listu računa kao plaćene.
Body: {ids: [int], paid_date?, payment_method?, iban_from?, iban_to?, reference?, tx_id?}"""
user = _resolve_user(authorization)
ids = body.get("ids") or []
if not ids or not isinstance(ids, list):
raise HTTPException(400, "ids je obavezna ne-prazna lista")
paid_date = body.get("paid_date") or date.today().isoformat()
payment_method = body.get("payment_method") or "transfer"
iban_from = body.get("iban_from")
iban_to = body.get("iban_to")
reference = body.get("reference")
tx_id = body.get("bank_transaction_id") or body.get("tx_id")
results = {"paid": [], "skipped": [], "forbidden": [], "errors": []}
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""SELECT i.*, k.savez_id FROM pgz_sport.invoices i
LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id
WHERE i.id = ANY(%s)""", (ids,))
rows = cur.fetchall()
for inv in rows:
if (inv.get("payment_status") or "").lower() == "paid":
results["skipped"].append(inv["id"]); continue
if user and not can_pay_invoice(user, inv):
results["forbidden"].append(inv["id"]); continue
try:
with _db() as c:
cur = c.cursor()
cur.execute(
"""UPDATE pgz_sport.invoices
SET payment_status='paid', paid_date=%s,
payment_method=COALESCE(%s,payment_method),
iban_from=COALESCE(%s,iban_from),
iban_to=COALESCE(%s,iban_to),
updated_at=NOW()
WHERE id=%s""",
(paid_date, payment_method, iban_from, iban_to, inv["id"]),
)
cur.execute(
"""INSERT INTO pgz_sport.payments
(klub_id, invoice_id, payment_date, amount, currency, payment_method,
iban_from, iban_to, reference, bank_transaction_id, matched_status)
VALUES (%s,%s,%s,%s,COALESCE(%s,'EUR'),%s,%s,%s,%s,%s,'matched')""",
(inv.get("klub_id"), inv["id"], paid_date, inv.get("amount_gross"),
inv.get("currency"), payment_method, iban_from, iban_to, reference, tx_id),
)
audit_invoice(user, inv["id"], "bulk_pay",
field="payment_status", old=inv.get("payment_status"), new="paid")
results["paid"].append(inv["id"])
except Exception as e:
results["errors"].append({"id": inv["id"], "err": str(e)[:200]})
return {"ok": True, "summary": {k: len(v) for k, v in results.items()}, "details": results}
@router.post("/invoices/bulk-cancel")
def invoices_bulk_cancel(body: dict = Body(...), authorization: Optional[str] = Header(None)):
"""Bulk otkaži (status='cancelled') — samo pgz_admin ili klub_admin svog kluba."""
user = _resolve_user(authorization)
ids = body.get("ids") or []
razlog = body.get("razlog") or body.get("reason") or "(bulk cancel)"
if not ids:
raise HTTPException(400, "ids je obavezna ne-prazna lista")
results = {"cancelled": [], "skipped": [], "forbidden": [], "errors": []}
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""SELECT i.*, k.savez_id FROM pgz_sport.invoices i
LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id
WHERE i.id = ANY(%s)""", (ids,))
rows = cur.fetchall()
for inv in rows:
if (inv.get("payment_status") or "").lower() in ("paid", "cancelled"):
results["skipped"].append(inv["id"]); continue
if user and not can_edit_invoice(user, inv):
results["forbidden"].append(inv["id"]); continue
try:
with _db() as c:
c.cursor().execute(
"""UPDATE pgz_sport.invoices
SET payment_status='cancelled',
notes = COALESCE(notes,'') || E'\n[CANCEL] ' || %s,
updated_at=NOW() WHERE id=%s""",
(razlog, inv["id"]),
)
audit_invoice(user, inv["id"], "bulk_cancel",
field="payment_status", old=inv.get("payment_status"),
new=f"cancelled: {razlog}")
results["cancelled"].append(inv["id"])
except Exception as e:
results["errors"].append({"id": inv["id"], "err": str(e)[:200]})
return {"ok": True, "summary": {k: len(v) for k, v in results.items()}, "details": results}
# ── R5.4 XLSX EXPORT ───────────────────────────────────────────────────
@router.get("/invoices/export.xlsx")
def invoices_export_xlsx(
tenant_id: Optional[int] = Query(None),
klub_id: Optional[int] = Query(None),
od: Optional[str] = Query(None, description="datum od YYYY-MM-DD"),
do: Optional[str] = Query(None, description="datum do YYYY-MM-DD"),
status: Optional[str] = None,
kind: Optional[str] = None,
authorization: Optional[str] = Header(None),
):
"""XLSX export računa za knjigovodstvo. Stupci: ID, datum, vrsta, broj,
izdavatelj, OIB, klub, neto, PDV, brutto, valuta, status, IBAN, opis."""
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
from io import BytesIO
from fastapi.responses import StreamingResponse
user = _resolve_user(authorization)
sql = """SELECT i.id, i.invoice_date, i.invoice_kind, i.invoice_no,
i.vendor_name, i.vendor_oib, i.customer_oib,
i.amount_net, i.amount_vat, i.amount_gross, i.vat_rate,
i.currency, i.payment_status, i.payment_method,
i.iban_to, i.description, i.category,
i.paid_date, i.tenant_id, i.klub_id,
k.naziv AS klub_naziv, k.savez_id
FROM pgz_sport.invoices i
LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id
WHERE 1=1"""
args: list = []
if tenant_id is not None: sql += " AND i.tenant_id=%s"; args.append(tenant_id)
if klub_id is not None: sql += " AND i.klub_id=%s"; args.append(klub_id)
if od: sql += " AND i.invoice_date >= %s"; args.append(od)
if do: sql += " AND i.invoice_date <= %s"; args.append(do)
if status: sql += " AND i.payment_status=%s"; args.append(status)
if kind: sql += " AND i.invoice_kind=%s"; args.append(kind)
sql += " ORDER BY i.invoice_date DESC, i.id DESC"
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql, args)
rows = cur.fetchall()
# Filter po user permissions
if user and not is_pgz_admin(user):
rows = [r for r in rows if can_view_invoice(user, r)]
wb = Workbook()
ws = wb.active
ws.title = "Računi"
headers = ["ID", "Datum", "Vrsta", "Broj računa", "Izdavatelj", "OIB",
"Klub", "Iznos neto", "PDV", "Brutto", "Stopa PDV",
"Valuta", "Status", "Datum uplate", "IBAN primatelja",
"Opis", "Kategorija"]
bold = Font(bold=True, color="FFFFFF")
fill = PatternFill("solid", fgColor="003087")
for col_idx, h in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_idx, value=h)
cell.font = bold; cell.fill = fill
cell.alignment = Alignment(horizontal="center")
for r_idx, r in enumerate(rows, 2):
ws.cell(row=r_idx, column=1, value=r.get("id"))
ws.cell(row=r_idx, column=2, value=str(r.get("invoice_date") or ""))
ws.cell(row=r_idx, column=3, value=r.get("invoice_kind"))
ws.cell(row=r_idx, column=4, value=r.get("invoice_no"))
ws.cell(row=r_idx, column=5, value=r.get("vendor_name"))
ws.cell(row=r_idx, column=6, value=r.get("vendor_oib"))
ws.cell(row=r_idx, column=7, value=r.get("klub_naziv"))
ws.cell(row=r_idx, column=8, value=float(r["amount_net"]) if r.get("amount_net") is not None else None)
ws.cell(row=r_idx, column=9, value=float(r["amount_vat"]) if r.get("amount_vat") is not None else None)
ws.cell(row=r_idx, column=10, value=float(r["amount_gross"]) if r.get("amount_gross") is not None else None)
ws.cell(row=r_idx, column=11, value=float(r["vat_rate"]) if r.get("vat_rate") is not None else None)
ws.cell(row=r_idx, column=12, value=r.get("currency"))
ws.cell(row=r_idx, column=13, value=r.get("payment_status"))
ws.cell(row=r_idx, column=14, value=str(r.get("paid_date") or ""))
ws.cell(row=r_idx, column=15, value=r.get("iban_to"))
ws.cell(row=r_idx, column=16, value=r.get("description"))
ws.cell(row=r_idx, column=17, value=r.get("category"))
# Auto width
widths = [6, 12, 12, 18, 28, 14, 24, 12, 12, 12, 8, 6, 11, 12, 22, 30, 12]
for i, w in enumerate(widths, 1):
ws.column_dimensions[ws.cell(row=1, column=i).column_letter].width = w
ws.freeze_panes = "A2"
buf = BytesIO()
wb.save(buf); buf.seek(0)
fname = f"racuni_{date.today().isoformat()}.xlsx"
return StreamingResponse(
buf, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
)
# ── R5.6 STATS ─────────────────────────────────────────────────────────
@router.get("/stats")
def erp_stats(
klub_id: Optional[int] = Query(None),
tenant_id: Optional[int] = Query(None),
authorization: Optional[str] = Header(None),
):
"""Statistika ERP-a: ukupno troškova mjesec/kvartal/godina po klubu/savezu,
breakdown po vrstama (gorivo/cestarina/hotel/oprema/ostalo)."""
user = _resolve_user(authorization)
today = date.today()
month_start = today.replace(day=1).isoformat()
qmonth = ((today.month - 1) // 3) * 3 + 1
quarter_start = today.replace(month=qmonth, day=1).isoformat()
year_start = today.replace(month=1, day=1).isoformat()
where = ["1=1"]; args: list = []
if klub_id is not None:
where.append("klub_id=%s"); args.append(klub_id)
if tenant_id is not None:
where.append("tenant_id=%s"); args.append(tenant_id)
where_sql = " AND ".join(where)
def q_sum(date_from):
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
f"""SELECT COUNT(*) AS n,
COALESCE(SUM(amount_gross),0)::float AS total,
COALESCE(SUM(CASE WHEN payment_status='paid' THEN amount_gross END),0)::float AS paid,
COALESCE(SUM(CASE WHEN payment_status<>'paid' THEN amount_gross END),0)::float AS unpaid
FROM pgz_sport.invoices
WHERE {where_sql} AND invoice_date >= %s""",
args + [date_from],
)
return cur.fetchone()
def q_breakdown(date_from):
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
f"""SELECT invoice_kind, COUNT(*) AS n,
COALESCE(SUM(amount_gross),0)::float AS total
FROM pgz_sport.invoices
WHERE {where_sql} AND invoice_date >= %s
GROUP BY invoice_kind ORDER BY total DESC""",
args + [date_from],
)
return cur.fetchall()
def q_top(date_from):
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
f"""SELECT i.klub_id, k.naziv AS klub_naziv,
COUNT(*) AS n, COALESCE(SUM(i.amount_gross),0)::float AS total
FROM pgz_sport.invoices i
LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id
WHERE {where_sql} AND i.invoice_date >= %s
GROUP BY i.klub_id, k.naziv ORDER BY total DESC LIMIT 10""",
args + [date_from],
)
return cur.fetchall()
# Putni nalozi totals
def q_pn(date_from):
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
pn_where = ["report_type='putni_nalog'"]; pn_args: list = []
if klub_id is not None:
pn_where.append("klub_id=%s"); pn_args.append(klub_id)
if tenant_id is not None:
pn_where.append("tenant_id=%s"); pn_args.append(tenant_id)
cur.execute(
f"""SELECT COUNT(*) AS n,
COALESCE(SUM(cost_total),0)::float AS total,
COALESCE(SUM(dnevnice_amount),0)::float AS dnevnice,
COALESCE(SUM(cost_transport),0)::float AS transport
FROM pgz_sport.expense_reports
WHERE {' AND '.join(pn_where)} AND date_from >= %s""",
pn_args + [date_from],
)
return cur.fetchone()
return {
"ok": True,
"as_of": today.isoformat(),
"filters": {"klub_id": klub_id, "tenant_id": tenant_id},
"invoices": {
"month": {"since": month_start, **q_sum(month_start), "by_kind": q_breakdown(month_start)},
"quarter": {"since": quarter_start, **q_sum(quarter_start), "by_kind": q_breakdown(quarter_start)},
"year": {"since": year_start, **q_sum(year_start), "by_kind": q_breakdown(year_start)},
},
"top_klubovi_godina": q_top(year_start),
"putni_nalozi": {
"month": {"since": month_start, **q_pn(month_start)},
"quarter": {"since": quarter_start, **q_pn(quarter_start)},
"year": {"since": year_start, **q_pn(year_start)},
},
}
@router.get("/invoices/uploads/list")
def uploads_list(klub_id: Optional[int] = None, status: Optional[str] = None, limit: int = 50):
sql = """SELECT id, klub_id, file_name, file_size, mime, ocr_status, ocr_engine,
+135 -26
View File
@@ -246,32 +246,32 @@ def get_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)):
if user and not can_view_putni_nalog(user, row):
raise HTTPException(403, "Nemate ovlasti vidjeti ovaj putni nalog")
# Lista vezanih računa (po klubu, datumu, ili ID-evima u attachments)
att = row.get("attachments") or {}
if isinstance(att, str):
try: att = json.loads(att)
except Exception: att = {}
invoice_ids = att.get("invoice_ids") or []
invoices = []
if invoice_ids:
cur.execute(
"""SELECT id, invoice_no, invoice_kind, vendor_name, vendor_oib,
invoice_date, amount_gross, payment_status, currency, category
FROM pgz_sport.invoices WHERE id = ANY(%s)
ORDER BY invoice_date DESC""", (invoice_ids,))
invoices = cur.fetchall()
else:
# Auto-suggest: računi kluba u rasponu putovanja s kategorijom putni-trošak
cur.execute(
"""SELECT id, invoice_no, invoice_kind, vendor_name, vendor_oib,
invoice_date, amount_gross, payment_status, currency, category
FROM pgz_sport.invoices
WHERE klub_id=%s AND invoice_date BETWEEN %s AND %s
AND invoice_kind IN ('gorivo','cestarina','hotel','restoran','ostalo')
ORDER BY invoice_date DESC LIMIT 50""",
(row.get("klub_id"), row.get("date_from"), row.get("date_to")),
)
invoices = cur.fetchall()
# Vezani računi iz m2m tablice
cur.execute(
"""SELECT i.id, i.invoice_no, i.invoice_kind, i.vendor_name, i.vendor_oib,
i.invoice_date, i.amount_gross, i.payment_status, i.currency, i.category,
pnr.kategorija AS attached_kategorija, pnr.attached_at
FROM pgz_sport.putni_nalog_racuni pnr
JOIN pgz_sport.invoices i ON i.id = pnr.invoice_id
WHERE pnr.putni_nalog_id=%s
ORDER BY i.invoice_date DESC""", (nalog_id,))
invoices = cur.fetchall()
# Auto-suggest: računi kluba u rasponu putovanja koji NISU jos vezani
cur.execute(
"""SELECT i.id, i.invoice_no, i.invoice_kind, i.vendor_name, i.vendor_oib,
i.invoice_date, i.amount_gross, i.payment_status, i.currency, i.category
FROM pgz_sport.invoices i
LEFT JOIN pgz_sport.putni_nalog_racuni pnr
ON pnr.invoice_id=i.id AND pnr.putni_nalog_id=%s
WHERE i.klub_id=%s
AND i.invoice_date BETWEEN %s AND %s
AND i.invoice_kind IN ('gorivo','cestarina','hotel','restoran','oprema','ostalo')
AND pnr.id IS NULL
ORDER BY i.invoice_date DESC LIMIT 50""",
(nalog_id, row.get("klub_id"), row.get("date_from"), row.get("date_to")),
)
suggested = cur.fetchall()
# Payments za ovaj putni nalog
cur.execute(
@@ -284,9 +284,64 @@ def get_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)):
audit = fetch_audit("pgz_sport.expense_reports", nalog_id, 50)
actions = putni_nalog_actions(user, row) if user else {"view": True, "edit": False, "submit": False, "approve": False, "reject": False, "pay": False, "delete": False}
return {"ok": True, "putni_nalog": row, "invoices": invoices,
"suggested_invoices": suggested,
"payments": payments, "audit": audit, "actions": actions}
@router.post("/putni-nalog/{nalog_id}/attach-invoice")
def attach_invoice(nalog_id: int, body: dict = Body(...),
authorization: Optional[str] = Header(None)):
"""Veži postojeći račun na putni nalog (m2m)."""
user = _resolve_user(authorization)
inv_id = body.get("invoice_id")
kategorija = body.get("kategorija") or body.get("category")
if not inv_id:
raise HTTPException(400, "invoice_id je obavezan")
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
pn = cur.fetchone()
if not pn:
raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_edit_putni_nalog(user, pn) and not is_pgz_admin(user):
raise HTTPException(403, "Nemate ovlasti za vezivanje računa")
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""INSERT INTO pgz_sport.putni_nalog_racuni
(putni_nalog_id, invoice_id, kategorija, attached_by)
VALUES (%s,%s,%s,%s)
ON CONFLICT (putni_nalog_id, invoice_id) DO UPDATE SET kategorija=EXCLUDED.kategorija
RETURNING id, attached_at""",
(nalog_id, inv_id, kategorija, (user.get("id") if user else None)),
)
link = cur.fetchone()
audit_putni(user, nalog_id, "attach_invoice", field="invoice_id", new=inv_id)
return {"ok": True, "link_id": link["id"], "attached_at": link["attached_at"]}
@router.delete("/putni-nalog/{nalog_id}/invoice/{invoice_id}")
def detach_invoice(nalog_id: int, invoice_id: int,
authorization: Optional[str] = Header(None)):
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
pn = cur.fetchone()
if not pn:
raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_edit_putni_nalog(user, pn) and not is_pgz_admin(user):
raise HTTPException(403, "Nemate ovlasti")
with _db() as c:
cur = c.cursor()
cur.execute(
"DELETE FROM pgz_sport.putni_nalog_racuni WHERE putni_nalog_id=%s AND invoice_id=%s",
(nalog_id, invoice_id),
)
audit_putni(user, nalog_id, "detach_invoice", field="invoice_id", old=invoice_id)
return {"ok": True}
@router.post("/putni-nalog/{nalog_id}/posalji")
def posalji_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)):
"""Voditelj/klub_admin šalje draft → poslan."""
@@ -385,6 +440,60 @@ def isplati_putni_nalog(nalog_id: int, body: dict = Body(default={}),
return {"ok": True, "putni_nalog": row, "payment_id": pay["id"] if pay else None}
@router.get("/putni-nalog/{nalog_id}/hub3.pdf")
def putni_hub3(nalog_id: int, iban: Optional[str] = None,
authorization: Optional[str] = Header(None)):
"""HUB-3 uplatnica + EPC QR za isplatu putnog naloga voditelju."""
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""SELECT er.*, k.naziv AS klub_naziv, k.savez_id, k.adresa AS klub_adresa
FROM pgz_sport.expense_reports er
LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id
WHERE er.id=%s AND er.report_type='putni_nalog'""", (nalog_id,))
pn = cur.fetchone()
if not pn:
raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_view_putni_nalog(user, pn):
raise HTTPException(403, "Nemate ovlasti")
try:
from crm.payments import build_hub3_pdf
except Exception as e:
raise HTTPException(500, f"HUB-3 helper nije dostupan: {e}")
from fastapi.responses import Response
att = pn.get("attachments") or {}
if isinstance(att, str):
try: att = json.loads(att)
except Exception: att = {}
voditelj = att.get("voditelj") or "Voditelj putovanja"
iban_to = (iban or "").strip() or att.get("iban_voditelja") or "HR0000000000000000000"
iznos = float(pn.get("cost_total") or 0)
if iznos <= 0:
raise HTTPException(400, "Iznos isplate mora biti veći od 0")
poziv = f"{nalog_id:08d}"
opis = f"Putni nalog #{nalog_id}: {pn.get('destination') or ''} ({pn.get('date_from')}{pn.get('date_to')})"[:140]
pdf = build_hub3_pdf(
platitelj_naziv=pn.get("klub_naziv") or "PGŽ Sport klub",
platitelj_adresa=pn.get("klub_adresa") or "",
primatelj_naziv=voditelj,
primatelj_adresa="",
iban=iban_to,
amount_eur=iznos,
model="HR99",
poziv_na_broj=poziv,
opis=opis,
sifra_namjene="SALA",
datum=date.today(),
)
return Response(content=pdf, media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="putni-nalog-{nalog_id}-HUB3.pdf"'})
@router.get("/putni-nalog/{nalog_id}/audit")
def putni_audit(nalog_id: int, limit: int = 100,
authorization: Optional[str] = Header(None)):