From 1db7e8a087603fb15ed4ba4bd49af8560a9bf5e4 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Sat, 7 Feb 2026 15:18:16 +0100 Subject: [PATCH] feat(billing): migrate frontend templates to feature provider system Replace hardcoded subscription fields (orders_limit, products_limit, team_members_limit) across 5 frontend pages with dynamic feature provider APIs. Add admin convenience endpoint for store subscription lookup. Remove legacy stubs (StoreSubscription, FeatureCode, Feature, TIER_LIMITS, FeatureInfo, FeatureUpgradeInfo) and schema aliases. Pages updated: - Admin subscriptions: dynamic feature overrides editor - Admin tiers: correct feature catalog/limits API URLs - Store billing: usage metrics from /store/billing/usage - Merchant subscription detail: tier.feature_limits rendering - Admin store detail: new GET /admin/subscriptions/store/{id} endpoint Co-Authored-By: Claude Opus 4.6 --- app/modules/billing/models/__init__.py | 52 +- app/modules/billing/routes/api/admin.py | 397 ++++----- app/modules/billing/schemas/__init__.py | 88 +- app/modules/billing/schemas/subscription.py | 176 ++-- app/modules/billing/services/__init__.py | 6 - .../billing/services/feature_service.py | 762 +++++++----------- .../static/admin/js/subscription-tiers.js | 85 +- .../billing/static/admin/js/subscriptions.js | 110 ++- .../billing/static/store/js/billing.js | 217 +++++ .../billing/admin/subscription-tiers.html | 85 +- .../billing/admin/subscriptions.html | 89 +- .../billing/merchant/subscription-detail.html | 215 +++++ .../templates/billing/store/billing.html | 406 ++++++++++ .../tenancy/static/admin/js/store-detail.js | 232 ++++++ .../templates/tenancy/admin/store-detail.html | 388 +++++++++ tests/unit/services/test_billing_service.py | 126 +-- tests/unit/services/test_feature_service.py | 93 ++- .../services/test_stripe_webhook_handler.py | 36 +- tests/unit/services/test_usage_service.py | 150 ++-- 19 files changed, 2508 insertions(+), 1205 deletions(-) create mode 100644 app/modules/billing/static/store/js/billing.js create mode 100644 app/modules/billing/templates/billing/merchant/subscription-detail.html create mode 100644 app/modules/billing/templates/billing/store/billing.html create mode 100644 app/modules/tenancy/static/admin/js/store-detail.js create mode 100644 app/modules/tenancy/templates/tenancy/admin/store-detail.html diff --git a/app/modules/billing/models/__init__.py b/app/modules/billing/models/__init__.py index 0676f682..f727ccdb 100644 --- a/app/modules/billing/models/__init__.py +++ b/app/modules/billing/models/__init__.py @@ -7,40 +7,31 @@ discovered and registered with SQLAlchemy's Base.metadata at startup. Usage: from app.modules.billing.models import ( - VendorSubscription, + MerchantSubscription, SubscriptionTier, SubscriptionStatus, TierCode, - Feature, - FeatureCode, + TierFeatureLimit, + MerchantFeatureOverride, ) """ +from app.modules.billing.models.merchant_subscription import MerchantSubscription from app.modules.billing.models.subscription import ( - # Enums - TierCode, - SubscriptionStatus, AddOnCategory, - BillingPeriod, - # Models - SubscriptionTier, AddOnProduct, - VendorAddOn, - StripeWebhookEvent, BillingHistory, - VendorSubscription, + BillingPeriod, CapacitySnapshot, - # Legacy constants - TIER_LIMITS, + StoreAddOn, + StripeWebhookEvent, + SubscriptionStatus, + SubscriptionTier, + TierCode, ) -from app.modules.billing.models.feature import ( - # Enums - FeatureCategory, - FeatureUILocation, - # Model - Feature, - # Constants - FeatureCode, +from app.modules.billing.models.tier_feature_limit import ( + MerchantFeatureOverride, + TierFeatureLimit, ) __all__ = [ @@ -52,18 +43,13 @@ __all__ = [ # Subscription Models "SubscriptionTier", "AddOnProduct", - "VendorAddOn", + "StoreAddOn", "StripeWebhookEvent", "BillingHistory", - "VendorSubscription", "CapacitySnapshot", - # Legacy constants - "TIER_LIMITS", - # Feature Enums - "FeatureCategory", - "FeatureUILocation", - # Feature Model - "Feature", - # Feature Constants - "FeatureCode", + # Merchant Subscription + "MerchantSubscription", + # Feature Limits + "TierFeatureLimit", + "MerchantFeatureOverride", ] diff --git a/app/modules/billing/routes/api/admin.py b/app/modules/billing/routes/api/admin.py index 3df4420e..19f8a5bf 100644 --- a/app/modules/billing/routes/api/admin.py +++ b/app/modules/billing/routes/api/admin.py @@ -1,39 +1,38 @@ -# app/modules/billing/routes/admin.py +# app/modules/billing/routes/api/admin.py """ Billing module admin routes. -This module wraps the existing admin subscription routes and adds -module-based access control. The actual route implementations remain -in app/api/v1/admin/subscriptions.py for now, but are accessed through -this module-aware router. - -Future: Move all route implementations here for full module isolation. +Provides admin API endpoints for subscription and billing management: +- Subscription tier CRUD +- Merchant subscription listing and management +- Billing history +- Subscription statistics """ import logging -from fastapi import APIRouter, Depends, Path, Query +from fastapi import APIRouter, Depends, HTTPException, Path, Query from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api, require_module_access from app.core.database import get_db from app.modules.billing.services import admin_subscription_service, subscription_service from app.modules.enums import FrontendType -from app.modules.tenancy.models import User from app.modules.billing.schemas import ( BillingHistoryListResponse, - BillingHistoryWithVendor, + BillingHistoryWithMerchant, + MerchantSubscriptionAdminCreate, + MerchantSubscriptionAdminResponse, + MerchantSubscriptionAdminUpdate, + MerchantSubscriptionListResponse, + MerchantSubscriptionWithMerchant, SubscriptionStatsResponse, SubscriptionTierCreate, SubscriptionTierListResponse, SubscriptionTierResponse, SubscriptionTierUpdate, - VendorSubscriptionCreate, - VendorSubscriptionListResponse, - VendorSubscriptionResponse, - VendorSubscriptionUpdate, - VendorSubscriptionWithVendor, ) +from models.schema.auth import UserContext logger = logging.getLogger(__name__) @@ -52,14 +51,10 @@ admin_router = APIRouter( @admin_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_api), + current_user: UserContext = Depends(get_current_admin_api), db: Session = Depends(get_db), ): - """ - List all subscription tiers. - - Returns all tiers with their limits, features, and Stripe configuration. - """ + """List all subscription tiers.""" tiers = admin_subscription_service.get_tiers(db, include_inactive=include_inactive) return SubscriptionTierListResponse( @@ -71,7 +66,7 @@ def list_subscription_tiers( @admin_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_api), + current_user: UserContext = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """Get a specific subscription tier by code.""" @@ -82,7 +77,7 @@ def get_subscription_tier( @admin_router.post("/tiers", response_model=SubscriptionTierResponse, status_code=201) def create_subscription_tier( tier_data: SubscriptionTierCreate, - current_user: User = Depends(get_current_admin_api), + current_user: UserContext = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """Create a new subscription tier.""" @@ -96,7 +91,7 @@ def create_subscription_tier( def update_subscription_tier( tier_data: SubscriptionTierUpdate, tier_code: str = Path(..., description="Tier code"), - current_user: User = Depends(get_current_admin_api), + current_user: UserContext = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """Update a subscription tier.""" @@ -110,52 +105,48 @@ def update_subscription_tier( @admin_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_api), + current_user: UserContext = Depends(get_current_admin_api), db: Session = Depends(get_db), ): - """ - Soft-delete a subscription tier. - - Sets is_active=False rather than deleting to preserve history. - """ + """Soft-delete a subscription tier.""" admin_subscription_service.deactivate_tier(db, tier_code) db.commit() # ============================================================================ -# Vendor Subscription Endpoints +# Merchant Subscription Endpoints # ============================================================================ -@admin_router.get("", response_model=VendorSubscriptionListResponse) -def list_vendor_subscriptions( +@admin_router.get("", response_model=MerchantSubscriptionListResponse) +def list_merchant_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_api), + tier: str | None = Query(None, description="Filter by tier code"), + search: str | None = Query(None, description="Search merchant name"), + current_user: UserContext = Depends(get_current_admin_api), db: Session = Depends(get_db), ): - """ - List all vendor subscriptions with filtering. - - Includes vendor information for each subscription. - """ + """List all merchant subscriptions with filtering.""" 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, - } - subscriptions.append(VendorSubscriptionWithVendor(**sub_dict)) + for sub, merchant in data["results"]: + sub_resp = MerchantSubscriptionAdminResponse.model_validate(sub) + tier_name = sub.tier.name if sub.tier else None + subscriptions.append( + MerchantSubscriptionWithMerchant( + **sub_resp.model_dump(), + merchant_name=merchant.name, + platform_name="", # Platform name can be resolved if needed + tier_name=tier_name, + ) + ) - return VendorSubscriptionListResponse( + return MerchantSubscriptionListResponse( subscriptions=subscriptions, total=data["total"], page=data["page"], @@ -164,6 +155,154 @@ def list_vendor_subscriptions( ) +@admin_router.post( + "/merchants/{merchant_id}/platforms/{platform_id}", + response_model=MerchantSubscriptionAdminResponse, + status_code=201, +) +def create_merchant_subscription( + create_data: MerchantSubscriptionAdminCreate, + merchant_id: int = Path(..., description="Merchant ID"), + platform_id: int = Path(..., description="Platform ID"), + current_user: UserContext = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Create a subscription for a merchant on a platform.""" + sub = subscription_service.get_or_create_subscription( + db, + merchant_id=merchant_id, + platform_id=platform_id, + tier_code=create_data.tier_code, + trial_days=create_data.trial_days, + ) + + # Update status if not trial + if create_data.status != "trial": + sub.status = create_data.status + + sub.is_annual = create_data.is_annual + + db.commit() + db.refresh(sub) + + logger.info( + f"Admin created subscription for merchant {merchant_id} " + f"on platform {platform_id}: tier={create_data.tier_code}" + ) + + return MerchantSubscriptionAdminResponse.model_validate(sub) + + +@admin_router.get( + "/merchants/{merchant_id}/platforms/{platform_id}", + response_model=MerchantSubscriptionAdminResponse, +) +def get_merchant_subscription( + merchant_id: int = Path(..., description="Merchant ID"), + platform_id: int = Path(..., description="Platform ID"), + current_user: UserContext = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Get subscription details for a specific merchant on a platform.""" + sub, merchant = admin_subscription_service.get_subscription( + db, merchant_id, platform_id + ) + return MerchantSubscriptionAdminResponse.model_validate(sub) + + +@admin_router.patch( + "/merchants/{merchant_id}/platforms/{platform_id}", + response_model=MerchantSubscriptionAdminResponse, +) +def update_merchant_subscription( + update_data: MerchantSubscriptionAdminUpdate, + merchant_id: int = Path(..., description="Merchant ID"), + platform_id: int = Path(..., description="Platform ID"), + current_user: UserContext = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Update a merchant's subscription.""" + data = update_data.model_dump(exclude_unset=True) + sub, merchant = admin_subscription_service.update_subscription( + db, merchant_id, platform_id, data + ) + db.commit() + db.refresh(sub) + + return MerchantSubscriptionAdminResponse.model_validate(sub) + + +# ============================================================================ +# Store Convenience Endpoint +# ============================================================================ + + +@admin_router.get("/store/{store_id}") +def get_subscription_for_store( + store_id: int = Path(..., description="Store ID"), + current_user: UserContext = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """ + Get subscription + feature usage for a store (resolves to merchant). + + Convenience endpoint for the admin store detail page. Resolves + store -> merchant -> subscription internally and returns subscription + info with feature usage metrics. + """ + from app.modules.billing.services.feature_service import feature_service + from app.modules.billing.schemas.subscription import FeatureSummaryResponse + + # Resolve store to merchant + merchant_id, platform_id = feature_service._get_merchant_for_store(db, store_id) + if merchant_id is None or platform_id is None: + raise HTTPException(status_code=404, detail="Store not found or has no merchant association") + + # Get subscription + try: + sub, merchant = admin_subscription_service.get_subscription( + db, merchant_id, platform_id + ) + except Exception: + return { + "subscription": None, + "tier": None, + "features": [], + } + + # Get feature summary + features_summary = feature_service.get_merchant_features_summary(db, merchant_id, platform_id) + + # Build tier info + tier_info = None + if sub.tier: + tier_info = { + "code": sub.tier.code, + "name": sub.tier.name, + "feature_codes": [fl.feature_code for fl in (sub.tier.feature_limits or [])], + } + + # Build usage metrics (quantitative features only) + usage_metrics = [] + for fs in features_summary: + if fs.feature_type == "quantitative" and fs.enabled: + usage_metrics.append({ + "name": fs.name_key.replace("_", " ").title(), + "current": fs.current or 0, + "limit": fs.limit, + "percentage": fs.percent_used or 0, + "is_unlimited": fs.limit is None, + "is_at_limit": fs.remaining == 0 if fs.remaining is not None else False, + "is_approaching_limit": (fs.percent_used or 0) >= 80, + }) + + return { + "subscription": MerchantSubscriptionAdminResponse.model_validate(sub).model_dump(), + "tier": tier_info, + "features": usage_metrics, + } + + # ============================================================================ # Statistics Endpoints # ============================================================================ @@ -171,7 +310,7 @@ def list_vendor_subscriptions( @admin_router.get("/stats", response_model=SubscriptionStatsResponse) def get_subscription_stats( - current_user: User = Depends(get_current_admin_api), + current_user: UserContext = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """Get subscription statistics for admin dashboard.""" @@ -188,39 +327,39 @@ def get_subscription_stats( 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"), + merchant_id: int | None = Query(None, description="Filter by merchant"), status: str | None = Query(None, description="Filter by status"), - current_user: User = Depends(get_current_admin_api), + current_user: UserContext = Depends(get_current_admin_api), db: Session = Depends(get_db), ): - """List billing history (invoices) across all vendors.""" + """List billing history (invoices) across all merchants.""" data = admin_subscription_service.list_billing_history( - db, page=page, per_page=per_page, vendor_id=vendor_id, status=status + db, page=page, per_page=per_page, merchant_id=merchant_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)) + for invoice, merchant in data["results"]: + invoices.append( + BillingHistoryWithMerchant( + id=invoice.id, + merchant_id=invoice.merchant_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, + merchant_name=merchant.name, + ) + ) return BillingHistoryListResponse( invoices=invoices, @@ -231,112 +370,6 @@ def list_billing_history( ) -# ============================================================================ -# Vendor Subscription Detail Endpoints -# ============================================================================ - - -@admin_router.post("/{vendor_id}", response_model=VendorSubscriptionWithVendor, status_code=201) -def create_vendor_subscription( - create_data: VendorSubscriptionCreate, - vendor_id: int = Path(..., description="Vendor ID"), - current_user: User = Depends(get_current_admin_api), - db: Session = Depends(get_db), -): - """ - Create a subscription for a vendor. - - Creates a new subscription with the specified tier and status. - Defaults to Essential tier with trial status. - """ - # Verify vendor exists - vendor = admin_subscription_service.get_vendor(db, vendor_id) - - # Create subscription using the subscription service - sub = subscription_service.get_or_create_subscription( - db, - vendor_id=vendor_id, - tier=create_data.tier, - trial_days=create_data.trial_days, - ) - - # Update status if not trial - if create_data.status != "trial": - sub.status = create_data.status - - sub.is_annual = create_data.is_annual - - db.commit() - db.refresh(sub) - - # Get usage counts - usage = admin_subscription_service.get_vendor_usage_counts(db, vendor_id) - - logger.info(f"Admin created subscription for vendor {vendor_id}: tier={create_data.tier}") - - return VendorSubscriptionWithVendor( - **VendorSubscriptionResponse.model_validate(sub).model_dump(), - vendor_name=vendor.name, - vendor_code=vendor.subdomain, - products_count=usage["products_count"], - team_count=usage["team_count"], - ) - - -@admin_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_api), - db: Session = Depends(get_db), -): - """Get subscription details for a specific vendor.""" - sub, vendor = admin_subscription_service.get_subscription(db, vendor_id) - - # Get usage counts - usage = admin_subscription_service.get_vendor_usage_counts(db, vendor_id) - - return VendorSubscriptionWithVendor( - **VendorSubscriptionResponse.model_validate(sub).model_dump(), - vendor_name=vendor.name, - vendor_code=vendor.subdomain, - products_count=usage["products_count"], - team_count=usage["team_count"], - ) - - -@admin_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_api), - 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) - db.commit() - db.refresh(sub) - - # Get usage counts - usage = admin_subscription_service.get_vendor_usage_counts(db, vendor_id) - - return VendorSubscriptionWithVendor( - **VendorSubscriptionResponse.model_validate(sub).model_dump(), - vendor_name=vendor.name, - vendor_code=vendor.subdomain, - products_count=usage["products_count"], - team_count=usage["team_count"], - ) - - # ============================================================================ # Aggregate Feature Management Routes # ============================================================================ diff --git a/app/modules/billing/schemas/__init__.py b/app/modules/billing/schemas/__init__.py index 61302cc1..244b6490 100644 --- a/app/modules/billing/schemas/__init__.py +++ b/app/modules/billing/schemas/__init__.py @@ -6,48 +6,47 @@ This is the canonical location for billing schemas. Usage: from app.modules.billing.schemas import ( - SubscriptionCreate, - SubscriptionResponse, + MerchantSubscriptionCreate, + MerchantSubscriptionResponse, TierInfo, ) """ from app.modules.billing.schemas.subscription import ( # Tier schemas - TierFeatures, - TierLimits, + TierFeatureLimitResponse, TierInfo, - # Subscription CRUD schemas - SubscriptionCreate, - SubscriptionUpdate, - SubscriptionResponse, - # Usage schemas - SubscriptionUsage, - UsageSummary, - SubscriptionStatusResponse, + # Subscription schemas + MerchantSubscriptionCreate, + MerchantSubscriptionUpdate, + MerchantSubscriptionResponse, + MerchantSubscriptionStatusResponse, + # Feature summary schemas + FeatureSummaryResponse, # Limit check schemas LimitCheckResult, - CanCreateOrderResponse, - CanAddProductResponse, - CanAddTeamMemberResponse, FeatureCheckResponse, ) from app.modules.billing.schemas.billing import ( # Subscription Tier Admin schemas + TierFeatureLimitEntry, SubscriptionTierBase, SubscriptionTierCreate, SubscriptionTierUpdate, SubscriptionTierResponse, SubscriptionTierListResponse, - # Vendor Subscription schemas - VendorSubscriptionResponse, - VendorSubscriptionWithVendor, - VendorSubscriptionListResponse, - VendorSubscriptionCreate, - VendorSubscriptionUpdate, + # Merchant Subscription Admin schemas + MerchantSubscriptionAdminResponse, + MerchantSubscriptionWithMerchant, + MerchantSubscriptionListResponse, + MerchantSubscriptionAdminCreate, + MerchantSubscriptionAdminUpdate, + # Merchant Feature Override schemas + MerchantFeatureOverrideEntry, + MerchantFeatureOverrideResponse, # Billing History schemas BillingHistoryResponse, - BillingHistoryWithVendor, + BillingHistoryWithMerchant, BillingHistoryListResponse, # Checkout & Portal schemas CheckoutRequest, @@ -55,42 +54,44 @@ from app.modules.billing.schemas.billing import ( PortalSessionResponse, # Stats schemas SubscriptionStatsResponse, + # Feature Catalog schemas + FeatureDeclarationResponse, + FeatureCatalogResponse, ) __all__ = [ # Tier schemas (subscription.py) - "TierFeatures", - "TierLimits", + "TierFeatureLimitResponse", "TierInfo", - # Subscription CRUD schemas (subscription.py) - "SubscriptionCreate", - "SubscriptionUpdate", - "SubscriptionResponse", - # Usage schemas (subscription.py) - "SubscriptionUsage", - "UsageSummary", - "SubscriptionStatusResponse", + # Subscription schemas (subscription.py) + "MerchantSubscriptionCreate", + "MerchantSubscriptionUpdate", + "MerchantSubscriptionResponse", + "MerchantSubscriptionStatusResponse", + # Feature summary schemas (subscription.py) + "FeatureSummaryResponse", # Limit check schemas (subscription.py) "LimitCheckResult", - "CanCreateOrderResponse", - "CanAddProductResponse", - "CanAddTeamMemberResponse", "FeatureCheckResponse", # Subscription Tier Admin schemas (billing.py) + "TierFeatureLimitEntry", "SubscriptionTierBase", "SubscriptionTierCreate", "SubscriptionTierUpdate", "SubscriptionTierResponse", "SubscriptionTierListResponse", - # Vendor Subscription schemas (billing.py) - "VendorSubscriptionResponse", - "VendorSubscriptionWithVendor", - "VendorSubscriptionListResponse", - "VendorSubscriptionCreate", - "VendorSubscriptionUpdate", + # Merchant Subscription Admin schemas (billing.py) + "MerchantSubscriptionAdminResponse", + "MerchantSubscriptionWithMerchant", + "MerchantSubscriptionListResponse", + "MerchantSubscriptionAdminCreate", + "MerchantSubscriptionAdminUpdate", + # Merchant Feature Override schemas (billing.py) + "MerchantFeatureOverrideEntry", + "MerchantFeatureOverrideResponse", # Billing History schemas (billing.py) "BillingHistoryResponse", - "BillingHistoryWithVendor", + "BillingHistoryWithMerchant", "BillingHistoryListResponse", # Checkout & Portal schemas (billing.py) "CheckoutRequest", @@ -98,4 +99,7 @@ __all__ = [ "PortalSessionResponse", # Stats schemas (billing.py) "SubscriptionStatsResponse", + # Feature Catalog schemas (billing.py) + "FeatureDeclarationResponse", + "FeatureCatalogResponse", ] diff --git a/app/modules/billing/schemas/subscription.py b/app/modules/billing/schemas/subscription.py index 76dacd4f..c1f3c31d 100644 --- a/app/modules/billing/schemas/subscription.py +++ b/app/modules/billing/schemas/subscription.py @@ -1,8 +1,8 @@ # app/modules/billing/schemas/subscription.py """ -Pydantic schemas for subscription operations. +Pydantic schemas for merchant-level subscription operations. -Supports subscription management and tier limit checks. +Supports subscription management, tier information, and feature summaries. """ from datetime import datetime @@ -15,48 +15,23 @@ from pydantic import BaseModel, ConfigDict, Field # ============================================================================ -class TierFeatures(BaseModel): - """Features included in a tier.""" +class TierFeatureLimitResponse(BaseModel): + """Feature limit entry for a tier.""" - letzshop_sync: bool = True - inventory_basic: bool = True - inventory_locations: bool = False - inventory_purchase_orders: bool = False - invoice_lu: bool = True - invoice_eu_vat: bool = False - invoice_bulk: bool = False - customer_view: bool = True - customer_export: bool = False - analytics_dashboard: bool = False - accounting_export: bool = False - api_access: bool = False - automation_rules: bool = False - team_roles: bool = False - white_label: bool = False - multi_vendor: bool = False - custom_integrations: bool = False - sla_guarantee: bool = False - dedicated_support: bool = False - - -class TierLimits(BaseModel): - """Limits for a subscription tier.""" - - orders_per_month: int | None = Field(None, description="None = unlimited") - products_limit: int | None = Field(None, description="None = unlimited") - team_members: int | None = Field(None, description="None = unlimited") - order_history_months: int | None = Field(None, description="None = unlimited") + feature_code: str + limit_value: int | None = Field(None, description="None = unlimited") class TierInfo(BaseModel): - """Full tier information.""" + """Full tier information with feature limits.""" code: str name: str + description: str | None = None price_monthly_cents: int price_annual_cents: int | None - limits: TierLimits - features: list[str] + feature_codes: list[str] = Field(default_factory=list) + feature_limits: list[TierFeatureLimitResponse] = Field(default_factory=list) # ============================================================================ @@ -64,47 +39,43 @@ class TierInfo(BaseModel): # ============================================================================ -class SubscriptionCreate(BaseModel): - """Schema for creating a subscription (admin/internal use).""" +class MerchantSubscriptionCreate(BaseModel): + """Schema for creating a merchant subscription.""" - tier: str = Field(default="essential", pattern="^(essential|professional|business|enterprise)$") + tier_code: str = Field(default="essential") is_annual: bool = False trial_days: int = Field(default=14, ge=0, le=30) -class SubscriptionUpdate(BaseModel): - """Schema for updating a subscription.""" +class MerchantSubscriptionUpdate(BaseModel): + """Schema for updating a merchant subscription.""" - tier: str | None = Field(None, pattern="^(essential|professional|business|enterprise)$") + tier_code: str | None = None status: str | None = Field(None, pattern="^(trial|active|past_due|cancelled|expired)$") is_annual: bool | None = None - custom_orders_limit: int | None = None - custom_products_limit: int | None = None - custom_team_limit: int | None = None -class SubscriptionResponse(BaseModel): - """Schema for subscription response.""" +class MerchantSubscriptionResponse(BaseModel): + """Schema for merchant subscription response.""" model_config = ConfigDict(from_attributes=True) id: int - vendor_id: int - tier: str - status: str + merchant_id: int + platform_id: int + tier_id: int | None + status: str + is_annual: bool period_start: datetime period_end: datetime - is_annual: bool - trial_ends_at: datetime | None - orders_this_period: int - orders_limit_reached_at: datetime | None - # Effective limits (with custom overrides applied) - orders_limit: int | None - products_limit: int | None - team_members_limit: int | None + # Stripe info (optional, may be hidden from client) + stripe_customer_id: str | None = None + + # Cancellation + cancelled_at: datetime | None = None # Computed properties is_active: bool @@ -115,47 +86,36 @@ class SubscriptionResponse(BaseModel): updated_at: datetime -class SubscriptionUsage(BaseModel): - """Current subscription usage statistics.""" - - orders_used: int - orders_limit: int | None - orders_remaining: int | None - orders_percent_used: float | None - - products_used: int - products_limit: int | None - products_remaining: int | None - products_percent_used: float | None - - team_members_used: int - team_members_limit: int | None - team_members_remaining: int | None - team_members_percent_used: float | None +# ============================================================================ +# Feature Summary Schemas +# ============================================================================ -class UsageSummary(BaseModel): - """Usage summary for billing page display.""" +class FeatureSummaryResponse(BaseModel): + """Feature summary for merchant portal display.""" - orders_this_period: int - orders_limit: int | None - orders_remaining: int | None - - products_count: int - products_limit: int | None - products_remaining: int | None - - team_count: int - team_limit: int | None - team_remaining: int | None + code: str + name_key: str + description_key: str + category: str + feature_type: str + scope: str + enabled: bool + limit: int | None = None + current: int | None = None + remaining: int | None = None + percent_used: float | None = None + is_override: bool = False + unit_key: str | None = None + ui_icon: str | None = None -class SubscriptionStatusResponse(BaseModel): - """Subscription status with usage and limits.""" +class MerchantSubscriptionStatusResponse(BaseModel): + """Full subscription status with tier info and feature summary.""" - subscription: SubscriptionResponse - usage: SubscriptionUsage - tier_info: TierInfo + subscription: MerchantSubscriptionResponse + tier: TierInfo | None = None + features: list[FeatureSummaryResponse] = Field(default_factory=list) # ============================================================================ @@ -173,37 +133,11 @@ class LimitCheckResult(BaseModel): message: str | None = None -class CanCreateOrderResponse(BaseModel): - """Response for order creation check.""" - - allowed: bool - orders_this_period: int - orders_limit: int | None - message: str | None = None - - -class CanAddProductResponse(BaseModel): - """Response for product addition check.""" - - allowed: bool - products_count: int - products_limit: int | None - message: str | None = None - - -class CanAddTeamMemberResponse(BaseModel): - """Response for team member addition check.""" - - allowed: bool - team_count: int - team_limit: int | None - message: str | None = None - - class FeatureCheckResponse(BaseModel): """Response for feature check.""" - feature: str + feature_code: str enabled: bool - tier_required: str | None = None message: str | None = None + + diff --git a/app/modules/billing/services/__init__.py b/app/modules/billing/services/__init__.py index f51b4078..9decb01c 100644 --- a/app/modules/billing/services/__init__.py +++ b/app/modules/billing/services/__init__.py @@ -32,9 +32,6 @@ from app.modules.billing.exceptions import ( from app.modules.billing.services.feature_service import ( FeatureService, feature_service, - FeatureInfo, - FeatureUpgradeInfo, - FeatureCode, ) from app.modules.billing.services.capacity_forecast_service import ( CapacityForecastService, @@ -62,9 +59,6 @@ __all__ = [ "SubscriptionNotCancelledError", "FeatureService", "feature_service", - "FeatureInfo", - "FeatureUpgradeInfo", - "FeatureCode", "CapacityForecastService", "capacity_forecast_service", "PlatformPricingService", diff --git a/app/modules/billing/services/feature_service.py b/app/modules/billing/services/feature_service.py index 9a5c95d6..0ef8f332 100644 --- a/app/modules/billing/services/feature_service.py +++ b/app/modules/billing/services/feature_service.py @@ -1,590 +1,438 @@ # app/modules/billing/services/feature_service.py """ -Feature service for tier-based access control. +Feature-agnostic billing service for merchant-level access control. -Provides: -- Feature availability checking with caching -- Vendor feature listing for API/UI -- Feature metadata for upgrade prompts -- Cache invalidation on subscription changes +Zero knowledge of what features exist. Works with: +- TierFeatureLimit (tier -> feature mappings) +- MerchantFeatureOverride (per-merchant exceptions) +- FeatureAggregatorService (discovers features from modules) Usage: from app.modules.billing.services.feature_service import feature_service - # Check if vendor has feature - if feature_service.has_feature(db, vendor_id, FeatureCode.ANALYTICS_DASHBOARD): + # Check if merchant has feature + if feature_service.has_feature(db, merchant_id, platform_id, "analytics_dashboard"): ... - # Get all features available to vendor - features = feature_service.get_vendor_features(db, vendor_id) + # Check quantitative limit + allowed, msg = feature_service.check_resource_limit( + db, "products_limit", store_id=store_id + ) - # Get feature info for upgrade prompt - info = feature_service.get_feature_upgrade_info(db, "analytics_dashboard") + # Get feature summary for merchant portal + summary = feature_service.get_merchant_features_summary(db, merchant_id, platform_id) """ import logging import time from dataclasses import dataclass -from functools import lru_cache from sqlalchemy.orm import Session, joinedload -from app.modules.billing.exceptions import ( - FeatureNotFoundError, - InvalidFeatureCodesError, - TierNotFoundError, +from app.modules.billing.models import ( + MerchantFeatureOverride, + MerchantSubscription, + SubscriptionTier, + TierFeatureLimit, ) -from app.modules.billing.models import Feature, FeatureCode -from app.modules.billing.models import SubscriptionTier, VendorSubscription +from app.modules.contracts.features import FeatureScope, FeatureType logger = logging.getLogger(__name__) @dataclass -class FeatureInfo: - """Feature information for API responses.""" +class FeatureSummary: + """Summary of a feature for merchant portal display.""" code: str - name: str - description: str | None + name_key: str + description_key: str category: str - ui_location: str | None + feature_type: str # "binary" or "quantitative" + scope: str # "store" or "merchant" + enabled: bool + limit: int | None # For quantitative: effective limit + current: int | None # For quantitative: current usage + remaining: int | None # For quantitative: remaining capacity + percent_used: float | None # For quantitative: usage percentage + is_override: bool # Whether an override is applied + unit_key: str | None ui_icon: str | None - ui_route: str | None - ui_badge_text: str | None - is_available: bool - minimum_tier_code: str | None - minimum_tier_name: str | None - - -@dataclass -class FeatureUpgradeInfo: - """Information for upgrade prompts.""" - - feature_code: str - feature_name: str - feature_description: str | None - required_tier_code: str - required_tier_name: str - required_tier_price_monthly_cents: int class FeatureCache: """ - In-memory cache for vendor features. + In-memory cache for merchant features. - Caches vendor_id -> set of feature codes with TTL. - Invalidated when subscription changes. + Caches (merchant_id, platform_id) -> set of feature codes with TTL. """ def __init__(self, ttl_seconds: int = 300): - self._cache: dict[int, tuple[set[str], float]] = {} + self._cache: dict[tuple[int, int], tuple[set[str], float]] = {} self._ttl = ttl_seconds - def get(self, vendor_id: int) -> set[str] | None: - """Get cached features for vendor, or None if not cached/expired.""" - if vendor_id not in self._cache: + def get(self, merchant_id: int, platform_id: int) -> set[str] | None: + key = (merchant_id, platform_id) + if key not in self._cache: return None - - features, timestamp = self._cache[vendor_id] + features, timestamp = self._cache[key] if time.time() - timestamp > self._ttl: - del self._cache[vendor_id] + del self._cache[key] return None - return features - def set(self, vendor_id: int, features: set[str]) -> None: - """Cache features for vendor.""" - self._cache[vendor_id] = (features, time.time()) + def set(self, merchant_id: int, platform_id: int, features: set[str]) -> None: + self._cache[(merchant_id, platform_id)] = (features, time.time()) - def invalidate(self, vendor_id: int) -> None: - """Invalidate cache for vendor.""" - self._cache.pop(vendor_id, None) + def invalidate(self, merchant_id: int, platform_id: int) -> None: + self._cache.pop((merchant_id, platform_id), None) def invalidate_all(self) -> None: - """Invalidate entire cache.""" self._cache.clear() class FeatureService: """ - Service for feature-based access control. + Feature-agnostic service for merchant-level billing. - Provides methods to check feature availability and get feature metadata. - Uses in-memory caching with TTL for performance. + Resolves feature access through: + 1. MerchantSubscription -> SubscriptionTier -> TierFeatureLimit + 2. MerchantFeatureOverride (admin-set exceptions) + 3. FeatureAggregator (for usage counts and declarations) """ def __init__(self): - self._cache = FeatureCache(ttl_seconds=300) # 5 minute cache - self._feature_registry_cache: dict[str, Feature] | None = None - self._feature_registry_timestamp: float = 0 + self._cache = FeatureCache(ttl_seconds=300) + + # ========================================================================= + # Store -> Merchant Resolution + # ========================================================================= + + def _get_merchant_for_store(self, db: Session, store_id: int) -> tuple[int | None, int | None]: + """ + Resolve store_id to (merchant_id, platform_id). + + Returns: + Tuple of (merchant_id, platform_id), either may be None + """ + from app.modules.tenancy.models import Store + + store = db.query(Store).filter(Store.id == store_id).first() + if not store: + return None, None + + merchant_id = store.merchant_id + # Get platform_id from store's platform association + platform_id = getattr(store, "platform_id", None) + if platform_id is None: + # Try StorePlatform junction + from app.modules.tenancy.models import StorePlatform + sp = ( + db.query(StorePlatform.platform_id) + .filter(StorePlatform.store_id == store_id) + .first() + ) + platform_id = sp[0] if sp else None + + return merchant_id, platform_id + + def _get_subscription( + self, db: Session, merchant_id: int, platform_id: int + ) -> MerchantSubscription | None: + """Get merchant subscription for a platform.""" + return ( + db.query(MerchantSubscription) + .options(joinedload(MerchantSubscription.tier).joinedload(SubscriptionTier.feature_limits)) + .filter( + MerchantSubscription.merchant_id == merchant_id, + MerchantSubscription.platform_id == platform_id, + ) + .first() + ) # ========================================================================= # Feature Availability # ========================================================================= - def has_feature(self, db: Session, vendor_id: int, feature_code: str) -> bool: + def has_feature( + self, db: Session, merchant_id: int, platform_id: int, feature_code: str + ) -> bool: """ - Check if vendor has access to a specific feature. + Check if merchant has access to a specific feature. + + Checks: + 1. MerchantFeatureOverride (force enable/disable) + 2. TierFeatureLimit (tier assignment) Args: db: Database session - vendor_id: Vendor ID - feature_code: Feature code (use FeatureCode constants) + merchant_id: Merchant ID + platform_id: Platform ID + feature_code: Feature code to check Returns: - True if vendor has access to the feature + True if merchant has access to the feature """ - vendor_features = self._get_vendor_feature_codes(db, vendor_id) - return feature_code in vendor_features + # Check override first + override = ( + db.query(MerchantFeatureOverride) + .filter( + MerchantFeatureOverride.merchant_id == merchant_id, + MerchantFeatureOverride.platform_id == platform_id, + MerchantFeatureOverride.feature_code == feature_code, + ) + .first() + ) + if override is not None: + return override.is_enabled - def get_vendor_feature_codes(self, db: Session, vendor_id: int) -> set[str]: + # Check tier assignment + subscription = self._get_subscription(db, merchant_id, platform_id) + if not subscription or not subscription.is_active or not subscription.tier: + return False + + return subscription.tier.has_feature(feature_code) + + def has_feature_for_store( + self, db: Session, store_id: int, feature_code: str + ) -> bool: """ - Get set of feature codes available to vendor. + Check if a store has access to a feature (resolves store -> merchant). - Args: - db: Database session - vendor_id: Vendor ID - - Returns: - Set of feature codes the vendor has access to + Convenience method for backwards compatibility. """ - return self._get_vendor_feature_codes(db, vendor_id) + merchant_id, platform_id = self._get_merchant_for_store(db, store_id) + if merchant_id is None or platform_id is None: + return False + return self.has_feature(db, merchant_id, platform_id, feature_code) - def _get_vendor_feature_codes(self, db: Session, vendor_id: int) -> set[str]: - """Internal method with caching.""" - # Check cache first - cached = self._cache.get(vendor_id) + def get_merchant_feature_codes( + self, db: Session, merchant_id: int, platform_id: int + ) -> set[str]: + """Get all feature codes available to merchant on a platform.""" + # Check cache + cached = self._cache.get(merchant_id, platform_id) if cached is not None: return cached - # Get subscription with tier relationship - subscription = ( - db.query(VendorSubscription) - .options(joinedload(VendorSubscription.tier_obj)) - .filter(VendorSubscription.vendor_id == vendor_id) - .first() - ) + features: set[str] = set() - if not subscription: - logger.warning(f"No subscription found for vendor {vendor_id}") - return set() + # Get tier features + subscription = self._get_subscription(db, merchant_id, platform_id) + if subscription and subscription.is_active and subscription.tier: + features = subscription.tier.get_feature_codes() - # Get features from tier - tier = subscription.tier_obj - if tier and tier.features: - features = set(tier.features) - else: - # Fallback: query tier by code - tier = ( - db.query(SubscriptionTier) - .filter(SubscriptionTier.code == subscription.tier) - .first() + # Apply overrides + overrides = ( + db.query(MerchantFeatureOverride) + .filter( + MerchantFeatureOverride.merchant_id == merchant_id, + MerchantFeatureOverride.platform_id == platform_id, ) - features = set(tier.features) if tier and tier.features else set() + .all() + ) + for override in overrides: + if override.is_enabled: + features.add(override.feature_code) + else: + features.discard(override.feature_code) - # Cache and return - self._cache.set(vendor_id, features) + self._cache.set(merchant_id, platform_id, features) return features # ========================================================================= - # Feature Listing + # Effective Limits # ========================================================================= - def get_vendor_features( + def get_effective_limit( + self, db: Session, merchant_id: int, platform_id: int, feature_code: str + ) -> int | None: + """ + Get the effective limit for a feature (override or tier default). + + Returns: + Limit value, or None for unlimited + """ + # Check override first + override = ( + db.query(MerchantFeatureOverride) + .filter( + MerchantFeatureOverride.merchant_id == merchant_id, + MerchantFeatureOverride.platform_id == platform_id, + MerchantFeatureOverride.feature_code == feature_code, + ) + .first() + ) + if override is not None: + return override.limit_value + + # Get from tier + subscription = self._get_subscription(db, merchant_id, platform_id) + if not subscription or not subscription.tier: + return 0 # No subscription = no access + + return subscription.tier.get_limit_for_feature(feature_code) + + # ========================================================================= + # Resource Limit Checks + # ========================================================================= + + def check_resource_limit( self, db: Session, - vendor_id: int, - category: str | None = None, - include_unavailable: bool = True, - ) -> list[FeatureInfo]: + feature_code: str, + store_id: int | None = None, + merchant_id: int | None = None, + platform_id: int | None = None, + ) -> tuple[bool, str | None]: """ - Get all features with availability status for vendor. + Check if a resource limit allows adding more items. + + Resolves store -> merchant if needed. Gets the declaration to + determine scope, then checks usage against limit. Args: db: Database session - vendor_id: Vendor ID - category: Optional category filter - include_unavailable: Include features not available to vendor + feature_code: Feature code (e.g., "products_limit") + store_id: Store ID (if checking per-store) + merchant_id: Merchant ID (if already known) + platform_id: Platform ID (if already known) Returns: - List of FeatureInfo with is_available flag + (allowed, error_message) tuple """ - vendor_features = self._get_vendor_feature_codes(db, vendor_id) + from app.modules.billing.services.feature_aggregator import feature_aggregator - # Query all active features - query = db.query(Feature).filter(Feature.is_active == True) # noqa: E712 + # Resolve store -> merchant if needed + if merchant_id is None and store_id is not None: + merchant_id, platform_id = self._get_merchant_for_store(db, store_id) - if category: - query = query.filter(Feature.category == category) + if merchant_id is None or platform_id is None: + return False, "No subscription found" - if not include_unavailable: - # Only return features the vendor has - query = query.filter(Feature.code.in_(vendor_features)) + # Check subscription is active + subscription = self._get_subscription(db, merchant_id, platform_id) + if not subscription or not subscription.is_active: + return False, "Subscription is not active" - features = ( - query.options(joinedload(Feature.minimum_tier)) - .order_by(Feature.category, Feature.display_order) - .all() + # Get feature declaration + decl = feature_aggregator.get_declaration(feature_code) + if decl is None: + logger.warning(f"Unknown feature code: {feature_code}") + return True, None # Unknown features are allowed by default + + # Binary features: just check if enabled + if decl.feature_type == FeatureType.BINARY: + if self.has_feature(db, merchant_id, platform_id, feature_code): + return True, None + return False, f"Feature '{feature_code}' requires an upgrade" + + # Quantitative: check usage against limit + limit = self.get_effective_limit(db, merchant_id, platform_id, feature_code) + if limit is None: # Unlimited + return True, None + + # Get current usage based on scope + usage = feature_aggregator.get_usage_for_feature( + db, feature_code, + store_id=store_id, + merchant_id=merchant_id, + platform_id=platform_id, ) + current = usage.current_count if usage else 0 - result = [] - for feature in features: - result.append( - FeatureInfo( - code=feature.code, - name=feature.name, - description=feature.description, - category=feature.category, - ui_location=feature.ui_location, - ui_icon=feature.ui_icon, - ui_route=feature.ui_route, - ui_badge_text=feature.ui_badge_text, - is_available=feature.code in vendor_features, - minimum_tier_code=feature.minimum_tier.code if feature.minimum_tier else None, - minimum_tier_name=feature.minimum_tier.name if feature.minimum_tier else None, - ) + if current >= limit: + return False, ( + f"Limit reached ({current}/{limit} {decl.unit_key or feature_code}). " + f"Upgrade to increase your limit." ) - return result - - def get_available_feature_codes(self, db: Session, vendor_id: int) -> list[str]: - """ - Get list of feature codes available to vendor (for frontend). - - Simple list for x-feature directive checks. - """ - return list(self._get_vendor_feature_codes(db, vendor_id)) + return True, None # ========================================================================= - # Feature Metadata + # Feature Summary # ========================================================================= - def get_feature_by_code(self, db: Session, feature_code: str) -> Feature | None: - """Get feature by code.""" - return ( - db.query(Feature) - .options(joinedload(Feature.minimum_tier)) - .filter(Feature.code == feature_code) - .first() - ) - - def get_feature_upgrade_info( - self, db: Session, feature_code: str - ) -> FeatureUpgradeInfo | None: + def get_merchant_features_summary( + self, db: Session, merchant_id: int, platform_id: int + ) -> list[FeatureSummary]: """ - Get upgrade information for a feature. + Get complete feature summary for merchant portal display. - Used for upgrade prompts when a feature is not available. + Returns all features with current status, limits, and usage. """ - feature = self.get_feature_by_code(db, feature_code) + from app.modules.billing.services.feature_aggregator import feature_aggregator - if not feature or not feature.minimum_tier: - return None + declarations = feature_aggregator.get_all_declarations() + merchant_features = self.get_merchant_feature_codes(db, merchant_id, platform_id) - tier = feature.minimum_tier - return FeatureUpgradeInfo( - feature_code=feature.code, - feature_name=feature.name, - feature_description=feature.description, - required_tier_code=tier.code, - required_tier_name=tier.name, - required_tier_price_monthly_cents=tier.price_monthly_cents, - ) + # Preload overrides + overrides = { + o.feature_code: o + for o in db.query(MerchantFeatureOverride).filter( + MerchantFeatureOverride.merchant_id == merchant_id, + MerchantFeatureOverride.platform_id == platform_id, + ).all() + } - def get_all_features( - self, - db: Session, - category: str | None = None, - active_only: bool = True, - ) -> list[Feature]: - """Get all features (for admin).""" - query = db.query(Feature).options(joinedload(Feature.minimum_tier)) + # Get all usage at once + store_usage = {} + merchant_usage = feature_aggregator.get_merchant_usage(db, merchant_id, platform_id) - if active_only: - query = query.filter(Feature.is_active == True) # noqa: E712 + summaries = [] + for code, decl in sorted(declarations.items(), key=lambda x: (x[1].category, x[1].display_order)): + enabled = code in merchant_features + is_override = code in overrides + limit = self.get_effective_limit(db, merchant_id, platform_id, code) if decl.feature_type == FeatureType.QUANTITATIVE else None - if category: - query = query.filter(Feature.category == category) + current = None + remaining = None + percent_used = None - return query.order_by(Feature.category, Feature.display_order).all() + if decl.feature_type == FeatureType.QUANTITATIVE: + usage_data = merchant_usage.get(code) + current = usage_data.current_count if usage_data else 0 + if limit is not None: + remaining = max(0, limit - current) + percent_used = min(100.0, (current / limit * 100)) if limit > 0 else 0.0 - def get_features_by_tier(self, db: Session, tier_code: str) -> list[str]: - """Get feature codes for a specific tier.""" - tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first() + summaries.append(FeatureSummary( + code=code, + name_key=decl.name_key, + description_key=decl.description_key, + category=decl.category, + feature_type=decl.feature_type.value, + scope=decl.scope.value, + enabled=enabled, + limit=limit, + current=current, + remaining=remaining, + percent_used=percent_used, + is_override=is_override, + unit_key=decl.unit_key, + ui_icon=decl.ui_icon, + )) - if not tier or not tier.features: - return [] - - return tier.features - - # ========================================================================= - # Feature Categories - # ========================================================================= - - def get_categories(self, db: Session) -> list[str]: - """Get all unique feature categories.""" - result = ( - db.query(Feature.category) - .filter(Feature.is_active == True) # noqa: E712 - .distinct() - .order_by(Feature.category) - .all() - ) - return [row[0] for row in result] - - def get_features_grouped_by_category( - self, db: Session, vendor_id: int - ) -> dict[str, list[FeatureInfo]]: - """Get features grouped by category with availability.""" - features = self.get_vendor_features(db, vendor_id, include_unavailable=True) - - grouped: dict[str, list[FeatureInfo]] = {} - for feature in features: - if feature.category not in grouped: - grouped[feature.category] = [] - grouped[feature.category].append(feature) - - return grouped + return summaries # ========================================================================= # Cache Management # ========================================================================= - def invalidate_vendor_cache(self, vendor_id: int) -> None: - """ - Invalidate cache for a specific vendor. - - Call this when: - - Vendor's subscription tier changes - - Tier features are updated (for all vendors on that tier) - """ - self._cache.invalidate(vendor_id) - logger.debug(f"Invalidated feature cache for vendor {vendor_id}") + def invalidate_cache(self, merchant_id: int, platform_id: int) -> None: + """Invalidate cache for a specific merchant/platform.""" + self._cache.invalidate(merchant_id, platform_id) def invalidate_all_cache(self) -> None: - """ - Invalidate entire cache. - - Call this when tier features are modified in admin. - """ + """Invalidate entire cache.""" self._cache.invalidate_all() - logger.debug("Invalidated all feature caches") - - # ========================================================================= - # Admin Operations - # ========================================================================= - - def get_all_tiers_with_features(self, db: Session) -> list[SubscriptionTier]: - """Get all active tiers with their features for admin.""" - return ( - db.query(SubscriptionTier) - .filter(SubscriptionTier.is_active == True) # noqa: E712 - .order_by(SubscriptionTier.display_order) - .all() - ) - - def get_tier_by_code(self, db: Session, tier_code: str) -> SubscriptionTier: - """ - Get tier by code, raising exception if not found. - - Raises: - TierNotFoundError: If tier not found - """ - tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first() - if not tier: - raise TierNotFoundError(tier_code) - return tier - - def get_tier_features_with_details( - self, db: Session, tier_code: str - ) -> tuple[SubscriptionTier, list[Feature]]: - """ - Get tier with full feature details. - - Returns: - Tuple of (tier, list of Feature objects) - - Raises: - TierNotFoundError: If tier not found - """ - tier = self.get_tier_by_code(db, tier_code) - feature_codes = tier.features or [] - - features = ( - db.query(Feature) - .filter(Feature.code.in_(feature_codes)) - .order_by(Feature.category, Feature.display_order) - .all() - ) - - return tier, features - - def update_tier_features( - self, db: Session, tier_code: str, feature_codes: list[str] - ) -> SubscriptionTier: - """ - Update features for a tier (admin operation). - - Args: - db: Database session - tier_code: Tier code - feature_codes: List of feature codes to assign - - Returns: - Updated tier - - Raises: - TierNotFoundError: If tier not found - InvalidFeatureCodesError: If any feature codes are invalid - """ - tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first() - - if not tier: - raise TierNotFoundError(tier_code) - - # Validate feature codes exist - # noqa: SVC-005 - Features are platform-level, not vendor-scoped - valid_codes = { - f.code for f in db.query(Feature.code).filter(Feature.is_active == True).all() # noqa: E712 - } - invalid = set(feature_codes) - valid_codes - if invalid: - raise InvalidFeatureCodesError(invalid) - - tier.features = feature_codes - - # Invalidate all caches since tier features changed - self.invalidate_all_cache() - - logger.info(f"Updated features for tier {tier_code}: {len(feature_codes)} features") - return tier - - def update_feature( - self, - db: Session, - feature_code: str, - name: str | None = None, - description: str | None = None, - category: str | None = None, - ui_location: str | None = None, - ui_icon: str | None = None, - ui_route: str | None = None, - ui_badge_text: str | None = None, - minimum_tier_code: str | None = None, - is_active: bool | None = None, - is_visible: bool | None = None, - display_order: int | None = None, - ) -> Feature: - """ - Update feature metadata. - - Args: - db: Database session - feature_code: Feature code to update - ... other optional fields to update - - Returns: - Updated feature - - Raises: - FeatureNotFoundError: If feature not found - TierNotFoundError: If minimum_tier_code provided but not found - """ - feature = ( - db.query(Feature) - .options(joinedload(Feature.minimum_tier)) - .filter(Feature.code == feature_code) - .first() - ) - - if not feature: - raise FeatureNotFoundError(feature_code) - - # Update fields if provided - if name is not None: - feature.name = name - if description is not None: - feature.description = description - if category is not None: - feature.category = category - if ui_location is not None: - feature.ui_location = ui_location - if ui_icon is not None: - feature.ui_icon = ui_icon - if ui_route is not None: - feature.ui_route = ui_route - if ui_badge_text is not None: - feature.ui_badge_text = ui_badge_text - if is_active is not None: - feature.is_active = is_active - if is_visible is not None: - feature.is_visible = is_visible - if display_order is not None: - feature.display_order = display_order - - # Update minimum tier if provided - if minimum_tier_code is not None: - if minimum_tier_code == "": - feature.minimum_tier_id = None - else: - tier = ( - db.query(SubscriptionTier) - .filter(SubscriptionTier.code == minimum_tier_code) - .first() - ) - if not tier: - raise TierNotFoundError(minimum_tier_code) - feature.minimum_tier_id = tier.id - - logger.info(f"Updated feature {feature_code}") - return feature - - def update_feature_minimum_tier( - self, db: Session, feature_code: str, tier_code: str | None - ) -> Feature: - """ - Update minimum tier for a feature (for upgrade prompts). - - Args: - db: Database session - feature_code: Feature code - tier_code: Tier code or None - - Raises: - FeatureNotFoundError: If feature not found - TierNotFoundError: If tier_code provided but not found - """ - feature = db.query(Feature).filter(Feature.code == feature_code).first() - - if not feature: - raise FeatureNotFoundError(feature_code) - - if tier_code: - tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first() - if not tier: - raise TierNotFoundError(tier_code) - feature.minimum_tier_id = tier.id - else: - feature.minimum_tier_id = None - - logger.info(f"Updated minimum tier for feature {feature_code}: {tier_code}") - return feature # Singleton instance feature_service = FeatureService() - -# ============================================================================ -# Convenience Exports -# ============================================================================ -# Re-export FeatureCode for easy imports - __all__ = [ "feature_service", "FeatureService", - "FeatureInfo", - "FeatureUpgradeInfo", - "FeatureCode", + "FeatureSummary", ] diff --git a/app/modules/billing/static/admin/js/subscription-tiers.js b/app/modules/billing/static/admin/js/subscription-tiers.js index 94b4c041..46bbf4bc 100644 --- a/app/modules/billing/static/admin/js/subscription-tiers.js +++ b/app/modules/billing/static/admin/js/subscription-tiers.js @@ -28,6 +28,7 @@ function adminSubscriptionTiers() { categories: [], featuresGrouped: {}, selectedFeatures: [], + featureLimits: {}, // { feature_code: limit_value } selectedTierForFeatures: null, showFeaturePanel: false, loadingFeatures: false, @@ -46,13 +47,9 @@ function adminSubscriptionTiers() { 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 }, @@ -70,8 +67,7 @@ function adminSubscriptionTiers() { await Promise.all([ this.loadTiers(), this.loadStats(), - this.loadFeatures(), - this.loadCategories() + this.loadFeatures() ]); tiersLog.info('=== SUBSCRIPTION TIERS PAGE INITIALIZED ==='); } catch (error) { @@ -137,13 +133,9 @@ function adminSubscriptionTiers() { 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 }; @@ -158,13 +150,9 @@ function adminSubscriptionTiers() { 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 }; @@ -184,9 +172,6 @@ function adminSubscriptionTiers() { // 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 @@ -233,24 +218,22 @@ function adminSubscriptionTiers() { async loadFeatures() { try { - const data = await apiClient.get('/admin/features'); - this.features = data.features || []; - tiersLog.info(`Loaded ${this.features.length} features`); + const data = await apiClient.get('/admin/subscriptions/features/catalog'); + // Parse grouped response: { features: { category: [FeatureDeclaration, ...] } } + this.features = []; + this.categories = []; + for (const [category, featureList] of Object.entries(data.features || {})) { + this.categories.push(category); + for (const f of featureList) { + this.features.push({ ...f, category }); + } + } + tiersLog.info(`Loaded ${this.features.length} features in ${this.categories.length} categories`); } catch (error) { tiersLog.error('Failed to load features:', error); } }, - async loadCategories() { - try { - const data = await apiClient.get('/admin/features/categories'); - this.categories = data.categories || []; - tiersLog.info(`Loaded ${this.categories.length} categories`); - } catch (error) { - tiersLog.error('Failed to load categories:', error); - } - }, - groupFeaturesByCategory() { this.featuresGrouped = {}; for (const category of this.categories) { @@ -263,18 +246,22 @@ function adminSubscriptionTiers() { this.selectedTierForFeatures = tier; this.loadingFeatures = true; this.showFeaturePanel = true; + this.featureLimits = {}; try { - // Load tier's current features - const data = await apiClient.get(`/admin/features/tiers/${tier.code}/features`); - if (data.features) { - this.selectedFeatures = data.features.map(f => f.code); - } else { - this.selectedFeatures = tier.features || []; + // Load tier's current feature limits + const data = await apiClient.get(`/admin/subscriptions/features/tiers/${tier.code}/limits`); + // data is TierFeatureLimitEntry[]: [{feature_code, limit_value, enabled}] + this.selectedFeatures = []; + for (const entry of (data || [])) { + this.selectedFeatures.push(entry.feature_code); + if (entry.limit_value !== null && entry.limit_value !== undefined) { + this.featureLimits[entry.feature_code] = entry.limit_value; + } } } catch (error) { tiersLog.error('Failed to load tier features:', error); - this.selectedFeatures = tier.features || []; + this.selectedFeatures = tier.feature_codes || tier.features || []; } finally { this.groupFeaturesByCategory(); this.loadingFeatures = false; @@ -285,6 +272,7 @@ function adminSubscriptionTiers() { this.showFeaturePanel = false; this.selectedTierForFeatures = null; this.selectedFeatures = []; + this.featureLimits = {}; this.featuresGrouped = {}; }, @@ -308,9 +296,16 @@ function adminSubscriptionTiers() { this.savingFeatures = true; try { + // Build TierFeatureLimitEntry[] payload + const entries = this.selectedFeatures.map(code => ({ + feature_code: code, + limit_value: this.featureLimits[code] ?? null, + enabled: true + })); + await apiClient.put( - `/admin/features/tiers/${this.selectedTierForFeatures.code}/features`, - { feature_codes: this.selectedFeatures } + `/admin/subscriptions/features/tiers/${this.selectedTierForFeatures.code}/limits`, + entries ); this.successMessage = `Features updated for ${this.selectedTierForFeatures.name}`; @@ -345,6 +340,18 @@ function adminSubscriptionTiers() { return categoryFeatures.every(f => this.selectedFeatures.includes(f.code)); }, + getFeatureLimitValue(featureCode) { + return this.featureLimits[featureCode] ?? ''; + }, + + setFeatureLimitValue(featureCode, value) { + if (value === '' || value === null || value === undefined) { + delete this.featureLimits[featureCode]; + } else { + this.featureLimits[featureCode] = parseInt(value, 10); + } + }, + formatCategoryName(category) { return category .split('_') diff --git a/app/modules/billing/static/admin/js/subscriptions.js b/app/modules/billing/static/admin/js/subscriptions.js index 82ba2ec6..7346b173 100644 --- a/app/modules/billing/static/admin/js/subscriptions.js +++ b/app/modules/billing/static/admin/js/subscriptions.js @@ -39,7 +39,7 @@ function adminSubscriptions() { }, // Sorting - sortBy: 'vendor_name', + sortBy: 'store_name', sortOrder: 'asc', // Modal state @@ -47,12 +47,14 @@ function adminSubscriptions() { editingSub: null, formData: { tier: '', - status: '', - custom_orders_limit: null, - custom_products_limit: null, - custom_team_limit: null + status: '' }, + // Feature overrides + featureOverrides: [], + quantitativeFeatures: [], + loadingOverrides: false, + // Computed: Total pages get totalPages() { return this.pagination.pages; @@ -203,16 +205,18 @@ function adminSubscriptions() { } }, - openEditModal(sub) { + async 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 + status: sub.status }; + this.featureOverrides = []; + this.quantitativeFeatures = []; this.showModal = true; + + // Load feature catalog and merchant overrides + await this.loadFeatureOverrides(sub.merchant_id); }, closeModal() { @@ -220,6 +224,77 @@ function adminSubscriptions() { this.editingSub = null; }, + async loadFeatureOverrides(merchantId) { + this.loadingOverrides = true; + try { + const [catalogData, overridesData] = await Promise.all([ + apiClient.get('/admin/subscriptions/features/catalog'), + apiClient.get(`/admin/subscriptions/features/merchants/${merchantId}/overrides`), + ]); + + // Extract quantitative features from catalog + const allFeatures = []; + for (const [, features] of Object.entries(catalogData.features || {})) { + for (const f of features) { + if (f.feature_type === 'quantitative') { + allFeatures.push(f); + } + } + } + this.quantitativeFeatures = allFeatures; + + // Map overrides by feature_code + this.featureOverrides = (overridesData || []).map(o => ({ + feature_code: o.feature_code, + limit_value: o.limit_value, + is_enabled: o.is_enabled + })); + + subsLog.info(`Loaded ${allFeatures.length} quantitative features and ${this.featureOverrides.length} overrides`); + } catch (error) { + subsLog.error('Failed to load feature overrides:', error); + } finally { + this.loadingOverrides = false; + } + }, + + getOverrideValue(featureCode) { + const override = this.featureOverrides.find(o => o.feature_code === featureCode); + return override?.limit_value ?? ''; + }, + + setOverrideValue(featureCode, value) { + const numValue = value === '' ? null : parseInt(value, 10); + const existing = this.featureOverrides.find(o => o.feature_code === featureCode); + if (existing) { + existing.limit_value = numValue; + } else if (numValue !== null) { + this.featureOverrides.push({ + feature_code: featureCode, + limit_value: numValue, + is_enabled: true + }); + } + }, + + async saveFeatureOverrides(merchantId) { + // Only send overrides that have a limit_value set + const entries = this.featureOverrides + .filter(o => o.limit_value !== null && o.limit_value !== undefined) + .map(o => ({ + feature_code: o.feature_code, + limit_value: o.limit_value, + is_enabled: true + })); + + if (entries.length > 0) { + await apiClient.put( + `/admin/subscriptions/features/merchants/${merchantId}/overrides`, + entries + ); + } + }, + async saveSubscription() { if (!this.editingSub) return; @@ -227,14 +302,17 @@ function adminSubscriptions() { 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`; + await apiClient.patch( + `/admin/subscriptions/merchants/${this.editingSub.merchant_id}/platforms/${this.editingSub.platform_id}`, + payload + ); + + // Save feature overrides + await this.saveFeatureOverrides(this.editingSub.merchant_id); + + this.successMessage = `Subscription for "${this.editingSub.store_name || this.editingSub.merchant_name}" updated`; this.closeModal(); await this.loadSubscriptions(); diff --git a/app/modules/billing/static/store/js/billing.js b/app/modules/billing/static/store/js/billing.js new file mode 100644 index 00000000..92584f93 --- /dev/null +++ b/app/modules/billing/static/store/js/billing.js @@ -0,0 +1,217 @@ +// app/modules/billing/static/store/js/billing.js +// Store billing and subscription management + +const billingLog = window.LogConfig?.createLogger('BILLING') || console; + +function storeBilling() { + return { + // Inherit base data (dark mode, sidebar, store info, etc.) + ...data(), + currentPage: 'billing', + + // State + loading: true, + subscription: null, + tiers: [], + addons: [], + myAddons: [], + invoices: [], + usageMetrics: [], + + // UI state + showTiersModal: false, + showAddonsModal: false, + showCancelModal: false, + showSuccessMessage: false, + showCancelMessage: false, + showAddonSuccessMessage: false, + cancelReason: '', + purchasingAddon: null, + + // Initialize + async init() { + // Load i18n translations + await I18n.loadModule('billing'); + + // Guard against multiple initialization + if (window._storeBillingInitialized) return; + window._storeBillingInitialized = true; + + // IMPORTANT: Call parent init first to set storeCode from URL + const parentInit = data().init; + if (parentInit) { + await parentInit.call(this); + } + + try { + // Check URL params for success/cancel + const params = new URLSearchParams(window.location.search); + if (params.get('success') === 'true') { + this.showSuccessMessage = true; + window.history.replaceState({}, document.title, window.location.pathname); + } + if (params.get('cancelled') === 'true') { + this.showCancelMessage = true; + window.history.replaceState({}, document.title, window.location.pathname); + } + if (params.get('addon_success') === 'true') { + this.showAddonSuccessMessage = true; + window.history.replaceState({}, document.title, window.location.pathname); + } + + await this.loadData(); + } catch (error) { + billingLog.error('Failed to initialize billing page:', error); + } + }, + + async loadData() { + this.loading = true; + try { + // Load all data in parallel + const [subscriptionRes, tiersRes, addonsRes, myAddonsRes, invoicesRes, usageRes] = await Promise.all([ + apiClient.get('/store/billing/subscription'), + apiClient.get('/store/billing/tiers'), + apiClient.get('/store/billing/addons'), + apiClient.get('/store/billing/my-addons'), + apiClient.get('/store/billing/invoices?limit=5'), + apiClient.get('/store/billing/usage').catch(() => ({ usage: [] })), + ]); + + this.subscription = subscriptionRes; + this.tiers = tiersRes.tiers || []; + this.addons = addonsRes || []; + this.myAddons = myAddonsRes || []; + this.invoices = invoicesRes.invoices || []; + this.usageMetrics = usageRes.usage || usageRes || []; + + } catch (error) { + billingLog.error('Error loading billing data:', error); + Utils.showToast(I18n.t('billing.messages.failed_to_load_billing_data'), 'error'); + } finally { + this.loading = false; + } + }, + + async selectTier(tier) { + if (tier.is_current) return; + + try { + const response = await apiClient.post('/store/billing/checkout', { + tier_code: tier.code, + is_annual: false + }); + + if (response.checkout_url) { + window.location.href = response.checkout_url; + } + } catch (error) { + billingLog.error('Error creating checkout:', error); + Utils.showToast(I18n.t('billing.messages.failed_to_create_checkout_session'), 'error'); + } + }, + + async openPortal() { + try { + const response = await apiClient.post('/store/billing/portal', {}); + if (response.portal_url) { + window.location.href = response.portal_url; + } + } catch (error) { + billingLog.error('Error opening portal:', error); + Utils.showToast(I18n.t('billing.messages.failed_to_open_payment_portal'), 'error'); + } + }, + + async cancelSubscription() { + try { + await apiClient.post('/store/billing/cancel', { + reason: this.cancelReason, + immediately: false + }); + + this.showCancelModal = false; + Utils.showToast(I18n.t('billing.messages.subscription_cancelled_you_have_access_u'), 'success'); + await this.loadData(); + + } catch (error) { + billingLog.error('Error cancelling subscription:', error); + Utils.showToast(I18n.t('billing.messages.failed_to_cancel_subscription'), 'error'); + } + }, + + async reactivate() { + try { + await apiClient.post('/store/billing/reactivate', {}); + Utils.showToast(I18n.t('billing.messages.subscription_reactivated'), 'success'); + await this.loadData(); + + } catch (error) { + billingLog.error('Error reactivating subscription:', error); + Utils.showToast(I18n.t('billing.messages.failed_to_reactivate_subscription'), 'error'); + } + }, + + async purchaseAddon(addon) { + this.purchasingAddon = addon.code; + try { + const response = await apiClient.post('/store/billing/addons/purchase', { + addon_code: addon.code, + quantity: 1 + }); + + if (response.checkout_url) { + window.location.href = response.checkout_url; + } + } catch (error) { + billingLog.error('Error purchasing addon:', error); + Utils.showToast(I18n.t('billing.messages.failed_to_purchase_addon'), 'error'); + } finally { + this.purchasingAddon = null; + } + }, + + async cancelAddon(addon) { + if (!confirm(`Are you sure you want to cancel ${addon.addon_name}?`)) { + return; + } + + try { + await apiClient.delete(`/store/billing/addons/${addon.id}`); + Utils.showToast(I18n.t('billing.messages.addon_cancelled_successfully'), 'success'); + await this.loadData(); + } catch (error) { + billingLog.error('Error cancelling addon:', error); + Utils.showToast(I18n.t('billing.messages.failed_to_cancel_addon'), 'error'); + } + }, + + // Check if addon is already purchased + isAddonPurchased(addonCode) { + return this.myAddons.some(a => a.addon_code === addonCode && a.status === 'active'); + }, + + // Formatters + formatDate(dateString) { + if (!dateString) return '-'; + const date = new Date(dateString); + const locale = window.STORE_CONFIG?.locale || 'en-GB'; + return date.toLocaleDateString(locale, { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + }, + + formatCurrency(cents, currency = 'EUR') { + if (cents === null || cents === undefined) return '-'; + const amount = cents / 100; + const locale = window.STORE_CONFIG?.locale || 'en-GB'; + const currencyCode = window.STORE_CONFIG?.currency || currency; + return new Intl.NumberFormat(locale, { + style: 'currency', + currency: currencyCode + }).format(amount); + } + }; +} diff --git a/app/modules/billing/templates/billing/admin/subscription-tiers.html b/app/modules/billing/templates/billing/admin/subscription-tiers.html index c3e4f946..782ca10b 100644 --- a/app/modules/billing/templates/billing/admin/subscription-tiers.html +++ b/app/modules/billing/templates/billing/admin/subscription-tiers.html @@ -91,9 +91,6 @@ {{ th_sortable('name', 'Name', 'sortBy', 'sortOrder') }} Monthly Annual - Orders/Mo - Products - Team Features Status Actions @@ -101,7 +98,7 @@