bmc_hub/app/billing/backend/router.py

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}")