@@ -9002,6 +9096,8 @@
| Beskrivelse |
Antal |
Enhedspris |
+ Periode fra |
+ Periode til |
Linjesum |
|
@@ -9016,6 +9112,8 @@
|
|
|
+ |
+ |
0,00 kr |
@@ -9034,6 +9132,12 @@
Total: 0,00 kr
+
+
+ Kun på første fakturaEngangsydelser som oprettelse, installation og konfiguration. De faktureres præcis én gang.
+ | Produkt | Beskrivelse | Antal | Enhedspris | Linjesum | |
|---|
| Ingen engangsydelser tilføjet | | Engangsbeløb: | 0 kr. | |
+
+
@@ -9053,6 +9157,16 @@
+
+
+
+ Live fakturavisning Sådan vil fakturaen se ud Live
+
+
+ Visningen er vejledende. Den endelige faktura dannes af fakturajobbet.
+
+
+
@@ -17956,11 +18070,89 @@
+{% endblock %}
diff --git a/app/services/subscription_agreement.py b/app/services/subscription_agreement.py
new file mode 100644
index 0000000..c2cdc31
--- /dev/null
+++ b/app/services/subscription_agreement.py
@@ -0,0 +1,28 @@
+"""Pure agreement status rules shared by API and tests."""
+
+from typing import Any, Dict, List
+
+
+def agreement_status(subscriptions: List[Dict[str, Any]], changes: List[Dict[str, Any]]) -> str:
+ change_statuses = {row.get("status") for row in changes}
+ if change_statuses.intersection({"failed", "partially_applied"}):
+ return "Kræver handling"
+ if "pending" in change_statuses or "draft" in change_statuses:
+ return "Ændring afventer"
+ if change_statuses.intersection({"approved_scheduled", "applying"}):
+ return "Planlagt ændring"
+ statuses = [row.get("status") for row in subscriptions]
+ if not statuses or all(status in {"cancelled", "expired"} for status in statuses):
+ return "Afsluttet"
+ live = [status for status in statuses if status not in {"cancelled", "expired"}]
+ if live and all(status == "terminating" for status in live):
+ return "Under opsigelse"
+ if any(status in {"cancelled", "expired", "terminating"} for status in statuses):
+ return "Delvist opsagt"
+ if any(status == "paused" for status in statuses) and any(status == "active" for status in statuses):
+ return "Delvist pauseret"
+ if any(status == "active" for status in statuses):
+ return "Aktiv"
+ if statuses and all(status == "scheduled" for status in statuses):
+ return "Planlagt"
+ return "Kladde"
diff --git a/app/services/subscription_billing_calendar.py b/app/services/subscription_billing_calendar.py
new file mode 100644
index 0000000..4938f42
--- /dev/null
+++ b/app/services/subscription_billing_calendar.py
@@ -0,0 +1,132 @@
+"""Deterministic billing dates for Danish subscriptions."""
+
+from __future__ import annotations
+
+from calendar import monthrange
+from datetime import date, timedelta
+from typing import Optional
+
+from dateutil.easter import easter
+from dateutil.relativedelta import relativedelta
+
+
+MONTH_BASED_INTERVALS = {"monthly", "quarterly", "yearly"}
+SCHEDULE_TYPES = {"fixed_day", "first_business_day", "last_business_day", "interval_anchor"}
+
+
+def validate_billing_schedule(
+ interval: str,
+ schedule_type: str,
+ billing_day: Optional[int],
+) -> tuple[str, int]:
+ """Return a runnable schedule or reject a combination the invoice job cannot execute."""
+ if interval not in {"daily", "biweekly", *MONTH_BASED_INTERVALS}:
+ raise ValueError("invalid billing_interval")
+ schedule_type = (schedule_type or "fixed_day").strip().lower()
+ day = int(billing_day or 1)
+ if interval in {"daily", "biweekly"}:
+ return "interval_anchor", day
+ if schedule_type == "interval_anchor":
+ raise ValueError("interval_anchor is only valid for daily and biweekly subscriptions")
+ if schedule_type not in SCHEDULE_TYPES:
+ raise ValueError("invalid billing_schedule_type")
+ if schedule_type == "fixed_day" and not 1 <= day <= 28:
+ raise ValueError("billing_day must be between 1 and 28")
+ return schedule_type, day
+
+
+def danish_bank_holidays(year: int) -> set[date]:
+ """Return Nationalbanken's recurring Danish bank closing days."""
+ easter_sunday = easter(year)
+ return {
+ date(year, 1, 1),
+ easter_sunday - timedelta(days=3), # Maundy Thursday
+ easter_sunday - timedelta(days=2), # Good Friday
+ easter_sunday + timedelta(days=1), # Easter Monday
+ easter_sunday + timedelta(days=39), # Ascension Day
+ easter_sunday + timedelta(days=40), # Bank holiday after Ascension
+ easter_sunday + timedelta(days=50), # Whit Monday
+ date(year, 6, 5),
+ date(year, 12, 24),
+ date(year, 12, 25),
+ date(year, 12, 26),
+ date(year, 12, 31),
+ }
+
+
+def is_danish_bank_day(value: date) -> bool:
+ return value.weekday() < 5 and value not in danish_bank_holidays(value.year)
+
+
+def resolve_month_date(year: int, month: int, schedule_type: str, billing_day: Optional[int]) -> date:
+ if schedule_type == "first_business_day":
+ candidate = date(year, month, 1)
+ while not is_danish_bank_day(candidate):
+ candidate += timedelta(days=1)
+ return candidate
+ if schedule_type == "last_business_day":
+ candidate = date(year, month, monthrange(year, month)[1])
+ while not is_danish_bank_day(candidate):
+ candidate -= timedelta(days=1)
+ return candidate
+ day = int(billing_day or 1)
+ if not 1 <= day <= 28:
+ raise ValueError("billing_day must be between 1 and 28")
+ return date(year, month, day)
+
+
+def add_interval(value: date, interval: str) -> date:
+ if interval == "daily":
+ return value + timedelta(days=1)
+ if interval == "biweekly":
+ return value + timedelta(days=14)
+ if interval == "quarterly":
+ return value + relativedelta(months=3)
+ if interval == "yearly":
+ return value + relativedelta(years=1)
+ return value + relativedelta(months=1)
+
+
+def next_billing_date(
+ anchor: date,
+ interval: str,
+ schedule_type: str = "fixed_day",
+ billing_day: Optional[int] = 1,
+) -> date:
+ """Advance one interval, then resolve the configured date in its target month."""
+ target = add_interval(anchor, interval)
+ if interval not in MONTH_BASED_INTERVALS or schedule_type == "interval_anchor":
+ return target
+ if schedule_type not in SCHEDULE_TYPES:
+ raise ValueError("invalid billing_schedule_type")
+ return resolve_month_date(target.year, target.month, schedule_type, billing_day)
+
+
+def billing_date_for_period(
+ period_start: date,
+ lead_months: int,
+ schedule_type: str = "fixed_day",
+ billing_day: Optional[int] = 1,
+) -> date:
+ """Resolve the invoice date N calendar months before a coverage period starts."""
+ target = period_start - relativedelta(months=max(0, int(lead_months or 0)))
+ if schedule_type == "interval_anchor":
+ return target
+ return resolve_month_date(target.year, target.month, schedule_type, billing_day)
+
+
+def advance_billing_periods(value: date, interval: str, periods: int = 1) -> date:
+ """Advance a coverage boundary by a number of complete billing periods."""
+ result = value
+ for _ in range(max(1, int(periods or 1))):
+ result = add_interval(result, interval)
+ return result
+
+
+def prorated_30_day_factor(period_start: date, first_full_period_start: date) -> float:
+ """30/360-style fraction for a short opening period, capped at one month."""
+ if period_start >= first_full_period_start:
+ return 0.0
+ months = (first_full_period_start.year - period_start.year) * 12 + first_full_period_start.month - period_start.month
+ synthetic_days = months * 30 + min(first_full_period_start.day, 30) - min(period_start.day, 30)
+ return max(0.0, min(float(synthetic_days) / 30.0, 1.0))
diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html
index ba239a2..0c38d3d 100644
--- a/app/shared/frontend/base.html
+++ b/app/shared/frontend/base.html
@@ -1104,6 +1104,7 @@
Manualer
+ Website-indhold
@@ -2629,6 +2630,7 @@ if (bmcOriginalFetch) {
{ key: 'menu-support-hardware-customers', label: 'Support: Kundehardware' },
{ key: 'menu-support-eset', label: 'Support: ESET Oversigt' },
{ key: 'menu-support-manual', label: 'Support: Manualer' },
+ { key: 'menu-support-website-content', label: 'Support: Website-indhold' },
{ key: 'menu-salg-orders', label: 'Salg: Ordre' },
{ key: 'menu-salg-products', label: 'Salg: Produkter' },
{ key: 'menu-salg-webshop', label: 'Salg: Webshop Administration' },
diff --git a/app/subscriptions/backend/router.py b/app/subscriptions/backend/router.py
index bbc4273..d8d3a50 100644
--- a/app/subscriptions/backend/router.py
+++ b/app/subscriptions/backend/router.py
@@ -2,7 +2,7 @@
Subscriptions API
Sag-based subscriptions listing and stats
"""
-from fastapi import APIRouter, HTTPException, Query
+from fastapi import APIRouter, Depends, HTTPException, Query
from typing import List, Dict, Any, Optional
from app.core.database import execute_query, execute_query_single, get_db_connection, release_db_connection
from psycopg2.extras import RealDictCursor
@@ -15,16 +15,32 @@ from dateutil.relativedelta import relativedelta
from fastapi import Request
from app.services.simplycrm_service import SimplyCRMService
from app.modules.internet_connections.backend.provisioning_utils import summarize_subscription_network_requirements
+from app.core.auth_dependencies import get_current_user, require_permission
+from app.services.subscription_billing_calendar import (
+ advance_billing_periods,
+ billing_date_for_period,
+ prorated_30_day_factor,
+ validate_billing_schedule,
+)
+from app.services.subscription_agreement import agreement_status
logger = logging.getLogger(__name__)
router = APIRouter()
-ALLOWED_STATUSES = {"draft", "active", "paused", "cancelled"}
+ALLOWED_STATUSES = {"draft", "scheduled", "active", "paused", "terminating", "cancelled", "expired", "blocked"}
STAGING_KEY_SQL = "COALESCE(source_account_id, 'name:' || LOWER(COALESCE(source_customer_name, 'ukendt')))"
ALLOWED_BILLING_DIRECTIONS = {"forward", "backward"}
ALLOWED_PRICE_CHANGE_STATUSES = {"pending", "approved", "rejected", "applied"}
ALLOWED_BILLING_INTERVALS = {"daily", "biweekly", "monthly", "quarterly", "yearly"}
+ALLOWED_SCHEDULE_TYPES = {"fixed_day", "first_business_day", "last_business_day", "interval_anchor"}
+CHANGE_OPEN_STATUSES = {"draft", "pending", "approved_scheduled", "applying", "partially_applied", "failed", "cancellation_pending"}
+CHANGE_EDITABLE_FIELDS = {
+ "product_name", "billing_interval", "billing_schedule_type", "billing_day", "start_date", "end_date", "period_start",
+ "notice_period_days", "status", "billing_direction", "advance_months", "billing_lead_months", "first_full_period_start",
+ "binding_months", "binding_start_date", "binding_end_date", "binding_group_key", "invoice_merge_key",
+ "price_type", "custom_price_override", "first_invoice_policy", "notes", "line_items",
+}
def _load_subscription_line_items(subscription_id: int) -> List[Dict[str, Any]]:
@@ -34,7 +50,18 @@ def _load_subscription_line_items(subscription_id: int) -> List[Dict[str, Any]]:
i.id,
i.line_no,
i.product_id,
+ i.asset_id,
+ CONCAT_WS(' ', h.brand, h.model) AS asset_name,
+ h.asset_type,
+ h.internal_asset_id,
+ h.customer_asset_id,
+ h.serial_number AS asset_serial_number,
+ h.status AS asset_status,
p.name AS product_name,
+ p.sku_internal,
+ p.er_number,
+ p.ean,
+ p.supplier_sku,
p.type AS product_type,
p.attributes_json,
i.description,
@@ -43,12 +70,17 @@ def _load_subscription_line_items(subscription_id: int) -> List[Dict[str, Any]]:
i.line_total,
i.period_from,
i.period_to,
+ i.price_type,
+ i.custom_price_override,
i.requires_serial_number,
i.serial_number,
i.billing_blocked,
- i.billing_block_reason
+ i.billing_block_reason,
+ i.created_at,
+ i.updated_at
FROM sag_subscription_items i
LEFT JOIN products p ON p.id = i.product_id
+ LEFT JOIN hardware_assets h ON h.id = i.asset_id
WHERE i.subscription_id = %s
ORDER BY i.line_no ASC, i.id ASC
""",
@@ -57,6 +89,31 @@ def _load_subscription_line_items(subscription_id: int) -> List[Dict[str, Any]]:
return [dict(row) for row in rows]
+def _load_first_invoice_items(subscription_id: int) -> List[Dict[str, Any]]:
+ rows = execute_query(
+ """SELECT fi.*, p.name AS product_name, p.sku_internal, p.er_number, p.ean
+ FROM sag_subscription_first_invoice_items fi
+ LEFT JOIN products p ON p.id = fi.product_id
+ WHERE fi.subscription_id = %s ORDER BY fi.line_no, fi.id""",
+ (subscription_id,),
+ ) or []
+ return [dict(row) for row in rows]
+
+
+def _load_subscription_asset_bindings(subscription_id: int) -> List[Dict[str, Any]]:
+ return execute_query(
+ """
+ SELECT b.*, h.asset_type, h.brand, h.model, h.serial_number,
+ h.internal_asset_id, h.customer_asset_id, h.status AS asset_status
+ FROM subscription_asset_bindings b
+ JOIN hardware_assets h ON h.id = b.asset_id
+ WHERE b.subscription_id = %s AND b.deleted_at IS NULL
+ ORDER BY b.status = 'active' DESC, b.start_date DESC, b.id DESC
+ """,
+ (subscription_id,),
+ ) or []
+
+
def _attach_network_provisioning(subscription: Dict[str, Any]) -> Dict[str, Any]:
line_items = subscription.get("line_items") or []
provisioning = summarize_subscription_network_requirements(line_items)
@@ -90,8 +147,11 @@ def _load_subscription_with_context(subscription_id: int) -> Dict[str, Any]:
c.name AS customer_name,
s.product_name,
s.billing_interval,
+ s.billing_schedule_type,
s.billing_direction,
s.advance_months,
+ s.billing_lead_months,
+ s.proration_basis,
s.first_full_period_start,
s.billing_day,
s.price,
@@ -103,6 +163,9 @@ def _load_subscription_with_context(subscription_id: int) -> Dict[str, Any]:
s.binding_start_date,
s.binding_end_date,
s.binding_group_key,
+ s.price_type,
+ s.custom_price_override,
+ s.first_invoice_policy,
s.notice_period_days,
s.billing_blocked,
s.billing_block_reason,
@@ -115,6 +178,7 @@ def _load_subscription_with_context(subscription_id: int) -> Dict[str, Any]:
s.cancellation_reason,
s.created_at,
s.updated_at
+ ,s.version
FROM sag_subscriptions s
LEFT JOIN sag_sager sg ON sg.id = s.sag_id
LEFT JOIN customers c ON c.id = s.customer_id
@@ -126,6 +190,7 @@ def _load_subscription_with_context(subscription_id: int) -> Dict[str, Any]:
raise HTTPException(status_code=404, detail="Subscription not found")
subscription = dict(subscription)
subscription["line_items"] = _load_subscription_line_items(subscription_id)
+ subscription["first_invoice_items"] = _load_first_invoice_items(subscription_id)
return _attach_network_provisioning(subscription)
@@ -178,6 +243,44 @@ def _next_invoice_date(start_date: date, interval: str) -> date:
return start_date + relativedelta(months=1)
+def _json_safe(value: Any) -> Any:
+ return json.loads(json.dumps(value, default=str))
+
+
+def _subscription_snapshot(subscription_id: int) -> Dict[str, Any]:
+ return _json_safe(_load_subscription_with_context(subscription_id))
+
+
+def _normalize_change_proposal(before: Dict[str, Any], proposal: Dict[str, Any]) -> Dict[str, Any]:
+ unknown = set(proposal) - CHANGE_EDITABLE_FIELDS
+ if unknown:
+ raise HTTPException(status_code=400, detail=f"Unsupported fields: {', '.join(sorted(unknown))}")
+ merged = {key: before.get(key) for key in CHANGE_EDITABLE_FIELDS if key != "line_items" and key in before}
+ merged["line_items"] = before.get("line_items") or []
+ merged.update(proposal)
+ interval = merged.get("billing_interval") or "monthly"
+ schedule_type = merged.get("billing_schedule_type") or "fixed_day"
+ try:
+ schedule_type, billing_day = validate_billing_schedule(interval, schedule_type, merged.get("billing_day"))
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ if merged.get("status") not in ALLOWED_STATUSES:
+ raise HTTPException(status_code=400, detail="Invalid subscription status")
+ merged["billing_schedule_type"] = schedule_type
+ merged["billing_day"] = billing_day
+ return _json_safe(merged)
+
+
+def _permissions_for(current_user: Dict[str, Any]) -> Dict[str, bool]:
+ permissions = set(current_user.get("permissions") or [])
+ elevated = bool(current_user.get("is_superadmin"))
+ return {
+ "view": elevated or "subscriptions.view" in permissions,
+ "request_change": elevated or "subscriptions.change_request" in permissions,
+ "approve": elevated or "subscriptions.approve" in permissions,
+ }
+
+
def _ensure_asset_not_booked(asset_id: int, start_dt: date, end_dt: Optional[date], exclude_subscription_id: Optional[int] = None):
"""Prevent overlapping asset usage across active/draft/paused subscriptions."""
if end_dt and end_dt < start_dt:
@@ -312,7 +415,7 @@ def _auto_map_customer(account_id: Optional[str], customer_name: Optional[str],
@router.get("/sag-subscriptions/by-sag/{sag_id}", response_model=Dict[str, Any])
async def get_subscription_by_sag(sag_id: int, allow_missing: bool = Query(False)):
- """Get latest subscription for a case."""
+ """Compatibility response. Consumers should use agreement-overview."""
try:
query = """
SELECT
@@ -343,6 +446,8 @@ async def get_subscription_by_sag(sag_id: int, allow_missing: bool = Query(False
return {"subscription": None, "line_items": []}
raise HTTPException(status_code=404, detail="Subscription not found")
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"]))
return _attach_network_provisioning(subscription)
except HTTPException:
raise
@@ -351,28 +456,574 @@ async def get_subscription_by_sag(sag_id: int, allow_missing: bool = Query(False
raise HTTPException(status_code=500, detail=str(e))
+@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)
+
+
@router.post("/sag-subscriptions", response_model=Dict[str, Any])
-async def create_subscription(payload: Dict[str, Any]):
+async def create_subscription(
+ payload: Dict[str, Any],
+ current_user: Dict[str, Any] = Depends(require_permission("subscriptions.change_request")),
+):
"""Create a new subscription tied to a case (status = draft)."""
try:
sag_id = payload.get("sag_id")
billing_interval = payload.get("billing_interval")
billing_day = payload.get("billing_day")
+ billing_schedule_type = (payload.get("billing_schedule_type") or "fixed_day").strip().lower()
start_date = payload.get("start_date")
+ end_date = payload.get("end_date")
+ period_start_raw = payload.get("period_start") or start_date
billing_direction = (payload.get("billing_direction") or "forward").strip().lower()
price_type = (payload.get("price_type") or "manual").strip().lower()
custom_price_override = bool(payload.get("custom_price_override"))
first_invoice_policy = (payload.get("first_invoice_policy") or "start_date").strip().lower()
advance_months = int(payload.get("advance_months") or 1)
+ billing_lead_months = int(payload.get("billing_lead_months") or 0)
first_full_period_start = payload.get("first_full_period_start")
binding_months = int(payload.get("binding_months") or 0)
binding_start_date_raw = payload.get("binding_start_date") or start_date
binding_group_key = payload.get("binding_group_key")
invoice_merge_key = payload.get("invoice_merge_key")
+ notice_period_days = int(payload.get("notice_period_days") or 0)
price_change_case_id = payload.get("price_change_case_id")
renewal_case_id = payload.get("renewal_case_id")
notes = payload.get("notes")
line_items = payload.get("line_items") or []
+ first_invoice_items = payload.get("first_invoice_items") or []
if not sag_id:
raise HTTPException(status_code=400, detail="sag_id is required")
@@ -386,6 +1037,12 @@ async def create_subscription(payload: Dict[str, Any]):
raise HTTPException(status_code=400, detail="line_items is required")
if billing_interval not in ALLOWED_BILLING_INTERVALS:
raise HTTPException(status_code=400, detail="billing_interval must be daily/biweekly/monthly/quarterly/yearly")
+ try:
+ billing_schedule_type, billing_day = validate_billing_schedule(
+ billing_interval, billing_schedule_type, billing_day
+ )
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
if billing_direction not in ALLOWED_BILLING_DIRECTIONS:
raise HTTPException(status_code=400, detail="billing_direction must be forward or backward")
if price_type not in {"manual", "day", "week", "month", "year"}:
@@ -394,12 +1051,24 @@ async def create_subscription(payload: Dict[str, Any]):
raise HTTPException(status_code=400, detail="first_invoice_policy must be start_date or next_cycle")
if advance_months < 1 or advance_months > 24:
raise HTTPException(status_code=400, detail="advance_months must be between 1 and 24")
+ if billing_lead_months < 0 or billing_lead_months > 24:
+ raise HTTPException(status_code=400, detail="billing_lead_months must be between 0 and 24")
if binding_months < 0:
raise HTTPException(status_code=400, detail="binding_months must be >= 0")
+ if notice_period_days < 0:
+ raise HTTPException(status_code=400, detail="notice_period_days must be >= 0")
start_dt = _safe_date(start_date)
if not start_dt:
raise HTTPException(status_code=400, detail="start_date must be a valid date")
+ period_start = _safe_date(period_start_raw)
+ if not period_start:
+ raise HTTPException(status_code=400, detail="period_start must be a valid date")
+ end_dt = _safe_date(end_date)
+ if end_date and not end_dt:
+ raise HTTPException(status_code=400, detail="end_date must be a valid date")
+ if end_dt and end_dt < start_dt:
+ raise HTTPException(status_code=400, detail="end_date cannot be before start_date")
sag = execute_query_single(
"SELECT id, customer_id FROM sag_sager WHERE id = %s",
@@ -408,18 +1077,6 @@ async def create_subscription(payload: Dict[str, Any]):
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:
@@ -519,6 +1176,22 @@ async def create_subscription(payload: Dict[str, Any]):
"billing_block_reason": billing_block_reason,
})
+ cleaned_first_invoice_items = []
+ for idx, item in enumerate(first_invoice_items, start=1):
+ description = str(item.get("description") or "").strip()
+ quantity = float(item.get("quantity") or 0)
+ unit_price = float(item.get("unit_price") or 0)
+ if not description or quantity <= 0 or unit_price < 0:
+ raise HTTPException(status_code=400, detail="Invalid first_invoice_items line")
+ cleaned_first_invoice_items.append({
+ "line_no": idx,
+ "product_id": item.get("product_id"),
+ "description": description,
+ "quantity": quantity,
+ "unit_price": unit_price,
+ "line_total": quantity * unit_price,
+ })
+
product_name = cleaned_items[0]["description"]
if len(cleaned_items) > 1:
product_name = f"{product_name} (+{len(cleaned_items) - 1})"
@@ -533,23 +1206,9 @@ async def create_subscription(payload: Dict[str, Any]):
if binding_months > 0:
binding_end_date = binding_start_date + relativedelta(months=binding_months)
- # Calculate next_invoice_date based on billing_interval
-
- 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
+ next_invoice_date = billing_date_for_period(
+ period_start, billing_lead_months, billing_schedule_type, int(billing_day)
+ )
conn = get_db_connection()
try:
@@ -561,8 +1220,11 @@ async def create_subscription(payload: Dict[str, Any]):
customer_id,
product_name,
billing_interval,
+ billing_schedule_type,
billing_direction,
advance_months,
+ billing_lead_months,
+ proration_basis,
first_full_period_start,
billing_day,
price,
@@ -581,12 +1243,14 @@ async def create_subscription(payload: Dict[str, Any]):
invoice_merge_key,
price_change_case_id,
renewal_case_id,
+ end_date,
+ notice_period_days,
status,
notes
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
- %s, %s, %s, %s, 'draft', %s
+ %s, %s, %s, %s, %s, %s, %s, %s, %s, 'draft', %s
)
RETURNING *
""",
@@ -595,8 +1259,11 @@ async def create_subscription(payload: Dict[str, Any]):
sag["customer_id"],
product_name,
billing_interval,
+ billing_schedule_type,
billing_direction,
advance_months,
+ billing_lead_months,
+ "30_day",
first_full_period_start,
billing_day,
total_price,
@@ -615,6 +1282,8 @@ async def create_subscription(payload: Dict[str, Any]):
invoice_merge_key,
price_change_case_id,
renewal_case_id,
+ end_date,
+ notice_period_days,
notes,
)
)
@@ -662,9 +1331,27 @@ async def create_subscription(payload: Dict[str, Any]):
)
)
+ for item in cleaned_first_invoice_items:
+ cursor.execute(
+ """INSERT INTO sag_subscription_first_invoice_items
+ (subscription_id, line_no, product_id, description, quantity, unit_price, line_total)
+ VALUES (%s,%s,%s,%s,%s,%s,%s)""",
+ (subscription["id"], item["line_no"], item["product_id"], item["description"],
+ item["quantity"], item["unit_price"], item["line_total"]),
+ )
+
conn.commit()
+ actor_id = current_user.get("id") if isinstance(current_user, dict) else None
+ execute_query(
+ """INSERT INTO subscription_events
+ (main_sag_id, subscription_id, event_type, actor_user_id, details)
+ VALUES (%s,%s,'subscription_created',%s,'{}'::jsonb)""",
+ (sag_id, subscription["id"], actor_id),
+ )
+ _sync_main_case_lifecycle(int(sag_id))
subscription["line_items"] = _load_subscription_line_items(int(subscription["id"]))
+ subscription["first_invoice_items"] = _load_first_invoice_items(int(subscription["id"]))
return _attach_network_provisioning(dict(subscription))
finally:
release_db_connection(conn)
@@ -688,9 +1375,19 @@ async def get_subscription(subscription_id: int):
@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."""
+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."""
try:
+ forbidden = set(payload) - {"notes"}
+ if forbidden:
+ raise HTTPException(
+ status_code=409,
+ detail="Business fields must be changed through a subscription change request",
+ )
subscription = execute_query_single(
"SELECT id, status FROM sag_subscriptions WHERE id = %s",
(subscription_id,)
@@ -745,7 +1442,7 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
"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",
+ "billing_direction", "advance_months", "billing_lead_months", "first_full_period_start",
"binding_months", "binding_start_date", "binding_end_date", "binding_group_key",
"billing_blocked", "billing_block_reason", "invoice_merge_key",
"price_type", "custom_price_override", "first_invoice_policy",
@@ -831,27 +1528,8 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
@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 _load_subscription_with_context(subscription_id)
- 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))
+ """Compatibility guard: lifecycle changes require four-eyes workflow."""
+ raise HTTPException(status_code=409, detail="Status must be changed through a subscription change request")
@router.get("/sag-subscriptions", response_model=List[Dict[str, Any]])
@@ -874,14 +1552,54 @@ async def list_subscriptions(status: str = Query("all")):
c.name AS customer_name,
s.product_name,
s.billing_interval,
+ s.billing_schedule_type,
s.billing_direction,
+ s.advance_months,
+ s.billing_lead_months,
s.billing_day,
s.price,
s.start_date,
+ s.period_start,
+ s.first_full_period_start,
s.end_date,
+ s.next_invoice_date,
+ s.notice_period_days,
s.billing_blocked,
+ s.billing_block_reason,
s.invoice_merge_key,
s.status,
+ (
+ SELECT cr.status FROM subscription_change_request_items cri
+ JOIN subscription_change_requests cr ON cr.id = cri.change_request_id
+ WHERE cri.subscription_id = s.id AND cr.status = ANY(%s)
+ ORDER BY cr.created_at DESC LIMIT 1
+ ) AS change_status,
+ (
+ SELECT cr.change_sag_id FROM subscription_change_request_items cri
+ JOIN subscription_change_requests cr ON cr.id = cri.change_request_id
+ WHERE cri.subscription_id = s.id AND cr.status = ANY(%s)
+ ORDER BY cr.created_at DESC LIMIT 1
+ ) AS change_sag_id,
+ (
+ SELECT STRING_AGG(DISTINCT CONCAT_WS(' ', p.name, p.sku_internal, p.er_number, p.ean, p.supplier_sku, si.description), ' ')
+ FROM sag_subscription_items si
+ LEFT JOIN products p ON p.id = si.product_id
+ WHERE si.subscription_id = s.id
+ ) AS product_search_text,
+ (
+ SELECT COALESCE(json_agg(json_build_object(
+ 'product_id', si.product_id,
+ 'product_name', p.name,
+ 'sku_internal', p.sku_internal,
+ 'er_number', p.er_number,
+ 'ean', p.ean,
+ 'supplier_sku', p.supplier_sku,
+ 'description', si.description
+ ) ORDER BY si.line_no, si.id), '[]'::json)
+ FROM sag_subscription_items si
+ LEFT JOIN products p ON p.id = si.product_id
+ WHERE si.subscription_id = s.id
+ ) AS product_lines,
(SELECT COUNT(*) FROM sag_subscription_items WHERE subscription_id = s.id) as item_count
FROM sag_subscriptions s
LEFT JOIN sag_sager sg ON sg.id = s.sag_id
@@ -889,7 +1607,8 @@ async def list_subscriptions(status: str = Query("all")):
{where_clause}
ORDER BY s.start_date DESC, s.id DESC
"""
- subscriptions = execute_query(query, tuple(params)) or []
+ query_params = [list(CHANGE_OPEN_STATUSES), list(CHANGE_OPEN_STATUSES), *params]
+ subscriptions = execute_query(query, tuple(query_params)) or []
# Add line_items array with count for display
for sub in subscriptions:
@@ -930,6 +1649,103 @@ async def subscription_stats(status: str = Query("all")):
raise HTTPException(status_code=500, detail=str(e))
+@router.get("/subscription-billing-forecast", response_model=Dict[str, Any])
+async def subscription_billing_forecast(
+ months: int = Query(12, ge=1, le=24),
+ current_user: Dict[str, Any] = Depends(get_current_user),
+):
+ """Read-only calendar of expected order drafts; uses the same date helpers as the billing job."""
+ today = date.today()
+ horizon = today + relativedelta(months=months)
+ rows = execute_query(
+ """SELECT s.*, c.name AS customer_name, sg.titel AS sag_title,
+ COALESCE((SELECT SUM(si.line_total) FROM sag_subscription_items si
+ WHERE si.subscription_id = s.id AND NOT si.billing_blocked), 0) AS recurring_total,
+ COALESCE((SELECT SUM(fi.line_total) FROM sag_subscription_first_invoice_items fi
+ WHERE fi.subscription_id = s.id AND fi.billed_at IS NULL), 0) AS unbilled_first_total
+ FROM sag_subscriptions s
+ LEFT JOIN customers c ON c.id = s.customer_id
+ LEFT JOIN sag_sager sg ON sg.id = s.sag_id
+ WHERE s.status IN ('active','scheduled','blocked') AND s.next_invoice_date IS NOT NULL
+ ORDER BY s.next_invoice_date, s.id"""
+ ) or []
+
+ grouped: Dict[tuple, Dict[str, Any]] = {}
+ for raw in rows:
+ sub = dict(raw)
+ invoice_date = _safe_date(sub.get("next_invoice_date"))
+ period_start = _safe_date(sub.get("period_start") or sub.get("start_date"))
+ first_full = _safe_date(sub.get("first_full_period_start"))
+ if not invoice_date or not period_start:
+ continue
+ first_occurrence = True
+ iterations = 0
+ while invoice_date <= horizon and iterations < 120:
+ iterations += 1
+ periods = max(1, int(sub.get("advance_months") or 1))
+ is_partial = bool(first_occurrence and first_full and period_start < first_full)
+ full_start = first_full if is_partial else period_start
+ coverage_end = advance_billing_periods(full_start, sub.get("billing_interval") or "monthly", periods)
+ recurring = float(sub.get("recurring_total") or sub.get("price") or 0)
+ partial_amount = recurring * prorated_30_day_factor(period_start, first_full) if is_partial else 0.0
+ first_amount = float(sub.get("unbilled_first_total") or 0) if first_occurrence else 0.0
+ amount = recurring * periods + partial_amount + first_amount
+ visible_date = max(invoice_date, today) if invoice_date < today else invoice_date
+ state = "blocked" if sub.get("billing_blocked") or sub.get("status") == "blocked" else ("overdue" if invoice_date < today else "planned")
+ key = (
+ visible_date,
+ int(sub["customer_id"]),
+ str(sub.get("invoice_merge_key") or f"cust-{sub['customer_id']}"),
+ str(sub.get("billing_direction") or "forward"),
+ state,
+ )
+ group = grouped.setdefault(key, {
+ "invoice_date": invoice_date.isoformat(),
+ "display_date": visible_date.isoformat(),
+ "state": state,
+ "customer_id": sub["customer_id"],
+ "customer_name": sub.get("customer_name") or f"Kunde #{sub['customer_id']}",
+ "invoice_merge_key": sub.get("invoice_merge_key") or f"cust-{sub['customer_id']}",
+ "billing_direction": sub.get("billing_direction") or "forward",
+ "amount": 0.0,
+ "subscription_count": 0,
+ "subscriptions": [],
+ })
+ group["amount"] = round(float(group["amount"]) + amount, 2)
+ group["subscription_count"] += 1
+ group["subscriptions"].append({
+ "id": sub["id"], "subscription_number": sub.get("subscription_number"),
+ "sag_id": sub.get("sag_id"), "sag_title": sub.get("sag_title"),
+ "product_name": sub.get("product_name"), "coverage_start": period_start.isoformat(),
+ "full_coverage_start": full_start.isoformat(), "coverage_end": coverage_end.isoformat(),
+ "recurring_amount": round(recurring * periods, 2),
+ "proration_amount": round(partial_amount, 2), "first_invoice_amount": round(first_amount, 2),
+ "blocked": bool(sub.get("billing_blocked")), "blocked_reason": sub.get("billing_block_reason"),
+ })
+ period_start = coverage_end
+ safe_billing_day = min(28, max(1, int(sub.get("billing_day") or 1)))
+ invoice_date = billing_date_for_period(
+ period_start, int(sub.get("billing_lead_months") or 0),
+ sub.get("billing_schedule_type") or "fixed_day", safe_billing_day,
+ )
+ first_occurrence = False
+
+ generated = execute_query(
+ """SELECT br.id AS billing_run_id, br.subscription_id, br.period_start, br.created_at,
+ br.ordre_draft_id, od.title, od.customer_id, od.coverage_start, od.coverage_end,
+ od.sync_status, od.lines_json
+ FROM subscription_billing_runs br
+ LEFT JOIN ordre_drafts od ON od.id = br.ordre_draft_id
+ WHERE br.created_at::date BETWEEN %s AND %s ORDER BY br.created_at DESC""",
+ (today - relativedelta(months=1), horizon),
+ ) or []
+ return {
+ "generated_at": datetime.now().isoformat(), "from_date": today.isoformat(), "to_date": horizon.isoformat(),
+ "forecast": sorted(grouped.values(), key=lambda item: (item["display_date"], item["customer_name"])),
+ "generated": [dict(item) for item in generated],
+ }
+
+
@router.post("/sag-subscriptions/process-invoices")
async def trigger_subscription_processing():
"""Manual trigger for subscription invoice processing (for testing)."""
@@ -1809,7 +2625,10 @@ async def approve_staging_customer_rows(customer_key: str, payload: Dict[str, An
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)
+ billing_day = min(max(start_date.day, 1), 28)
+ billing_schedule_type, billing_day = validate_billing_schedule(
+ billing_interval, "fixed_day", billing_day
+ )
next_invoice_date = _next_invoice_date(start_date, billing_interval)
source_subject = (row.get("source_subject") or row.get("source_salesorder_no") or "Simply abonnement").strip()
@@ -1849,6 +2668,7 @@ async def approve_staging_customer_rows(customer_key: str, payload: Dict[str, An
customer_id,
product_name,
billing_interval,
+ billing_schedule_type,
billing_day,
price,
start_date,
@@ -1856,7 +2676,7 @@ async def approve_staging_customer_rows(customer_key: str, payload: Dict[str, An
next_invoice_date,
status,
notes
- ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'draft', %s)
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'draft', %s)
RETURNING id
""",
(
@@ -1864,6 +2684,7 @@ async def approve_staging_customer_rows(customer_key: str, payload: Dict[str, An
hub_customer_id,
source_subject,
billing_interval,
+ billing_schedule_type,
billing_day,
amount,
start_date,
diff --git a/app/subscriptions/frontend/list.html b/app/subscriptions/frontend/list.html
index a264068..d314f66 100644
--- a/app/subscriptions/frontend/list.html
+++ b/app/subscriptions/frontend/list.html
@@ -3,84 +3,83 @@
{% block title %}Abonnementer - BMC Hub{% endblock %}
{% block content %}
-
-
-
- 🔁 Abonnementer
- Alle solgte, aktive abonnementer
-
-
-
- Simply Import Oversigt
-
-
+
+
+
+
+ Recurring revenue cockpit
+
+ Abonnementer, helt under kontrol.Se økonomi, fakturerytme og ændringer i ét levende overblik — fra første bankdag til sidste godkendelse.
+
+
+
+
+
+
+
+
-
-
-
- Aktive Abonnementer
- -
-
-
-
-
-
-
- Total Pris (aktive)
- -
-
-
-
-
+
+
+
+
-
-
@@ -88,7 +87,7 @@
-
+
-
@@ -149,13 +156,13 @@
@@ -172,9 +179,13 @@
@@ -184,6 +195,10 @@
+
+
+
GodkendelsespakkeÆndringen oprettes som undersag og skal godkendes af en anden bruger.
+
@@ -223,7 +238,7 @@
@@ -263,6 +278,9 @@
diff --git a/deploy/website/admin-content.php b/deploy/website/admin-content.php
new file mode 100644
index 0000000..a9cbea4
--- /dev/null
+++ b/deploy/website/admin-content.php
@@ -0,0 +1,344 @@
+ 'unauthorized', 'message' => 'Ugyldig admin-token.'], 401);
+ }
+ return $provided;
+}
+
+function adminDb(string $credential): PDO
+{
+ try {
+ return bmc_db();
+ } catch (RuntimeException $e) {
+ if (!str_contains($e->getMessage(), 'environment variables are missing')) {
+ throw $e;
+ }
+ }
+ return new PDO(
+ 'mysql:host=127.0.0.1;port=3306;dbname=bmcnetworks_26;charset=utf8mb4',
+ 'bmc_26dcrhccr',
+ $credential,
+ [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
+ );
+}
+
+function body(): array
+{
+ $decoded = json_decode(file_get_contents('php://input') ?: '{}', true);
+ if (!is_array($decoded)) {
+ bmc_json_response(['error' => 'invalid_json', 'message' => 'Ugyldig JSON.'], 400);
+ }
+ return $decoded;
+}
+
+function resourceConfig(string $resource): array
+{
+ $configs = [
+ 'customers' => [
+ 'table' => 'customer_references',
+ 'fields' => ['customer_name', 'logo_url', 'website_url', 'sort_order', 'is_active'],
+ 'required' => ['customer_name'],
+ 'visibility' => 'is_active',
+ 'order' => 'sort_order ASC, customer_name ASC',
+ 'select' => 'id, customer_name, logo_url, website_url, sort_order, is_active, source, updated_at',
+ ],
+ 'operations' => [
+ 'table' => 'operations_status',
+ 'fields' => ['title', 'severity', 'message', 'starts_at', 'ends_at', 'is_active'],
+ 'required' => ['title', 'message'],
+ 'visibility' => 'is_active',
+ 'order' => 'updated_at DESC',
+ 'select' => 'id, title, severity, message, starts_at, ends_at, is_active, source, updated_at',
+ ],
+ 'incidents' => [
+ 'table' => 'operations_incidents',
+ 'fields' => ['title', 'severity', 'message', 'starts_at', 'ends_at', 'is_public'],
+ 'required' => ['title', 'message'],
+ 'visibility' => 'is_public',
+ 'order' => 'updated_at DESC',
+ 'select' => 'id, title, severity, message, starts_at, ends_at, is_public, source, updated_at',
+ ],
+ ];
+ if (!isset($configs[$resource])) {
+ bmc_json_response(['error' => 'invalid_resource'], 404);
+ }
+ return $configs[$resource];
+}
+
+function cleanValues(array $input, array $config, bool $creating): array
+{
+ $values = [];
+ foreach ($config['fields'] as $field) {
+ if (array_key_exists($field, $input)) {
+ $value = $input[$field];
+ if (in_array($field, ['is_active', 'is_public'], true)) {
+ $value = $value ? 1 : 0;
+ }
+ if ($field === 'sort_order') {
+ $value = max(0, (int)$value);
+ }
+ if ($field === 'severity' && !in_array($value, ['ok', 'info', 'warning', 'critical'], true)) {
+ bmc_json_response(['error' => 'validation_failed', 'message' => 'Ugyldig severity.'], 422);
+ }
+ if (in_array($field, ['starts_at', 'ends_at', 'website_url'], true) && $value === '') {
+ $value = null;
+ }
+ $values[$field] = $value;
+ }
+ }
+ if ($creating) {
+ foreach ($config['required'] as $field) {
+ if (!isset($values[$field]) || trim((string)$values[$field]) === '') {
+ bmc_json_response(['error' => 'validation_failed', 'message' => "$field mangler."], 422);
+ }
+ }
+ }
+ return $values;
+}
+
+function fetchItem(PDO $pdo, array $config, int $id): array
+{
+ $statement = $pdo->prepare("SELECT {$config['select']} FROM {$config['table']} WHERE id = ? LIMIT 1");
+ $statement->execute([$id]);
+ $item = $statement->fetch();
+ if (!$item) {
+ bmc_json_response(['error' => 'not_found'], 404);
+ }
+ return $item;
+}
+
+function atomicWrite(string $path, string $contents): void
+{
+ $temporary = $path . '.tmp.' . bin2hex(random_bytes(6));
+ if (file_put_contents($temporary, $contents, LOCK_EX) === false || !rename($temporary, $path)) {
+ @unlink($temporary);
+ throw new RuntimeException('Kunne ikke opdatere den offentlige content-cache.');
+ }
+}
+
+function refreshPublicCache(PDO $pdo): void
+{
+ $customers = $pdo->query(
+ 'SELECT customer_name, logo_url, website_url
+ FROM customer_references
+ WHERE is_active = 1
+ ORDER BY sort_order ASC, customer_name ASC
+ LIMIT 50'
+ )->fetchAll();
+ $current = $pdo->query(
+ 'SELECT title, severity, message, starts_at, ends_at, updated_at
+ FROM operations_status
+ WHERE is_active = 1
+ AND (starts_at IS NULL OR starts_at <= NOW())
+ AND (ends_at IS NULL OR ends_at >= NOW())
+ ORDER BY updated_at DESC
+ LIMIT 1'
+ )->fetch() ?: null;
+ $history = $pdo->query(
+ 'SELECT title, severity, message, starts_at, ends_at, updated_at
+ FROM operations_incidents
+ WHERE is_public = 1
+ ORDER BY updated_at DESC
+ LIMIT 20'
+ )->fetchAll();
+
+ $json = json_encode([
+ 'meta' => ['generated_at' => gmdate('c')],
+ 'customers' => $customers,
+ 'operations' => ['current' => $current, 'history' => $history],
+ ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
+ atomicWrite(__DIR__ . '/content-cache.json', $json);
+
+ $logoDirectory = __DIR__ . '/content-cache-logos';
+ if (!is_dir($logoDirectory) && !mkdir($logoDirectory, 0755, true) && !is_dir($logoDirectory)) {
+ throw new RuntimeException('Kunne ikke oprette logo-cache.');
+ }
+ $extensions = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/webp' => 'webp', 'image/gif' => 'gif'];
+ $logos = $pdo->query(
+ 'SELECT id, logo_blob, logo_mime_type
+ FROM customer_references
+ WHERE is_active = 1 AND logo_blob IS NOT NULL'
+ )->fetchAll();
+ $activeFiles = [];
+ foreach ($logos as $logo) {
+ $extension = $extensions[(string)$logo['logo_mime_type']] ?? null;
+ if ($extension === null || !is_string($logo['logo_blob'])) {
+ continue;
+ }
+ $filename = (int)$logo['id'] . '.' . $extension;
+ atomicWrite($logoDirectory . '/' . $filename, $logo['logo_blob']);
+ $activeFiles[$filename] = true;
+ }
+ foreach (glob($logoDirectory . '/*.{png,jpg,webp,gif}', GLOB_BRACE) ?: [] as $cachedLogo) {
+ if (!isset($activeFiles[basename($cachedLogo)])) {
+ @unlink($cachedLogo);
+ }
+ }
+}
+
+function outputLogo(PDO $pdo, int $id): void
+{
+ $statement = $pdo->prepare('SELECT logo_blob, logo_mime_type FROM customer_references WHERE id = ? LIMIT 1');
+ $statement->execute([$id]);
+ $logo = $statement->fetch();
+ if (!$logo || !is_string($logo['logo_blob'])) {
+ bmc_json_response(['error' => 'not_found'], 404);
+ }
+ header('Content-Type: ' . ($logo['logo_mime_type'] ?: 'application/octet-stream'));
+ header('Content-Length: ' . strlen($logo['logo_blob']));
+ header('Cache-Control: private, max-age=60');
+ header('X-Content-Type-Options: nosniff');
+ echo $logo['logo_blob'];
+ exit;
+}
+
+function uploadLogo(PDO $pdo, int $id, array $config): void
+{
+ if (!isset($_FILES['logo']) || $_FILES['logo']['error'] !== UPLOAD_ERR_OK) {
+ bmc_json_response(['error' => 'invalid_upload', 'message' => 'Logo mangler.'], 422);
+ }
+ $file = $_FILES['logo'];
+ if ((int)$file['size'] < 1 || (int)$file['size'] > MAX_LOGO_BYTES) {
+ bmc_json_response(['error' => 'file_too_large', 'message' => 'Logo må højst fylde 5 MB.'], 413);
+ }
+ $mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
+ if (!in_array($mime, ALLOWED_LOGO_TYPES, true)) {
+ bmc_json_response(['error' => 'invalid_file_type'], 415);
+ }
+ $blob = file_get_contents($file['tmp_name']);
+ $logoUrl = '/api/content.php?logo=' . $id;
+ $statement = $pdo->prepare(
+ 'UPDATE customer_references SET logo_blob = ?, logo_mime_type = ?, logo_url = ? WHERE id = ?'
+ );
+ $statement->bindParam(1, $blob, PDO::PARAM_LOB);
+ $statement->bindValue(2, $mime);
+ $statement->bindValue(3, $logoUrl);
+ $statement->bindValue(4, $id, PDO::PARAM_INT);
+ $statement->execute();
+ refreshPublicCache($pdo);
+ bmc_json_response(fetchItem($pdo, $config, $id));
+}
+
+$adminCredential = requireAdminToken();
+
+$resource = (string)($_GET['resource'] ?? '');
+$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT) ?: null;
+$action = (string)($_GET['action'] ?? '');
+$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
+$config = resourceConfig($resource);
+
+try {
+ $pdo = adminDb($adminCredential);
+
+ if ($resource === 'customers' && $id && $action === 'logo') {
+ if ($method === 'GET') {
+ outputLogo($pdo, $id);
+ }
+ uploadLogo($pdo, $id, $config);
+ }
+
+ // A normal authenticated read also repairs/initializes the public cache.
+ refreshPublicCache($pdo);
+
+ if ($resource === 'operations' && $id && $action === 'complete' && $method === 'POST') {
+ $input = body();
+ $endedAt = $input['ends_at'] ?: date('Y-m-d H:i:s');
+ $pdo->beginTransaction();
+ try {
+ $statement = $pdo->prepare('SELECT * FROM operations_status WHERE id = ? FOR UPDATE');
+ $statement->execute([$id]);
+ $operation = $statement->fetch();
+ if (!$operation) {
+ $pdo->rollBack();
+ bmc_json_response(['error' => 'not_found'], 404);
+ }
+ $insert = $pdo->prepare(
+ "INSERT INTO operations_incidents
+ (title, severity, message, starts_at, ends_at, is_public, source)
+ VALUES (?, ?, ?, ?, ?, ?, 'hub')"
+ );
+ $insert->execute([
+ $operation['title'], $operation['severity'], $operation['message'],
+ $operation['starts_at'], $endedAt, !empty($input['is_public']) ? 1 : 0,
+ ]);
+ $incidentId = (int)$pdo->lastInsertId();
+ $pdo->prepare('UPDATE operations_status SET is_active = 0, ends_at = ? WHERE id = ?')
+ ->execute([$endedAt, $id]);
+ $pdo->commit();
+ refreshPublicCache($pdo);
+ bmc_json_response(fetchItem($pdo, resourceConfig('incidents'), $incidentId), 201);
+ } catch (Throwable $e) {
+ if ($pdo->inTransaction()) {
+ $pdo->rollBack();
+ }
+ throw $e;
+ }
+ }
+
+ if ($method === 'GET' && $id) {
+ bmc_json_response(fetchItem($pdo, $config, $id));
+ }
+ if ($method === 'GET') {
+ $where = !filter_var($_GET['include_hidden'] ?? true, FILTER_VALIDATE_BOOL)
+ ? " WHERE {$config['visibility']} = 1" : '';
+ $items = $pdo->query("SELECT {$config['select']} FROM {$config['table']}{$where} ORDER BY {$config['order']}")
+ ->fetchAll();
+ bmc_json_response(['items' => $items]);
+ }
+ if ($method === 'POST' && !$id) {
+ $values = cleanValues(body(), $config, true);
+ $values['source'] = 'hub';
+ if ($resource === 'customers' && empty($values['logo_url'])) {
+ $values['logo_url'] = '';
+ }
+ $columns = array_keys($values);
+ $sql = "INSERT INTO {$config['table']} (" . implode(',', $columns) . ') VALUES ('
+ . implode(',', array_fill(0, count($columns), '?')) . ')';
+ $pdo->prepare($sql)->execute(array_values($values));
+ $newId = (int)$pdo->lastInsertId();
+ if ($resource === 'customers' && $values['logo_url'] === '') {
+ $pdo->prepare('UPDATE customer_references SET logo_url = ? WHERE id = ?')
+ ->execute(['/api/content.php?logo=' . $newId, $newId]);
+ }
+ refreshPublicCache($pdo);
+ bmc_json_response(fetchItem($pdo, $config, $newId), 201);
+ }
+ if ($method === 'PATCH' && $id) {
+ $values = cleanValues(body(), $config, false);
+ if (!$values) {
+ bmc_json_response(fetchItem($pdo, $config, $id));
+ }
+ $assignments = implode(',', array_map(fn($field) => "$field = ?", array_keys($values)));
+ $pdo->prepare("UPDATE {$config['table']} SET $assignments WHERE id = ?")
+ ->execute([...array_values($values), $id]);
+ refreshPublicCache($pdo);
+ bmc_json_response(fetchItem($pdo, $config, $id));
+ }
+ bmc_json_response(['error' => 'method_not_allowed'], 405);
+} catch (Throwable $e) {
+ error_log('admin-content.php: ' . $e->getMessage());
+ bmc_json_response([
+ 'error' => 'content_admin_unavailable',
+ 'message' => 'Website-databasen er ikke tilgængelig: ' . $e->getMessage(),
+ ], 503);
+}
diff --git a/deploy/website/content.php b/deploy/website/content.php
new file mode 100644
index 0000000..91f5e3b
--- /dev/null
+++ b/deploy/website/content.php
@@ -0,0 +1,163 @@
+prepare(
+ 'SELECT logo_blob, logo_mime_type, updated_at
+ FROM customer_references
+ WHERE id = ? AND is_active = 1 AND logo_blob IS NOT NULL
+ LIMIT 1'
+ );
+ $statement->execute([$id]);
+ $logo = $statement->fetch();
+ $allowed = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
+ if (!$logo || !is_string($logo['logo_blob'])) {
+ http_response_code(404);
+ exit;
+ }
+ $mime = (string)($logo['logo_mime_type'] ?? '');
+ if (!in_array($mime, $allowed, true)) {
+ http_response_code(415);
+ exit;
+ }
+ $etag = '"' . sha1((string)$logo['updated_at'] . ':' . strlen($logo['logo_blob'])) . '"';
+ if (trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? '')) === $etag) {
+ http_response_code(304);
+ exit;
+ }
+ header('Content-Type: ' . $mime);
+ header('Content-Length: ' . strlen($logo['logo_blob']));
+ header('Cache-Control: public, max-age=3600');
+ header('ETag: ' . $etag);
+ header('X-Content-Type-Options: nosniff');
+ echo $logo['logo_blob'];
+ exit;
+}
+
+function outputCachedCustomerLogo(int $id): void
+{
+ $types = ['png' => 'image/png', 'jpg' => 'image/jpeg', 'webp' => 'image/webp', 'gif' => 'image/gif'];
+ foreach ($types as $extension => $mime) {
+ $path = __DIR__ . '/content-cache-logos/' . $id . '.' . $extension;
+ if (!is_file($path)) {
+ continue;
+ }
+ header('Content-Type: ' . $mime);
+ header('Content-Length: ' . filesize($path));
+ header('Cache-Control: public, max-age=3600');
+ header('X-Content-Type-Options: nosniff');
+ readfile($path);
+ exit;
+ }
+ http_response_code(404);
+ exit;
+}
+
+function cachedContent(): ?array
+{
+ $path = __DIR__ . '/content-cache.json';
+ if (!is_file($path)) {
+ return null;
+ }
+ $decoded = json_decode((string)file_get_contents($path), true);
+ return is_array($decoded) ? $decoded : null;
+}
+
+if (isset($_GET['logo'])) {
+ $logoId = filter_input(INPUT_GET, 'logo', FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
+ if (!$logoId) {
+ http_response_code(400);
+ exit;
+ }
+ try {
+ outputCustomerLogo(bmc_db(), $logoId);
+ } catch (Throwable $e) {
+ outputCachedCustomerLogo($logoId);
+ }
+}
+
+function fetchCustomers(PDO $pdo): array
+{
+ $sql = "SELECT customer_name, logo_url, website_url
+ FROM customer_references
+ WHERE is_active = 1
+ ORDER BY sort_order ASC, customer_name ASC
+ LIMIT 50";
+
+ return $pdo->query($sql)->fetchAll();
+}
+
+function fetchCurrentOperation(PDO $pdo): ?array
+{
+ $sql = "SELECT title, severity, message, starts_at, ends_at, updated_at
+ FROM operations_status
+ WHERE is_active = 1
+ AND (starts_at IS NULL OR starts_at <= NOW())
+ AND (ends_at IS NULL OR ends_at >= NOW())
+ ORDER BY updated_at DESC
+ LIMIT 1";
+
+ $row = $pdo->query($sql)->fetch();
+ return $row ?: null;
+}
+
+function fetchOperationHistory(PDO $pdo): array
+{
+ $sql = "SELECT title, severity, message, starts_at, ends_at, updated_at
+ FROM operations_incidents
+ WHERE is_public = 1
+ ORDER BY updated_at DESC
+ LIMIT 20";
+
+ return $pdo->query($sql)->fetchAll();
+}
+
+try {
+ $pdo = bmc_db();
+
+ $customers = [];
+ $current = null;
+ $history = [];
+
+ try {
+ $customers = fetchCustomers($pdo);
+ } catch (Throwable $e) {
+ $customers = [];
+ }
+
+ try {
+ $current = fetchCurrentOperation($pdo);
+ } catch (Throwable $e) {
+ $current = null;
+ }
+
+ try {
+ $history = fetchOperationHistory($pdo);
+ } catch (Throwable $e) {
+ $history = [];
+ }
+
+ bmc_json_response([
+ 'meta' => [
+ 'generated_at' => gmdate('c'),
+ ],
+ 'customers' => $customers,
+ 'operations' => [
+ 'current' => $current,
+ 'history' => $history,
+ ],
+ ]);
+} catch (Throwable $e) {
+ $cached = cachedContent();
+ if ($cached !== null) {
+ bmc_json_response($cached);
+ }
+ bmc_json_response([
+ 'error' => 'content_unavailable',
+ 'message' => 'Kunne ikke hente dynamisk indhold.',
+ ], 503);
+}
diff --git a/main.py b/main.py
index d4a6c4b..9c075bf 100644
--- a/main.py
+++ b/main.py
@@ -152,6 +152,8 @@ from app.modules.invoice_error_finder.backend import router as invoice_error_fin
from app.modules.invoice_error_finder.frontend import views as invoice_error_finder_views
from app.modules.migration_center.backend import router as migration_center_api
from app.modules.migration_center.frontend import views as migration_center_views
+from app.modules.website_content.backend import router as website_content_api
+from app.modules.website_content.frontend import views as website_content_views
from app.bug_reports.backend import router as bug_reports_api
# Configure logging
@@ -505,6 +507,7 @@ app.include_router(drift_api, prefix="/api/v1", tags=["Drift"])
app.include_router(internet_connections_api.router, prefix="/api/v1", tags=["Internetforbindelser"])
app.include_router(invoice_error_finder_api.router, prefix="/api/v1/invoice-error-finder", tags=["Invoice Error Finder"])
app.include_router(migration_center_api.router, prefix="/api/v1/migration-center", tags=["Migration Center"])
+app.include_router(website_content_api.router, prefix="/api/v1/website-content", tags=["Website Content"])
if settings.LINKS_MODULE_ENABLED:
from app.modules.links.backend import router as links_api
@@ -545,6 +548,7 @@ app.include_router(drift_views.router, tags=["Frontend"])
app.include_router(internet_connections_views.router, tags=["Frontend"])
app.include_router(invoice_error_finder_views.router, tags=["Frontend"])
app.include_router(migration_center_views.router, tags=["Frontend"])
+app.include_router(website_content_views.router, tags=["Frontend"])
if settings.LINKS_MODULE_ENABLED:
from app.modules.links.frontend import views as links_views
diff --git a/migrations/1019_subscription_change_workflow.sql b/migrations/1019_subscription_change_workflow.sql
new file mode 100644
index 0000000..09a62c8
--- /dev/null
+++ b/migrations/1019_subscription_change_workflow.sql
@@ -0,0 +1,123 @@
+-- Subscription agreement overview, approval workflow and invoice idempotency.
+
+ALTER TABLE sag_subscriptions
+ ADD COLUMN IF NOT EXISTS billing_schedule_type VARCHAR(30) NOT NULL DEFAULT 'fixed_day',
+ ADD COLUMN IF NOT EXISTS version INTEGER NOT NULL DEFAULT 1;
+
+ALTER TABLE sag_subscriptions DROP CONSTRAINT IF EXISTS sag_subscriptions_status_check;
+ALTER TABLE sag_subscriptions ADD CONSTRAINT sag_subscriptions_status_check
+ CHECK (status IN ('draft','scheduled','active','paused','terminating','cancelled','expired','blocked')) NOT VALID;
+ALTER TABLE sag_subscriptions VALIDATE CONSTRAINT sag_subscriptions_status_check;
+
+ALTER TABLE sag_subscriptions DROP CONSTRAINT IF EXISTS sag_subscriptions_billing_schedule_type_check;
+ALTER TABLE sag_subscriptions ADD CONSTRAINT sag_subscriptions_billing_schedule_type_check
+ CHECK (billing_schedule_type IN ('fixed_day','first_business_day','last_business_day','interval_anchor'));
+
+CREATE TABLE IF NOT EXISTS subscription_change_requests (
+ id BIGSERIAL PRIMARY KEY,
+ main_sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
+ change_sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE RESTRICT,
+ status VARCHAR(30) NOT NULL DEFAULT 'draft'
+ CHECK (status IN ('draft','pending','approved_scheduled','applying','partially_applied','applied','rejected','failed','cancellation_pending','cancelled')),
+ reason TEXT,
+ effective_date DATE NOT NULL DEFAULT CURRENT_DATE,
+ created_by_user_id INTEGER NOT NULL REFERENCES users(user_id),
+ submitted_at TIMESTAMP,
+ approved_by_user_id INTEGER REFERENCES users(user_id),
+ approved_at TIMESTAMP,
+ rejected_by_user_id INTEGER REFERENCES users(user_id),
+ rejected_at TIMESTAMP,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_subscription_change_request_open_per_sag
+ ON subscription_change_requests(main_sag_id)
+ WHERE status IN ('draft','pending','approved_scheduled','applying','partially_applied','failed','cancellation_pending');
+CREATE UNIQUE INDEX IF NOT EXISTS uq_subscription_change_request_case
+ ON subscription_change_requests(change_sag_id);
+
+CREATE TABLE IF NOT EXISTS subscription_change_request_items (
+ id BIGSERIAL PRIMARY KEY,
+ change_request_id BIGINT NOT NULL REFERENCES subscription_change_requests(id) ON DELETE CASCADE,
+ subscription_id INTEGER NOT NULL REFERENCES sag_subscriptions(id) ON DELETE RESTRICT,
+ base_version INTEGER NOT NULL,
+ before_snapshot JSONB NOT NULL,
+ proposed_snapshot JSONB NOT NULL,
+ apply_status VARCHAR(20) NOT NULL DEFAULT 'pending'
+ CHECK (apply_status IN ('pending','applied','failed','abandoned')),
+ error_message TEXT,
+ applied_at TIMESTAMP,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(change_request_id, subscription_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_subscription_change_items_subscription
+ ON subscription_change_request_items(subscription_id);
+
+CREATE TABLE IF NOT EXISTS subscription_change_cancellations (
+ id BIGSERIAL PRIMARY KEY,
+ change_request_id BIGINT NOT NULL REFERENCES subscription_change_requests(id) ON DELETE CASCADE,
+ reason TEXT NOT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected')),
+ requested_by_user_id INTEGER NOT NULL REFERENCES users(user_id),
+ decided_by_user_id INTEGER REFERENCES users(user_id),
+ decided_at TIMESTAMP,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+CREATE UNIQUE INDEX IF NOT EXISTS uq_subscription_change_cancellation_pending
+ ON subscription_change_cancellations(change_request_id) WHERE status = 'pending';
+
+CREATE TABLE IF NOT EXISTS subscription_events (
+ id BIGSERIAL PRIMARY KEY,
+ main_sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
+ subscription_id INTEGER REFERENCES sag_subscriptions(id) ON DELETE SET NULL,
+ change_request_id BIGINT REFERENCES subscription_change_requests(id) ON DELETE SET NULL,
+ event_type VARCHAR(50) NOT NULL,
+ actor_user_id INTEGER REFERENCES users(user_id),
+ details JSONB NOT NULL DEFAULT '{}'::jsonb,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+CREATE INDEX IF NOT EXISTS idx_subscription_events_sag_created
+ ON subscription_events(main_sag_id, created_at DESC);
+
+CREATE TABLE IF NOT EXISTS subscription_billing_runs (
+ id BIGSERIAL PRIMARY KEY,
+ subscription_id INTEGER NOT NULL REFERENCES sag_subscriptions(id) ON DELETE RESTRICT,
+ period_start DATE NOT NULL,
+ ordre_draft_id INTEGER REFERENCES ordre_drafts(id) ON DELETE SET NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(subscription_id, period_start)
+);
+
+INSERT INTO permissions (code, description, category) VALUES
+ ('subscriptions.view', 'View subscriptions and agreement history', 'subscriptions'),
+ ('subscriptions.change_request', 'Create subscription change requests', 'subscriptions'),
+ ('subscriptions.approve', 'Approve and retry subscription changes', 'subscriptions')
+ON CONFLICT (code) DO UPDATE SET
+ description = EXCLUDED.description,
+ category = EXCLUDED.category;
+
+WITH permission_map(subscription_code, case_code) AS (
+ VALUES
+ ('subscriptions.view', 'cases.view'),
+ ('subscriptions.change_request', 'cases.edit')
+)
+INSERT INTO group_permissions (group_id, permission_id)
+SELECT DISTINCT gp.group_id, subscription_permission.id
+FROM group_permissions gp
+JOIN permissions case_permission ON case_permission.id = gp.permission_id
+JOIN permission_map mapping ON mapping.case_code = case_permission.code
+JOIN permissions subscription_permission ON subscription_permission.code = mapping.subscription_code
+ON CONFLICT DO NOTHING;
+
+-- Existing out-of-range days need an explicit review instead of silent correction.
+UPDATE sag_subscriptions
+SET billing_blocked = TRUE,
+ billing_block_reason = CONCAT_WS('; ', NULLIF(billing_block_reason, ''), 'Fakturadag 29-31 kræver manuel gennemgang')
+WHERE billing_day > 28
+ AND COALESCE(billing_block_reason, '') NOT LIKE '%Fakturadag 29-31 kræver manuel gennemgang%';
+
+CREATE INDEX IF NOT EXISTS idx_sag_subscriptions_sag_lifecycle
+ ON sag_subscriptions(sag_id, status, start_date, end_date);
diff --git a/migrations/1020_subscription_first_invoice_items.sql b/migrations/1020_subscription_first_invoice_items.sql
new file mode 100644
index 0000000..64f4314
--- /dev/null
+++ b/migrations/1020_subscription_first_invoice_items.sql
@@ -0,0 +1,20 @@
+-- One-time charges that are included once, on the first generated invoice.
+CREATE TABLE IF NOT EXISTS sag_subscription_first_invoice_items (
+ id BIGSERIAL PRIMARY KEY,
+ subscription_id BIGINT NOT NULL REFERENCES sag_subscriptions(id) ON DELETE CASCADE,
+ line_no INTEGER NOT NULL,
+ product_id BIGINT REFERENCES products(id) ON DELETE SET NULL,
+ description TEXT NOT NULL,
+ quantity NUMERIC(14,4) NOT NULL DEFAULT 1 CHECK (quantity > 0),
+ unit_price NUMERIC(14,2) NOT NULL DEFAULT 0 CHECK (unit_price >= 0),
+ line_total NUMERIC(14,2) NOT NULL DEFAULT 0,
+ billed_at TIMESTAMPTZ,
+ billing_run_id BIGINT REFERENCES subscription_billing_runs(id) ON DELETE SET NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE (subscription_id, line_no)
+);
+
+CREATE INDEX IF NOT EXISTS idx_subscription_first_invoice_unbilled
+ ON sag_subscription_first_invoice_items(subscription_id)
+ WHERE billed_at IS NULL;
diff --git a/migrations/1021_subscription_advance_billing_and_proration.sql b/migrations/1021_subscription_advance_billing_and_proration.sql
new file mode 100644
index 0000000..1f0d979
--- /dev/null
+++ b/migrations/1021_subscription_advance_billing_and_proration.sql
@@ -0,0 +1,11 @@
+ALTER TABLE sag_subscriptions
+ ADD COLUMN IF NOT EXISTS billing_lead_months INTEGER NOT NULL DEFAULT 0,
+ ADD COLUMN IF NOT EXISTS proration_basis VARCHAR(20) NOT NULL DEFAULT '30_day';
+
+ALTER TABLE sag_subscriptions DROP CONSTRAINT IF EXISTS sag_subscriptions_billing_lead_months_check;
+ALTER TABLE sag_subscriptions ADD CONSTRAINT sag_subscriptions_billing_lead_months_check
+ CHECK (billing_lead_months BETWEEN 0 AND 24);
+
+ALTER TABLE sag_subscriptions DROP CONSTRAINT IF EXISTS sag_subscriptions_proration_basis_check;
+ALTER TABLE sag_subscriptions ADD CONSTRAINT sag_subscriptions_proration_basis_check
+ CHECK (proration_basis IN ('30_day'));
diff --git a/migrations/1022_subscription_schedule_integrity.sql b/migrations/1022_subscription_schedule_integrity.sql
new file mode 100644
index 0000000..f7b2722
--- /dev/null
+++ b/migrations/1022_subscription_schedule_integrity.sql
@@ -0,0 +1,31 @@
+-- Ensure every saved billing rule can be executed by the invoice job.
+UPDATE sag_subscriptions
+SET billing_schedule_type = 'interval_anchor',
+ updated_at = CURRENT_TIMESTAMP
+WHERE billing_interval IN ('daily', 'biweekly')
+ AND billing_schedule_type IS DISTINCT FROM 'interval_anchor';
+
+UPDATE sag_subscriptions
+SET billing_schedule_type = 'fixed_day',
+ updated_at = CURRENT_TIMESTAMP
+WHERE billing_interval IN ('monthly', 'quarterly', 'yearly')
+ AND billing_schedule_type = 'interval_anchor';
+
+ALTER TABLE sag_subscriptions
+ DROP CONSTRAINT IF EXISTS sag_subscriptions_runnable_schedule_check;
+ALTER TABLE sag_subscriptions
+ ADD CONSTRAINT sag_subscriptions_runnable_schedule_check CHECK (
+ (billing_interval IN ('daily', 'biweekly') AND billing_schedule_type = 'interval_anchor')
+ OR
+ (billing_interval IN ('monthly', 'quarterly', 'yearly')
+ AND billing_schedule_type IN ('fixed_day', 'first_business_day', 'last_business_day'))
+ );
+
+-- Existing legacy days 29-31 remain visible for manual review, but new/updated
+-- fixed-day rules must always be executable.
+ALTER TABLE sag_subscriptions
+ DROP CONSTRAINT IF EXISTS sag_subscriptions_runnable_billing_day_check;
+ALTER TABLE sag_subscriptions
+ ADD CONSTRAINT sag_subscriptions_runnable_billing_day_check CHECK (
+ billing_schedule_type <> 'fixed_day' OR billing_day BETWEEN 1 AND 28
+ ) NOT VALID;
diff --git a/migrations/232_website_content_permissions.sql b/migrations/232_website_content_permissions.sql
new file mode 100644
index 0000000..3997378
--- /dev/null
+++ b/migrations/232_website_content_permissions.sql
@@ -0,0 +1,18 @@
+INSERT INTO permissions (code, description, category) VALUES
+('website_content.view', 'Se administration af website-indhold', 'website_content'),
+('website_content.edit', 'Rediger website-indhold og driftsstatus', 'website_content')
+ON CONFLICT (code) DO NOTHING;
+
+INSERT INTO group_permissions (group_id, permission_id)
+SELECT g.id, p.id
+FROM groups g
+CROSS JOIN permissions p
+WHERE g.name = 'Administrators' AND p.category = 'website_content'
+ON CONFLICT DO NOTHING;
+
+INSERT INTO group_permissions (group_id, permission_id)
+SELECT g.id, p.id
+FROM groups g
+CROSS JOIN permissions p
+WHERE g.name = 'Managers' AND p.category = 'website_content'
+ON CONFLICT DO NOTHING;
diff --git a/requirements.txt b/requirements.txt
index 8887a4e..e7735f3 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -5,6 +5,7 @@ pydantic==2.10.3
pydantic-settings==2.6.1
python-dotenv==1.0.1
python-multipart==0.0.17
+extract-msg==0.56.1
python-dateutil==2.8.2
jinja2==3.1.4
aiohttp==3.10.10
diff --git a/tests/test_contacts_router_simple.py b/tests/test_contacts_router_simple.py
index 77db046..d81af82 100644
--- a/tests/test_contacts_router_simple.py
+++ b/tests/test_contacts_router_simple.py
@@ -1,4 +1,5 @@
import asyncio
+import io
import sys
from pathlib import Path
@@ -52,3 +53,162 @@ def test_router_simple_create_contact_supports_extended_payload_and_company_link
assert company_link_params[0][1] == 10
assert company_link_params[1][1] == 20
assert update_calls
+
+
+def test_contact_email_regex_uses_exact_address_boundaries():
+ import re
+ from app.contacts.backend.router_simple import _exact_email_pattern
+
+ pattern = re.compile(_exact_email_pattern("ada@example.com"))
+ assert pattern.search("Ada , other@example.com")
+ assert not pattern.search("notada@example.com")
+ assert not pattern.search("ada@example.com.evil.test")
+
+
+def test_contact_detail_has_cases_and_email_tabs():
+ template = Path("app/contacts/frontend/contact_detail.html").read_text(encoding="utf-8")
+ assert 'href="#cases"' in template
+ assert 'href="#emails"' in template
+ assert "/cases?limit=${contactRelatedPageSize}" in template
+ assert "/emails?limit=${contactRelatedPageSize}" in template
+
+
+def test_contact_email_analysis_returns_review_only_changes():
+ from app.contacts.backend.router_simple import _contact_suggestions_from_email
+
+ contact = {
+ "first_name": "Ada",
+ "last_name": "Lovelace",
+ "email": "ada@old.example",
+ "phone": "11111111",
+ "mobile": None,
+ "title": "Udvikler",
+ "department": None,
+ }
+ parsed = {
+ "sender_name": "Ada Lovelace ",
+ "sender_email": "ada@new.example",
+ "recipient_email": "support@bmc.example",
+ "body_text": "Hej\n\nMobil: +45 22 33 44 55\nTitel: CTO\nAfdeling: IT\n",
+ }
+
+ suggestions = _contact_suggestions_from_email(contact, parsed)
+ by_field = {item["field"]: item for item in suggestions}
+
+ assert by_field["email"]["suggested"] == "ada@new.example"
+ assert by_field["mobile"]["suggested"] == "+45 22 33 44 55"
+ assert by_field["title"]["suggested"] == "CTO"
+ assert by_field["department"]["suggested"] == "IT"
+ assert "first_name" not in by_field
+ assert "last_name" not in by_field
+ assert "phone" not in by_field
+
+
+def test_contact_email_analysis_does_not_treat_our_sender_as_the_contact():
+ from app.contacts.backend.router_simple import _contact_suggestions_from_email
+
+ contact = {"email": "customer@example.com"}
+ parsed = {
+ "sender_name": "Support Agent",
+ "sender_email": "support@bmc.example",
+ "recipient_email": "Customer ",
+ "body_text": "Venlig hilsen",
+ }
+
+ assert _contact_suggestions_from_email(contact, parsed) == []
+
+
+def test_contact_email_analysis_handles_createx_outlook_signature():
+ from app.contacts.backend.router_simple import _contact_suggestions_from_email
+
+ contact = {
+ "first_name": "Ida", "last_name": "Gundersen",
+ "email": "ida@createx-onstage.com", "mobile": None, "title": None,
+ }
+ parsed = {
+ "sender_name": "Ida ",
+ "sender_email": "ida@createx-onstage.com",
+ "recipient_email": "support@example.com",
+ "body_text": """Ida
+Kind regards
+**Ida Gundersen**
+*Technical Advisor & Co-owner*
+
+**Mobile:** +45 42 25 59 08 **DK: **+45 55 86 05 00
+**FI:** +358 40 550 5865 **NO:** +47 62 41 84 05
+**Email:** ida\\@createx-onstage.com
+""",
+ }
+
+ by_field = {
+ item["field"]: item
+ for item in _contact_suggestions_from_email(contact, parsed)
+ }
+ assert by_field["mobile"]["suggested"] == "+45 42 25 59 08"
+ assert by_field["title"]["suggested"] == "Technical Advisor & Co-owner"
+
+
+def test_contact_detail_has_outlook_dropzone_and_review_modal():
+ template = Path("app/contacts/frontend/contact_detail.html").read_text(encoding="utf-8")
+ assert 'id="contactEmailDropzone"' in template
+ assert 'accept=".msg,.eml,message/rfc822,application/vnd.ms-outlook"' in template
+ assert "/analyze-email" in template
+ assert 'id="contactEmailSuggestionsModal"' in template
+ assert "applyContactEmailSuggestions()" in template
+
+
+def test_new_contact_email_analysis_extracts_company_cvr_and_name():
+ from app.contacts.backend.router_simple import _company_from_email_body
+
+ company = _company_from_email_body(
+ "Ida Gundersen\nTechnical Advisor & Co-owner\nCreatex ApS\nStoregade 4C | 4780 Stege\nCVR: 12 34 56 78",
+ "Ida Gundersen",
+ )
+ assert company == {"name": "Createx ApS", "cvr_number": "12345678"}
+
+
+def test_contacts_page_can_create_contact_from_email():
+ template = Path("app/contacts/frontend/contacts.html").read_text(encoding="utf-8")
+ assert "Træk Outlook-mail hertil" in template
+ assert 'id="createFromEmailInput"' in template
+ assert 'id="createFromEmailDropzone"' in template
+ assert "event.dataTransfer?.files?.[0]" in template
+ assert "initializeCreateFromEmailDropzone()" in template
+ assert "'/api/v1/contacts/analyze-email'" in template
+ assert "'/api/v1/contacts/resolve-email-company'" in template
+ assert 'id="createFromEmailModal"' in template
+
+
+def test_new_contact_email_uses_existing_cvr_lookup(monkeypatch):
+ from starlette.datastructures import UploadFile
+ from app.contacts.backend import router_simple
+ from app.services.email_service import EmailService
+
+ parsed = {
+ "sender_name": "Ida Gundersen", "sender_email": "ida@example.com",
+ "recipient_email": "support@example.com", "subject": "Hej",
+ "body_text": "Ida Gundersen\nRådgiver\nForkert navn\nStoregade 4, 4780 Stege\nCVR: 12345678",
+ }
+
+ class FakeCvrService:
+ async def lookup_by_cvr(self, cvr):
+ assert cvr == "12345678"
+ return {"name": "Officielt Firma ApS", "address": "Torvet 1", "postal_code": "4780", "city": "Stege", "source": "firmaapi"}
+
+ monkeypatch.setattr(EmailService, "parse_eml_file", lambda self, content: parsed)
+ monkeypatch.setattr(router_simple, "get_cvr_service", lambda: FakeCvrService())
+ monkeypatch.setattr(router_simple, "execute_query_single", lambda *args, **kwargs: None)
+
+ result = asyncio.run(router_simple.analyze_email_for_new_contact(
+ UploadFile(filename="mail.eml", file=io.BytesIO(b"mail"))
+ ))
+ assert result["company"]["lookup_found"] is True
+ assert result["company"]["name"] == "Officielt Firma ApS"
+ assert result["company"]["address"] == "Torvet 1"
+
+
+def test_contacts_without_search_uses_valid_neutral_ordering():
+ source = Path("app/contacts/backend/router_simple.py").read_text(encoding="utf-8")
+ assert 'rank_order_sql = ""' in source
+ assert "ORDER BY {rank_order_sql} c.last_name" in source
+ assert 'rank_sql = "0"' not in source
diff --git a/tests/test_customer_crm_improvements.py b/tests/test_customer_crm_improvements.py
new file mode 100644
index 0000000..556a197
--- /dev/null
+++ b/tests/test_customer_crm_improvements.py
@@ -0,0 +1,23 @@
+import re
+from pathlib import Path
+
+
+def test_customer_email_patterns_are_exact():
+ from app.customers.backend.router import _email_address_pattern, _email_domain_pattern
+
+ address = re.compile(_email_address_pattern("info@example.com"))
+ assert address.search("Info ")
+ assert not address.search("otherinfo@example.com")
+
+ domain = re.compile(_email_domain_pattern("example.com"))
+ assert domain.search("person@example.com")
+ assert not domain.search("person@example.com.evil.test")
+ assert not domain.search("person@notexample.com")
+
+
+def test_customer_detail_exposes_email_tab_and_supplier_service_checkbox():
+ template = Path("app/customers/frontend/customer_detail.html").read_text(encoding="utf-8")
+ assert 'href="#emails"' in template
+ assert 'id="supplierServiceEnrolled"' in template
+ assert "supplier_service_enrolled: checkbox.checked" in template
+ assert "/emails?limit=${customerEmailsLimit}" in template
diff --git a/tests/test_sag_module.py b/tests/test_sag_module.py
index 85dd043..d2d622b 100644
--- a/tests/test_sag_module.py
+++ b/tests/test_sag_module.py
@@ -155,6 +155,24 @@ def test_case_create_lists_contacts_for_selected_customer():
assert "resetCustomerContactSearch();" in template
+def test_case_create_defaults_responsible_to_current_user():
+ template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8")
+ router = Path("app/modules/sag/backend/router.py").read_text(encoding="utf-8")
+ assert "selectCurrentUserAsResponsible();" in template
+ assert "raw_responsible = data.get" in router
+ assert 'if "ansvarlig_bruger_id" in data else current_user_id' in router
+
+
+def test_case_v3_contact_actions_and_company_link_include_case_context():
+ template = Path("app/modules/sag/templates/detail_v3.html").read_text(encoding="utf-8")
+ assert 'href="/customers/{{ customer.id }}"' in template
+ assert "sag_id: {{ case.id }}" in template
+ assert "contact_id: opts.contactId || null" in template
+ assert 'title="Ring til mobil"' in template
+ assert 'title="Send SMS"' in template
+ assert 'id="caseCallHistoryBody"' in template
+
+
def test_time_employee_picker_is_clearly_separate_from_live_tracking():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
diff --git a/tests/test_subscription_billing_calendar.py b/tests/test_subscription_billing_calendar.py
new file mode 100644
index 0000000..73a5a03
--- /dev/null
+++ b/tests/test_subscription_billing_calendar.py
@@ -0,0 +1,75 @@
+from datetime import date
+from pathlib import Path
+import sys
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from app.services.subscription_billing_calendar import (
+ advance_billing_periods,
+ billing_date_for_period,
+ is_danish_bank_day,
+ next_billing_date,
+ prorated_30_day_factor,
+ resolve_month_date,
+ validate_billing_schedule,
+)
+import pytest
+from app.services.subscription_agreement import agreement_status
+
+
+def test_fixed_day_is_resolved_in_target_month():
+ assert next_billing_date(date(2026, 1, 17), "monthly", "fixed_day", 5) == date(2026, 2, 5)
+
+
+def test_first_bank_day_skips_weekend_and_new_year():
+ assert resolve_month_date(2026, 1, "first_business_day", None) == date(2026, 1, 2)
+
+
+def test_last_bank_day_skips_new_years_eve():
+ assert resolve_month_date(2026, 12, "last_business_day", None) == date(2026, 12, 30)
+
+
+def test_bank_holiday_after_ascension_is_closed():
+ assert not is_danish_bank_day(date(2026, 5, 15))
+
+
+def test_biweekly_keeps_exact_interval_even_with_month_schedule():
+ assert next_billing_date(date(2026, 4, 1), "biweekly", "first_business_day", 1) == date(2026, 4, 15)
+
+
+def test_short_intervals_are_forced_to_runnable_anchor_schedule():
+ assert validate_billing_schedule("daily", "fixed_day", 17) == ("interval_anchor", 17)
+
+
+def test_monthly_interval_anchor_is_rejected():
+ with pytest.raises(ValueError, match="only valid"):
+ validate_billing_schedule("monthly", "interval_anchor", 1)
+
+
+def test_new_fixed_day_rules_cannot_use_day_29_to_31():
+ with pytest.raises(ValueError, match="between 1 and 28"):
+ validate_billing_schedule("quarterly", "fixed_day", 31)
+
+
+def test_period_can_be_invoiced_two_months_in_advance():
+ assert billing_date_for_period(date(2027, 1, 1), 2, "fixed_day", 1) == date(2026, 11, 1)
+
+
+def test_three_monthly_periods_cover_a_quarter():
+ assert advance_billing_periods(date(2027, 1, 1), "monthly", 3) == date(2027, 4, 1)
+
+
+def test_short_opening_period_uses_30_day_basis():
+ assert prorated_30_day_factor(date(2027, 1, 5), date(2027, 2, 1)) == 26 / 30
+
+
+def test_agreement_status_prioritizes_failures_and_pending_changes():
+ subscriptions = [{"status": "active"}, {"status": "paused"}]
+ assert agreement_status(subscriptions, [{"status": "failed"}]) == "Kræver handling"
+ assert agreement_status(subscriptions, [{"status": "pending"}]) == "Ændring afventer"
+ assert agreement_status(subscriptions, []) == "Delvist pauseret"
+
+
+def test_agreement_status_handles_partial_termination_and_closed():
+ assert agreement_status([{"status": "active"}, {"status": "cancelled"}], []) == "Delvist opsagt"
+ assert agreement_status([{"status": "expired"}, {"status": "cancelled"}], []) == "Afsluttet"
diff --git a/tests/test_telefoni_call_logging.py b/tests/test_telefoni_call_logging.py
index e658113..01a848a 100644
--- a/tests/test_telefoni_call_logging.py
+++ b/tests/test_telefoni_call_logging.py
@@ -118,3 +118,16 @@ def test_stale_termination_duration_is_not_derived_from_current_time(monkeypatch
assert TelefoniService.terminate_call("stale-call", None) is True
assert "INTERVAL '12 hours'" in queries[0]
+
+
+def test_case_click_to_call_contract_tracks_case_contact_and_time():
+ schema = Path("app/modules/telefoni/backend/schemas.py").read_text(encoding="utf-8")
+ router = Path("app/modules/telefoni/backend/router.py").read_text(encoding="utf-8")
+ service = Path("app/modules/telefoni/backend/service.py").read_text(encoding="utf-8")
+
+ assert "sag_id: Optional[int]" in schema
+ assert "contact_id: Optional[int]" in schema
+ assert 'pending_callid = f"click-to-call:' in router
+ assert "_register_completed_call_time(resolved_callid)" in router
+ assert "INSERT INTO tmodule_times" in router
+ assert "callid LIKE 'click-to-call:%'" in service
diff --git a/tests/test_website_content.py b/tests/test_website_content.py
new file mode 100644
index 0000000..a803f11
--- /dev/null
+++ b/tests/test_website_content.py
@@ -0,0 +1,60 @@
+from datetime import datetime
+import json
+
+import httpx
+import pytest
+
+from app.modules.website_content.backend.service import NotFoundError, WebsiteContentAPIError, WebsiteContentService
+
+
+def service_for(handler):
+ return WebsiteContentService(
+ "https://website.test/api/admin-content.php", "secret",
+ httpx.Client(transport=httpx.MockTransport(handler)),
+ )
+
+
+def test_list_uses_authenticated_https_api():
+ def handler(request):
+ assert request.headers["x-website-admin-token"] == "secret"
+ assert request.url.params["resource"] == "customers"
+ assert request.url.params["include_hidden"] == "1"
+ return httpx.Response(200, json={"items": [{"id": 1, "customer_name": "Kunde"}]})
+
+ assert service_for(handler).list("customers") == [{"id": 1, "customer_name": "Kunde"}]
+
+
+def test_complete_operation_calls_transactional_webhook_action():
+ def handler(request):
+ assert request.method == "POST"
+ assert request.url.params["resource"] == "operations"
+ assert request.url.params["id"] == "12"
+ assert request.url.params["action"] == "complete"
+ payload = json.loads(request.content)
+ assert payload == {"ends_at": "2026-08-26T10:00:00", "is_public": True}
+ return httpx.Response(201, json={"id": 77, "title": "Fiberfejl"})
+
+ result = service_for(handler).complete_operation(12, datetime(2026, 8, 26, 10), True)
+ assert result["id"] == 77
+
+
+def test_logo_is_sent_as_multipart():
+ def handler(request):
+ assert request.url.params["action"] == "logo"
+ assert request.headers["content-type"].startswith("multipart/form-data")
+ assert b"PNG-data" in request.content
+ return httpx.Response(200, json={"id": 3, "logo_url": "/api/content.php?logo=3"})
+
+ assert service_for(handler).upload_logo(3, b"PNG-data", "image/png")["id"] == 3
+
+
+def test_404_is_mapped_to_not_found():
+ service = service_for(lambda _request: httpx.Response(404, json={"error": "not_found"}))
+ with pytest.raises(NotFoundError):
+ service.get("customers", 999)
+
+
+def test_missing_token_is_rejected_before_network_call():
+ service = WebsiteContentService("https://website.test/admin.php", "", httpx.Client())
+ with pytest.raises(WebsiteContentAPIError, match="ikke konfigureret"):
+ service.list("customers")
|