- 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.
309 lines
11 KiB
Python
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))
|