""" Billing Router API endpoints for billing operations """ from fastapi import APIRouter, HTTPException from typing import Any, Dict, List from datetime import datetime, date import json from dateutil.relativedelta import relativedelta from app.core.database import execute_query, get_db_connection, release_db_connection from psycopg2.extras import RealDictCursor from app.jobs.reconcile_ordre_drafts import reconcile_ordre_drafts_sync_status from . import supplier_invoices router = APIRouter() # Include supplier invoices router router.include_router(supplier_invoices.router, prefix="", tags=["Supplier Invoices"]) @router.get("/billing/drafts/sync-dashboard") async def get_draft_sync_dashboard(limit: int = 20): """Operational dashboard data for ordre draft sync lifecycle.""" try: summary = execute_query( """ SELECT COUNT(*) FILTER (WHERE sync_status = 'pending') AS pending_count, COUNT(*) FILTER (WHERE sync_status = 'exported') AS exported_count, COUNT(*) FILTER (WHERE sync_status = 'failed') AS failed_count, COUNT(*) FILTER (WHERE sync_status = 'posted') AS posted_count, COUNT(*) FILTER (WHERE sync_status = 'paid') AS paid_count, COUNT(*) AS total_count FROM ordre_drafts """, (), ) or [] attention = execute_query( """ SELECT d.id, d.title, d.customer_id, d.sync_status, d.economic_order_number, d.economic_invoice_number, d.last_sync_at, d.updated_at, ev.event_type AS latest_event_type, ev.created_at AS latest_event_at FROM ordre_drafts d LEFT JOIN LATERAL ( SELECT event_type, created_at FROM ordre_draft_sync_events WHERE draft_id = d.id ORDER BY created_at DESC, id DESC LIMIT 1 ) ev ON TRUE WHERE d.sync_status IN ('pending', 'failed') ORDER BY d.updated_at DESC LIMIT %s """, (max(1, min(limit, 200)),), ) or [] recent_events = execute_query( """ SELECT ev.id, ev.draft_id, ev.event_type, ev.from_status, ev.to_status, ev.event_payload, ev.created_by_user_id, ev.created_at, d.title AS draft_title, d.customer_id, d.sync_status FROM ordre_draft_sync_events ev JOIN ordre_drafts d ON d.id = ev.draft_id ORDER BY ev.created_at DESC, ev.id DESC LIMIT %s """, (max(1, min(limit, 200)),), ) or [] return { "summary": summary[0] if summary else {}, "attention_items": attention, "recent_events": recent_events, } except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to load sync dashboard: {e}") @router.get("/billing/invoices") async def list_invoices(): """List all invoices""" return {"message": "Billing integration coming soon"} @router.post("/billing/sync") async def sync_to_economic(): """Sync data to e-conomic""" return {"message": "e-conomic sync coming soon"} def _to_date(value: Any) -> date | None: if value is None: return None if isinstance(value, date): return value if isinstance(value, datetime): return value.date() text = str(value).strip() if not text: return None try: return datetime.fromisoformat(text.replace("Z", "+00:00")).date() except ValueError: return None def _next_period(start: date, interval: str) -> date: normalized = (interval or "monthly").strip().lower() if normalized == "daily": return start + relativedelta(days=1) if normalized == "biweekly": return start + relativedelta(weeks=2) if normalized == "quarterly": return start + relativedelta(months=3) if normalized == "yearly": return start + relativedelta(years=1) return start + relativedelta(months=1) @router.post("/billing/subscriptions/preview") async def preview_subscription_billing(payload: Dict[str, Any]): """ Preview aggregated customer billing from due subscriptions. Generates prorata suggestions for approved-but-not-applied price changes. """ try: as_of = _to_date(payload.get("as_of")) or date.today() customer_id = payload.get("customer_id") where = ["s.status = 'active'", "s.next_invoice_date <= %s", "COALESCE(s.billing_blocked, false) = false"] params: List[Any] = [as_of] if customer_id: where.append("s.customer_id = %s") params.append(customer_id) subscriptions = execute_query( f""" SELECT s.id, s.customer_id, c.name AS customer_name, s.product_name, s.billing_interval, s.billing_direction, s.invoice_merge_key, s.next_invoice_date, s.period_start, s.price, COALESCE( ( SELECT json_agg( json_build_object( 'id', i.id, 'description', i.description, 'quantity', i.quantity, 'unit_price', i.unit_price, 'line_total', i.line_total, 'asset_id', i.asset_id, 'period_from', i.period_from, 'period_to', i.period_to, 'billing_blocked', i.billing_blocked ) ORDER BY i.line_no ASC, i.id ASC ) FROM sag_subscription_items i WHERE i.subscription_id = s.id ), '[]'::json ) AS line_items FROM sag_subscriptions s LEFT JOIN customers c ON c.id = s.customer_id WHERE {' AND '.join(where)} ORDER BY s.customer_id, s.next_invoice_date, s.id """, tuple(params), ) or [] groups: Dict[str, Dict[str, Any]] = {} for sub in subscriptions: merge_key = sub.get("invoice_merge_key") or f"cust-{sub['customer_id']}" key = f"{sub['customer_id']}|{merge_key}|{sub.get('billing_direction') or 'forward'}|{sub.get('next_invoice_date')}" grp = groups.setdefault( key, { "customer_id": sub["customer_id"], "customer_name": sub.get("customer_name"), "merge_key": merge_key, "billing_direction": sub.get("billing_direction") or "forward", "invoice_date": str(sub.get("next_invoice_date")), "coverage_start": None, "coverage_end": None, "subscription_ids": [], "line_count": 0, "amount_total": 0.0, }, ) sub_id = int(sub["id"]) grp["subscription_ids"].append(sub_id) start = _to_date(sub.get("period_start") or sub.get("next_invoice_date")) or as_of end = _next_period(start, sub.get("billing_interval") or "monthly") grp["coverage_start"] = str(start) if grp["coverage_start"] is None or str(start) < grp["coverage_start"] else grp["coverage_start"] grp["coverage_end"] = str(end) if grp["coverage_end"] is None or str(end) > grp["coverage_end"] else grp["coverage_end"] for item in sub.get("line_items") or []: if item.get("billing_blocked"): continue grp["line_count"] += 1 grp["amount_total"] += float(item.get("line_total") or 0) price_changes = execute_query( """ SELECT spc.id, spc.subscription_id, spc.subscription_item_id, spc.old_unit_price, spc.new_unit_price, spc.effective_date, spc.approval_status, spc.reason, s.period_start, s.billing_interval FROM subscription_price_changes spc JOIN sag_subscriptions s ON s.id = spc.subscription_id WHERE spc.deleted_at IS NULL AND spc.approval_status IN ('approved', 'pending') AND spc.effective_date <= %s ORDER BY spc.effective_date ASC, spc.id ASC """, (as_of,), ) or [] prorata_suggestions: List[Dict[str, Any]] = [] for change in price_changes: period_start = _to_date(change.get("period_start")) if not period_start: continue period_end = _next_period(period_start, change.get("billing_interval") or "monthly") eff = _to_date(change.get("effective_date")) if not eff: continue if eff <= period_start or eff >= period_end: continue total_days = max((period_end - period_start).days, 1) remaining_days = max((period_end - eff).days, 0) old_price = float(change.get("old_unit_price") or 0) new_price = float(change.get("new_unit_price") or 0) delta = new_price - old_price prorata_amount = round(delta * (remaining_days / total_days), 2) if prorata_amount == 0: continue prorata_suggestions.append( { "price_change_id": change.get("id"), "subscription_id": change.get("subscription_id"), "subscription_item_id": change.get("subscription_item_id"), "effective_date": str(eff), "period_start": str(period_start), "period_end": str(period_end), "old_unit_price": old_price, "new_unit_price": new_price, "remaining_days": remaining_days, "total_days": total_days, "suggested_adjustment": prorata_amount, "adjustment_type": "debit" if prorata_amount > 0 else "credit", "reason": change.get("reason"), "requires_manual_approval": True, } ) return { "status": "preview", "as_of": str(as_of), "group_count": len(groups), "groups": list(groups.values()), "prorata_suggestions": prorata_suggestions, } except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to preview subscription billing: {e}") @router.post("/billing/prorata-adjustments/draft") async def create_prorata_adjustment_draft(payload: Dict[str, Any]): """ Create a manual adjustment draft from an approved prorata suggestion. Payload expects customer_id, subscription_id, amount, reason and optional effective dates. """ conn = get_db_connection() try: customer_id = payload.get("customer_id") subscription_id = payload.get("subscription_id") amount = float(payload.get("amount") or 0) reason = (payload.get("reason") or "Prorata justering").strip() effective_date = _to_date(payload.get("effective_date")) or date.today() period_start = _to_date(payload.get("period_start")) period_end = _to_date(payload.get("period_end")) if not customer_id: raise HTTPException(status_code=400, detail="customer_id is required") if not subscription_id: raise HTTPException(status_code=400, detail="subscription_id is required") if amount == 0: raise HTTPException(status_code=400, detail="amount must be non-zero") with conn.cursor(cursor_factory=RealDictCursor) as cursor: cursor.execute( """ SELECT id, customer_id, product_name FROM sag_subscriptions WHERE id = %s """, (subscription_id,), ) sub = cursor.fetchone() if not sub: raise HTTPException(status_code=404, detail="Subscription not found") if int(sub.get("customer_id") or 0) != int(customer_id): raise HTTPException(status_code=400, detail="customer_id mismatch for subscription") adjustment_label = "Prorata tillæg" if amount > 0 else "Prorata kredit" line = { "product": { "productNumber": "PRORATA", "description": f"{adjustment_label}: {sub.get('product_name') or 'Abonnement'}" }, "quantity": 1, "unitNetPrice": amount, "totalNetAmount": amount, "discountPercentage": 0, "metadata": { "subscription_id": subscription_id, "effective_date": str(effective_date), "period_start": str(period_start) if period_start else None, "period_end": str(period_end) if period_end else None, "reason": reason, "manual_approval": True, } } cursor.execute( """ INSERT INTO ordre_drafts ( title, customer_id, lines_json, notes, coverage_start, coverage_end, billing_direction, source_subscription_ids, invoice_aggregate_key, layout_number, created_by_user_id, sync_status, export_status_json, updated_at ) VALUES ( %s, %s, %s::jsonb, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP ) RETURNING id, created_at """, ( f"Manuel {adjustment_label}", customer_id, json.dumps([line], ensure_ascii=False), reason, period_start, period_end, "backward", [subscription_id], f"manual-prorata-{customer_id}", 1, payload.get("created_by_user_id"), "pending", json.dumps( { "source": "prorata_manual", "subscription_id": subscription_id, "effective_date": str(effective_date), }, ensure_ascii=False, ), ), ) created = cursor.fetchone() conn.commit() return { "status": "draft_created", "draft_id": created.get("id") if created else None, "created_at": created.get("created_at") if created else None, "subscription_id": subscription_id, "amount": amount, } except HTTPException: conn.rollback() raise except Exception as e: conn.rollback() raise HTTPException(status_code=500, detail=f"Failed to create prorata adjustment draft: {e}") finally: release_db_connection(conn) @router.post("/billing/drafts/reconcile-sync-status") async def reconcile_draft_sync_status(payload: Dict[str, Any]): """ Reconcile ordre_drafts sync_status from known economic references. Rules: - pending/failed + economic_order_number -> exported - exported + economic_invoice_number -> posted - posted + mark_paid_ids contains draft id -> paid """ try: apply_changes = bool(payload.get("apply", False)) result = await reconcile_ordre_drafts_sync_status(apply_changes=apply_changes) mark_paid_ids = set(int(x) for x in (payload.get("mark_paid_ids") or []) if str(x).isdigit()) if apply_changes and mark_paid_ids: conn = get_db_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cursor: for draft_id in mark_paid_ids: cursor.execute("SELECT sync_status FROM ordre_drafts WHERE id = %s", (draft_id,)) before = cursor.fetchone() from_status = (before or {}).get("sync_status") cursor.execute( """ UPDATE ordre_drafts SET sync_status = 'paid', last_sync_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP, last_exported_at = CURRENT_TIMESTAMP WHERE id = %s AND sync_status = 'posted' RETURNING id """, (draft_id,), ) updated = cursor.fetchone() if updated: cursor.execute( """ INSERT INTO ordre_draft_sync_events ( draft_id, event_type, from_status, to_status, event_payload, created_by_user_id ) VALUES (%s, %s, %s, %s, %s::jsonb, NULL) """, ( draft_id, 'sync_status_manual_paid', from_status, 'paid', '{"source":"billing_reconcile_endpoint"}', ), ) conn.commit() finally: release_db_connection(conn) if mark_paid_ids: result["mark_paid_ids"] = sorted(mark_paid_ids) return result except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to reconcile draft sync status: {e}")