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