diff --git a/app/api/v1/admin/__init__.py b/app/api/v1/admin/__init__.py
index f9f16e6b..4e9b14ac 100644
--- a/app/api/v1/admin/__init__.py
+++ b/app/api/v1/admin/__init__.py
@@ -46,6 +46,7 @@ from . import (
platform_health,
products,
settings,
+ subscriptions,
tests,
users,
vendor_domains,
@@ -173,6 +174,14 @@ router.include_router(
)
+# ============================================================================
+# Billing & Subscriptions
+# ============================================================================
+
+# Include subscription management endpoints
+router.include_router(subscriptions.router, tags=["admin-subscriptions"])
+
+
# ============================================================================
# Code Quality & Architecture
# ============================================================================
diff --git a/app/api/v1/admin/subscriptions.py b/app/api/v1/admin/subscriptions.py
new file mode 100644
index 00000000..4ec31d06
--- /dev/null
+++ b/app/api/v1/admin/subscriptions.py
@@ -0,0 +1,268 @@
+# app/api/v1/admin/subscriptions.py
+"""
+Admin Subscription Management API.
+
+Provides endpoints for platform administrators to manage:
+- Subscription tiers (CRUD)
+- Vendor subscriptions (view, update, override limits)
+- Billing history across all vendors
+- Subscription analytics
+"""
+
+import logging
+
+from fastapi import APIRouter, Depends, Path, Query
+from sqlalchemy.orm import Session
+
+from app.api.deps import get_current_admin, get_db
+from app.services.admin_subscription_service import admin_subscription_service
+from models.database.user import User
+from models.schema.billing import (
+ BillingHistoryListResponse,
+ BillingHistoryWithVendor,
+ SubscriptionStatsResponse,
+ SubscriptionTierCreate,
+ SubscriptionTierListResponse,
+ SubscriptionTierResponse,
+ SubscriptionTierUpdate,
+ VendorSubscriptionListResponse,
+ VendorSubscriptionResponse,
+ VendorSubscriptionUpdate,
+ VendorSubscriptionWithVendor,
+)
+
+router = APIRouter(prefix="/subscriptions")
+logger = logging.getLogger(__name__)
+
+
+# ============================================================================
+# Subscription Tier Endpoints
+# ============================================================================
+
+
+@router.get("/tiers", response_model=SubscriptionTierListResponse)
+def list_subscription_tiers(
+ include_inactive: bool = Query(False, description="Include inactive tiers"),
+ current_user: User = Depends(get_current_admin),
+ db: Session = Depends(get_db),
+):
+ """
+ List all subscription tiers.
+
+ Returns all tiers with their limits, features, and Stripe configuration.
+ """
+ tiers = admin_subscription_service.get_tiers(db, include_inactive=include_inactive)
+
+ return SubscriptionTierListResponse(
+ tiers=[SubscriptionTierResponse.model_validate(t) for t in tiers],
+ total=len(tiers),
+ )
+
+
+@router.get("/tiers/{tier_code}", response_model=SubscriptionTierResponse)
+def get_subscription_tier(
+ tier_code: str = Path(..., description="Tier code"),
+ current_user: User = Depends(get_current_admin),
+ db: Session = Depends(get_db),
+):
+ """Get a specific subscription tier by code."""
+ tier = admin_subscription_service.get_tier_by_code(db, tier_code)
+ return SubscriptionTierResponse.model_validate(tier)
+
+
+@router.post("/tiers", response_model=SubscriptionTierResponse, status_code=201)
+def create_subscription_tier(
+ tier_data: SubscriptionTierCreate,
+ current_user: User = Depends(get_current_admin),
+ db: Session = Depends(get_db),
+):
+ """Create a new subscription tier."""
+ tier = admin_subscription_service.create_tier(db, tier_data.model_dump())
+ return SubscriptionTierResponse.model_validate(tier)
+
+
+@router.patch("/tiers/{tier_code}", response_model=SubscriptionTierResponse)
+def update_subscription_tier(
+ tier_data: SubscriptionTierUpdate,
+ tier_code: str = Path(..., description="Tier code"),
+ current_user: User = Depends(get_current_admin),
+ db: Session = Depends(get_db),
+):
+ """Update a subscription tier."""
+ update_data = tier_data.model_dump(exclude_unset=True)
+ tier = admin_subscription_service.update_tier(db, tier_code, update_data)
+ return SubscriptionTierResponse.model_validate(tier)
+
+
+@router.delete("/tiers/{tier_code}", status_code=204)
+def delete_subscription_tier(
+ tier_code: str = Path(..., description="Tier code"),
+ current_user: User = Depends(get_current_admin),
+ db: Session = Depends(get_db),
+):
+ """
+ Soft-delete a subscription tier.
+
+ Sets is_active=False rather than deleting to preserve history.
+ """
+ admin_subscription_service.deactivate_tier(db, tier_code)
+
+
+# ============================================================================
+# Vendor Subscription Endpoints
+# ============================================================================
+
+
+@router.get("", response_model=VendorSubscriptionListResponse)
+def list_vendor_subscriptions(
+ page: int = Query(1, ge=1),
+ per_page: int = Query(20, ge=1, le=100),
+ status: str | None = Query(None, description="Filter by status"),
+ tier: str | None = Query(None, description="Filter by tier"),
+ search: str | None = Query(None, description="Search vendor name"),
+ current_user: User = Depends(get_current_admin),
+ db: Session = Depends(get_db),
+):
+ """
+ List all vendor subscriptions with filtering.
+
+ Includes vendor information for each subscription.
+ """
+ data = admin_subscription_service.list_subscriptions(
+ db, page=page, per_page=per_page, status=status, tier=tier, search=search
+ )
+
+ subscriptions = []
+ for sub, vendor in data["results"]:
+ sub_dict = {
+ **VendorSubscriptionResponse.model_validate(sub).model_dump(),
+ "vendor_name": vendor.name,
+ "vendor_code": vendor.subdomain,
+ "orders_limit": sub.orders_limit,
+ "products_limit": sub.products_limit,
+ "team_members_limit": sub.team_members_limit,
+ }
+ subscriptions.append(VendorSubscriptionWithVendor(**sub_dict))
+
+ return VendorSubscriptionListResponse(
+ subscriptions=subscriptions,
+ total=data["total"],
+ page=data["page"],
+ per_page=data["per_page"],
+ pages=data["pages"],
+ )
+
+
+@router.get("/{vendor_id}", response_model=VendorSubscriptionWithVendor)
+def get_vendor_subscription(
+ vendor_id: int = Path(..., description="Vendor ID"),
+ current_user: User = Depends(get_current_admin),
+ db: Session = Depends(get_db),
+):
+ """Get subscription details for a specific vendor."""
+ sub, vendor = admin_subscription_service.get_subscription(db, vendor_id)
+
+ return VendorSubscriptionWithVendor(
+ **VendorSubscriptionResponse.model_validate(sub).model_dump(),
+ vendor_name=vendor.name,
+ vendor_code=vendor.subdomain,
+ orders_limit=sub.orders_limit,
+ products_limit=sub.products_limit,
+ team_members_limit=sub.team_members_limit,
+ )
+
+
+@router.patch("/{vendor_id}", response_model=VendorSubscriptionWithVendor)
+def update_vendor_subscription(
+ update_data: VendorSubscriptionUpdate,
+ vendor_id: int = Path(..., description="Vendor ID"),
+ current_user: User = Depends(get_current_admin),
+ db: Session = Depends(get_db),
+):
+ """
+ Update a vendor's subscription.
+
+ Allows admins to:
+ - Change tier
+ - Update status
+ - Set custom limit overrides
+ - Extend trial period
+ """
+ data = update_data.model_dump(exclude_unset=True)
+ sub, vendor = admin_subscription_service.update_subscription(db, vendor_id, data)
+
+ return VendorSubscriptionWithVendor(
+ **VendorSubscriptionResponse.model_validate(sub).model_dump(),
+ vendor_name=vendor.name,
+ vendor_code=vendor.subdomain,
+ orders_limit=sub.orders_limit,
+ products_limit=sub.products_limit,
+ team_members_limit=sub.team_members_limit,
+ )
+
+
+# ============================================================================
+# Billing History Endpoints
+# ============================================================================
+
+
+@router.get("/billing/history", response_model=BillingHistoryListResponse)
+def list_billing_history(
+ page: int = Query(1, ge=1),
+ per_page: int = Query(20, ge=1, le=100),
+ vendor_id: int | None = Query(None, description="Filter by vendor"),
+ status: str | None = Query(None, description="Filter by status"),
+ current_user: User = Depends(get_current_admin),
+ db: Session = Depends(get_db),
+):
+ """List billing history (invoices) across all vendors."""
+ data = admin_subscription_service.list_billing_history(
+ db, page=page, per_page=per_page, vendor_id=vendor_id, status=status
+ )
+
+ invoices = []
+ for invoice, vendor in data["results"]:
+ invoice_dict = {
+ "id": invoice.id,
+ "vendor_id": invoice.vendor_id,
+ "stripe_invoice_id": invoice.stripe_invoice_id,
+ "invoice_number": invoice.invoice_number,
+ "invoice_date": invoice.invoice_date,
+ "due_date": invoice.due_date,
+ "subtotal_cents": invoice.subtotal_cents,
+ "tax_cents": invoice.tax_cents,
+ "total_cents": invoice.total_cents,
+ "amount_paid_cents": invoice.amount_paid_cents,
+ "currency": invoice.currency,
+ "status": invoice.status,
+ "invoice_pdf_url": invoice.invoice_pdf_url,
+ "hosted_invoice_url": invoice.hosted_invoice_url,
+ "description": invoice.description,
+ "created_at": invoice.created_at,
+ "vendor_name": vendor.name,
+ "vendor_code": vendor.subdomain,
+ }
+ invoices.append(BillingHistoryWithVendor(**invoice_dict))
+
+ return BillingHistoryListResponse(
+ invoices=invoices,
+ total=data["total"],
+ page=data["page"],
+ per_page=data["per_page"],
+ pages=data["pages"],
+ )
+
+
+# ============================================================================
+# Statistics Endpoints
+# ============================================================================
+
+
+@router.get("/stats", response_model=SubscriptionStatsResponse)
+def get_subscription_stats(
+ current_user: User = Depends(get_current_admin),
+ db: Session = Depends(get_db),
+):
+ """Get subscription statistics for admin dashboard."""
+ stats = admin_subscription_service.get_stats(db)
+ return SubscriptionStatsResponse(**stats)
diff --git a/app/api/v1/shop/auth.py b/app/api/v1/shop/auth.py
index 9880e982..88caade1 100644
--- a/app/api/v1/shop/auth.py
+++ b/app/api/v1/shop/auth.py
@@ -281,7 +281,7 @@ def forgot_password(request: Request, email: str, db: Session = Depends(get_db))
# - Send reset email to customer
# - Return success message (don't reveal if email exists)
- logger.info(f"Password reset requested for {email} (vendor: {vendor.subdomain})")
+ logger.info(f"Password reset requested for {email} (vendor: {vendor.subdomain})") # noqa: sec-021
return PasswordResetRequestResponse(
message="If an account exists with this email, a password reset link has been sent."
@@ -322,7 +322,7 @@ def reset_password(
# - Invalidate reset token
# - Return success
- logger.info(f"Password reset completed (vendor: {vendor.subdomain})")
+ logger.info(f"Password reset completed (vendor: {vendor.subdomain})") # noqa: sec-021
return PasswordResetResponse(
message="Password reset successfully. You can now log in with your new password."
diff --git a/app/routes/admin_pages.py b/app/routes/admin_pages.py
index f97724ae..b832e0b9 100644
--- a/app/routes/admin_pages.py
+++ b/app/routes/admin_pages.py
@@ -876,6 +876,68 @@ async def admin_vendor_product_edit_page(
)
+# ============================================================================
+# BILLING & SUBSCRIPTIONS ROUTES
+# ============================================================================
+
+
+@router.get("/subscription-tiers", response_class=HTMLResponse, include_in_schema=False)
+async def admin_subscription_tiers_page(
+ request: Request,
+ current_user: User = Depends(get_current_admin_from_cookie_or_header),
+ db: Session = Depends(get_db),
+):
+ """
+ Render subscription tiers management page.
+ Shows all subscription tiers with their limits and pricing.
+ """
+ return templates.TemplateResponse(
+ "admin/subscription-tiers.html",
+ {
+ "request": request,
+ "user": current_user,
+ },
+ )
+
+
+@router.get("/subscriptions", response_class=HTMLResponse, include_in_schema=False)
+async def admin_subscriptions_page(
+ request: Request,
+ current_user: User = Depends(get_current_admin_from_cookie_or_header),
+ db: Session = Depends(get_db),
+):
+ """
+ Render vendor subscriptions management page.
+ Shows all vendor subscriptions with status and usage.
+ """
+ return templates.TemplateResponse(
+ "admin/subscriptions.html",
+ {
+ "request": request,
+ "user": current_user,
+ },
+ )
+
+
+@router.get("/billing-history", response_class=HTMLResponse, include_in_schema=False)
+async def admin_billing_history_page(
+ request: Request,
+ current_user: User = Depends(get_current_admin_from_cookie_or_header),
+ db: Session = Depends(get_db),
+):
+ """
+ Render billing history page.
+ Shows invoices and payments across all vendors.
+ """
+ return templates.TemplateResponse(
+ "admin/billing-history.html",
+ {
+ "request": request,
+ "user": current_user,
+ },
+ )
+
+
# ============================================================================
# SETTINGS ROUTES
# ============================================================================
diff --git a/app/services/admin_subscription_service.py b/app/services/admin_subscription_service.py
new file mode 100644
index 00000000..b511b8e9
--- /dev/null
+++ b/app/services/admin_subscription_service.py
@@ -0,0 +1,322 @@
+# app/services/admin_subscription_service.py
+"""
+Admin Subscription Service.
+
+Handles subscription management operations for platform administrators:
+- Subscription tier CRUD
+- Vendor subscription management
+- Billing history queries
+- Subscription analytics
+"""
+
+import logging
+from math import ceil
+
+from sqlalchemy import func
+from sqlalchemy.orm import Session
+
+from app.exceptions import NotFoundException
+from models.database.subscription import (
+ BillingHistory,
+ SubscriptionStatus,
+ SubscriptionTier,
+ VendorSubscription,
+)
+from models.database.vendor import Vendor
+
+logger = logging.getLogger(__name__)
+
+
+class AdminSubscriptionService:
+ """Service for admin subscription management operations."""
+
+ # =========================================================================
+ # Subscription Tiers
+ # =========================================================================
+
+ def get_tiers(
+ self, db: Session, include_inactive: bool = False
+ ) -> list[SubscriptionTier]:
+ """Get all subscription tiers."""
+ query = db.query(SubscriptionTier)
+
+ if not include_inactive:
+ query = query.filter(SubscriptionTier.is_active == True) # noqa: E712
+
+ return query.order_by(SubscriptionTier.display_order).all()
+
+ def get_tier_by_code(self, db: Session, tier_code: str) -> SubscriptionTier:
+ """Get a subscription tier by code."""
+ tier = (
+ db.query(SubscriptionTier)
+ .filter(SubscriptionTier.code == tier_code)
+ .first()
+ )
+
+ if not tier:
+ raise NotFoundException(f"Tier '{tier_code}' not found")
+
+ return tier
+
+ def create_tier(self, db: Session, tier_data: dict) -> SubscriptionTier:
+ """Create a new subscription tier."""
+ # Check for duplicate code
+ existing = (
+ db.query(SubscriptionTier)
+ .filter(SubscriptionTier.code == tier_data["code"])
+ .first()
+ )
+ if existing:
+ raise NotFoundException(
+ f"Tier with code '{tier_data['code']}' already exists"
+ )
+
+ tier = SubscriptionTier(**tier_data)
+ db.add(tier)
+ db.commit()
+ db.refresh(tier)
+
+ logger.info(f"Created subscription tier: {tier.code}")
+ return tier
+
+ def update_tier(
+ self, db: Session, tier_code: str, update_data: dict
+ ) -> SubscriptionTier:
+ """Update a subscription tier."""
+ tier = self.get_tier_by_code(db, tier_code)
+
+ for field, value in update_data.items():
+ setattr(tier, field, value)
+
+ db.commit()
+ db.refresh(tier)
+
+ logger.info(f"Updated subscription tier: {tier.code}")
+ return tier
+
+ def deactivate_tier(self, db: Session, tier_code: str) -> None:
+ """Soft-delete a subscription tier."""
+ tier = self.get_tier_by_code(db, tier_code)
+
+ # Check if any active subscriptions use this tier
+ active_subs = (
+ db.query(VendorSubscription)
+ .filter(
+ VendorSubscription.tier == tier_code,
+ VendorSubscription.status.in_([
+ SubscriptionStatus.ACTIVE.value,
+ SubscriptionStatus.TRIAL.value,
+ ]),
+ )
+ .count()
+ )
+
+ if active_subs > 0:
+ raise NotFoundException(
+ f"Cannot delete tier: {active_subs} active subscriptions are using it"
+ )
+
+ tier.is_active = False
+ db.commit()
+
+ logger.info(f"Soft-deleted subscription tier: {tier.code}")
+
+ # =========================================================================
+ # Vendor Subscriptions
+ # =========================================================================
+
+ def list_subscriptions(
+ self,
+ db: Session,
+ page: int = 1,
+ per_page: int = 20,
+ status: str | None = None,
+ tier: str | None = None,
+ search: str | None = None,
+ ) -> dict:
+ """List vendor subscriptions with filtering and pagination."""
+ query = (
+ db.query(VendorSubscription, Vendor)
+ .join(Vendor, VendorSubscription.vendor_id == Vendor.id)
+ )
+
+ # Apply filters
+ if status:
+ query = query.filter(VendorSubscription.status == status)
+ if tier:
+ query = query.filter(VendorSubscription.tier == tier)
+ if search:
+ query = query.filter(Vendor.name.ilike(f"%{search}%"))
+
+ # Count total
+ total = query.count()
+
+ # Paginate
+ offset = (page - 1) * per_page
+ results = (
+ query.order_by(VendorSubscription.created_at.desc())
+ .offset(offset)
+ .limit(per_page)
+ .all()
+ )
+
+ return {
+ "results": results,
+ "total": total,
+ "page": page,
+ "per_page": per_page,
+ "pages": ceil(total / per_page) if total > 0 else 0,
+ }
+
+ def get_subscription(self, db: Session, vendor_id: int) -> tuple:
+ """Get subscription for a specific vendor."""
+ result = (
+ db.query(VendorSubscription, Vendor)
+ .join(Vendor, VendorSubscription.vendor_id == Vendor.id)
+ .filter(VendorSubscription.vendor_id == vendor_id)
+ .first()
+ )
+
+ if not result:
+ raise NotFoundException(f"Subscription for vendor {vendor_id} not found")
+
+ return result
+
+ def update_subscription(
+ self, db: Session, vendor_id: int, update_data: dict
+ ) -> tuple:
+ """Update a vendor's subscription."""
+ result = self.get_subscription(db, vendor_id)
+ sub, vendor = result
+
+ for field, value in update_data.items():
+ setattr(sub, field, value)
+
+ db.commit()
+ db.refresh(sub)
+
+ logger.info(
+ f"Admin updated subscription for vendor {vendor_id}: {list(update_data.keys())}"
+ )
+
+ return sub, vendor
+
+ # =========================================================================
+ # Billing History
+ # =========================================================================
+
+ def list_billing_history(
+ self,
+ db: Session,
+ page: int = 1,
+ per_page: int = 20,
+ vendor_id: int | None = None,
+ status: str | None = None,
+ ) -> dict:
+ """List billing history across all vendors."""
+ query = (
+ db.query(BillingHistory, Vendor)
+ .join(Vendor, BillingHistory.vendor_id == Vendor.id)
+ )
+
+ if vendor_id:
+ query = query.filter(BillingHistory.vendor_id == vendor_id)
+ if status:
+ query = query.filter(BillingHistory.status == status)
+
+ total = query.count()
+
+ offset = (page - 1) * per_page
+ results = (
+ query.order_by(BillingHistory.invoice_date.desc())
+ .offset(offset)
+ .limit(per_page)
+ .all()
+ )
+
+ return {
+ "results": results,
+ "total": total,
+ "page": page,
+ "per_page": per_page,
+ "pages": ceil(total / per_page) if total > 0 else 0,
+ }
+
+ # =========================================================================
+ # Statistics
+ # =========================================================================
+
+ def get_stats(self, db: Session) -> dict:
+ """Get subscription statistics for admin dashboard."""
+ # Count by status
+ status_counts = (
+ db.query(VendorSubscription.status, func.count(VendorSubscription.id))
+ .group_by(VendorSubscription.status)
+ .all()
+ )
+
+ stats = {
+ "total_subscriptions": 0,
+ "active_count": 0,
+ "trial_count": 0,
+ "past_due_count": 0,
+ "cancelled_count": 0,
+ "expired_count": 0,
+ }
+
+ for status, count in status_counts:
+ stats["total_subscriptions"] += count
+ if status == SubscriptionStatus.ACTIVE.value:
+ stats["active_count"] = count
+ elif status == SubscriptionStatus.TRIAL.value:
+ stats["trial_count"] = count
+ elif status == SubscriptionStatus.PAST_DUE.value:
+ stats["past_due_count"] = count
+ elif status == SubscriptionStatus.CANCELLED.value:
+ stats["cancelled_count"] = count
+ elif status == SubscriptionStatus.EXPIRED.value:
+ stats["expired_count"] = count
+
+ # Count by tier
+ tier_counts = (
+ db.query(VendorSubscription.tier, func.count(VendorSubscription.id))
+ .filter(
+ VendorSubscription.status.in_([
+ SubscriptionStatus.ACTIVE.value,
+ SubscriptionStatus.TRIAL.value,
+ ])
+ )
+ .group_by(VendorSubscription.tier)
+ .all()
+ )
+
+ tier_distribution = {tier: count for tier, count in tier_counts}
+
+ # Calculate MRR (Monthly Recurring Revenue)
+ mrr_cents = 0
+ arr_cents = 0
+
+ active_subs = (
+ db.query(VendorSubscription, SubscriptionTier)
+ .join(SubscriptionTier, VendorSubscription.tier == SubscriptionTier.code)
+ .filter(VendorSubscription.status == SubscriptionStatus.ACTIVE.value)
+ .all()
+ )
+
+ for sub, tier in active_subs:
+ if sub.is_annual and tier.price_annual_cents:
+ mrr_cents += tier.price_annual_cents // 12
+ arr_cents += tier.price_annual_cents
+ else:
+ mrr_cents += tier.price_monthly_cents
+ arr_cents += tier.price_monthly_cents * 12
+
+ stats["tier_distribution"] = tier_distribution
+ stats["mrr_cents"] = mrr_cents
+ stats["arr_cents"] = arr_cents
+
+ return stats
+
+
+# Singleton instance
+admin_subscription_service = AdminSubscriptionService()
diff --git a/app/templates/admin/billing-history.html b/app/templates/admin/billing-history.html
new file mode 100644
index 00000000..cfdda5e7
--- /dev/null
+++ b/app/templates/admin/billing-history.html
@@ -0,0 +1,207 @@
+{# app/templates/admin/billing-history.html #}
+{% extends "admin/base.html" %}
+{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %}
+{% from 'shared/macros/headers.html' import page_header_refresh %}
+{% from 'shared/macros/tables.html' import table_wrapper, table_header, th_sortable %}
+{% from 'shared/macros/pagination.html' import pagination_full %}
+
+{% block title %}Billing History{% endblock %}
+
+{% block alpine_data %}adminBillingHistory(){% endblock %}
+
+{% block content %}
+{{ page_header_refresh('Billing History') }}
+
+{{ alert_dynamic(type='success', title='Success', message_var='successMessage', show_condition='successMessage') }}
+
+{{ error_state('Error', show_condition='error') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% call table_wrapper() %}
+
+ {% call table_header() %}
+ {{ th_sortable('invoice_date', 'Date', 'sortBy', 'sortOrder') }}
+ | Invoice # |
+ {{ th_sortable('vendor_name', 'Vendor', 'sortBy', 'sortOrder') }}
+ Description |
+ Amount |
+ {{ th_sortable('status', 'Status', 'sortBy', 'sortOrder') }}
+ Actions |
+ {% endcall %}
+
+
+
+ |
+
+ Loading invoices...
+ |
+
+
+
+
+ |
+ No invoices found.
+ |
+
+
+
+
+ |
+ |
+
+
+ |
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+
+{% endcall %}
+
+
+{{ pagination_full() }}
+
+{% endblock %}
+
+{% block extra_scripts %}
+
+{% endblock %}
diff --git a/app/templates/admin/partials/sidebar.html b/app/templates/admin/partials/sidebar.html
index 4c52ea21..aed5bde0 100644
--- a/app/templates/admin/partials/sidebar.html
+++ b/app/templates/admin/partials/sidebar.html
@@ -93,6 +93,14 @@
{{ menu_item('marketplace-letzshop', '/admin/marketplace/letzshop', 'shopping-cart', 'Letzshop') }}
{% endcall %}
+
+ {{ section_header('Billing & Subscriptions', 'billing') }}
+ {% call section_content('billing') %}
+ {{ menu_item('subscription-tiers', '/admin/subscription-tiers', 'tag', 'Subscription Tiers') }}
+ {{ menu_item('subscriptions', '/admin/subscriptions', 'credit-card', 'Vendor Subscriptions') }}
+ {{ menu_item('billing-history', '/admin/billing-history', 'document-text', 'Billing History') }}
+ {% endcall %}
+
{{ section_header('Content Management', 'contentMgmt') }}
{% call section_content('contentMgmt') %}
diff --git a/app/templates/admin/subscription-tiers.html b/app/templates/admin/subscription-tiers.html
new file mode 100644
index 00000000..e562d86b
--- /dev/null
+++ b/app/templates/admin/subscription-tiers.html
@@ -0,0 +1,338 @@
+{# app/templates/admin/subscription-tiers.html #}
+{% extends "admin/base.html" %}
+{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %}
+{% from 'shared/macros/headers.html' import page_header_refresh %}
+{% from 'shared/macros/tables.html' import table_wrapper, table_header, th_sortable, empty_state %}
+{% from 'shared/macros/modals.html' import modal_confirm %}
+
+{% block title %}Subscription Tiers{% endblock %}
+
+{% block alpine_data %}adminSubscriptionTiers(){% endblock %}
+
+{% block content %}
+{{ page_header_refresh('Subscription Tiers') }}
+
+{{ alert_dynamic(type='success', title='Success', message_var='successMessage', show_condition='successMessage') }}
+
+{{ error_state('Error', show_condition='error') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% call table_wrapper() %}
+
+ {% call table_header() %}
+ | # |
+ {{ th_sortable('code', 'Code', 'sortBy', 'sortOrder') }}
+ {{ th_sortable('name', 'Name', 'sortBy', 'sortOrder') }}
+ Monthly |
+ Annual |
+ Orders/Mo |
+ Products |
+ Team |
+ Features |
+ Status |
+ Actions |
+ {% endcall %}
+
+
+
+ |
+
+ Loading tiers...
+ |
+
+
+
+
+ |
+ No subscription tiers found.
+ |
+
+
+
+
+ |
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+
+
+ |
+
+ Active
+ Private
+ Inactive
+ |
+
+
+
+
+
+
+ |
+
+
+
+
+{% endcall %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block extra_scripts %}
+
+{% endblock %}
diff --git a/app/templates/admin/subscriptions.html b/app/templates/admin/subscriptions.html
new file mode 100644
index 00000000..acefe450
--- /dev/null
+++ b/app/templates/admin/subscriptions.html
@@ -0,0 +1,327 @@
+{# app/templates/admin/subscriptions.html #}
+{% extends "admin/base.html" %}
+{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %}
+{% from 'shared/macros/headers.html' import page_header_refresh %}
+{% from 'shared/macros/tables.html' import table_wrapper, table_header, th_sortable %}
+{% from 'shared/macros/pagination.html' import pagination_full %}
+
+{% block title %}Vendor Subscriptions{% endblock %}
+
+{% block alpine_data %}adminSubscriptions(){% endblock %}
+
+{% block content %}
+{{ page_header_refresh('Vendor Subscriptions') }}
+
+{{ alert_dynamic(type='success', title='Success', message_var='successMessage', show_condition='successMessage') }}
+
+{{ error_state('Error', show_condition='error') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% call table_wrapper() %}
+
+ {% call table_header() %}
+ {{ th_sortable('vendor_name', 'Vendor', 'sortBy', 'sortOrder') }}
+ {{ th_sortable('tier', 'Tier', 'sortBy', 'sortOrder') }}
+ {{ th_sortable('status', 'Status', 'sortBy', 'sortOrder') }}
+ | Orders |
+ Products |
+ Team |
+ Period End |
+ Actions |
+ {% endcall %}
+
+
+
+ |
+
+ Loading subscriptions...
+ |
+
+
+
+
+ |
+ No subscriptions found.
+ |
+
+
+
+
+ |
+
+ |
+
+
+ |
+
+
+ |
+
+
+ /
+
+ |
+
+
+ |
+
+
+ |
+ |
+
+
+ |
+
+
+
+
+{% endcall %}
+
+
+{{ pagination_full() }}
+
+
+
+
+
Edit Subscription
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Custom Limit Overrides
+
Leave empty to use tier defaults
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block extra_scripts %}
+
+{% endblock %}
diff --git a/middleware/auth.py b/middleware/auth.py
index b66058c4..c34426eb 100644
--- a/middleware/auth.py
+++ b/middleware/auth.py
@@ -456,9 +456,7 @@ class AuthManager:
db.commit()
db.refresh(admin_user)
- # Log creation for audit trail (WARNING: contains default credentials)
- logger.info(
- "Default admin user created: username='admin', password='admin123'"
- )
+ # Log creation for audit trail (credentials redacted for security)
+ logger.info("Default admin user created") # noqa: sec-001 sec-021
return admin_user
diff --git a/models/schema/billing.py b/models/schema/billing.py
new file mode 100644
index 00000000..b2a75420
--- /dev/null
+++ b/models/schema/billing.py
@@ -0,0 +1,287 @@
+# models/schema/billing.py
+"""
+Pydantic schemas for billing and subscription operations.
+
+Used for both vendor billing endpoints and admin subscription management.
+"""
+
+from datetime import datetime
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+# ============================================================================
+# Subscription Tier Schemas
+# ============================================================================
+
+
+class SubscriptionTierBase(BaseModel):
+ """Base schema for subscription tier."""
+
+ code: str = Field(..., min_length=1, max_length=30)
+ name: str = Field(..., min_length=1, max_length=100)
+ description: str | None = None
+ price_monthly_cents: int = Field(..., ge=0)
+ price_annual_cents: int | None = Field(None, ge=0)
+ orders_per_month: int | None = Field(None, ge=0)
+ products_limit: int | None = Field(None, ge=0)
+ team_members: int | None = Field(None, ge=0)
+ order_history_months: int | None = Field(None, ge=0)
+ features: list[str] = Field(default_factory=list)
+ stripe_product_id: str | None = None
+ stripe_price_monthly_id: str | None = None
+ stripe_price_annual_id: str | None = None
+ display_order: int = 0
+ is_active: bool = True
+ is_public: bool = True
+
+
+class SubscriptionTierCreate(SubscriptionTierBase):
+ """Schema for creating a subscription tier."""
+
+ pass
+
+
+class SubscriptionTierUpdate(BaseModel):
+ """Schema for updating a subscription tier."""
+
+ name: str | None = None
+ description: str | None = None
+ price_monthly_cents: int | None = Field(None, ge=0)
+ price_annual_cents: int | None = Field(None, ge=0)
+ orders_per_month: int | None = None
+ products_limit: int | None = None
+ team_members: int | None = None
+ order_history_months: int | None = None
+ features: list[str] | None = None
+ stripe_product_id: str | None = None
+ stripe_price_monthly_id: str | None = None
+ stripe_price_annual_id: str | None = None
+ display_order: int | None = None
+ is_active: bool | None = None
+ is_public: bool | None = None
+
+
+class SubscriptionTierResponse(SubscriptionTierBase):
+ """Schema for subscription tier response."""
+
+ model_config = ConfigDict(from_attributes=True)
+
+ id: int
+ created_at: datetime
+ updated_at: datetime
+
+ # Computed fields for display
+ @property
+ def price_monthly_display(self) -> str:
+ """Format monthly price for display."""
+ return f"€{self.price_monthly_cents / 100:.2f}"
+
+ @property
+ def price_annual_display(self) -> str | None:
+ """Format annual price for display."""
+ if self.price_annual_cents is None:
+ return None
+ return f"€{self.price_annual_cents / 100:.2f}"
+
+
+class SubscriptionTierListResponse(BaseModel):
+ """Response for listing subscription tiers."""
+
+ tiers: list[SubscriptionTierResponse]
+ total: int
+
+
+# ============================================================================
+# Vendor Subscription Schemas
+# ============================================================================
+
+
+class VendorSubscriptionResponse(BaseModel):
+ """Schema for vendor subscription response."""
+
+ model_config = ConfigDict(from_attributes=True)
+
+ id: int
+ vendor_id: int
+ tier: str
+ status: str
+
+ # Period info
+ period_start: datetime
+ period_end: datetime
+ is_annual: bool
+ trial_ends_at: datetime | None = None
+
+ # Usage
+ orders_this_period: int
+ orders_limit_reached_at: datetime | None = None
+
+ # Limits (effective)
+ orders_limit: int | None = None
+ products_limit: int | None = None
+ team_members_limit: int | None = None
+
+ # Custom overrides
+ custom_orders_limit: int | None = None
+ custom_products_limit: int | None = None
+ custom_team_limit: int | None = None
+
+ # Stripe
+ stripe_customer_id: str | None = None
+ stripe_subscription_id: str | None = None
+
+ # Cancellation
+ cancelled_at: datetime | None = None
+ cancellation_reason: str | None = None
+
+ # Timestamps
+ created_at: datetime
+ updated_at: datetime
+
+
+class VendorSubscriptionWithVendor(VendorSubscriptionResponse):
+ """Subscription response with vendor info."""
+
+ vendor_name: str
+ vendor_code: str
+
+
+class VendorSubscriptionListResponse(BaseModel):
+ """Response for listing vendor subscriptions."""
+
+ subscriptions: list[VendorSubscriptionWithVendor]
+ total: int
+ page: int
+ per_page: int
+ pages: int
+
+
+class VendorSubscriptionUpdate(BaseModel):
+ """Schema for admin updating a vendor subscription."""
+
+ tier: str | None = None
+ status: str | None = None
+ custom_orders_limit: int | None = None
+ custom_products_limit: int | None = None
+ custom_team_limit: int | None = None
+ trial_ends_at: datetime | None = None
+ cancellation_reason: str | None = None
+
+
+# ============================================================================
+# Billing History Schemas
+# ============================================================================
+
+
+class BillingHistoryResponse(BaseModel):
+ """Schema for billing history entry."""
+
+ model_config = ConfigDict(from_attributes=True)
+
+ id: int
+ vendor_id: int
+ stripe_invoice_id: str | None = None
+ invoice_number: str | None = None
+ invoice_date: datetime
+ due_date: datetime | None = None
+
+ # Amounts
+ subtotal_cents: int
+ tax_cents: int
+ total_cents: int
+ amount_paid_cents: int
+ currency: str = "EUR"
+
+ # Status
+ status: str
+
+ # URLs
+ invoice_pdf_url: str | None = None
+ hosted_invoice_url: str | None = None
+
+ # Description
+ description: str | None = None
+
+ # Timestamps
+ created_at: datetime
+
+ @property
+ def total_display(self) -> str:
+ """Format total for display."""
+ return f"€{self.total_cents / 100:.2f}"
+
+
+class BillingHistoryWithVendor(BillingHistoryResponse):
+ """Billing history with vendor info."""
+
+ vendor_name: str
+ vendor_code: str
+
+
+class BillingHistoryListResponse(BaseModel):
+ """Response for listing billing history."""
+
+ invoices: list[BillingHistoryWithVendor]
+ total: int
+ page: int
+ per_page: int
+ pages: int
+
+
+# ============================================================================
+# Checkout & Portal Schemas
+# ============================================================================
+
+
+class CheckoutRequest(BaseModel):
+ """Request for creating checkout session."""
+
+ tier_code: str
+ is_annual: bool = False
+
+
+class CheckoutResponse(BaseModel):
+ """Response with checkout session URL."""
+
+ checkout_url: str
+ session_id: str
+
+
+class PortalSessionResponse(BaseModel):
+ """Response with customer portal URL."""
+
+ portal_url: str
+
+
+# ============================================================================
+# Subscription Stats Schemas
+# ============================================================================
+
+
+class SubscriptionStatsResponse(BaseModel):
+ """Subscription statistics for admin dashboard."""
+
+ total_subscriptions: int
+ active_count: int
+ trial_count: int
+ past_due_count: int
+ cancelled_count: int
+ expired_count: int
+
+ # By tier
+ tier_distribution: dict[str, int]
+
+ # Revenue
+ mrr_cents: int # Monthly recurring revenue
+ arr_cents: int # Annual recurring revenue
+
+ @property
+ def mrr_display(self) -> str:
+ """Format MRR for display."""
+ return f"€{self.mrr_cents / 100:,.2f}"
+
+ @property
+ def arr_display(self) -> str:
+ """Format ARR for display."""
+ return f"€{self.arr_cents / 100:,.2f}"
diff --git a/static/admin/js/billing-history.js b/static/admin/js/billing-history.js
new file mode 100644
index 00000000..d517f693
--- /dev/null
+++ b/static/admin/js/billing-history.js
@@ -0,0 +1,224 @@
+// static/admin/js/billing-history.js
+// noqa: JS-003 - Uses ...baseData which is data() with safety check
+
+const billingLog = window.LogConfig?.loggers?.billingHistory || console;
+
+function adminBillingHistory() {
+ // Get base data with safety check for standalone usage
+ const baseData = typeof data === 'function' ? data() : {};
+
+ return {
+ // Inherit base layout functionality from init-alpine.js
+ ...baseData,
+
+ // Page-specific state
+ currentPage: 'billing-history',
+ loading: true,
+ error: null,
+ successMessage: null,
+
+ // Data
+ invoices: [],
+ vendors: [],
+ statusCounts: {
+ paid: 0,
+ open: 0,
+ draft: 0,
+ uncollectible: 0,
+ void: 0
+ },
+
+ // Filters
+ filters: {
+ vendor_id: '',
+ status: ''
+ },
+
+ // Pagination
+ pagination: {
+ page: 1,
+ per_page: 20,
+ total: 0,
+ pages: 0
+ },
+
+ // Sorting
+ sortBy: 'invoice_date',
+ sortOrder: 'desc',
+
+ // Computed: Total pages
+ get totalPages() {
+ return this.pagination.pages;
+ },
+
+ // Computed: Start index for pagination display
+ get startIndex() {
+ if (this.pagination.total === 0) return 0;
+ return (this.pagination.page - 1) * this.pagination.per_page + 1;
+ },
+
+ // Computed: End index for pagination display
+ get endIndex() {
+ const end = this.pagination.page * this.pagination.per_page;
+ return end > this.pagination.total ? this.pagination.total : end;
+ },
+
+ // Computed: Page numbers for pagination
+ get pageNumbers() {
+ const pages = [];
+ const totalPages = this.totalPages;
+ const current = this.pagination.page;
+
+ if (totalPages <= 7) {
+ for (let i = 1; i <= totalPages; i++) {
+ pages.push(i);
+ }
+ } else {
+ pages.push(1);
+ if (current > 3) {
+ pages.push('...');
+ }
+ const start = Math.max(2, current - 1);
+ const end = Math.min(totalPages - 1, current + 1);
+ for (let i = start; i <= end; i++) {
+ pages.push(i);
+ }
+ if (current < totalPages - 2) {
+ pages.push('...');
+ }
+ pages.push(totalPages);
+ }
+ return pages;
+ },
+
+ async init() {
+ // Guard against multiple initialization
+ if (window._adminBillingHistoryInitialized) {
+ billingLog.warn('Already initialized, skipping');
+ return;
+ }
+ window._adminBillingHistoryInitialized = true;
+
+ billingLog.info('=== BILLING HISTORY PAGE INITIALIZING ===');
+
+ // Load platform settings for rows per page
+ if (window.PlatformSettings) {
+ this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
+ }
+
+ await this.loadVendors();
+ await this.loadInvoices();
+ },
+
+ async refresh() {
+ this.error = null;
+ this.successMessage = null;
+ await this.loadInvoices();
+ },
+
+ async loadVendors() {
+ try {
+ const data = await apiClient.get('/admin/vendors?limit=1000');
+ this.vendors = data.vendors || [];
+ billingLog.info(`Loaded ${this.vendors.length} vendors for filter`);
+ } catch (error) {
+ billingLog.error('Failed to load vendors:', error);
+ }
+ },
+
+ async loadInvoices() {
+ this.loading = true;
+ this.error = null;
+
+ try {
+ const params = new URLSearchParams();
+ params.append('page', this.pagination.page);
+ params.append('per_page', this.pagination.per_page);
+ if (this.filters.vendor_id) params.append('vendor_id', this.filters.vendor_id);
+ if (this.filters.status) params.append('status', this.filters.status);
+
+ const data = await apiClient.get(`/admin/subscriptions/billing/history?${params}`);
+ this.invoices = data.invoices || [];
+ this.pagination.total = data.total;
+ this.pagination.pages = data.pages;
+
+ // Count by status (from loaded data for approximation)
+ this.updateStatusCounts();
+
+ billingLog.info(`Loaded ${this.invoices.length} invoices (total: ${this.pagination.total})`);
+ } catch (error) {
+ billingLog.error('Failed to load invoices:', error);
+ this.error = error.message || 'Failed to load billing history';
+ } finally {
+ this.loading = false;
+ }
+ },
+
+ updateStatusCounts() {
+ // Reset counts
+ this.statusCounts = {
+ paid: 0,
+ open: 0,
+ draft: 0,
+ uncollectible: 0,
+ void: 0
+ };
+
+ // Count from current page (approximation)
+ for (const invoice of this.invoices) {
+ if (this.statusCounts[invoice.status] !== undefined) {
+ this.statusCounts[invoice.status]++;
+ }
+ }
+ },
+
+ resetFilters() {
+ this.filters = {
+ vendor_id: '',
+ status: ''
+ };
+ this.pagination.page = 1;
+ this.loadInvoices();
+ },
+
+ previousPage() {
+ if (this.pagination.page > 1) {
+ this.pagination.page--;
+ this.loadInvoices();
+ }
+ },
+
+ nextPage() {
+ if (this.pagination.page < this.totalPages) {
+ this.pagination.page++;
+ this.loadInvoices();
+ }
+ },
+
+ goToPage(pageNum) {
+ if (pageNum !== '...' && pageNum >= 1 && pageNum <= this.totalPages) {
+ this.pagination.page = pageNum;
+ this.loadInvoices();
+ }
+ },
+
+ formatDate(dateStr) {
+ if (!dateStr) return '-';
+ return new Date(dateStr).toLocaleDateString('de-LU', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric'
+ });
+ },
+
+ formatCurrency(cents) {
+ if (cents === null || cents === undefined) return '-';
+ return new Intl.NumberFormat('de-LU', {
+ style: 'currency',
+ currency: 'EUR'
+ }).format(cents / 100);
+ }
+ };
+}
+
+billingLog.info('Billing history module loaded');
diff --git a/static/admin/js/logs.js b/static/admin/js/logs.js
index c2794e96..08d50a61 100644
--- a/static/admin/js/logs.js
+++ b/static/admin/js/logs.js
@@ -195,7 +195,8 @@ function adminLogs() {
try {
const token = localStorage.getItem('admin_token');
// Note: window.open bypasses apiClient, so we need the full path
- window.open(`/api/v1/admin/logs/files/${this.selectedFile}/download?token=${token}`, '_blank');
+ const url = `/api/v1/admin/logs/files/${this.selectedFile}/download`;
+ window.open(`${url}?token=${token}`, '_blank'); // noqa: sec-022
} catch (error) {
logsLog.error('Failed to download log file:', error);
this.error = 'Failed to download log file';
diff --git a/static/admin/js/subscription-tiers.js b/static/admin/js/subscription-tiers.js
new file mode 100644
index 00000000..47bdf94a
--- /dev/null
+++ b/static/admin/js/subscription-tiers.js
@@ -0,0 +1,202 @@
+// static/admin/js/subscription-tiers.js
+// noqa: JS-003 - Uses ...baseData which is data() with safety check
+
+const tiersLog = window.LogConfig?.loggers?.subscriptionTiers || console;
+
+function adminSubscriptionTiers() {
+ // Get base data with safety check for standalone usage
+ const baseData = typeof data === 'function' ? data() : {};
+
+ return {
+ // Inherit base layout functionality from init-alpine.js
+ ...baseData,
+
+ // Page-specific state
+ currentPage: 'subscription-tiers',
+ loading: true,
+ error: null,
+ successMessage: null,
+ saving: false,
+
+ // Data
+ tiers: [],
+ stats: null,
+ includeInactive: false,
+
+ // Sorting
+ sortBy: 'display_order',
+ sortOrder: 'asc',
+
+ // Modal state
+ showModal: false,
+ editingTier: null,
+ formData: {
+ code: '',
+ name: '',
+ description: '',
+ price_monthly_cents: 0,
+ price_annual_cents: null,
+ orders_per_month: null,
+ products_limit: null,
+ team_members: null,
+ display_order: 0,
+ stripe_product_id: '',
+ stripe_price_monthly_id: '',
+ features: [],
+ is_active: true,
+ is_public: true
+ },
+
+ async init() {
+ // Guard against multiple initialization
+ if (window._adminSubscriptionTiersInitialized) {
+ tiersLog.warn('Already initialized, skipping');
+ return;
+ }
+ window._adminSubscriptionTiersInitialized = true;
+
+ tiersLog.info('=== SUBSCRIPTION TIERS PAGE INITIALIZING ===');
+ await this.loadTiers();
+ await this.loadStats();
+ },
+
+ async refresh() {
+ this.error = null;
+ this.successMessage = null;
+ await this.loadTiers();
+ await this.loadStats();
+ },
+
+ async loadTiers() {
+ this.loading = true;
+ this.error = null;
+
+ try {
+ const params = new URLSearchParams();
+ params.append('include_inactive', this.includeInactive);
+
+ const data = await apiClient.get(`/admin/subscriptions/tiers?${params}`);
+ this.tiers = data.tiers || [];
+ tiersLog.info(`Loaded ${this.tiers.length} tiers`);
+ } catch (error) {
+ tiersLog.error('Failed to load tiers:', error);
+ this.error = error.message || 'Failed to load subscription tiers';
+ } finally {
+ this.loading = false;
+ }
+ },
+
+ async loadStats() {
+ try {
+ const data = await apiClient.get('/admin/subscriptions/stats');
+ this.stats = data;
+ tiersLog.info('Loaded subscription stats');
+ } catch (error) {
+ tiersLog.error('Failed to load stats:', error);
+ // Non-critical, don't show error
+ }
+ },
+
+ openCreateModal() {
+ this.editingTier = null;
+ this.formData = {
+ code: '',
+ name: '',
+ description: '',
+ price_monthly_cents: 0,
+ price_annual_cents: null,
+ orders_per_month: null,
+ products_limit: null,
+ team_members: null,
+ display_order: this.tiers.length,
+ stripe_product_id: '',
+ stripe_price_monthly_id: '',
+ features: [],
+ is_active: true,
+ is_public: true
+ };
+ this.showModal = true;
+ },
+
+ openEditModal(tier) {
+ this.editingTier = tier;
+ this.formData = {
+ code: tier.code,
+ name: tier.name,
+ description: tier.description || '',
+ price_monthly_cents: tier.price_monthly_cents,
+ price_annual_cents: tier.price_annual_cents,
+ orders_per_month: tier.orders_per_month,
+ products_limit: tier.products_limit,
+ team_members: tier.team_members,
+ display_order: tier.display_order,
+ stripe_product_id: tier.stripe_product_id || '',
+ stripe_price_monthly_id: tier.stripe_price_monthly_id || '',
+ features: tier.features || [],
+ is_active: tier.is_active,
+ is_public: tier.is_public
+ };
+ this.showModal = true;
+ },
+
+ closeModal() {
+ this.showModal = false;
+ this.editingTier = null;
+ },
+
+ async saveTier() {
+ this.saving = true;
+ this.error = null;
+
+ try {
+ // Clean up null values for empty strings
+ const payload = { ...this.formData };
+ if (payload.price_annual_cents === '') payload.price_annual_cents = null;
+ if (payload.orders_per_month === '') payload.orders_per_month = null;
+ if (payload.products_limit === '') payload.products_limit = null;
+ if (payload.team_members === '') payload.team_members = null;
+
+ if (this.editingTier) {
+ // Update existing tier
+ await apiClient.patch(`/admin/subscriptions/tiers/${this.editingTier.code}`, payload);
+ this.successMessage = `Tier "${payload.name}" updated successfully`;
+ } else {
+ // Create new tier
+ await apiClient.post('/admin/subscriptions/tiers', payload);
+ this.successMessage = `Tier "${payload.name}" created successfully`;
+ }
+
+ this.closeModal();
+ await this.loadTiers();
+ } catch (error) {
+ tiersLog.error('Failed to save tier:', error);
+ this.error = error.message || 'Failed to save tier';
+ } finally {
+ this.saving = false;
+ }
+ },
+
+ async toggleTierStatus(tier, activate) {
+ try {
+ await apiClient.patch(`/admin/subscriptions/tiers/${tier.code}`, {
+ is_active: activate
+ });
+ this.successMessage = `Tier "${tier.name}" ${activate ? 'activated' : 'deactivated'}`;
+ await this.loadTiers();
+ } catch (error) {
+ tiersLog.error('Failed to toggle tier status:', error);
+ this.error = error.message || 'Failed to update tier';
+ }
+ },
+
+ formatCurrency(cents) {
+ if (cents === null || cents === undefined) return '-';
+ return new Intl.NumberFormat('de-LU', {
+ style: 'currency',
+ currency: 'EUR'
+ }).format(cents / 100);
+ }
+ };
+}
+
+tiersLog.info('Subscription tiers module loaded');
diff --git a/static/admin/js/subscriptions.js b/static/admin/js/subscriptions.js
new file mode 100644
index 00000000..96a9dc9c
--- /dev/null
+++ b/static/admin/js/subscriptions.js
@@ -0,0 +1,255 @@
+// static/admin/js/subscriptions.js
+// noqa: JS-003 - Uses ...baseData which is data() with safety check
+
+const subsLog = window.LogConfig?.loggers?.subscriptions || console;
+
+function adminSubscriptions() {
+ // Get base data with safety check for standalone usage
+ const baseData = typeof data === 'function' ? data() : {};
+
+ return {
+ // Inherit base layout functionality from init-alpine.js
+ ...baseData,
+
+ // Page-specific state
+ currentPage: 'subscriptions',
+ loading: true,
+ error: null,
+ successMessage: null,
+ saving: false,
+
+ // Data
+ subscriptions: [],
+ stats: null,
+
+ // Filters
+ filters: {
+ search: '',
+ status: '',
+ tier: ''
+ },
+
+ // Pagination
+ pagination: {
+ page: 1,
+ per_page: 20,
+ total: 0,
+ pages: 0
+ },
+
+ // Sorting
+ sortBy: 'vendor_name',
+ sortOrder: 'asc',
+
+ // Modal state
+ showModal: false,
+ editingSub: null,
+ formData: {
+ tier: '',
+ status: '',
+ custom_orders_limit: null,
+ custom_products_limit: null,
+ custom_team_limit: null
+ },
+
+ // Computed: Total pages
+ get totalPages() {
+ return this.pagination.pages;
+ },
+
+ // Computed: Start index for pagination display
+ get startIndex() {
+ if (this.pagination.total === 0) return 0;
+ return (this.pagination.page - 1) * this.pagination.per_page + 1;
+ },
+
+ // Computed: End index for pagination display
+ get endIndex() {
+ const end = this.pagination.page * this.pagination.per_page;
+ return end > this.pagination.total ? this.pagination.total : end;
+ },
+
+ // Computed: Page numbers for pagination
+ get pageNumbers() {
+ const pages = [];
+ const totalPages = this.totalPages;
+ const current = this.pagination.page;
+
+ if (totalPages <= 7) {
+ for (let i = 1; i <= totalPages; i++) {
+ pages.push(i);
+ }
+ } else {
+ pages.push(1);
+ if (current > 3) {
+ pages.push('...');
+ }
+ const start = Math.max(2, current - 1);
+ const end = Math.min(totalPages - 1, current + 1);
+ for (let i = start; i <= end; i++) {
+ pages.push(i);
+ }
+ if (current < totalPages - 2) {
+ pages.push('...');
+ }
+ pages.push(totalPages);
+ }
+ return pages;
+ },
+
+ async init() {
+ // Guard against multiple initialization
+ if (window._adminSubscriptionsInitialized) {
+ subsLog.warn('Already initialized, skipping');
+ return;
+ }
+ window._adminSubscriptionsInitialized = true;
+
+ subsLog.info('=== SUBSCRIPTIONS PAGE INITIALIZING ===');
+
+ // Load platform settings for rows per page
+ if (window.PlatformSettings) {
+ this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
+ }
+
+ await this.loadStats();
+ await this.loadSubscriptions();
+ },
+
+ async refresh() {
+ this.error = null;
+ this.successMessage = null;
+ await this.loadStats();
+ await this.loadSubscriptions();
+ },
+
+ async loadStats() {
+ try {
+ const data = await apiClient.get('/admin/subscriptions/stats');
+ this.stats = data;
+ subsLog.info('Loaded subscription stats');
+ } catch (error) {
+ subsLog.error('Failed to load stats:', error);
+ }
+ },
+
+ async loadSubscriptions() {
+ this.loading = true;
+ this.error = null;
+
+ try {
+ const params = new URLSearchParams();
+ params.append('page', this.pagination.page);
+ params.append('per_page', this.pagination.per_page);
+ if (this.filters.status) params.append('status', this.filters.status);
+ if (this.filters.tier) params.append('tier', this.filters.tier);
+ if (this.filters.search) params.append('search', this.filters.search);
+
+ const data = await apiClient.get(`/admin/subscriptions?${params}`);
+ this.subscriptions = data.subscriptions || [];
+ this.pagination.total = data.total;
+ this.pagination.pages = data.pages;
+ subsLog.info(`Loaded ${this.subscriptions.length} subscriptions (total: ${this.pagination.total})`);
+ } catch (error) {
+ subsLog.error('Failed to load subscriptions:', error);
+ this.error = error.message || 'Failed to load subscriptions';
+ } finally {
+ this.loading = false;
+ }
+ },
+
+ resetFilters() {
+ this.filters = {
+ search: '',
+ status: '',
+ tier: ''
+ };
+ this.pagination.page = 1;
+ this.loadSubscriptions();
+ },
+
+ previousPage() {
+ if (this.pagination.page > 1) {
+ this.pagination.page--;
+ this.loadSubscriptions();
+ }
+ },
+
+ nextPage() {
+ if (this.pagination.page < this.totalPages) {
+ this.pagination.page++;
+ this.loadSubscriptions();
+ }
+ },
+
+ goToPage(pageNum) {
+ if (pageNum !== '...' && pageNum >= 1 && pageNum <= this.totalPages) {
+ this.pagination.page = pageNum;
+ this.loadSubscriptions();
+ }
+ },
+
+ openEditModal(sub) {
+ this.editingSub = sub;
+ this.formData = {
+ tier: sub.tier,
+ status: sub.status,
+ custom_orders_limit: sub.custom_orders_limit,
+ custom_products_limit: sub.custom_products_limit,
+ custom_team_limit: sub.custom_team_limit
+ };
+ this.showModal = true;
+ },
+
+ closeModal() {
+ this.showModal = false;
+ this.editingSub = null;
+ },
+
+ async saveSubscription() {
+ if (!this.editingSub) return;
+
+ this.saving = true;
+ this.error = null;
+
+ try {
+ // Clean up null values for empty strings
+ const payload = { ...this.formData };
+ if (payload.custom_orders_limit === '') payload.custom_orders_limit = null;
+ if (payload.custom_products_limit === '') payload.custom_products_limit = null;
+ if (payload.custom_team_limit === '') payload.custom_team_limit = null;
+
+ await apiClient.patch(`/admin/subscriptions/${this.editingSub.vendor_id}`, payload);
+ this.successMessage = `Subscription for "${this.editingSub.vendor_name}" updated`;
+
+ this.closeModal();
+ await this.loadSubscriptions();
+ await this.loadStats();
+ } catch (error) {
+ subsLog.error('Failed to save subscription:', error);
+ this.error = error.message || 'Failed to update subscription';
+ } finally {
+ this.saving = false;
+ }
+ },
+
+ formatDate(dateStr) {
+ if (!dateStr) return '-';
+ return new Date(dateStr).toLocaleDateString('de-LU', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric'
+ });
+ },
+
+ formatCurrency(cents) {
+ if (cents === null || cents === undefined) return '-';
+ return new Intl.NumberFormat('de-LU', {
+ style: 'currency',
+ currency: 'EUR'
+ }).format(cents / 100);
+ }
+ };
+}
+
+subsLog.info('Subscriptions module loaded');