bmc_hub/app/subscriptions/backend/router.py

2840 lines
124 KiB
Python
Raw Normal View History

"""
Subscriptions API
Sag-based subscriptions listing and stats
"""
2026-08-28 20:49:55 +02:00
from fastapi import APIRouter, Depends, 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
from app.modules.internet_connections.backend.provisioning_utils import summarize_subscription_network_requirements
2026-08-28 20:49:55 +02:00
from app.core.auth_dependencies import get_current_user, require_permission
from app.services.subscription_billing_calendar import (
advance_billing_periods,
billing_date_for_period,
prorated_30_day_factor,
validate_billing_schedule,
)
from app.services.subscription_agreement import agreement_status
logger = logging.getLogger(__name__)
router = APIRouter()
2026-08-28 20:49:55 +02:00
ALLOWED_STATUSES = {"draft", "scheduled", "active", "paused", "terminating", "cancelled", "expired", "blocked"}
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"}
ALLOWED_BILLING_INTERVALS = {"daily", "biweekly", "monthly", "quarterly", "yearly"}
2026-08-28 20:49:55 +02:00
ALLOWED_SCHEDULE_TYPES = {"fixed_day", "first_business_day", "last_business_day", "interval_anchor"}
CHANGE_OPEN_STATUSES = {"draft", "pending", "approved_scheduled", "applying", "partially_applied", "failed", "cancellation_pending"}
CHANGE_EDITABLE_FIELDS = {
"product_name", "billing_interval", "billing_schedule_type", "billing_day", "start_date", "end_date", "period_start",
"notice_period_days", "status", "billing_direction", "advance_months", "billing_lead_months", "first_full_period_start",
"binding_months", "binding_start_date", "binding_end_date", "binding_group_key", "invoice_merge_key",
"price_type", "custom_price_override", "first_invoice_policy", "notes", "line_items",
}
def _load_subscription_line_items(subscription_id: int) -> List[Dict[str, Any]]:
rows = execute_query(
"""
SELECT
i.id,
i.line_no,
i.product_id,
2026-08-28 20:49:55 +02:00
i.asset_id,
CONCAT_WS(' ', h.brand, h.model) AS asset_name,
h.asset_type,
h.internal_asset_id,
h.customer_asset_id,
h.serial_number AS asset_serial_number,
h.status AS asset_status,
p.name AS product_name,
2026-08-28 20:49:55 +02:00
p.sku_internal,
p.er_number,
p.ean,
p.supplier_sku,
p.type AS product_type,
p.attributes_json,
i.description,
i.quantity,
i.unit_price,
i.line_total,
i.period_from,
i.period_to,
2026-08-28 20:49:55 +02:00
i.price_type,
i.custom_price_override,
i.requires_serial_number,
i.serial_number,
i.billing_blocked,
2026-08-28 20:49:55 +02:00
i.billing_block_reason,
i.created_at,
i.updated_at
FROM sag_subscription_items i
LEFT JOIN products p ON p.id = i.product_id
2026-08-28 20:49:55 +02:00
LEFT JOIN hardware_assets h ON h.id = i.asset_id
WHERE i.subscription_id = %s
ORDER BY i.line_no ASC, i.id ASC
""",
(subscription_id,),
) or []
return [dict(row) for row in rows]
2026-08-28 20:49:55 +02:00
def _load_first_invoice_items(subscription_id: int) -> List[Dict[str, Any]]:
rows = execute_query(
"""SELECT fi.*, p.name AS product_name, p.sku_internal, p.er_number, p.ean
FROM sag_subscription_first_invoice_items fi
LEFT JOIN products p ON p.id = fi.product_id
WHERE fi.subscription_id = %s ORDER BY fi.line_no, fi.id""",
(subscription_id,),
) or []
return [dict(row) for row in rows]
def _load_subscription_asset_bindings(subscription_id: int) -> List[Dict[str, Any]]:
return execute_query(
"""
SELECT b.*, h.asset_type, h.brand, h.model, h.serial_number,
h.internal_asset_id, h.customer_asset_id, h.status AS asset_status
FROM subscription_asset_bindings b
JOIN hardware_assets h ON h.id = b.asset_id
WHERE b.subscription_id = %s AND b.deleted_at IS NULL
ORDER BY b.status = 'active' DESC, b.start_date DESC, b.id DESC
""",
(subscription_id,),
) or []
def _attach_network_provisioning(subscription: Dict[str, Any]) -> Dict[str, Any]:
line_items = subscription.get("line_items") or []
provisioning = summarize_subscription_network_requirements(line_items)
existing_connection = execute_query_single(
"""
SELECT id, parent_id
FROM internet_connections_connections
WHERE subscription_id = %s
AND deleted_at IS NULL
ORDER BY id ASC
LIMIT 1
""",
(subscription.get("id"),),
)
provisioning["existing_connection_id"] = existing_connection.get("id") if existing_connection else None
provisioning["is_provisioned"] = bool(existing_connection)
subscription["requires_network_provisioning"] = provisioning["requires_provisioning"]
subscription["network_provisioning"] = provisioning
return subscription
def _load_subscription_with_context(subscription_id: int) -> Dict[str, Any]:
subscription = execute_query_single(
"""
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,
2026-08-28 20:49:55 +02:00
s.billing_schedule_type,
s.billing_direction,
s.advance_months,
2026-08-28 20:49:55 +02:00
s.billing_lead_months,
s.proration_basis,
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,
2026-08-28 20:49:55 +02:00
s.price_type,
s.custom_price_override,
s.first_invoice_policy,
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
2026-08-28 20:49:55 +02:00
,s.version
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_id,),
)
if not subscription:
raise HTTPException(status_code=404, detail="Subscription not found")
subscription = dict(subscription)
subscription["line_items"] = _load_subscription_line_items(subscription_id)
2026-08-28 20:49:55 +02:00
subscription["first_invoice_items"] = _load_first_invoice_items(subscription_id)
return _attach_network_provisioning(subscription)
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)
2026-08-28 20:49:55 +02:00
def _json_safe(value: Any) -> Any:
return json.loads(json.dumps(value, default=str))
def _subscription_snapshot(subscription_id: int) -> Dict[str, Any]:
return _json_safe(_load_subscription_with_context(subscription_id))
def _normalize_change_proposal(before: Dict[str, Any], proposal: Dict[str, Any]) -> Dict[str, Any]:
unknown = set(proposal) - CHANGE_EDITABLE_FIELDS
if unknown:
raise HTTPException(status_code=400, detail=f"Unsupported fields: {', '.join(sorted(unknown))}")
merged = {key: before.get(key) for key in CHANGE_EDITABLE_FIELDS if key != "line_items" and key in before}
merged["line_items"] = before.get("line_items") or []
merged.update(proposal)
interval = merged.get("billing_interval") or "monthly"
schedule_type = merged.get("billing_schedule_type") or "fixed_day"
try:
schedule_type, billing_day = validate_billing_schedule(interval, schedule_type, merged.get("billing_day"))
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if merged.get("status") not in ALLOWED_STATUSES:
raise HTTPException(status_code=400, detail="Invalid subscription status")
merged["billing_schedule_type"] = schedule_type
merged["billing_day"] = billing_day
return _json_safe(merged)
def _permissions_for(current_user: Dict[str, Any]) -> Dict[str, bool]:
permissions = set(current_user.get("permissions") or [])
elevated = bool(current_user.get("is_superadmin"))
return {
"view": elevated or "subscriptions.view" in permissions,
"request_change": elevated or "subscriptions.change_request" in permissions,
"approve": elevated or "subscriptions.approve" in permissions,
}
def _ensure_asset_not_booked(asset_id: int, start_dt: date, end_dt: Optional[date], exclude_subscription_id: Optional[int] = None):
"""Prevent overlapping asset usage across active/draft/paused subscriptions."""
if end_dt and end_dt < start_dt:
raise HTTPException(status_code=400, detail="period_to cannot be before period_from")
params: List[Any] = [asset_id, start_dt, end_dt]
exclude_sql = ""
if exclude_subscription_id:
exclude_sql = " AND s.id <> %s"
params.append(exclude_subscription_id)
conflict = execute_query_single(
f"""
SELECT
s.id AS subscription_id,
i.id AS line_item_id,
COALESCE(i.period_from, s.start_date) AS existing_period_from,
COALESCE(i.period_to, s.end_date) AS existing_period_to
FROM sag_subscription_items i
JOIN sag_subscriptions s ON s.id = i.subscription_id
WHERE i.asset_id = %s
AND s.status IN ('draft', 'active', 'paused')
AND daterange(COALESCE(i.period_from, s.start_date), COALESCE(i.period_to, s.end_date, 'infinity'::date), '[]')
&& daterange(%s, COALESCE(%s, 'infinity'::date), '[]')
{exclude_sql}
LIMIT 1
""",
tuple(params),
)
if conflict:
raise HTTPException(
status_code=400,
detail=(
"Asset is already booked in overlapping period "
f"(subscription_id={conflict.get('subscription_id')}, line_item_id={conflict.get('line_item_id')})"
),
)
def _ensure_binding_not_overlapping(asset_id: int, start_dt: date, end_dt: Optional[date], exclude_binding_id: Optional[int] = None):
"""Prevent overlapping active rows in subscription_asset_bindings for the same asset."""
if end_dt and end_dt < start_dt:
raise HTTPException(status_code=400, detail="end_date cannot be before start_date")
params: List[Any] = [asset_id, start_dt, end_dt]
exclude_sql = ""
if exclude_binding_id:
exclude_sql = " AND b.id <> %s"
params.append(exclude_binding_id)
conflict = execute_query_single(
f"""
SELECT b.id
FROM subscription_asset_bindings b
WHERE b.asset_id = %s
AND b.deleted_at IS NULL
AND b.status = 'active'
AND daterange(b.start_date, COALESCE(b.end_date, 'infinity'::date), '[]')
&& daterange(%s, COALESCE(%s, 'infinity'::date), '[]')
{exclude_sql}
LIMIT 1
""",
tuple(params),
)
if conflict:
raise HTTPException(
status_code=400,
detail=f"Asset already has an overlapping active binding (binding_id={conflict.get('id')})",
)
def _sync_asset_rental_status(asset_id: int):
"""Keep hardware_assets.rental_status aligned with active subscription bindings."""
active_binding = execute_query_single(
"""
SELECT b.id
FROM subscription_asset_bindings b
JOIN sag_subscriptions s ON s.id = b.subscription_id
WHERE b.asset_id = %s
AND b.deleted_at IS NULL
AND b.status = 'active'
AND s.status IN ('draft', 'active', 'paused')
AND (b.end_date IS NULL OR b.end_date >= CURRENT_DATE)
LIMIT 1
""",
(asset_id,),
)
target_status = "udlejet" if active_binding else "ledig"
execute_query(
"""
UPDATE hardware_assets
SET rental_status = CASE
WHEN rental_status = 'defekt' THEN rental_status
ELSE %s
END,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
AND deleted_at IS NULL
""",
(target_status, asset_id),
)
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, allow_missing: bool = Query(False)):
2026-08-28 20:49:55 +02:00
"""Compatibility response. Consumers should use agreement-overview."""
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:
if allow_missing:
return {"subscription": None, "line_items": []}
raise HTTPException(status_code=404, detail="Subscription not found")
subscription["line_items"] = _load_subscription_line_items(int(subscription["id"]))
2026-08-28 20:49:55 +02:00
subscription["first_invoice_items"] = _load_first_invoice_items(int(subscription["id"]))
subscription["asset_bindings"] = _load_subscription_asset_bindings(int(subscription["id"]))
return _attach_network_provisioning(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))
2026-08-28 20:49:55 +02:00
@router.get("/sag-subscriptions/agreement-overview/{sag_id}", response_model=Dict[str, Any])
async def get_subscription_agreement_overview(
sag_id: int,
current_user: Dict[str, Any] = Depends(require_permission("subscriptions.view")),
):
"""Return every subscription and change belonging to a main subscription case."""
case = execute_query_single(
"SELECT id, titel, status, customer_id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(sag_id,),
)
if not case:
raise HTTPException(status_code=404, detail="Sag not found")
rows = execute_query(
"""
SELECT s.*, c.name AS customer_name
FROM sag_subscriptions s
LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.sag_id = %s
ORDER BY s.start_date, s.id
""",
(sag_id,),
) or []
subscriptions = []
today = date.today()
for row in rows:
subscription = dict(row)
subscription["line_items"] = _load_subscription_line_items(int(subscription["id"]))
subscription["first_invoice_items"] = _load_first_invoice_items(int(subscription["id"]))
subscription["asset_bindings"] = _load_subscription_asset_bindings(int(subscription["id"]))
subscriptions.append(_attach_network_provisioning(subscription))
changes = execute_query(
"""
SELECT cr.*, sg.titel AS change_sag_title,
COALESCE(json_agg(json_build_object(
'id', ci.id, 'subscription_id', ci.subscription_id,
'apply_status', ci.apply_status, 'error_message', ci.error_message,
'before_snapshot', ci.before_snapshot, 'proposed_snapshot', ci.proposed_snapshot
) ORDER BY ci.id) FILTER (WHERE ci.id IS NOT NULL), '[]'::json) AS items
FROM subscription_change_requests cr
JOIN sag_sager sg ON sg.id = cr.change_sag_id
LEFT JOIN subscription_change_request_items ci ON ci.change_request_id = cr.id
WHERE cr.main_sag_id = %s
GROUP BY cr.id, sg.titel
ORDER BY cr.created_at DESC
""",
(sag_id,),
) or []
events = execute_query(
"""
SELECT e.*, COALESCE(u.full_name, u.username) AS actor_name
FROM subscription_events e
LEFT JOIN users u ON u.user_id = e.actor_user_id
WHERE e.main_sag_id = %s
ORDER BY e.created_at DESC LIMIT 100
""",
(sag_id,),
) or []
current = [
s for s in subscriptions
if s.get("status") not in {"cancelled", "expired", "scheduled"}
and (not s.get("start_date") or s["start_date"] <= today)
]
upcoming = [s for s in subscriptions if s.get("status") == "scheduled" or (s.get("start_date") and s["start_date"] > today)]
ended = [s for s in subscriptions if s.get("status") in {"cancelled", "expired"}]
open_changes = [row for row in changes if row.get("status") in CHANGE_OPEN_STATUSES]
return {
"case": case,
"agreement_status": agreement_status(subscriptions, open_changes),
"current_subscriptions": current,
"upcoming_subscriptions": upcoming,
"ended_subscriptions": ended,
"open_changes": open_changes,
"change_history": changes,
"events": events,
"allowed_actions": _permissions_for(current_user),
}
@router.get("/sag-subscriptions/change-requests/by-case/{change_sag_id}", response_model=Dict[str, Any])
async def get_subscription_change_request_by_case(
change_sag_id: int,
current_user: Dict[str, Any] = Depends(require_permission("subscriptions.view")),
):
change = execute_query_single(
"""SELECT cr.*, COALESCE(cu.full_name, cu.username) AS created_by_name,
COALESCE(au.full_name, au.username) AS approved_by_name
FROM subscription_change_requests cr
LEFT JOIN users cu ON cu.user_id = cr.created_by_user_id
LEFT JOIN users au ON au.user_id = cr.approved_by_user_id
WHERE cr.change_sag_id = %s""",
(change_sag_id,),
)
if not change:
return {
"change_request": None,
"items": [],
"allowed_actions": _permissions_for(current_user),
}
2026-08-28 20:49:55 +02:00
items = execute_query(
"""SELECT ci.*, s.subscription_number, s.product_name
FROM subscription_change_request_items ci
JOIN sag_subscriptions s ON s.id = ci.subscription_id
WHERE ci.change_request_id = %s ORDER BY ci.id""",
(change["id"],),
) or []
allowed = _permissions_for(current_user)
allowed["approve_this"] = bool(
allowed["approve"]
and int(change["created_by_user_id"]) != int(current_user["id"])
and change.get("status") == "pending"
)
return {"change_request": change, "items": items, "allowed_actions": allowed}
def _create_change_case(cursor, main_case: Dict[str, Any], user_id: int, reason: Optional[str]) -> int:
cursor.execute(
"""
INSERT INTO sag_sager (titel, beskrivelse, template_key, status, customer_id, created_by_user_id)
VALUES (%s, %s, 'abonnement', 'åben', %s, %s)
RETURNING id
""",
(f"Abonnementsændring · {main_case.get('titel') or '#' + str(main_case['id'])}", reason or "", main_case.get("customer_id"), user_id),
)
change_sag_id = int(cursor.fetchone()[0])
cursor.execute(
"""
INSERT INTO sag_relationer (kilde_sag_id, målsag_id, relationstype)
VALUES (%s, %s, 'undersag')
""",
(main_case["id"], change_sag_id),
)
return change_sag_id
@router.post("/sag-subscriptions/change-requests", response_model=Dict[str, Any])
async def save_subscription_change_request(
payload: Dict[str, Any],
current_user: Dict[str, Any] = Depends(require_permission("subscriptions.change_request")),
):
main_sag_id = int(payload.get("main_sag_id") or 0)
proposals = payload.get("proposals") or []
if not main_sag_id or not proposals:
raise HTTPException(status_code=400, detail="main_sag_id and proposals are required")
effective_date = _safe_date(payload.get("effective_date") or date.today())
if not effective_date:
raise HTTPException(status_code=400, detail="Invalid effective_date")
main_case = execute_query_single(
"SELECT id, titel, customer_id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(main_sag_id,),
)
if not main_case:
raise HTTPException(status_code=404, detail="Main case not found")
user_id = int(current_user["id"])
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(
"""SELECT * FROM subscription_change_requests
WHERE main_sag_id = %s AND status = ANY(%s) FOR UPDATE""",
(main_sag_id, list(CHANGE_OPEN_STATUSES)),
)
change = cursor.fetchone()
if not change:
change_sag_id = _create_change_case(cursor, main_case, user_id, payload.get("reason"))
cursor.execute(
"""INSERT INTO subscription_change_requests
(main_sag_id, change_sag_id, reason, effective_date, created_by_user_id)
VALUES (%s, %s, %s, %s, %s) RETURNING *""",
(main_sag_id, change_sag_id, payload.get("reason"), effective_date, user_id),
)
change = cursor.fetchone()
else:
cursor.execute(
"""UPDATE subscription_change_requests SET status = 'draft', reason = %s,
effective_date = %s, submitted_at = NULL, approved_by_user_id = NULL,
approved_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = %s RETURNING *""",
(payload.get("reason"), effective_date, change["id"]),
)
change = cursor.fetchone()
keep_ids = []
for proposal in proposals:
subscription_id = int(proposal.get("subscription_id") or 0)
cursor.execute("SELECT id, sag_id, version FROM sag_subscriptions WHERE id = %s FOR UPDATE", (subscription_id,))
subscription = cursor.fetchone()
if not subscription or int(subscription["sag_id"]) != main_sag_id:
raise HTTPException(status_code=400, detail=f"Subscription {subscription_id} does not belong to main case")
before = _subscription_snapshot(subscription_id)
proposed = _normalize_change_proposal(before, proposal.get("changes") or {})
cursor.execute(
"""INSERT INTO subscription_change_request_items
(change_request_id, subscription_id, base_version, before_snapshot, proposed_snapshot)
VALUES (%s, %s, %s, %s::jsonb, %s::jsonb)
ON CONFLICT (change_request_id, subscription_id) DO UPDATE SET
base_version = EXCLUDED.base_version, before_snapshot = EXCLUDED.before_snapshot,
proposed_snapshot = EXCLUDED.proposed_snapshot, apply_status = 'pending',
error_message = NULL, applied_at = NULL, updated_at = CURRENT_TIMESTAMP
RETURNING id""",
(change["id"], subscription_id, subscription["version"], json.dumps(before), json.dumps(proposed)),
)
keep_ids.append(int(cursor.fetchone()["id"]))
cursor.execute(
"DELETE FROM subscription_change_request_items WHERE change_request_id = %s AND NOT (id = ANY(%s))",
(change["id"], keep_ids),
)
cursor.execute(
"""INSERT INTO subscription_events (main_sag_id, change_request_id, event_type, actor_user_id, details)
VALUES (%s, %s, 'change_draft_saved', %s, %s::jsonb)""",
(main_sag_id, change["id"], user_id, json.dumps({"subscription_count": len(keep_ids)})),
)
conn.commit()
return {"id": change["id"], "change_sag_id": change["change_sag_id"], "status": "draft", "case_url": f"/sag/{change['change_sag_id']}/v3"}
except HTTPException:
conn.rollback()
raise
except Exception as exc:
conn.rollback()
logger.error("Failed saving subscription change request", exc_info=True)
raise HTTPException(status_code=500, detail=str(exc))
finally:
release_db_connection(conn)
@router.post("/sag-subscriptions/change-requests/{change_id}/submit", response_model=Dict[str, Any])
async def submit_subscription_change_request(
change_id: int,
current_user: Dict[str, Any] = Depends(require_permission("subscriptions.change_request")),
):
rows = execute_query(
"""UPDATE subscription_change_requests SET status = 'pending', submitted_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP WHERE id = %s AND status = 'draft' RETURNING *""",
(change_id,),
)
if not rows:
raise HTTPException(status_code=409, detail="Only draft changes can be submitted")
execute_query(
"""INSERT INTO subscription_events (main_sag_id, change_request_id, event_type, actor_user_id)
VALUES (%s, %s, 'change_submitted', %s)""",
(rows[0]["main_sag_id"], change_id, current_user["id"]),
)
return dict(rows[0])
def _apply_change_item(change: Dict[str, Any], item: Dict[str, Any], actor_user_id: int) -> tuple[bool, Optional[str]]:
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute("SELECT * FROM sag_subscriptions WHERE id = %s FOR UPDATE", (item["subscription_id"],))
current = cursor.fetchone()
if not current:
raise ValueError("Abonnementet findes ikke længere")
if int(current.get("version") or 1) != int(item["base_version"]):
raise ValueError("Abonnementet er ændret siden forslaget blev oprettet")
proposed = item["proposed_snapshot"]
if isinstance(proposed, str):
proposed = json.loads(proposed)
interval = proposed.get("billing_interval") or current.get("billing_interval")
schedule_type = proposed.get("billing_schedule_type") or current.get("billing_schedule_type") or "fixed_day"
billing_day = int(proposed.get("billing_day") or current.get("billing_day") or 1)
anchor = _safe_date(current.get("period_start") or proposed.get("start_date") or current.get("start_date"))
lead_months = int(proposed.get("billing_lead_months") or current.get("billing_lead_months") or 0)
next_date = billing_date_for_period(anchor, lead_months, schedule_type, billing_day)
scalar_fields = sorted(CHANGE_EDITABLE_FIELDS - {"line_items", "notes"}) + ["notes"]
updates = []
values = []
for field in scalar_fields:
if field in proposed:
updates.append(f"{field} = %s")
values.append(proposed.get(field))
updates.extend(["next_invoice_date = %s", "version = version + 1", "updated_at = CURRENT_TIMESTAMP"])
values.extend([next_date, item["subscription_id"], item["base_version"]])
cursor.execute(
f"UPDATE sag_subscriptions SET {', '.join(updates)} WHERE id = %s AND version = %s RETURNING id",
tuple(values),
)
if not cursor.fetchone():
raise ValueError("Versionskonflikt ved anvendelse")
if "line_items" in proposed:
lines = proposed.get("line_items") or []
if not lines:
raise ValueError("Et abonnement skal have mindst én fakturalinje")
cursor.execute("DELETE FROM sag_subscription_items WHERE subscription_id = %s", (item["subscription_id"],))
total = 0.0
descriptions = []
for line_no, line in enumerate(lines, start=1):
description = str(line.get("description") or "").strip()
quantity = float(line.get("quantity") or 0)
unit_price = float(line.get("unit_price") or 0)
if not description or quantity <= 0 or unit_price < 0:
raise ValueError("Ugyldig fakturalinje")
line_total = quantity * unit_price
total += line_total
descriptions.append(description)
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, price_type, custom_price_override,
requires_serial_number, serial_number, billing_blocked, billing_block_reason)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(item["subscription_id"], line_no, line.get("product_id"), line.get("asset_id"), description,
quantity, unit_price, line_total, line.get("period_from"), line.get("period_to"),
line.get("price_type") or "manual", bool(line.get("custom_price_override")),
bool(line.get("requires_serial_number")), line.get("serial_number"),
bool(line.get("billing_blocked")), line.get("billing_block_reason")),
)
product_name = descriptions[0] + (f" (+{len(descriptions) - 1})" if len(descriptions) > 1 else "")
cursor.execute(
"UPDATE sag_subscriptions SET price = %s, product_name = %s WHERE id = %s",
(total, product_name, item["subscription_id"]),
)
cursor.execute(
"""UPDATE subscription_change_request_items SET apply_status = 'applied', error_message = NULL,
applied_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = %s""",
(item["id"],),
)
cursor.execute(
"""INSERT INTO subscription_events
(main_sag_id, subscription_id, change_request_id, event_type, actor_user_id, details)
VALUES (%s,%s,%s,'change_applied',%s,%s::jsonb)""",
(change["main_sag_id"], item["subscription_id"], change["id"], actor_user_id,
json.dumps({"before": item["before_snapshot"], "after": proposed}, default=str)),
)
conn.commit()
return True, None
except Exception as exc:
conn.rollback()
execute_query(
"""UPDATE subscription_change_request_items SET apply_status = 'failed', error_message = %s,
updated_at = CURRENT_TIMESTAMP WHERE id = %s""",
(str(exc), item["id"]),
)
execute_query(
"""INSERT INTO subscription_events
(main_sag_id, subscription_id, change_request_id, event_type, actor_user_id, details)
VALUES (%s,%s,%s,'change_failed',%s,%s::jsonb)""",
(change["main_sag_id"], item["subscription_id"], change["id"], actor_user_id, json.dumps({"error": str(exc)})),
)
return False, str(exc)
finally:
release_db_connection(conn)
def apply_subscription_change_request(change_id: int, actor_user_id: int, failed_only: bool = False) -> Dict[str, Any]:
change = execute_query_single("SELECT * FROM subscription_change_requests WHERE id = %s", (change_id,))
if not change:
raise HTTPException(status_code=404, detail="Change request not found")
if change.get("status") not in {"approved_scheduled", "partially_applied", "failed"}:
raise HTTPException(status_code=409, detail="Change request is not approved for application")
if not failed_only:
claimed = execute_query(
"""UPDATE subscription_change_requests SET status = 'applying', updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND status = 'approved_scheduled' RETURNING id""",
(change_id,),
)
if not claimed:
return {"id": change_id, "status": "already_claimed", "applied": 0, "failed": 0, "pending": 0}
status_filter = "AND apply_status = 'failed'" if failed_only else "AND apply_status IN ('pending','failed')"
items = execute_query(
f"SELECT * FROM subscription_change_request_items WHERE change_request_id = %s {status_filter} ORDER BY id",
(change_id,),
) or []
for item in items:
_apply_change_item(dict(change), dict(item), actor_user_id)
counts = execute_query_single(
"""SELECT COUNT(*) FILTER (WHERE apply_status = 'applied') AS applied,
COUNT(*) FILTER (WHERE apply_status = 'failed') AS failed,
COUNT(*) FILTER (WHERE apply_status = 'pending') AS pending
FROM subscription_change_request_items WHERE change_request_id = %s""",
(change_id,),
)
if int(counts.get("failed") or 0):
final_status = "partially_applied" if int(counts.get("applied") or 0) else "failed"
elif int(counts.get("pending") or 0):
final_status = "approved_scheduled"
else:
final_status = "applied"
execute_query(
"UPDATE subscription_change_requests SET status = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
(final_status, change_id),
)
_sync_main_case_lifecycle(int(change["main_sag_id"]))
return {"id": change_id, "status": final_status, **dict(counts or {})}
def _sync_main_case_lifecycle(main_sag_id: int) -> None:
state = execute_query_single(
"""SELECT COUNT(*) FILTER (WHERE status NOT IN ('cancelled','expired')) AS live,
COUNT(*) FILTER (WHERE status = 'scheduled') AS scheduled
FROM sag_subscriptions WHERE sag_id = %s""",
(main_sag_id,),
) or {}
open_change = execute_query_single(
"SELECT id FROM subscription_change_requests WHERE main_sag_id = %s AND status = ANY(%s) LIMIT 1",
(main_sag_id, list(CHANGE_OPEN_STATUSES)),
)
should_close = int(state.get("live") or 0) == 0 and int(state.get("scheduled") or 0) == 0 and not open_change
execute_query(
"UPDATE sag_sager SET status = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s AND deleted_at IS NULL",
("lukket" if should_close else "åben", main_sag_id),
)
@router.post("/sag-subscriptions/change-requests/{change_id}/approve", response_model=Dict[str, Any])
async def approve_subscription_change_request(
change_id: int,
current_user: Dict[str, Any] = Depends(require_permission("subscriptions.approve")),
):
change = execute_query_single("SELECT * FROM subscription_change_requests WHERE id = %s", (change_id,))
if not change:
raise HTTPException(status_code=404, detail="Change request not found")
if change.get("status") != "pending":
raise HTTPException(status_code=409, detail="Only pending changes can be approved")
if int(change["created_by_user_id"]) == int(current_user["id"]):
raise HTTPException(status_code=403, detail="Fire-eyes rule: creator cannot approve own change")
execute_query(
"""UPDATE subscription_change_requests SET status = 'approved_scheduled', approved_by_user_id = %s,
approved_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = %s""",
(current_user["id"], change_id),
)
execute_query(
"""INSERT INTO subscription_events (main_sag_id, change_request_id, event_type, actor_user_id)
VALUES (%s,%s,'change_approved',%s)""",
(change["main_sag_id"], change_id, current_user["id"]),
)
if _safe_date(change["effective_date"]) <= date.today():
return apply_subscription_change_request(change_id, int(current_user["id"]))
return {"id": change_id, "status": "approved_scheduled", "effective_date": change["effective_date"]}
@router.post("/sag-subscriptions/change-requests/{change_id}/reject", response_model=Dict[str, Any])
async def reject_subscription_change_request(
change_id: int,
current_user: Dict[str, Any] = Depends(require_permission("subscriptions.approve")),
):
rows = execute_query(
"""UPDATE subscription_change_requests SET status = 'rejected', rejected_by_user_id = %s,
rejected_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND status = 'pending' AND created_by_user_id <> %s RETURNING *""",
(current_user["id"], change_id, current_user["id"]),
)
if not rows:
raise HTTPException(status_code=403, detail="Change cannot be rejected or violates four-eyes rule")
return dict(rows[0])
@router.post("/sag-subscriptions/change-requests/{change_id}/retry", response_model=Dict[str, Any])
async def retry_subscription_change_request(
change_id: int,
current_user: Dict[str, Any] = Depends(require_permission("subscriptions.approve")),
):
return apply_subscription_change_request(change_id, int(current_user["id"]), failed_only=True)
@router.post("/sag-subscriptions/change-requests/{change_id}/request-cancellation", response_model=Dict[str, Any])
async def request_scheduled_change_cancellation(
change_id: int,
payload: Dict[str, Any],
current_user: Dict[str, Any] = Depends(require_permission("subscriptions.change_request")),
):
change = execute_query_single(
"SELECT * FROM subscription_change_requests WHERE id = %s AND status = 'approved_scheduled'",
(change_id,),
)
if not change:
raise HTTPException(status_code=409, detail="Only a scheduled approved change can be cancelled")
reason = str(payload.get("reason") or "").strip()
if not reason:
raise HTTPException(status_code=400, detail="reason is required")
rows = execute_query(
"""INSERT INTO subscription_change_cancellations
(change_request_id, reason, requested_by_user_id) VALUES (%s,%s,%s) RETURNING *""",
(change_id, reason, current_user["id"]),
)
execute_query(
"UPDATE subscription_change_requests SET status = 'cancellation_pending', updated_at = CURRENT_TIMESTAMP WHERE id = %s",
(change_id,),
)
return dict(rows[0])
@router.post("/sag-subscriptions/change-cancellations/{cancellation_id}/approve", response_model=Dict[str, Any])
async def approve_scheduled_change_cancellation(
cancellation_id: int,
current_user: Dict[str, Any] = Depends(require_permission("subscriptions.approve")),
):
cancellation = execute_query_single(
"SELECT * FROM subscription_change_cancellations WHERE id = %s AND status = 'pending'",
(cancellation_id,),
)
if not cancellation:
raise HTTPException(status_code=404, detail="Pending cancellation not found")
if int(cancellation["requested_by_user_id"]) == int(current_user["id"]):
raise HTTPException(status_code=403, detail="Fire-eyes rule: requester cannot approve cancellation")
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute(
"""UPDATE subscription_change_cancellations SET status = 'approved', decided_by_user_id = %s,
decided_at = CURRENT_TIMESTAMP WHERE id = %s""",
(current_user["id"], cancellation_id),
)
cursor.execute(
"UPDATE subscription_change_requests SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP WHERE id = %s",
(cancellation["change_request_id"],),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
release_db_connection(conn)
return {"id": cancellation_id, "status": "approved", "change_status": "cancelled"}
def apply_due_subscription_changes() -> int:
changes = execute_query(
"""SELECT id, approved_by_user_id FROM subscription_change_requests
WHERE status = 'approved_scheduled' AND effective_date <= CURRENT_DATE ORDER BY id"""
) or []
for change in changes:
apply_subscription_change_request(int(change["id"]), int(change.get("approved_by_user_id") or 0))
return len(changes)
def expire_ended_subscriptions() -> int:
rows = execute_query(
"""UPDATE sag_subscriptions SET status = 'expired', version = version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE status IN ('active','paused','terminating') AND end_date < CURRENT_DATE
RETURNING id, sag_id"""
) or []
for row in rows:
execute_query(
"""INSERT INTO subscription_events (main_sag_id, subscription_id, event_type, details)
VALUES (%s,%s,'subscription_expired','{}'::jsonb)""",
(row["sag_id"], row["id"]),
)
for sag_id in {int(row["sag_id"]) for row in rows}:
_sync_main_case_lifecycle(sag_id)
return len(rows)
@router.post("/sag-subscriptions", response_model=Dict[str, Any])
2026-08-28 20:49:55 +02:00
async def create_subscription(
payload: Dict[str, Any],
current_user: Dict[str, Any] = Depends(require_permission("subscriptions.change_request")),
):
"""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")
2026-08-28 20:49:55 +02:00
billing_schedule_type = (payload.get("billing_schedule_type") or "fixed_day").strip().lower()
start_date = payload.get("start_date")
2026-08-28 20:49:55 +02:00
end_date = payload.get("end_date")
period_start_raw = payload.get("period_start") or start_date
billing_direction = (payload.get("billing_direction") or "forward").strip().lower()
price_type = (payload.get("price_type") or "manual").strip().lower()
custom_price_override = bool(payload.get("custom_price_override"))
first_invoice_policy = (payload.get("first_invoice_policy") or "start_date").strip().lower()
advance_months = int(payload.get("advance_months") or 1)
2026-08-28 20:49:55 +02:00
billing_lead_months = int(payload.get("billing_lead_months") or 0)
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")
2026-08-28 20:49:55 +02:00
notice_period_days = int(payload.get("notice_period_days") or 0)
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 []
2026-08-28 20:49:55 +02:00
first_invoice_items = payload.get("first_invoice_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_interval not in ALLOWED_BILLING_INTERVALS:
raise HTTPException(status_code=400, detail="billing_interval must be daily/biweekly/monthly/quarterly/yearly")
2026-08-28 20:49:55 +02:00
try:
billing_schedule_type, billing_day = validate_billing_schedule(
billing_interval, billing_schedule_type, billing_day
)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if billing_direction not in ALLOWED_BILLING_DIRECTIONS:
raise HTTPException(status_code=400, detail="billing_direction must be forward or backward")
if price_type not in {"manual", "day", "week", "month", "year"}:
raise HTTPException(status_code=400, detail="price_type must be manual/day/week/month/year")
if first_invoice_policy not in {"start_date", "next_cycle"}:
raise HTTPException(status_code=400, detail="first_invoice_policy must be start_date or next_cycle")
if advance_months < 1 or advance_months > 24:
raise HTTPException(status_code=400, detail="advance_months must be between 1 and 24")
2026-08-28 20:49:55 +02:00
if billing_lead_months < 0 or billing_lead_months > 24:
raise HTTPException(status_code=400, detail="billing_lead_months must be between 0 and 24")
if binding_months < 0:
raise HTTPException(status_code=400, detail="binding_months must be >= 0")
2026-08-28 20:49:55 +02:00
if notice_period_days < 0:
raise HTTPException(status_code=400, detail="notice_period_days must be >= 0")
start_dt = _safe_date(start_date)
if not start_dt:
raise HTTPException(status_code=400, detail="start_date must be a valid date")
2026-08-28 20:49:55 +02:00
period_start = _safe_date(period_start_raw)
if not period_start:
raise HTTPException(status_code=400, detail="period_start must be a valid date")
end_dt = _safe_date(end_date)
if end_date and not end_dt:
raise HTTPException(status_code=400, detail="end_date must be a valid date")
if end_dt and end_dt < start_dt:
raise HTTPException(status_code=400, detail="end_date cannot be before start_date")
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")
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 = []
line_asset_ranges: Dict[int, List[tuple[date, Optional[date]]]] = {}
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")
period_from_dt = _safe_date(period_from) or start_dt
period_to_dt = _safe_date(period_to)
if period_to_dt and period_to_dt < period_from_dt:
raise HTTPException(status_code=400, detail="line_items period_to cannot be before period_from")
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")
_ensure_asset_not_booked(asset_id, period_from_dt, period_to_dt)
existing_ranges = line_asset_ranges.get(int(asset_id), [])
for existing_start, existing_end in existing_ranges:
latest_start = max(existing_start, period_from_dt)
effective_existing_end = existing_end or date.max
effective_new_end = period_to_dt or date.max
earliest_end = min(effective_existing_end, effective_new_end)
if latest_start <= earliest_end:
raise HTTPException(
status_code=400,
detail=f"line_items contains overlapping periods for asset_id {asset_id}",
)
existing_ranges.append((period_from_dt, period_to_dt))
line_asset_ranges[int(asset_id)] = existing_ranges
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,
"price_type": (item.get("price_type") or price_type or "manual"),
"custom_price_override": bool(item.get("custom_price_override", custom_price_override)),
"requires_serial_number": requires_serial_number,
"serial_number": serial_number,
"billing_blocked": billing_blocked,
"billing_block_reason": billing_block_reason,
})
2026-08-28 20:49:55 +02:00
cleaned_first_invoice_items = []
for idx, item in enumerate(first_invoice_items, start=1):
description = str(item.get("description") or "").strip()
quantity = float(item.get("quantity") or 0)
unit_price = float(item.get("unit_price") or 0)
if not description or quantity <= 0 or unit_price < 0:
raise HTTPException(status_code=400, detail="Invalid first_invoice_items line")
cleaned_first_invoice_items.append({
"line_no": idx,
"product_id": item.get("product_id"),
"description": description,
"quantity": quantity,
"unit_price": unit_price,
"line_total": quantity * unit_price,
})
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)
2026-08-28 20:49:55 +02:00
next_invoice_date = billing_date_for_period(
period_start, billing_lead_months, billing_schedule_type, int(billing_day)
)
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,
2026-08-28 20:49:55 +02:00
billing_schedule_type,
billing_direction,
advance_months,
2026-08-28 20:49:55 +02:00
billing_lead_months,
proration_basis,
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,
price_type,
custom_price_override,
first_invoice_policy,
invoice_merge_key,
price_change_case_id,
renewal_case_id,
2026-08-28 20:49:55 +02:00
end_date,
notice_period_days,
status,
notes
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
2026-08-28 20:49:55 +02:00
%s, %s, %s, %s, %s, %s, %s, %s, %s, 'draft', %s
)
RETURNING *
""",
(
sag_id,
sag["customer_id"],
product_name,
billing_interval,
2026-08-28 20:49:55 +02:00
billing_schedule_type,
billing_direction,
advance_months,
2026-08-28 20:49:55 +02:00
billing_lead_months,
"30_day",
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,
price_type,
custom_price_override,
first_invoice_policy,
invoice_merge_key,
price_change_case_id,
renewal_case_id,
2026-08-28 20:49:55 +02:00
end_date,
notice_period_days,
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,
price_type,
custom_price_override,
requires_serial_number,
serial_number,
billing_blocked,
billing_block_reason
) VALUES (%s, %s, %s, %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["price_type"],
item["custom_price_override"],
item["requires_serial_number"],
item["serial_number"],
item["billing_blocked"],
item["billing_block_reason"],
)
)
2026-08-28 20:49:55 +02:00
for item in cleaned_first_invoice_items:
cursor.execute(
"""INSERT INTO sag_subscription_first_invoice_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()
2026-08-28 20:49:55 +02:00
actor_id = current_user.get("id") if isinstance(current_user, dict) else None
execute_query(
"""INSERT INTO subscription_events
(main_sag_id, subscription_id, event_type, actor_user_id, details)
VALUES (%s,%s,'subscription_created',%s,'{}'::jsonb)""",
(sag_id, subscription["id"], actor_id),
)
_sync_main_case_lifecycle(int(sag_id))
subscription["line_items"] = _load_subscription_line_items(int(subscription["id"]))
2026-08-28 20:49:55 +02:00
subscription["first_invoice_items"] = _load_first_invoice_items(int(subscription["id"]))
return _attach_network_provisioning(dict(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:
return _load_subscription_with_context(subscription_id)
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])
2026-08-28 20:49:55 +02:00
async def update_subscription(
subscription_id: int,
payload: Dict[str, Any],
current_user: Dict[str, Any] = Depends(require_permission("subscriptions.change_request")),
):
"""A draft can be corrected directly; live agreements use change requests."""
try:
subscription = execute_query_single(
"""SELECT id, status, period_start, billing_lead_months,
billing_schedule_type, billing_day
FROM sag_subscriptions WHERE id = %s""",
(subscription_id,),
)
if not subscription:
raise HTTPException(status_code=404, detail="Subscription not found")
# A draft has not created an invoice or a committed agreement yet, so
# the creator must be able to correct it without a change case.
draft_fields = {
"product_name", "billing_interval", "billing_schedule_type", "billing_day", "price",
"start_date", "end_date", "period_start", "notice_period_days", "notes",
"billing_direction", "advance_months", "billing_lead_months", "first_full_period_start",
"binding_months", "binding_start_date", "binding_end_date", "binding_group_key",
"invoice_merge_key", "price_type", "custom_price_override", "first_invoice_policy",
"line_items", "first_invoice_items",
}
allowed_direct_fields = draft_fields if subscription.get("status") == "draft" else {"notes"}
forbidden = set(payload) - allowed_direct_fields
2026-08-28 20:49:55 +02:00
if forbidden:
raise HTTPException(
status_code=409,
detail="Business fields must be changed through a subscription change request",
)
# The case editor always posts this collection. First-invoice lines
# are intentionally left untouched here until their dedicated draft
# editor is used; they are not a recurring agreement change.
payload.pop("first_invoice_items", None)
# Extract line_items before processing other fields
line_items = payload.pop("line_items", None)
normalized_line_items = None
if line_items is not None:
normalized_line_items = []
total_price = 0.0
first_description = None
for item in line_items:
description = (item.get("description", "") or "").strip()
quantity = float(item.get("quantity", 0) or 0)
unit_price = float(item.get("unit_price", 0) or 0)
if not description or quantity <= 0:
continue
line_total = quantity * unit_price
total_price += line_total
if first_description is None:
first_description = description
normalized_line_items.append({
"description": description,
"quantity": quantity,
"unit_price": unit_price,
"line_total": line_total,
"product_id": item.get("product_id"),
"asset_id": item.get("asset_id"),
"period_from": item.get("period_from"),
"period_to": item.get("period_to"),
"price_type": item.get("price_type", "manual"),
"custom_price_override": bool(item.get("custom_price_override")),
"requires_serial_number": bool(item.get("requires_serial_number")),
"serial_number": item.get("serial_number"),
"billing_blocked": bool(item.get("billing_blocked")),
"billing_block_reason": item.get("billing_block_reason"),
})
if not normalized_line_items:
raise HTTPException(status_code=400, detail="line_items must contain at least one valid line")
payload["price"] = total_price
payload["product_name"] = (
f"{first_description} (+{len(normalized_line_items) - 1})"
if len(normalized_line_items) > 1
else first_description
)
if subscription.get("status") == "draft" and any(
field in payload for field in {"period_start", "billing_lead_months", "billing_schedule_type", "billing_day"}
):
period_start = _safe_date(payload.get("period_start") or subscription.get("period_start"))
if not period_start:
raise HTTPException(status_code=400, detail="period_start must be a valid date")
payload["next_invoice_date"] = billing_date_for_period(
period_start,
int(payload.get("billing_lead_months", subscription.get("billing_lead_months") or 0)),
payload.get("billing_schedule_type") or subscription.get("billing_schedule_type") or "fixed_day",
int(payload.get("billing_day", subscription.get("billing_day") or 1)),
)
# Build dynamic update query
allowed_fields = {
"product_name", "billing_interval", "billing_schedule_type", "billing_day", "price",
"start_date", "end_date", "next_invoice_date", "period_start",
"notice_period_days", "status", "notes",
2026-08-28 20:49:55 +02:00
"billing_direction", "advance_months", "billing_lead_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_type", "custom_price_override", "first_invoice_policy",
"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 normalized_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(normalized_line_items, start=1):
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,
price_type, custom_price_override,
requires_serial_number, serial_number,
billing_blocked, billing_block_reason
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
subscription_id, idx, item["description"],
item["quantity"], item["unit_price"], item["line_total"],
item.get("product_id"),
item.get("asset_id"),
item.get("period_from"),
item.get("period_to"),
item.get("price_type", "manual"),
bool(item.get("custom_price_override")),
bool(item.get("requires_serial_number")),
item.get("serial_number"),
bool(item.get("billing_blocked")),
item.get("billing_block_reason"),
)
)
conn.commit()
return _load_subscription_with_context(subscription_id)
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],
current_user: Dict[str, Any] = Depends(require_permission("subscriptions.change_request")),
):
"""Activate an initial draft. Changes to a live agreement still need approval."""
status = str(payload.get("status") or "").strip().lower()
subscription = execute_query_single(
"SELECT id, status FROM sag_subscriptions WHERE id = %s", (subscription_id,)
)
if not subscription:
raise HTTPException(status_code=404, detail="Abonnementet findes ikke")
if subscription.get("status") != "draft" or status != "active":
raise HTTPException(status_code=409, detail="Status must be changed through a subscription change request")
execute_query(
"""UPDATE sag_subscriptions SET status = 'active', updated_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(subscription_id,),
fetch=False,
)
return _load_subscription_with_context(subscription_id)
@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,
2026-08-28 20:49:55 +02:00
s.billing_schedule_type,
s.billing_direction,
2026-08-28 20:49:55 +02:00
s.advance_months,
s.billing_lead_months,
s.billing_day,
s.price,
s.start_date,
2026-08-28 20:49:55 +02:00
s.period_start,
s.first_full_period_start,
s.end_date,
2026-08-28 20:49:55 +02:00
s.next_invoice_date,
s.notice_period_days,
s.billing_blocked,
2026-08-28 20:49:55 +02:00
s.billing_block_reason,
s.invoice_merge_key,
s.status,
2026-08-28 20:49:55 +02:00
(
SELECT cr.status FROM subscription_change_request_items cri
JOIN subscription_change_requests cr ON cr.id = cri.change_request_id
WHERE cri.subscription_id = s.id AND cr.status = ANY(%s)
ORDER BY cr.created_at DESC LIMIT 1
) AS change_status,
(
SELECT cr.change_sag_id FROM subscription_change_request_items cri
JOIN subscription_change_requests cr ON cr.id = cri.change_request_id
WHERE cri.subscription_id = s.id AND cr.status = ANY(%s)
ORDER BY cr.created_at DESC LIMIT 1
) AS change_sag_id,
(
SELECT STRING_AGG(DISTINCT CONCAT_WS(' ', p.name, p.sku_internal, p.er_number, p.ean, p.supplier_sku, si.description), ' ')
FROM sag_subscription_items si
LEFT JOIN products p ON p.id = si.product_id
WHERE si.subscription_id = s.id
) AS product_search_text,
(
SELECT COALESCE(json_agg(json_build_object(
'product_id', si.product_id,
'product_name', p.name,
'sku_internal', p.sku_internal,
'er_number', p.er_number,
'ean', p.ean,
'supplier_sku', p.supplier_sku,
'description', si.description
) ORDER BY si.line_no, si.id), '[]'::json)
FROM sag_subscription_items si
LEFT JOIN products p ON p.id = si.product_id
WHERE si.subscription_id = s.id
) AS product_lines,
(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
"""
2026-08-28 20:49:55 +02:00
query_params = [list(CHANGE_OPEN_STATUSES), list(CHANGE_OPEN_STATUSES), *params]
subscriptions = execute_query(query, tuple(query_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))
2026-08-28 20:49:55 +02:00
@router.get("/subscription-billing-forecast", response_model=Dict[str, Any])
async def subscription_billing_forecast(
months: int = Query(12, ge=1, le=24),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Read-only calendar of expected order drafts; uses the same date helpers as the billing job."""
today = date.today()
horizon = today + relativedelta(months=months)
rows = execute_query(
"""SELECT s.*, c.name AS customer_name, sg.titel AS sag_title,
COALESCE((SELECT SUM(si.line_total) FROM sag_subscription_items si
WHERE si.subscription_id = s.id AND NOT si.billing_blocked), 0) AS recurring_total,
COALESCE((SELECT SUM(fi.line_total) FROM sag_subscription_first_invoice_items fi
WHERE fi.subscription_id = s.id AND fi.billed_at IS NULL), 0) AS unbilled_first_total
FROM sag_subscriptions s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN sag_sager sg ON sg.id = s.sag_id
WHERE s.status IN ('active','scheduled','blocked') AND s.next_invoice_date IS NOT NULL
ORDER BY s.next_invoice_date, s.id"""
) or []
grouped: Dict[tuple, Dict[str, Any]] = {}
for raw in rows:
sub = dict(raw)
invoice_date = _safe_date(sub.get("next_invoice_date"))
period_start = _safe_date(sub.get("period_start") or sub.get("start_date"))
first_full = _safe_date(sub.get("first_full_period_start"))
if not invoice_date or not period_start:
continue
first_occurrence = True
iterations = 0
while invoice_date <= horizon and iterations < 120:
iterations += 1
periods = max(1, int(sub.get("advance_months") or 1))
is_partial = bool(first_occurrence and first_full and period_start < first_full)
full_start = first_full if is_partial else period_start
coverage_end = advance_billing_periods(full_start, sub.get("billing_interval") or "monthly", periods)
recurring = float(sub.get("recurring_total") or sub.get("price") or 0)
partial_amount = recurring * prorated_30_day_factor(period_start, first_full) if is_partial else 0.0
first_amount = float(sub.get("unbilled_first_total") or 0) if first_occurrence else 0.0
amount = recurring * periods + partial_amount + first_amount
visible_date = max(invoice_date, today) if invoice_date < today else invoice_date
state = "blocked" if sub.get("billing_blocked") or sub.get("status") == "blocked" else ("overdue" if invoice_date < today else "planned")
key = (
visible_date,
int(sub["customer_id"]),
str(sub.get("invoice_merge_key") or f"cust-{sub['customer_id']}"),
str(sub.get("billing_direction") or "forward"),
state,
)
group = grouped.setdefault(key, {
"invoice_date": invoice_date.isoformat(),
"display_date": visible_date.isoformat(),
"state": state,
"customer_id": sub["customer_id"],
"customer_name": sub.get("customer_name") or f"Kunde #{sub['customer_id']}",
"invoice_merge_key": sub.get("invoice_merge_key") or f"cust-{sub['customer_id']}",
"billing_direction": sub.get("billing_direction") or "forward",
"amount": 0.0,
"subscription_count": 0,
"subscriptions": [],
})
group["amount"] = round(float(group["amount"]) + amount, 2)
group["subscription_count"] += 1
group["subscriptions"].append({
"id": sub["id"], "subscription_number": sub.get("subscription_number"),
"sag_id": sub.get("sag_id"), "sag_title": sub.get("sag_title"),
"product_name": sub.get("product_name"), "coverage_start": period_start.isoformat(),
"full_coverage_start": full_start.isoformat(), "coverage_end": coverage_end.isoformat(),
"recurring_amount": round(recurring * periods, 2),
"proration_amount": round(partial_amount, 2), "first_invoice_amount": round(first_amount, 2),
"blocked": bool(sub.get("billing_blocked")), "blocked_reason": sub.get("billing_block_reason"),
})
period_start = coverage_end
safe_billing_day = min(28, max(1, int(sub.get("billing_day") or 1)))
invoice_date = billing_date_for_period(
period_start, int(sub.get("billing_lead_months") or 0),
sub.get("billing_schedule_type") or "fixed_day", safe_billing_day,
)
first_occurrence = False
generated = execute_query(
"""SELECT br.id AS billing_run_id, br.subscription_id, br.period_start, br.created_at,
br.ordre_draft_id, od.title, od.customer_id, od.coverage_start, od.coverage_end,
od.sync_status, od.lines_json
FROM subscription_billing_runs br
LEFT JOIN ordre_drafts od ON od.id = br.ordre_draft_id
WHERE br.created_at::date BETWEEN %s AND %s ORDER BY br.created_at DESC""",
(today - relativedelta(months=1), horizon),
) or []
return {
"generated_at": datetime.now().isoformat(), "from_date": today.isoformat(), "to_date": horizon.isoformat(),
"forecast": sorted(grouped.values(), key=lambda item: (item["display_date"], item["customer_name"])),
"generated": [dict(item) for item in generated],
}
@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.post("/sag-subscriptions/{subscription_id}/process-invoice")
async def trigger_single_subscription_processing(subscription_id: int):
"""Process one due subscription without starting billing for other customers."""
subscription = execute_query_single(
"SELECT id, status, next_invoice_date FROM sag_subscriptions WHERE id = %s",
(subscription_id,),
)
if not subscription:
raise HTTPException(status_code=404, detail="Abonnementet findes ikke")
if subscription.get("status") != "active":
raise HTTPException(status_code=409, detail="Abonnementet skal være aktivt, før det kan faktureres")
if not subscription.get("next_invoice_date") or subscription["next_invoice_date"] > date.today():
raise HTTPException(status_code=409, detail="Abonnementet er ikke klar til fakturering endnu")
try:
from app.jobs.process_subscriptions import process_subscriptions
await process_subscriptions(subscription_ids=[subscription_id])
return {"status": "success", "message": "Abonnementets fakturering er kørt"}
except Exception as e:
logger.error("❌ Manual processing failed for subscription %s: %s", subscription_id, 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)
_ensure_binding_not_overlapping(asset_id, start_date, end_date)
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")
_sync_asset_rental_status(asset_id)
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
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")
status_for_overlap = payload.get("status", existing.get("status"))
start_for_overlap = _safe_date(payload.get("start_date")) or _safe_date(existing.get("start_date")) or date.today()
end_for_overlap = _safe_date(payload.get("end_date"))
if end_for_overlap is None:
end_for_overlap = _safe_date(existing.get("end_date"))
asset_for_overlap = int(existing.get("asset_id"))
if status_for_overlap == "active":
_ensure_binding_not_overlapping(asset_for_overlap, start_for_overlap, end_for_overlap, exclude_binding_id=binding_id)
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")
updated = result[0]
_sync_asset_rental_status(int(updated.get("asset_id")))
return updated
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")
binding = execute_query_single(
"SELECT asset_id FROM subscription_asset_bindings WHERE id = %s",
(binding_id,)
)
if binding and binding.get("asset_id"):
_sync_asset_rental_status(int(binding.get("asset_id")))
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"))
2026-08-28 20:49:55 +02:00
billing_day = min(max(start_date.day, 1), 28)
billing_schedule_type, billing_day = validate_billing_schedule(
billing_interval, "fixed_day", billing_day
)
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,
2026-08-28 20:49:55 +02:00
billing_schedule_type,
billing_day,
price,
start_date,
period_start,
next_invoice_date,
status,
notes
2026-08-28 20:49:55 +02:00
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'draft', %s)
RETURNING id
""",
(
sag_id,
hub_customer_id,
source_subject,
billing_interval,
2026-08-28 20:49:55 +02:00
billing_schedule_type,
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")