""" Subscriptions API Sag-based subscriptions listing and stats """ from fastapi import APIRouter, HTTPException, Query from typing import List, Dict, Any, Optional from app.core.database import execute_query, execute_query_single, get_db_connection, release_db_connection from psycopg2.extras import RealDictCursor import logging import hashlib import json from uuid import uuid4 from datetime import datetime, date, timedelta from dateutil.relativedelta import relativedelta from fastapi import Request from app.services.simplycrm_service import SimplyCRMService logger = logging.getLogger(__name__) router = APIRouter() ALLOWED_STATUSES = {"draft", "active", "paused", "cancelled"} STAGING_KEY_SQL = "COALESCE(source_account_id, 'name:' || LOWER(COALESCE(source_customer_name, 'ukendt')))" ALLOWED_BILLING_DIRECTIONS = {"forward", "backward"} ALLOWED_PRICE_CHANGE_STATUSES = {"pending", "approved", "rejected", "applied"} def _staging_status_with_mapping(status: str, has_customer: bool) -> str: if status == "approved": return "approved" return "mapped" if has_customer else "pending" def _safe_date(value: Optional[Any]) -> Optional[date]: 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 _simply_to_hub_interval(frequency: Optional[str]) -> str: normalized = (frequency or "").strip().lower() mapping = { "daily": "daily", "biweekly": "biweekly", "weekly": "biweekly", "monthly": "monthly", "quarterly": "quarterly", "yearly": "yearly", "annually": "yearly", "semi_annual": "yearly", } return mapping.get(normalized, "monthly") def _next_invoice_date(start_date: date, interval: str) -> date: if interval == "daily": return start_date + timedelta(days=1) if interval == "biweekly": return start_date + timedelta(days=14) if interval == "quarterly": return start_date + relativedelta(months=3) if interval == "yearly": return start_date + relativedelta(years=1) return start_date + relativedelta(months=1) def _auto_map_customer(account_id: Optional[str], customer_name: Optional[str], customer_cvr: Optional[str]) -> Optional[int]: if account_id: row = execute_query_single( "SELECT id FROM customers WHERE vtiger_id = %s LIMIT 1", (account_id,) ) if row and row.get("id"): return int(row["id"]) if customer_cvr: row = execute_query_single( "SELECT id FROM customers WHERE cvr_number = %s LIMIT 1", (customer_cvr,) ) if row and row.get("id"): return int(row["id"]) if customer_name: row = execute_query_single( "SELECT id FROM customers WHERE LOWER(name) = LOWER(%s) LIMIT 1", (customer_name,) ) if row and row.get("id"): return int(row["id"]) return None @router.get("/sag-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("/sag-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") billing_direction = (payload.get("billing_direction") or "forward").strip().lower() advance_months = int(payload.get("advance_months") or 1) first_full_period_start = payload.get("first_full_period_start") binding_months = int(payload.get("binding_months") or 0) binding_start_date_raw = payload.get("binding_start_date") or start_date binding_group_key = payload.get("binding_group_key") invoice_merge_key = payload.get("invoice_merge_key") price_change_case_id = payload.get("price_change_case_id") renewal_case_id = payload.get("renewal_case_id") 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") if billing_direction not in ALLOWED_BILLING_DIRECTIONS: raise HTTPException(status_code=400, detail="billing_direction must be forward or backward") if advance_months < 1 or advance_months > 24: raise HTTPException(status_code=400, detail="advance_months must be between 1 and 24") if binding_months < 0: raise HTTPException(status_code=400, detail="binding_months must be >= 0") 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, serial_number_required, asset_required FROM products WHERE id = ANY(%s) """, (product_ids,) ) product_map = {row["id"]: row for row in (rows or [])} cleaned_items = [] total_price = 0 blocked_reasons = [] 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") asset_id = item.get("asset_id") serial_number = (item.get("serial_number") or "").strip() or None period_from = item.get("period_from") period_to = item.get("period_to") 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") if asset_id is not None: asset = execute_query_single( "SELECT id FROM hardware_assets WHERE id = %s AND deleted_at IS NULL", (asset_id,) ) if not asset: raise HTTPException(status_code=400, detail=f"asset_id {asset_id} was not found") requires_asset = bool(product and product.get("asset_required")) requires_serial_number = bool(product and product.get("serial_number_required")) item_block_reasons: List[str] = [] if requires_asset and not asset_id: item_block_reasons.append("Asset mangler") if requires_serial_number and not serial_number: item_block_reasons.append("Serienummer mangler") line_total = float(quantity) * float(unit_price) total_price += line_total billing_blocked = len(item_block_reasons) > 0 billing_block_reason = "; ".join(item_block_reasons) if billing_blocked else None if billing_block_reason: blocked_reasons.append(f"{description}: {billing_block_reason}") cleaned_items.append({ "line_no": idx, "product_id": product_id, "asset_id": asset_id, "description": description, "quantity": quantity, "unit_price": unit_price, "line_total": line_total, "period_from": period_from, "period_to": period_to, "requires_serial_number": requires_serial_number, "serial_number": serial_number, "billing_blocked": billing_blocked, "billing_block_reason": billing_block_reason, }) product_name = cleaned_items[0]["description"] if len(cleaned_items) > 1: product_name = f"{product_name} (+{len(cleaned_items) - 1})" billing_blocked = len(blocked_reasons) > 0 billing_block_reason = " | ".join(blocked_reasons) if billing_blocked else None binding_start_date = _safe_date(binding_start_date_raw) if not binding_start_date: raise HTTPException(status_code=400, detail="binding_start_date must be a valid date") binding_end_date = None if binding_months > 0: binding_end_date = binding_start_date + relativedelta(months=binding_months) # Calculate next_invoice_date based on billing_interval start_dt = datetime.strptime(start_date, "%Y-%m-%d").date() period_start = start_dt # Calculate next invoice date if billing_interval == "daily": next_invoice_date = start_dt + timedelta(days=1) elif billing_interval == "biweekly": next_invoice_date = start_dt + timedelta(days=14) elif billing_interval == "monthly": next_invoice_date = start_dt + relativedelta(months=1) elif billing_interval == "quarterly": next_invoice_date = start_dt + relativedelta(months=3) elif billing_interval == "yearly": next_invoice_date = start_dt + relativedelta(years=1) else: next_invoice_date = start_dt + relativedelta(months=1) # Default to monthly 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_direction, advance_months, first_full_period_start, billing_day, price, start_date, period_start, next_invoice_date, binding_months, binding_start_date, binding_end_date, binding_group_key, billing_blocked, billing_block_reason, invoice_merge_key, price_change_case_id, renewal_case_id, status, notes ) VALUES ( %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'draft', %s ) RETURNING * """, ( sag_id, sag["customer_id"], product_name, billing_interval, billing_direction, advance_months, first_full_period_start, billing_day, total_price, start_date, period_start, next_invoice_date, binding_months, binding_start_date, binding_end_date, binding_group_key, billing_blocked, billing_block_reason, invoice_merge_key, price_change_case_id, renewal_case_id, notes, ) ) subscription = cursor.fetchone() for item in cleaned_items: cursor.execute( """ INSERT INTO sag_subscription_items ( subscription_id, line_no, product_id, asset_id, description, quantity, unit_price, line_total, period_from, period_to, requires_serial_number, serial_number, billing_blocked, billing_block_reason ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( subscription["id"], item["line_no"], item["product_id"], item["asset_id"], item["description"], item["quantity"], item["unit_price"], item["line_total"], item["period_from"], item["period_to"], item["requires_serial_number"], item["serial_number"], item["billing_blocked"], item["billing_block_reason"], ) ) 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.get("/sag-subscriptions/{subscription_id}", response_model=Dict[str, Any]) async def get_subscription(subscription_id: int): """Get a single subscription by ID with all details.""" 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_direction, s.advance_months, s.first_full_period_start, s.billing_day, s.price, s.start_date, s.end_date, s.next_invoice_date, s.period_start, s.binding_months, s.binding_start_date, s.binding_end_date, s.binding_group_key, s.notice_period_days, s.billing_blocked, s.billing_block_reason, s.invoice_merge_key, s.price_change_case_id, s.renewal_case_id, s.status, s.notes, s.cancelled_at, s.cancellation_reason, s.created_at, s.updated_at 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.id = %s """ subscription = execute_query_single(query, (subscription_id,)) if not subscription: raise HTTPException(status_code=404, detail="Subscription not found") # Get line items items = execute_query( """ SELECT i.id, i.line_no, i.product_id, i.asset_id, p.name AS product_name, i.description, i.quantity, i.unit_price, i.line_total, i.period_from, i.period_to, i.requires_serial_number, i.serial_number, i.billing_blocked, i.billing_block_reason 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: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.patch("/sag-subscriptions/{subscription_id}", response_model=Dict[str, Any]) async def update_subscription(subscription_id: int, payload: Dict[str, Any]): """Update subscription - all fields editable including line items.""" try: subscription = execute_query_single( "SELECT id, status FROM sag_subscriptions WHERE id = %s", (subscription_id,) ) if not subscription: raise HTTPException(status_code=404, detail="Subscription not found") # Extract line_items before processing other fields line_items = payload.pop("line_items", None) # Build dynamic update query allowed_fields = { "product_name", "billing_interval", "billing_day", "price", "start_date", "end_date", "next_invoice_date", "period_start", "notice_period_days", "status", "notes", "billing_direction", "advance_months", "first_full_period_start", "binding_months", "binding_start_date", "binding_end_date", "binding_group_key", "billing_blocked", "billing_block_reason", "invoice_merge_key", "price_change_case_id", "renewal_case_id" } updates = [] values = [] for field, value in payload.items(): if field in allowed_fields: updates.append(f"{field} = %s") values.append(value) # Validate status if provided if "status" in payload and payload["status"] not in ALLOWED_STATUSES: raise HTTPException(status_code=400, detail="Invalid status") conn = get_db_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cursor: # Update subscription fields if any if updates: values.append(subscription_id) query = f""" UPDATE sag_subscriptions SET {', '.join(updates)}, updated_at = CURRENT_TIMESTAMP WHERE id = %s RETURNING * """ cursor.execute(query, tuple(values)) result = cursor.fetchone() else: cursor.execute("SELECT * FROM sag_subscriptions WHERE id = %s", (subscription_id,)) result = cursor.fetchone() # Update line items if provided if line_items is not None: # Delete existing line items cursor.execute( "DELETE FROM sag_subscription_items WHERE subscription_id = %s", (subscription_id,) ) # Insert new line items for idx, item in enumerate(line_items, start=1): description = item.get("description", "").strip() quantity = float(item.get("quantity", 0)) unit_price = float(item.get("unit_price", 0)) if not description or quantity <= 0: continue line_total = quantity * unit_price cursor.execute( """ INSERT INTO sag_subscription_items ( subscription_id, line_no, description, quantity, unit_price, line_total, product_id, asset_id, period_from, period_to, requires_serial_number, serial_number, billing_blocked, billing_block_reason ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( subscription_id, idx, description, quantity, unit_price, line_total, item.get("product_id"), item.get("asset_id"), item.get("period_from"), item.get("period_to"), bool(item.get("requires_serial_number")), item.get("serial_number"), bool(item.get("billing_blocked")), item.get("billing_block_reason"), ) ) conn.commit() return result finally: release_db_connection(conn) except HTTPException: raise except Exception as e: logger.error(f"❌ Error updating subscription: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.patch("/sag-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("/sag-subscriptions", response_model=List[Dict[str, Any]]) async def list_subscriptions(status: str = Query("all")): """List subscriptions by status (default: all) with line item counts.""" try: where_clause = "" params: List[Any] = [] if status and status != "all": where_clause = "WHERE s.status = %s" params.append(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_direction, s.billing_day, s.price, s.start_date, s.end_date, s.billing_blocked, s.invoice_merge_key, s.status, (SELECT COUNT(*) FROM sag_subscription_items WHERE subscription_id = s.id) as item_count 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 """ subscriptions = execute_query(query, tuple(params)) or [] # Add line_items array with count for display for sub in subscriptions: item_count = sub.get('item_count', 0) sub['line_items'] = [{'count': item_count}] if item_count > 0 else [] return subscriptions except Exception as e: logger.error(f"❌ Error listing subscriptions: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get("/sag-subscriptions/stats/summary", response_model=Dict[str, Any]) async def subscription_stats(status: str = Query("all")): """Summary stats for subscriptions by status (default: all).""" try: where_clause = "" params: List[Any] = [] if status and status != "all": where_clause = "WHERE status = %s" params.append(status) query = f""" SELECT COUNT(*) AS subscription_count, COALESCE(SUM(price), 0) AS total_amount, COALESCE(AVG(price), 0) AS avg_amount FROM sag_subscriptions {where_clause} """ result = execute_query(query, tuple(params)) 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)) @router.post("/sag-subscriptions/process-invoices") async def trigger_subscription_processing(): """Manual trigger for subscription invoice processing (for testing).""" try: from app.jobs.process_subscriptions import process_subscriptions await process_subscriptions() return {"status": "success", "message": "Subscription processing completed"} except Exception as e: logger.error(f"❌ Manual subscription processing failed: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get("/sag-subscriptions/{subscription_id}/price-changes", response_model=List[Dict[str, Any]]) async def list_subscription_price_changes(subscription_id: int): """List planned price changes for one subscription.""" try: query = """ SELECT spc.id, spc.subscription_id, spc.subscription_item_id, spc.sag_id, sg.titel AS sag_title, spc.change_scope, spc.old_unit_price, spc.new_unit_price, spc.effective_date, spc.approval_status, spc.reason, spc.approved_by_user_id, spc.approved_at, spc.created_by_user_id, spc.created_at, spc.updated_at FROM subscription_price_changes spc LEFT JOIN sag_sager sg ON sg.id = spc.sag_id WHERE spc.subscription_id = %s AND spc.deleted_at IS NULL ORDER BY spc.effective_date ASC, spc.id ASC """ return execute_query(query, (subscription_id,)) or [] except Exception as e: logger.error(f"❌ Error listing subscription price changes: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post("/sag-subscriptions/{subscription_id}/price-changes", response_model=Dict[str, Any]) async def create_subscription_price_change(subscription_id: int, payload: Dict[str, Any]): """Create a planned price change (case is mandatory).""" try: new_unit_price = payload.get("new_unit_price") effective_date = payload.get("effective_date") sag_id = payload.get("sag_id") subscription_item_id = payload.get("subscription_item_id") reason = payload.get("reason") created_by_user_id = payload.get("created_by_user_id") if new_unit_price is None: raise HTTPException(status_code=400, detail="new_unit_price is required") if float(new_unit_price) < 0: raise HTTPException(status_code=400, detail="new_unit_price must be >= 0") if not effective_date: raise HTTPException(status_code=400, detail="effective_date is required") if not sag_id: raise HTTPException(status_code=400, detail="sag_id is required") subscription = execute_query_single( "SELECT id, customer_id, price FROM sag_subscriptions WHERE id = %s", (subscription_id,) ) if not subscription: raise HTTPException(status_code=404, detail="Subscription not found") sag = execute_query_single( "SELECT id, customer_id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,) ) if not sag: raise HTTPException(status_code=400, detail="Sag not found") if int(sag.get("customer_id") or 0) != int(subscription.get("customer_id") or 0): raise HTTPException(status_code=400, detail="Sag customer mismatch for subscription") change_scope = "subscription" old_unit_price = subscription.get("price") if subscription_item_id is not None: item = execute_query_single( """ SELECT id, unit_price FROM sag_subscription_items WHERE id = %s AND subscription_id = %s """, (subscription_item_id, subscription_id) ) if not item: raise HTTPException(status_code=400, detail="subscription_item_id not found on this subscription") change_scope = "item" old_unit_price = item.get("unit_price") result = execute_query( """ INSERT INTO subscription_price_changes ( subscription_id, subscription_item_id, sag_id, change_scope, old_unit_price, new_unit_price, effective_date, approval_status, reason, created_by_user_id ) VALUES (%s, %s, %s, %s, %s, %s, %s, 'pending', %s, %s) RETURNING * """, ( subscription_id, subscription_item_id, sag_id, change_scope, old_unit_price, new_unit_price, effective_date, reason, created_by_user_id, ) ) return result[0] if result else {} except HTTPException: raise except Exception as e: logger.error(f"❌ Error creating subscription price change: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.patch("/sag-subscriptions/price-changes/{change_id}/approve", response_model=Dict[str, Any]) async def approve_subscription_price_change(change_id: int, payload: Dict[str, Any]): """Approve or reject a planned price change.""" try: approval_status = (payload.get("approval_status") or "approved").strip().lower() approved_by_user_id = payload.get("approved_by_user_id") if approval_status not in ALLOWED_PRICE_CHANGE_STATUSES: raise HTTPException(status_code=400, detail="Invalid approval_status") if approval_status == "applied": raise HTTPException(status_code=400, detail="Use apply endpoint to set applied status") result = execute_query( """ UPDATE subscription_price_changes SET approval_status = %s, approved_by_user_id = %s, approved_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = %s AND deleted_at IS NULL RETURNING * """, (approval_status, approved_by_user_id, change_id) ) if not result: raise HTTPException(status_code=404, detail="Price change not found") return result[0] except HTTPException: raise except Exception as e: logger.error(f"❌ Error approving subscription price change: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.patch("/sag-subscriptions/price-changes/{change_id}/apply", response_model=Dict[str, Any]) async def apply_subscription_price_change(change_id: int): """Apply an approved price change to subscription or item pricing.""" conn = get_db_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cursor: cursor.execute( """ SELECT * FROM subscription_price_changes WHERE id = %s AND deleted_at IS NULL """, (change_id,) ) change = cursor.fetchone() if not change: raise HTTPException(status_code=404, detail="Price change not found") if change.get("approval_status") not in ("approved", "pending"): raise HTTPException(status_code=400, detail="Price change must be approved or pending before apply") subscription_id = int(change["subscription_id"]) change_scope = change.get("change_scope") new_unit_price = float(change.get("new_unit_price") or 0) if change_scope == "item" and change.get("subscription_item_id"): cursor.execute( """ UPDATE sag_subscription_items SET unit_price = %s, line_total = ROUND((quantity * %s)::numeric, 2), updated_at = CURRENT_TIMESTAMP WHERE id = %s """, (new_unit_price, new_unit_price, change["subscription_item_id"]) ) else: cursor.execute( """ UPDATE sag_subscription_items SET unit_price = %s, line_total = ROUND((quantity * %s)::numeric, 2), updated_at = CURRENT_TIMESTAMP WHERE subscription_id = %s """, (new_unit_price, new_unit_price, subscription_id) ) cursor.execute( """ SELECT COALESCE(SUM(line_total), 0) AS total FROM sag_subscription_items WHERE subscription_id = %s """, (subscription_id,) ) row = cursor.fetchone() or {"total": 0} cursor.execute( """ UPDATE sag_subscriptions SET price = %s, price_change_case_id = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s """, (row.get("total") or 0, change.get("sag_id"), subscription_id) ) cursor.execute( """ UPDATE subscription_price_changes SET approval_status = 'applied', approved_at = COALESCE(approved_at, CURRENT_TIMESTAMP), updated_at = CURRENT_TIMESTAMP WHERE id = %s RETURNING * """, (change_id,) ) updated_change = cursor.fetchone() conn.commit() return updated_change or {} except HTTPException: conn.rollback() raise except Exception as e: conn.rollback() logger.error(f"❌ Error applying subscription price change: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) finally: release_db_connection(conn) @router.get("/sag-subscriptions/{subscription_id}/asset-bindings", response_model=List[Dict[str, Any]]) async def list_subscription_asset_bindings(subscription_id: int): """List asset bindings attached to a subscription.""" try: return execute_query( """ SELECT b.id, b.subscription_id, b.asset_id, b.shared_binding_key, b.binding_months, b.start_date, b.end_date, b.notice_period_days, b.status, b.sag_id, b.created_by_user_id, b.created_at, b.updated_at, h.brand, h.model, h.serial_number AS asset_serial_number, h.internal_asset_id, h.status AS asset_status FROM subscription_asset_bindings b LEFT JOIN hardware_assets h ON h.id = b.asset_id WHERE b.subscription_id = %s AND b.deleted_at IS NULL ORDER BY b.start_date DESC, b.id DESC """, (subscription_id,) ) or [] except Exception as e: logger.error(f"❌ Error listing subscription asset bindings: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post("/sag-subscriptions/{subscription_id}/asset-bindings", response_model=Dict[str, Any]) async def create_subscription_asset_binding(subscription_id: int, payload: Dict[str, Any]): """Create a binding for one asset under a subscription.""" try: asset_id = payload.get("asset_id") start_date_raw = payload.get("start_date") end_date_raw = payload.get("end_date") binding_months = int(payload.get("binding_months") or 0) shared_binding_key = payload.get("shared_binding_key") notice_period_days = int(payload.get("notice_period_days") or 30) sag_id = payload.get("sag_id") created_by_user_id = payload.get("created_by_user_id") if not asset_id: raise HTTPException(status_code=400, detail="asset_id is required") if notice_period_days < 0: raise HTTPException(status_code=400, detail="notice_period_days must be >= 0") if binding_months < 0: raise HTTPException(status_code=400, detail="binding_months must be >= 0") subscription = execute_query_single( "SELECT id, customer_id, start_date FROM sag_subscriptions WHERE id = %s", (subscription_id,) ) if not subscription: raise HTTPException(status_code=404, detail="Subscription not found") asset = execute_query_single( "SELECT id FROM hardware_assets WHERE id = %s AND deleted_at IS NULL", (asset_id,) ) if not asset: raise HTTPException(status_code=400, detail="Asset not found") if sag_id: sag = execute_query_single( "SELECT id, customer_id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,) ) if not sag: raise HTTPException(status_code=400, detail="Sag not found") if int(sag.get("customer_id") or 0) != int(subscription.get("customer_id") or 0): raise HTTPException(status_code=400, detail="Sag customer mismatch for subscription") start_date = _safe_date(start_date_raw) or _safe_date(subscription.get("start_date")) or date.today() end_date = _safe_date(end_date_raw) if not end_date and binding_months > 0: end_date = start_date + relativedelta(months=binding_months) result = execute_query( """ INSERT INTO subscription_asset_bindings ( subscription_id, asset_id, shared_binding_key, binding_months, start_date, end_date, notice_period_days, status, sag_id, created_by_user_id ) VALUES (%s, %s, %s, %s, %s, %s, %s, 'active', %s, %s) RETURNING * """, ( subscription_id, asset_id, shared_binding_key, binding_months, start_date, end_date, notice_period_days, sag_id, created_by_user_id, ) ) if not result: raise HTTPException(status_code=500, detail="Could not create binding") execute_query( """ UPDATE sag_subscription_items SET asset_id = %s, updated_at = CURRENT_TIMESTAMP WHERE subscription_id = %s AND asset_id IS NULL """, (asset_id, subscription_id) ) return result[0] except HTTPException: raise except Exception as e: logger.error(f"❌ Error creating subscription asset binding: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.patch("/sag-subscriptions/asset-bindings/{binding_id}", response_model=Dict[str, Any]) async def update_subscription_asset_binding(binding_id: int, payload: Dict[str, Any]): """Update status/dates/notice for a subscription asset binding.""" try: allowed_fields = { "shared_binding_key", "binding_months", "start_date", "end_date", "notice_period_days", "status", "sag_id", } updates = [] values = [] for field, value in payload.items(): if field in allowed_fields: updates.append(f"{field} = %s") values.append(value) if "status" in payload and payload.get("status") not in {"active", "ended", "cancelled"}: raise HTTPException(status_code=400, detail="Invalid binding status") if "notice_period_days" in payload and int(payload.get("notice_period_days") or 0) < 0: raise HTTPException(status_code=400, detail="notice_period_days must be >= 0") if not updates: existing = execute_query_single( "SELECT * FROM subscription_asset_bindings WHERE id = %s AND deleted_at IS NULL", (binding_id,) ) if not existing: raise HTTPException(status_code=404, detail="Binding not found") return existing values.append(binding_id) result = execute_query( f""" UPDATE subscription_asset_bindings SET {', '.join(updates)}, updated_at = CURRENT_TIMESTAMP WHERE id = %s AND deleted_at IS NULL RETURNING * """, tuple(values) ) if not result: raise HTTPException(status_code=404, detail="Binding not found") return result[0] except HTTPException: raise except Exception as e: logger.error(f"❌ Error updating subscription asset binding: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.delete("/sag-subscriptions/asset-bindings/{binding_id}", response_model=Dict[str, Any]) async def delete_subscription_asset_binding(binding_id: int): """Soft-delete a subscription asset binding.""" try: result = execute_query( """ UPDATE subscription_asset_bindings SET deleted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = %s AND deleted_at IS NULL RETURNING id """, (binding_id,) ) if not result: raise HTTPException(status_code=404, detail="Binding not found") return {"status": "deleted", "id": result[0].get("id")} except HTTPException: raise except Exception as e: logger.error(f"❌ Error deleting subscription asset binding: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post("/simply-subscription-staging/import", response_model=Dict[str, Any]) async def import_simply_subscriptions_to_staging(): """Import recurring Simply CRM SalesOrders into staging (parking area).""" try: async with SimplyCRMService() as service: raw_subscriptions = await service.fetch_active_subscriptions() import_batch_id = str(uuid4()) account_cache: Dict[str, Dict[str, Any]] = {} upserted = 0 auto_mapped = 0 for raw in raw_subscriptions: normalized = service.extract_subscription_data(raw) source_record_id = str(normalized.get("simplycrm_id") or raw.get("id") or "").strip() if not source_record_id: continue source_account_id = normalized.get("account_id") source_customer_name = None source_customer_cvr = None if source_account_id: if source_account_id not in account_cache: account_cache[source_account_id] = await service.fetch_account_by_id(source_account_id) or {} account = account_cache[source_account_id] source_customer_name = (account.get("accountname") or "").strip() or None source_customer_cvr = (account.get("siccode") or account.get("vat_number") or "").strip() or None if not source_customer_name: source_customer_name = (raw.get("accountname") or raw.get("account_id") or "").strip() or None hub_customer_id = _auto_map_customer(source_account_id, source_customer_name, source_customer_cvr) if hub_customer_id: auto_mapped += 1 source_status = (normalized.get("status") or "active").strip() source_subject = (normalized.get("name") or raw.get("subject") or "").strip() or None source_total_amount = float(normalized.get("total_amount") or normalized.get("subtotal") or 0) source_currency = (normalized.get("currency") or "DKK").strip() or "DKK" source_start_date = _safe_date(normalized.get("start_date")) source_end_date = _safe_date(normalized.get("end_date")) source_binding_end_date = _safe_date(normalized.get("binding_end_date")) source_billing_frequency = _simply_to_hub_interval(normalized.get("billing_frequency")) sync_hash = hashlib.sha256( json.dumps(raw, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8") ).hexdigest() execute_query( """ INSERT INTO simply_subscription_staging ( source_system, source_record_id, source_account_id, source_customer_name, source_customer_cvr, source_salesorder_no, source_subject, source_status, source_start_date, source_end_date, source_binding_end_date, source_billing_frequency, source_total_amount, source_currency, source_raw, sync_hash, hub_customer_id, approval_status, import_batch_id, imported_at, updated_at ) VALUES ( %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s, %s::uuid, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) ON CONFLICT (source_system, source_record_id) DO UPDATE SET source_account_id = EXCLUDED.source_account_id, source_customer_name = EXCLUDED.source_customer_name, source_customer_cvr = EXCLUDED.source_customer_cvr, source_salesorder_no = EXCLUDED.source_salesorder_no, source_subject = EXCLUDED.source_subject, source_status = EXCLUDED.source_status, source_start_date = EXCLUDED.source_start_date, source_end_date = EXCLUDED.source_end_date, source_binding_end_date = EXCLUDED.source_binding_end_date, source_billing_frequency = EXCLUDED.source_billing_frequency, source_total_amount = EXCLUDED.source_total_amount, source_currency = EXCLUDED.source_currency, source_raw = EXCLUDED.source_raw, sync_hash = EXCLUDED.sync_hash, hub_customer_id = COALESCE(simply_subscription_staging.hub_customer_id, EXCLUDED.hub_customer_id), approval_status = CASE WHEN simply_subscription_staging.approval_status = 'approved' THEN 'approved' ELSE %s END, approval_error = CASE WHEN simply_subscription_staging.approval_status = 'approved' THEN simply_subscription_staging.approval_error ELSE NULL END, import_batch_id = EXCLUDED.import_batch_id, imported_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP """, ( "simplycrm", source_record_id, source_account_id, source_customer_name, source_customer_cvr, normalized.get("salesorder_no"), source_subject, source_status, source_start_date, source_end_date, source_binding_end_date, source_billing_frequency, source_total_amount, source_currency, json.dumps(raw, ensure_ascii=False, default=str), sync_hash, hub_customer_id, _staging_status_with_mapping("pending", bool(hub_customer_id)), import_batch_id, _staging_status_with_mapping("pending", bool(hub_customer_id)), ) ) upserted += 1 return { "status": "success", "batch_id": import_batch_id, "fetched": len(raw_subscriptions), "upserted": upserted, "auto_mapped": auto_mapped, "pending_manual": max(upserted - auto_mapped, 0), } except Exception as e: logger.error(f"❌ Simply staging import failed: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Could not import subscriptions from Simply CRM") @router.get("/simply-subscription-staging/customers", response_model=List[Dict[str, Any]]) async def list_staging_customers(status: str = Query("pending")): """List staging queue grouped by customer/account key.""" try: where_clauses = [] params: List[Any] = [] if status and status != "all": where_clauses.append("approval_status = %s") params.append(status) where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else "" query = f""" SELECT {STAGING_KEY_SQL} AS customer_key, COALESCE(MAX(source_customer_name), 'Ukendt kunde') AS source_customer_name, MAX(source_account_id) AS source_account_id, COUNT(*) AS row_count, COUNT(*) FILTER (WHERE hub_customer_id IS NOT NULL) AS mapped_count, COUNT(*) FILTER (WHERE approval_status = 'approved') AS approved_count, COUNT(*) FILTER (WHERE approval_status = 'error') AS error_count, COALESCE(SUM(source_total_amount), 0) AS total_amount, MAX(updated_at) AS updated_at FROM simply_subscription_staging {where_sql} GROUP BY {STAGING_KEY_SQL} ORDER BY MAX(updated_at) DESC """ return execute_query(query, tuple(params)) or [] except Exception as e: logger.error(f"❌ Failed listing staging customers: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Could not list staging customers") @router.get("/simply-subscription-staging/customers/{customer_key}/rows", response_model=List[Dict[str, Any]]) async def list_staging_customer_rows(customer_key: str): """List staging rows for one customer group.""" try: query = f""" SELECT s.id, s.source_record_id, s.source_salesorder_no, s.source_subject, s.source_status, s.source_billing_frequency, s.source_start_date, s.source_end_date, s.source_total_amount, s.source_currency, s.hub_customer_id, c.name AS hub_customer_name, s.hub_sag_id, s.approval_status, s.approval_error, s.approved_at, s.updated_at FROM simply_subscription_staging s LEFT JOIN customers c ON c.id = s.hub_customer_id WHERE {STAGING_KEY_SQL} = %s ORDER BY s.source_salesorder_no NULLS LAST, s.id ASC """ return execute_query(query, (customer_key,)) or [] except Exception as e: logger.error(f"❌ Failed listing staging rows for customer key {customer_key}: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Could not list staging rows") @router.get("/simply-subscription-staging/rows", response_model=List[Dict[str, Any]]) async def list_all_staging_rows( status: str = Query("all"), limit: int = Query(500, ge=1, le=2000), ): """List all imported staging rows for overview page/table.""" try: where_clauses = [] params: List[Any] = [] if status and status != "all": where_clauses.append("s.approval_status = %s") params.append(status) where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else "" query = f""" SELECT s.id, s.source_record_id, s.source_salesorder_no, s.source_account_id, s.source_customer_name, s.source_customer_cvr, s.source_subject, s.source_status, s.source_billing_frequency, s.source_start_date, s.source_end_date, s.source_total_amount, s.source_currency, s.hub_customer_id, c.name AS hub_customer_name, s.hub_sag_id, s.approval_status, s.approval_error, s.approved_at, s.import_batch_id, s.imported_at, s.updated_at FROM simply_subscription_staging s LEFT JOIN customers c ON c.id = s.hub_customer_id {where_sql} ORDER BY s.updated_at DESC, s.id DESC LIMIT %s """ params.append(limit) return execute_query(query, tuple(params)) or [] except Exception as e: logger.error(f"❌ Failed listing all staging rows: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Could not list imported staging rows") @router.patch("/simply-subscription-staging/{staging_id}/map", response_model=Dict[str, Any]) async def map_staging_row(staging_id: int, payload: Dict[str, Any]): """Map a staging row to Hub customer (and optional existing sag).""" try: hub_customer_id = payload.get("hub_customer_id") hub_sag_id = payload.get("hub_sag_id") if not hub_customer_id: raise HTTPException(status_code=400, detail="hub_customer_id is required") customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (hub_customer_id,)) if not customer: raise HTTPException(status_code=400, detail="Hub customer not found") if hub_sag_id: sag = execute_query_single( "SELECT id, customer_id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (hub_sag_id,) ) if not sag: raise HTTPException(status_code=400, detail="Hub sag not found") if int(sag.get("customer_id") or 0) != int(hub_customer_id): raise HTTPException(status_code=400, detail="Hub sag does not belong to selected customer") result = execute_query( """ UPDATE simply_subscription_staging SET hub_customer_id = %s, hub_sag_id = %s, approval_status = CASE WHEN approval_status = 'approved' THEN 'approved' ELSE 'mapped' END, approval_error = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = %s RETURNING * """, (hub_customer_id, hub_sag_id, staging_id) ) if not result: raise HTTPException(status_code=404, detail="Staging row not found") return result[0] except HTTPException: raise except Exception as e: logger.error(f"❌ Failed mapping staging row: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Could not map staging row") @router.post("/simply-subscription-staging/customers/{customer_key}/approve", response_model=Dict[str, Any]) async def approve_staging_customer_rows(customer_key: str, payload: Dict[str, Any], request: Request): """Approve selected rows for one customer key and copy to Hub subscriptions.""" try: row_ids = payload.get("row_ids") or [] if not isinstance(row_ids, list) or not row_ids: raise HTTPException(status_code=400, detail="row_ids is required") user_id = getattr(request.state, "user_id", None) created_by_user_id = int(user_id) if user_id is not None else 1 rows = execute_query( f""" SELECT * FROM simply_subscription_staging WHERE {STAGING_KEY_SQL} = %s AND id = ANY(%s) """, (customer_key, row_ids) ) or [] if not rows: raise HTTPException(status_code=404, detail="No staging rows found for customer + selection") success_rows: List[int] = [] error_rows: List[Dict[str, Any]] = [] for row in rows: row_id = int(row["id"]) hub_customer_id = row.get("hub_customer_id") if not hub_customer_id: error_message = "Missing hub_customer_id mapping" execute_query( """ UPDATE simply_subscription_staging SET approval_status = 'error', approval_error = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s """, (error_message, row_id) ) error_rows.append({"id": row_id, "error": error_message}) continue conn = get_db_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cursor: start_date = _safe_date(row.get("source_start_date")) or date.today() billing_interval = _simply_to_hub_interval(row.get("source_billing_frequency")) billing_day = min(max(start_date.day, 1), 31) next_invoice_date = _next_invoice_date(start_date, billing_interval) source_subject = (row.get("source_subject") or row.get("source_salesorder_no") or "Simply abonnement").strip() source_record_id = row.get("source_record_id") or str(row_id) source_salesorder_no = row.get("source_salesorder_no") or source_record_id amount = float(row.get("source_total_amount") or 0) sag_id = row.get("hub_sag_id") if not sag_id: cursor.execute( """ INSERT INTO sag_sager ( titel, beskrivelse, template_key, status, customer_id, created_by_user_id ) VALUES (%s, %s, %s, %s, %s, %s) RETURNING id """, ( f"Simply abonnement {source_salesorder_no}", f"Auto-oprettet fra Simply CRM staging row {source_record_id}", "subscription", "åben", hub_customer_id, created_by_user_id, ) ) sag_id = cursor.fetchone()["id"] cursor.execute( """ INSERT INTO sag_subscriptions ( sag_id, customer_id, product_name, billing_interval, billing_day, price, start_date, period_start, next_invoice_date, status, notes ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'draft', %s) RETURNING id """, ( sag_id, hub_customer_id, source_subject, billing_interval, billing_day, amount, start_date, start_date, next_invoice_date, f"Imported from Simply CRM source {source_record_id}", ) ) subscription_id = cursor.fetchone()["id"] cursor.execute( """ INSERT INTO sag_subscription_items ( subscription_id, line_no, product_id, description, quantity, unit_price, line_total ) VALUES (%s, 1, NULL, %s, 1, %s, %s) """, ( subscription_id, source_subject, amount, amount, ) ) cursor.execute( """ UPDATE simply_subscription_staging SET hub_sag_id = %s, approval_status = 'approved', approval_error = NULL, approved_at = CURRENT_TIMESTAMP, approved_by_user_id = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s """, (sag_id, created_by_user_id, row_id) ) conn.commit() success_rows.append(row_id) except Exception as row_exc: conn.rollback() error_message = str(row_exc) execute_query( """ UPDATE simply_subscription_staging SET approval_status = 'error', approval_error = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s """, (error_message[:1000], row_id) ) error_rows.append({"id": row_id, "error": error_message}) finally: release_db_connection(conn) return { "status": "completed", "selected_count": len(row_ids), "approved_count": len(success_rows), "error_count": len(error_rows), "approved_row_ids": success_rows, "errors": error_rows, } except HTTPException: raise except Exception as e: logger.error(f"❌ Failed approving staging rows: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Could not approve selected staging rows")