496 lines
19 KiB
Python
496 lines
19 KiB
Python
"""
|
|
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}")
|