2026-02-08 12:42:19 +01:00
|
|
|
"""
|
|
|
|
|
Subscriptions API
|
|
|
|
|
Sag-based subscriptions listing and stats
|
|
|
|
|
"""
|
2026-08-28 20:49:55 +02:00
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
2026-02-17 08:29:05 +01:00
|
|
|
from typing import List, Dict, Any, Optional
|
2026-02-08 12:42:19 +01:00
|
|
|
from app.core.database import execute_query, execute_query_single, get_db_connection, release_db_connection
|
|
|
|
|
from psycopg2.extras import RealDictCursor
|
|
|
|
|
import logging
|
2026-02-17 08:29:05 +01:00
|
|
|
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
|
2026-07-09 23:44:30 +02:00
|
|
|
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
|
2026-02-08 12:42:19 +01:00
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 20:49:55 +02:00
|
|
|
ALLOWED_STATUSES = {"draft", "scheduled", "active", "paused", "terminating", "cancelled", "expired", "blocked"}
|
2026-02-17 08:29:05 +01:00
|
|
|
STAGING_KEY_SQL = "COALESCE(source_account_id, 'name:' || LOWER(COALESCE(source_customer_name, 'ukendt')))"
|
2026-03-23 20:35:15 +01:00
|
|
|
ALLOWED_BILLING_DIRECTIONS = {"forward", "backward"}
|
|
|
|
|
ALLOWED_PRICE_CHANGE_STATUSES = {"pending", "approved", "rejected", "applied"}
|
2026-04-12 02:27:01 +02:00
|
|
|
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",
|
|
|
|
|
}
|
2026-02-08 12:42:19 +01:00
|
|
|
|
|
|
|
|
|
2026-07-09 23:44:30 +02:00
|
|
|
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,
|
2026-07-09 23:44:30 +02:00
|
|
|
p.name AS product_name,
|
2026-08-28 20:49:55 +02:00
|
|
|
p.sku_internal,
|
|
|
|
|
p.er_number,
|
|
|
|
|
p.ean,
|
|
|
|
|
p.supplier_sku,
|
2026-07-09 23:44:30 +02:00
|
|
|
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,
|
2026-07-09 23:44:30 +02:00
|
|
|
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
|
2026-07-09 23:44:30 +02:00
|
|
|
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
|
2026-07-09 23:44:30 +02:00
|
|
|
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 []
|
|
|
|
|
|
|
|
|
|
|
2026-07-09 23:44:30 +02:00
|
|
|
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,
|
2026-07-09 23:44:30 +02:00
|
|
|
s.billing_direction,
|
|
|
|
|
s.advance_months,
|
2026-08-28 20:49:55 +02:00
|
|
|
s.billing_lead_months,
|
|
|
|
|
s.proration_basis,
|
2026-07-09 23:44:30 +02:00
|
|
|
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,
|
2026-07-09 23:44:30 +02:00
|
|
|
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
|
2026-07-09 23:44:30 +02:00
|
|
|
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)
|
2026-07-09 23:44:30 +02:00
|
|
|
return _attach_network_provisioning(subscription)
|
|
|
|
|
|
|
|
|
|
|
2026-02-17 08:29:05 +01:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-04-12 02:27:01 +02:00
|
|
|
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),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-02-17 08:29:05 +01:00
|
|
|
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])
|
2026-04-26 13:14:53 +02:00
|
|
|
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."""
|
2026-02-08 12:42:19 +01:00
|
|
|
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:
|
2026-04-26 13:14:53 +02:00
|
|
|
if allow_missing:
|
|
|
|
|
return {"subscription": None, "line_items": []}
|
2026-02-08 12:42:19 +01:00
|
|
|
raise HTTPException(status_code=404, detail="Subscription not found")
|
2026-07-09 23:44:30 +02:00
|
|
|
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"]))
|
2026-07-09 23:44:30 +02:00
|
|
|
return _attach_network_provisioning(subscription)
|
2026-02-08 12:42:19 +01:00
|
|
|
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:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Change request not found")
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-02-17 08:29:05 +01:00
|
|
|
@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")),
|
|
|
|
|
):
|
2026-02-08 12:42:19 +01:00
|
|
|
"""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()
|
2026-02-08 12:42:19 +01:00
|
|
|
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
|
2026-03-23 20:35:15 +01:00
|
|
|
billing_direction = (payload.get("billing_direction") or "forward").strip().lower()
|
2026-04-12 02:27:01 +02:00
|
|
|
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()
|
2026-03-23 20:35:15 +01:00
|
|
|
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)
|
2026-03-23 20:35:15 +01:00
|
|
|
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)
|
2026-03-23 20:35:15 +01:00
|
|
|
price_change_case_id = payload.get("price_change_case_id")
|
|
|
|
|
renewal_case_id = payload.get("renewal_case_id")
|
2026-02-08 12:42:19 +01:00
|
|
|
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 []
|
2026-02-08 12:42:19 +01:00
|
|
|
|
|
|
|
|
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")
|
2026-04-12 02:27:01 +02:00
|
|
|
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
|
2026-03-23 20:35:15 +01:00
|
|
|
if billing_direction not in ALLOWED_BILLING_DIRECTIONS:
|
|
|
|
|
raise HTTPException(status_code=400, detail="billing_direction must be forward or backward")
|
2026-04-12 02:27:01 +02:00
|
|
|
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")
|
2026-03-23 20:35:15 +01:00
|
|
|
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")
|
2026-03-23 20:35:15 +01:00
|
|
|
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")
|
2026-02-08 12:42:19 +01:00
|
|
|
|
2026-04-12 02:27:01 +02:00
|
|
|
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")
|
2026-04-12 02:27:01 +02:00
|
|
|
|
2026-02-08 12:42:19 +01:00
|
|
|
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(
|
2026-03-23 20:35:15 +01:00
|
|
|
"""
|
|
|
|
|
SELECT id, name, sales_price, serial_number_required, asset_required
|
|
|
|
|
FROM products
|
|
|
|
|
WHERE id = ANY(%s)
|
|
|
|
|
""",
|
2026-02-08 12:42:19 +01:00
|
|
|
(product_ids,)
|
|
|
|
|
)
|
|
|
|
|
product_map = {row["id"]: row for row in (rows or [])}
|
|
|
|
|
|
|
|
|
|
cleaned_items = []
|
2026-04-12 02:27:01 +02:00
|
|
|
line_asset_ranges: Dict[int, List[tuple[date, Optional[date]]]] = {}
|
2026-02-08 12:42:19 +01:00
|
|
|
total_price = 0
|
2026-03-23 20:35:15 +01:00
|
|
|
blocked_reasons = []
|
2026-02-08 12:42:19 +01:00
|
|
|
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")
|
2026-03-23 20:35:15 +01:00
|
|
|
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")
|
2026-04-12 02:27:01 +02:00
|
|
|
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")
|
2026-02-08 12:42:19 +01:00
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
2026-03-23 20:35:15 +01:00
|
|
|
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")
|
2026-04-12 02:27:01 +02:00
|
|
|
_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
|
2026-03-23 20:35:15 +01:00
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
2026-02-08 12:42:19 +01:00
|
|
|
line_total = float(quantity) * float(unit_price)
|
|
|
|
|
total_price += line_total
|
2026-03-23 20:35:15 +01:00
|
|
|
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}")
|
2026-02-08 12:42:19 +01:00
|
|
|
cleaned_items.append({
|
|
|
|
|
"line_no": idx,
|
|
|
|
|
"product_id": product_id,
|
2026-03-23 20:35:15 +01:00
|
|
|
"asset_id": asset_id,
|
2026-02-08 12:42:19 +01:00
|
|
|
"description": description,
|
|
|
|
|
"quantity": quantity,
|
|
|
|
|
"unit_price": unit_price,
|
|
|
|
|
"line_total": line_total,
|
2026-03-23 20:35:15 +01:00
|
|
|
"period_from": period_from,
|
|
|
|
|
"period_to": period_to,
|
2026-04-12 02:27:01 +02:00
|
|
|
"price_type": (item.get("price_type") or price_type or "manual"),
|
|
|
|
|
"custom_price_override": bool(item.get("custom_price_override", custom_price_override)),
|
2026-03-23 20:35:15 +01:00
|
|
|
"requires_serial_number": requires_serial_number,
|
|
|
|
|
"serial_number": serial_number,
|
|
|
|
|
"billing_blocked": billing_blocked,
|
|
|
|
|
"billing_block_reason": billing_block_reason,
|
2026-02-08 12:42:19 +01:00
|
|
|
})
|
|
|
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-08 12:42:19 +01:00
|
|
|
product_name = cleaned_items[0]["description"]
|
|
|
|
|
if len(cleaned_items) > 1:
|
|
|
|
|
product_name = f"{product_name} (+{len(cleaned_items) - 1})"
|
|
|
|
|
|
2026-03-23 20:35:15 +01:00
|
|
|
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)
|
|
|
|
|
)
|
2026-02-17 08:29:05 +01:00
|
|
|
|
2026-02-08 12:42:19 +01:00
|
|
|
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,
|
2026-03-23 20:35:15 +01:00
|
|
|
billing_direction,
|
|
|
|
|
advance_months,
|
2026-08-28 20:49:55 +02:00
|
|
|
billing_lead_months,
|
|
|
|
|
proration_basis,
|
2026-03-23 20:35:15 +01:00
|
|
|
first_full_period_start,
|
2026-02-08 12:42:19 +01:00
|
|
|
billing_day,
|
|
|
|
|
price,
|
|
|
|
|
start_date,
|
2026-02-17 08:29:05 +01:00
|
|
|
period_start,
|
|
|
|
|
next_invoice_date,
|
2026-03-23 20:35:15 +01:00
|
|
|
binding_months,
|
|
|
|
|
binding_start_date,
|
|
|
|
|
binding_end_date,
|
|
|
|
|
binding_group_key,
|
|
|
|
|
billing_blocked,
|
|
|
|
|
billing_block_reason,
|
2026-04-12 02:27:01 +02:00
|
|
|
price_type,
|
|
|
|
|
custom_price_override,
|
|
|
|
|
first_invoice_policy,
|
2026-03-23 20:35:15 +01:00
|
|
|
invoice_merge_key,
|
|
|
|
|
price_change_case_id,
|
|
|
|
|
renewal_case_id,
|
2026-08-28 20:49:55 +02:00
|
|
|
end_date,
|
|
|
|
|
notice_period_days,
|
2026-02-08 12:42:19 +01:00
|
|
|
status,
|
|
|
|
|
notes
|
2026-03-23 20:35:15 +01:00
|
|
|
) 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
|
2026-03-23 20:35:15 +01:00
|
|
|
)
|
2026-02-08 12:42:19 +01:00
|
|
|
RETURNING *
|
|
|
|
|
""",
|
|
|
|
|
(
|
|
|
|
|
sag_id,
|
|
|
|
|
sag["customer_id"],
|
|
|
|
|
product_name,
|
|
|
|
|
billing_interval,
|
2026-08-28 20:49:55 +02:00
|
|
|
billing_schedule_type,
|
2026-03-23 20:35:15 +01:00
|
|
|
billing_direction,
|
|
|
|
|
advance_months,
|
2026-08-28 20:49:55 +02:00
|
|
|
billing_lead_months,
|
|
|
|
|
"30_day",
|
2026-03-23 20:35:15 +01:00
|
|
|
first_full_period_start,
|
2026-02-08 12:42:19 +01:00
|
|
|
billing_day,
|
|
|
|
|
total_price,
|
|
|
|
|
start_date,
|
2026-02-17 08:29:05 +01:00
|
|
|
period_start,
|
|
|
|
|
next_invoice_date,
|
2026-03-23 20:35:15 +01:00
|
|
|
binding_months,
|
|
|
|
|
binding_start_date,
|
|
|
|
|
binding_end_date,
|
|
|
|
|
binding_group_key,
|
|
|
|
|
billing_blocked,
|
|
|
|
|
billing_block_reason,
|
2026-04-12 02:27:01 +02:00
|
|
|
price_type,
|
|
|
|
|
custom_price_override,
|
|
|
|
|
first_invoice_policy,
|
2026-03-23 20:35:15 +01:00
|
|
|
invoice_merge_key,
|
|
|
|
|
price_change_case_id,
|
|
|
|
|
renewal_case_id,
|
2026-08-28 20:49:55 +02:00
|
|
|
end_date,
|
|
|
|
|
notice_period_days,
|
2026-02-08 12:42:19 +01:00
|
|
|
notes,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
subscription = cursor.fetchone()
|
|
|
|
|
|
|
|
|
|
for item in cleaned_items:
|
|
|
|
|
cursor.execute(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO sag_subscription_items (
|
|
|
|
|
subscription_id,
|
|
|
|
|
line_no,
|
|
|
|
|
product_id,
|
2026-03-23 20:35:15 +01:00
|
|
|
asset_id,
|
2026-02-08 12:42:19 +01:00
|
|
|
description,
|
|
|
|
|
quantity,
|
|
|
|
|
unit_price,
|
2026-03-23 20:35:15 +01:00
|
|
|
line_total,
|
|
|
|
|
period_from,
|
|
|
|
|
period_to,
|
2026-04-12 02:27:01 +02:00
|
|
|
price_type,
|
|
|
|
|
custom_price_override,
|
2026-03-23 20:35:15 +01:00
|
|
|
requires_serial_number,
|
|
|
|
|
serial_number,
|
|
|
|
|
billing_blocked,
|
|
|
|
|
billing_block_reason
|
2026-04-12 02:27:01 +02:00
|
|
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
2026-02-08 12:42:19 +01:00
|
|
|
""",
|
|
|
|
|
(
|
|
|
|
|
subscription["id"],
|
|
|
|
|
item["line_no"],
|
|
|
|
|
item["product_id"],
|
2026-03-23 20:35:15 +01:00
|
|
|
item["asset_id"],
|
2026-02-08 12:42:19 +01:00
|
|
|
item["description"],
|
|
|
|
|
item["quantity"],
|
|
|
|
|
item["unit_price"],
|
|
|
|
|
item["line_total"],
|
2026-03-23 20:35:15 +01:00
|
|
|
item["period_from"],
|
|
|
|
|
item["period_to"],
|
2026-04-12 02:27:01 +02:00
|
|
|
item["price_type"],
|
|
|
|
|
item["custom_price_override"],
|
2026-03-23 20:35:15 +01:00
|
|
|
item["requires_serial_number"],
|
|
|
|
|
item["serial_number"],
|
|
|
|
|
item["billing_blocked"],
|
|
|
|
|
item["billing_block_reason"],
|
2026-02-08 12:42:19 +01:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
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"]),
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-08 12:42:19 +01:00
|
|
|
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))
|
2026-07-09 23:44:30 +02:00
|
|
|
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"]))
|
2026-07-09 23:44:30 +02:00
|
|
|
return _attach_network_provisioning(dict(subscription))
|
2026-02-08 12:42:19 +01:00
|
|
|
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))
|
|
|
|
|
|
|
|
|
|
|
2026-02-17 08:29:05 +01:00
|
|
|
@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:
|
2026-07-09 23:44:30 +02:00
|
|
|
return _load_subscription_with_context(subscription_id)
|
2026-02-17 08:29:05 +01:00
|
|
|
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")),
|
|
|
|
|
):
|
|
|
|
|
"""Direct edits are limited to notes; business changes require an approved request."""
|
2026-02-17 08:29:05 +01:00
|
|
|
try:
|
2026-08-28 20:49:55 +02:00
|
|
|
forbidden = set(payload) - {"notes"}
|
|
|
|
|
if forbidden:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=409,
|
|
|
|
|
detail="Business fields must be changed through a subscription change request",
|
|
|
|
|
)
|
2026-02-17 08:29:05 +01:00
|
|
|
subscription = execute_query_single(
|
|
|
|
|
"SELECT id, status FROM sag_subscriptions WHERE id = %s",
|
|
|
|
|
(subscription_id,)
|
|
|
|
|
)
|
|
|
|
|
if not subscription:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Subscription not found")
|
|
|
|
|
|
|
|
|
|
# Extract line_items before processing other fields
|
|
|
|
|
line_items = payload.pop("line_items", None)
|
2026-07-09 23:44:30 +02:00
|
|
|
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
|
|
|
|
|
)
|
2026-02-17 08:29:05 +01:00
|
|
|
|
|
|
|
|
# Build dynamic update query
|
|
|
|
|
allowed_fields = {
|
|
|
|
|
"product_name", "billing_interval", "billing_day", "price",
|
|
|
|
|
"start_date", "end_date", "next_invoice_date", "period_start",
|
2026-03-23 20:35:15 +01:00
|
|
|
"notice_period_days", "status", "notes",
|
2026-08-28 20:49:55 +02:00
|
|
|
"billing_direction", "advance_months", "billing_lead_months", "first_full_period_start",
|
2026-03-23 20:35:15 +01:00
|
|
|
"binding_months", "binding_start_date", "binding_end_date", "binding_group_key",
|
|
|
|
|
"billing_blocked", "billing_block_reason", "invoice_merge_key",
|
2026-04-12 02:27:01 +02:00
|
|
|
"price_type", "custom_price_override", "first_invoice_policy",
|
2026-03-23 20:35:15 +01:00
|
|
|
"price_change_case_id", "renewal_case_id"
|
2026-02-17 08:29:05 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
2026-07-09 23:44:30 +02:00
|
|
|
if normalized_line_items is not None:
|
2026-02-17 08:29:05 +01:00
|
|
|
# Delete existing line items
|
|
|
|
|
cursor.execute(
|
|
|
|
|
"DELETE FROM sag_subscription_items WHERE subscription_id = %s",
|
|
|
|
|
(subscription_id,)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Insert new line items
|
2026-07-09 23:44:30 +02:00
|
|
|
for idx, item in enumerate(normalized_line_items, start=1):
|
2026-02-17 08:29:05 +01:00
|
|
|
cursor.execute(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO sag_subscription_items (
|
|
|
|
|
subscription_id, line_no, description,
|
2026-03-23 20:35:15 +01:00
|
|
|
quantity, unit_price, line_total, product_id,
|
|
|
|
|
asset_id, period_from, period_to,
|
2026-04-12 02:27:01 +02:00
|
|
|
price_type, custom_price_override,
|
2026-03-23 20:35:15 +01:00
|
|
|
requires_serial_number, serial_number,
|
|
|
|
|
billing_blocked, billing_block_reason
|
2026-04-12 02:27:01 +02:00
|
|
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
2026-02-17 08:29:05 +01:00
|
|
|
""",
|
|
|
|
|
(
|
2026-07-09 23:44:30 +02:00
|
|
|
subscription_id, idx, item["description"],
|
|
|
|
|
item["quantity"], item["unit_price"], item["line_total"],
|
2026-03-23 20:35:15 +01:00
|
|
|
item.get("product_id"),
|
|
|
|
|
item.get("asset_id"),
|
|
|
|
|
item.get("period_from"),
|
|
|
|
|
item.get("period_to"),
|
2026-04-12 02:27:01 +02:00
|
|
|
item.get("price_type", "manual"),
|
|
|
|
|
bool(item.get("custom_price_override")),
|
2026-03-23 20:35:15 +01:00
|
|
|
bool(item.get("requires_serial_number")),
|
|
|
|
|
item.get("serial_number"),
|
|
|
|
|
bool(item.get("billing_blocked")),
|
|
|
|
|
item.get("billing_block_reason"),
|
2026-02-17 08:29:05 +01:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
conn.commit()
|
2026-07-09 23:44:30 +02:00
|
|
|
return _load_subscription_with_context(subscription_id)
|
2026-02-17 08:29:05 +01:00
|
|
|
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])
|
2026-02-08 12:42:19 +01:00
|
|
|
async def update_subscription_status(subscription_id: int, payload: Dict[str, Any]):
|
2026-08-28 20:49:55 +02:00
|
|
|
"""Compatibility guard: lifecycle changes require four-eyes workflow."""
|
|
|
|
|
raise HTTPException(status_code=409, detail="Status must be changed through a subscription change request")
|
2026-02-08 12:42:19 +01:00
|
|
|
|
|
|
|
|
|
2026-02-17 08:29:05 +01:00
|
|
|
@router.get("/sag-subscriptions", response_model=List[Dict[str, Any]])
|
2026-02-09 15:30:07 +01:00
|
|
|
async def list_subscriptions(status: str = Query("all")):
|
2026-02-17 08:29:05 +01:00
|
|
|
"""List subscriptions by status (default: all) with line item counts."""
|
2026-02-08 12:42:19 +01:00
|
|
|
try:
|
2026-02-09 15:30:07 +01:00
|
|
|
where_clause = ""
|
|
|
|
|
params: List[Any] = []
|
|
|
|
|
if status and status != "all":
|
|
|
|
|
where_clause = "WHERE s.status = %s"
|
|
|
|
|
params.append(status)
|
2026-02-08 12:42:19 +01:00
|
|
|
|
|
|
|
|
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,
|
2026-03-23 20:35:15 +01:00
|
|
|
s.billing_direction,
|
2026-08-28 20:49:55 +02:00
|
|
|
s.advance_months,
|
|
|
|
|
s.billing_lead_months,
|
2026-02-08 12:42:19 +01:00
|
|
|
s.billing_day,
|
|
|
|
|
s.price,
|
|
|
|
|
s.start_date,
|
2026-08-28 20:49:55 +02:00
|
|
|
s.period_start,
|
|
|
|
|
s.first_full_period_start,
|
2026-02-08 12:42:19 +01:00
|
|
|
s.end_date,
|
2026-08-28 20:49:55 +02:00
|
|
|
s.next_invoice_date,
|
|
|
|
|
s.notice_period_days,
|
2026-03-23 20:35:15 +01:00
|
|
|
s.billing_blocked,
|
2026-08-28 20:49:55 +02:00
|
|
|
s.billing_block_reason,
|
2026-03-23 20:35:15 +01:00
|
|
|
s.invoice_merge_key,
|
2026-02-17 08:29:05 +01:00
|
|
|
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,
|
2026-02-17 08:29:05 +01:00
|
|
|
(SELECT COUNT(*) FROM sag_subscription_items WHERE subscription_id = s.id) as item_count
|
2026-02-08 12:42:19 +01:00
|
|
|
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 []
|
2026-02-17 08:29:05 +01:00
|
|
|
|
|
|
|
|
# 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
|
2026-02-08 12:42:19 +01:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"❌ Error listing subscriptions: {e}", exc_info=True)
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
2026-02-17 08:29:05 +01:00
|
|
|
@router.get("/sag-subscriptions/stats/summary", response_model=Dict[str, Any])
|
2026-02-09 15:30:07 +01:00
|
|
|
async def subscription_stats(status: str = Query("all")):
|
|
|
|
|
"""Summary stats for subscriptions by status (default: all)."""
|
2026-02-08 12:42:19 +01:00
|
|
|
try:
|
2026-02-09 15:30:07 +01:00
|
|
|
where_clause = ""
|
|
|
|
|
params: List[Any] = []
|
|
|
|
|
if status and status != "all":
|
|
|
|
|
where_clause = "WHERE status = %s"
|
|
|
|
|
params.append(status)
|
|
|
|
|
query = f"""
|
2026-02-08 12:42:19 +01:00
|
|
|
SELECT
|
|
|
|
|
COUNT(*) AS subscription_count,
|
|
|
|
|
COALESCE(SUM(price), 0) AS total_amount,
|
|
|
|
|
COALESCE(AVG(price), 0) AS avg_amount
|
|
|
|
|
FROM sag_subscriptions
|
2026-02-09 15:30:07 +01:00
|
|
|
{where_clause}
|
2026-02-08 12:42:19 +01:00
|
|
|
"""
|
2026-02-09 15:30:07 +01:00
|
|
|
result = execute_query(query, tuple(params))
|
2026-02-08 12:42:19 +01:00
|
|
|
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-02-17 08:29:05 +01:00
|
|
|
|
|
|
|
|
|
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],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-02-17 08:29:05 +01:00
|
|
|
@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)
|
2026-03-23 20:35:15 +01:00
|
|
|
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)
|
|
|
|
|
|
2026-04-12 02:27:01 +02:00
|
|
|
_ensure_binding_not_overlapping(asset_id, start_date, end_date)
|
|
|
|
|
|
2026-03-23 20:35:15 +01:00
|
|
|
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")
|
|
|
|
|
|
2026-04-12 02:27:01 +02:00
|
|
|
_sync_asset_rental_status(asset_id)
|
|
|
|
|
|
2026-03-23 20:35:15 +01:00
|
|
|
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
|
|
|
|
|
|
2026-04-12 02:27:01 +02:00
|
|
|
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)
|
|
|
|
|
|
2026-03-23 20:35:15 +01:00
|
|
|
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")
|
2026-04-12 02:27:01 +02:00
|
|
|
updated = result[0]
|
|
|
|
|
_sync_asset_rental_status(int(updated.get("asset_id")))
|
|
|
|
|
return updated
|
2026-03-23 20:35:15 +01:00
|
|
|
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")
|
2026-04-12 02:27:01 +02:00
|
|
|
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")))
|
2026-03-23 20:35:15 +01:00
|
|
|
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)
|
2026-02-17 08:29:05 +01:00
|
|
|
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
|
|
|
|
|
)
|
2026-02-17 08:29:05 +01:00
|
|
|
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,
|
2026-02-17 08:29:05 +01:00
|
|
|
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)
|
2026-02-17 08:29:05 +01:00
|
|
|
RETURNING id
|
|
|
|
|
""",
|
|
|
|
|
(
|
|
|
|
|
sag_id,
|
|
|
|
|
hub_customer_id,
|
|
|
|
|
source_subject,
|
|
|
|
|
billing_interval,
|
2026-08-28 20:49:55 +02:00
|
|
|
billing_schedule_type,
|
2026-02-17 08:29:05 +01:00
|
|
|
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")
|