bmc_hub/app/subscriptions/backend/router.py
Christian 6320809f17 feat: Add subscriptions and products management
- Implemented frontend views for products and subscriptions using FastAPI and Jinja2 templates.
- Created API endpoints for managing subscriptions, including creation, listing, and status updates.
- Added HTML templates for displaying active subscriptions and their statistics.
- Established database migrations for sag_subscriptions, sag_subscription_items, and products, including necessary indexes and triggers for automatic subscription number generation.
- Introduced product price history tracking to monitor changes in product pricing.
2026-02-08 12:42:19 +01:00

309 lines
11 KiB
Python

"""
Subscriptions API
Sag-based subscriptions listing and stats
"""
from fastapi import APIRouter, HTTPException, Query
from typing import List, Dict, Any
from app.core.database import execute_query, execute_query_single, get_db_connection, release_db_connection
from psycopg2.extras import RealDictCursor
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
ALLOWED_STATUSES = {"draft", "active", "paused", "cancelled"}
@router.get("/subscriptions/by-sag/{sag_id}", response_model=Dict[str, Any])
async def get_subscription_by_sag(sag_id: int):
"""Get latest subscription for a case."""
try:
query = """
SELECT
s.id,
s.subscription_number,
s.sag_id,
sg.titel AS sag_title,
s.customer_id,
c.name AS customer_name,
s.product_name,
s.billing_interval,
s.billing_day,
s.price,
s.start_date,
s.end_date,
s.status,
s.notes
FROM sag_subscriptions s
LEFT JOIN sag_sager sg ON sg.id = s.sag_id
LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.sag_id = %s
ORDER BY s.id DESC
LIMIT 1
"""
subscription = execute_query_single(query, (sag_id,))
if not subscription:
raise HTTPException(status_code=404, detail="Subscription not found")
items = execute_query(
"""
SELECT
i.id,
i.line_no,
i.product_id,
p.name AS product_name,
i.description,
i.quantity,
i.unit_price,
i.line_total
FROM sag_subscription_items i
LEFT JOIN products p ON p.id = i.product_id
WHERE i.subscription_id = %s
ORDER BY i.line_no ASC, i.id ASC
""",
(subscription["id"],)
)
subscription["line_items"] = items or []
return subscription
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error loading subscription by case: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/subscriptions", response_model=Dict[str, Any])
async def create_subscription(payload: Dict[str, Any]):
"""Create a new subscription tied to a case (status = draft)."""
try:
sag_id = payload.get("sag_id")
billing_interval = payload.get("billing_interval")
billing_day = payload.get("billing_day")
start_date = payload.get("start_date")
notes = payload.get("notes")
line_items = payload.get("line_items") or []
if not sag_id:
raise HTTPException(status_code=400, detail="sag_id is required")
if not billing_interval:
raise HTTPException(status_code=400, detail="billing_interval is required")
if billing_day is None:
raise HTTPException(status_code=400, detail="billing_day is required")
if not start_date:
raise HTTPException(status_code=400, detail="start_date is required")
if not line_items:
raise HTTPException(status_code=400, detail="line_items is required")
sag = execute_query_single(
"SELECT id, customer_id FROM sag_sager WHERE id = %s",
(sag_id,)
)
if not sag or not sag.get("customer_id"):
raise HTTPException(status_code=400, detail="Case must have a customer")
existing = execute_query_single(
"""
SELECT id FROM sag_subscriptions
WHERE sag_id = %s AND status != 'cancelled'
ORDER BY id DESC
LIMIT 1
""",
(sag_id,)
)
if existing:
raise HTTPException(status_code=400, detail="Subscription already exists for this case")
product_ids = [item.get("product_id") for item in line_items if item.get("product_id")]
product_map = {}
if product_ids:
rows = execute_query(
"SELECT id, name, sales_price FROM products WHERE id = ANY(%s)",
(product_ids,)
)
product_map = {row["id"]: row for row in (rows or [])}
cleaned_items = []
total_price = 0
for idx, item in enumerate(line_items, start=1):
product_id = item.get("product_id")
description = (item.get("description") or "").strip()
quantity = item.get("quantity")
unit_price = item.get("unit_price")
product = product_map.get(product_id)
if not description and product:
description = product.get("name") or ""
if unit_price is None and product and product.get("sales_price") is not None:
unit_price = product.get("sales_price")
if not description:
raise HTTPException(status_code=400, detail="line_items description is required")
if quantity is None or float(quantity) <= 0:
raise HTTPException(status_code=400, detail="line_items quantity must be > 0")
if unit_price is None or float(unit_price) < 0:
raise HTTPException(status_code=400, detail="line_items unit_price must be >= 0")
line_total = float(quantity) * float(unit_price)
total_price += line_total
cleaned_items.append({
"line_no": idx,
"product_id": product_id,
"description": description,
"quantity": quantity,
"unit_price": unit_price,
"line_total": line_total,
})
product_name = cleaned_items[0]["description"]
if len(cleaned_items) > 1:
product_name = f"{product_name} (+{len(cleaned_items) - 1})"
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(
"""
INSERT INTO sag_subscriptions (
sag_id,
customer_id,
product_name,
billing_interval,
billing_day,
price,
start_date,
status,
notes
) VALUES (%s, %s, %s, %s, %s, %s, %s, 'draft', %s)
RETURNING *
""",
(
sag_id,
sag["customer_id"],
product_name,
billing_interval,
billing_day,
total_price,
start_date,
notes,
)
)
subscription = cursor.fetchone()
for item in cleaned_items:
cursor.execute(
"""
INSERT INTO sag_subscription_items (
subscription_id,
line_no,
product_id,
description,
quantity,
unit_price,
line_total
) VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(
subscription["id"],
item["line_no"],
item["product_id"],
item["description"],
item["quantity"],
item["unit_price"],
item["line_total"],
)
)
conn.commit()
subscription["line_items"] = cleaned_items
return subscription
finally:
release_db_connection(conn)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error creating subscription: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/subscriptions/{subscription_id}/status", response_model=Dict[str, Any])
async def update_subscription_status(subscription_id: int, payload: Dict[str, Any]):
"""Update subscription status."""
try:
status = payload.get("status")
if status not in ALLOWED_STATUSES:
raise HTTPException(status_code=400, detail="Invalid status")
query = """
UPDATE sag_subscriptions
SET status = %s, updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING *
"""
result = execute_query(query, (status, subscription_id))
if not result:
raise HTTPException(status_code=404, detail="Subscription not found")
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error updating subscription status: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/subscriptions", response_model=List[Dict[str, Any]])
async def list_subscriptions(status: str = Query("active")):
"""List subscriptions by status (default: active)."""
try:
where_clause = "WHERE s.status = %s"
params = (status,)
query = f"""
SELECT
s.id,
s.subscription_number,
s.sag_id,
sg.titel AS sag_title,
s.customer_id,
c.name AS customer_name,
s.product_name,
s.billing_interval,
s.billing_day,
s.price,
s.start_date,
s.end_date,
s.status
FROM sag_subscriptions s
LEFT JOIN sag_sager sg ON sg.id = s.sag_id
LEFT JOIN customers c ON c.id = s.customer_id
{where_clause}
ORDER BY s.start_date DESC, s.id DESC
"""
return execute_query(query, params) or []
except Exception as e:
logger.error(f"❌ Error listing subscriptions: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/subscriptions/stats/summary", response_model=Dict[str, Any])
async def subscription_stats(status: str = Query("active")):
"""Summary stats for subscriptions by status (default: active)."""
try:
query = """
SELECT
COUNT(*) AS subscription_count,
COALESCE(SUM(price), 0) AS total_amount,
COALESCE(AVG(price), 0) AS avg_amount
FROM sag_subscriptions
WHERE status = %s
"""
result = execute_query(query, (status,))
return result[0] if result else {
"subscription_count": 0,
"total_amount": 0,
"avg_amount": 0
}
except Exception as e:
logger.error(f"❌ Error loading subscription stats: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))