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') }}
| Orders | -Products | -Team | +Features | Period End | Actions | {% endcall %}||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| + | Loading subscriptions... | @@ -160,7 +158,7 @@||||||||||||||||||||||||||||||||||||||||||
| + | No subscriptions found. |
-
-
+
+
|
@@ -197,15 +195,8 @@
x-text="sub.status.replace('_', ' ').toUpperCase()">
- - / - - | -- - | -- + |
@@ -213,7 +204,7 @@
-
+
@@ -233,7 +224,7 @@
Edit Subscription- +
@@ -265,39 +256,35 @@
-
+
- Custom Limit Overrides+Feature OverridesLeave empty to use tier defaults -
-
diff --git a/app/modules/billing/templates/billing/merchant/subscription-detail.html b/app/modules/billing/templates/billing/merchant/subscription-detail.html
new file mode 100644
index 00000000..3f6a9be8
--- /dev/null
+++ b/app/modules/billing/templates/billing/merchant/subscription-detail.html
@@ -0,0 +1,215 @@
+{# app/modules/billing/templates/billing/merchant/subscription-detail.html #}
+{% extends "merchant/base.html" %}
+
+{% block title %}Subscription Details{% endblock %}
+
+{% block content %}
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+ Loading features...
+
+
+
+
+
+
+
+
+
+
+ No quantitative features available +
+
+
+
+{% endblock %}
+
+{% block extra_scripts %}
+
+{% endblock %}
diff --git a/app/modules/billing/templates/billing/store/billing.html b/app/modules/billing/templates/billing/store/billing.html
new file mode 100644
index 00000000..7d1df469
--- /dev/null
+++ b/app/modules/billing/templates/billing/store/billing.html
@@ -0,0 +1,406 @@
+{# app/templates/store/billing.html #}
+{% extends "store/base.html" %}
+{% from 'shared/macros/headers.html' import page_header %}
+{% from 'shared/macros/modals.html' import modal_simple %}
+
+{% block title %}Billing & Subscription{% endblock %}
+
+{% block alpine_data %}storeBilling(){% endblock %}
+
+{% block content %}
+{{ page_header('Billing & Subscription') }}
+
+
+
+
+
+
+ Back to Subscriptions
+
+
+
+
+ Subscription Details+
+
+ Loading...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Plan Features+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No feature limits configured for this tier +
+
+
+
+
+
+
+
+
+
+
+ Your subscription has been updated successfully!
+
+
+
+
+
+
+
+
+
+ Checkout was cancelled. No changes were made to your subscription.
+
+
+
+
+
+
+
+
+
+
+ Add-on purchased successfully!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Current Plan+ + + +
+
+
+
+
+ + Trial ends + + + ++ Cancels on + + +
+
+
+
+ + Next billing: + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Usage This Period+ + +No usage data available + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + Payment issue: + +
+
+ Quick Actions+ +
+
+
+
+
+
+
+
+
+ View Invoices
+
+
+
+
+
+
+
+
+{% call modal_simple('tiersModal', 'Choose Your Plan', show_var='showTiersModal', size='xl') %}
+Recent Invoices+ + +No invoices yet + + + +
+
+
+
+
+
+{% endcall %}
+
+
+
+
+ Current
+
+
+
+
++ + /mo + +
+
+
+
+
+
+
+
+ Add-ons+ +
+
+
+
+
+
+
+
+
+ Your Active Add-ons+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + +Available Add-ons+ +No add-ons available + +
+
+
+
+
+
+
+
+
+
+
+ + + + +
+
+
+{% endblock %}
+
+{% block extra_scripts %}
+
+{% endblock %}
diff --git a/app/modules/tenancy/static/admin/js/store-detail.js b/app/modules/tenancy/static/admin/js/store-detail.js
new file mode 100644
index 00000000..733da8d1
--- /dev/null
+++ b/app/modules/tenancy/static/admin/js/store-detail.js
@@ -0,0 +1,232 @@
+// noqa: js-006 - async init pattern is safe, loadData has try/catch
+// static/admin/js/store-detail.js
+
+// ✅ Use centralized logger - ONE LINE!
+// Create custom logger for store detail
+const detailLog = window.LogConfig.createLogger('STORE-DETAIL');
+
+function adminStoreDetail() {
+ return {
+ // Inherit base layout functionality from init-alpine.js
+ ...data(),
+
+ // Store detail page specific state
+ currentPage: 'store-detail',
+ store: null,
+ subscription: null,
+ subscriptionTier: null,
+ usageMetrics: [],
+ loading: false,
+ error: null,
+ storeCode: null,
+ showSubscriptionModal: false,
+
+ // Initialize
+ async init() {
+ // Load i18n translations
+ await I18n.loadModule('tenancy');
+
+ detailLog.info('=== STORE DETAIL PAGE INITIALIZING ===');
+
+ // Prevent multiple initializations
+ if (window._storeDetailInitialized) {
+ detailLog.warn('Store detail page already initialized, skipping...');
+ return;
+ }
+ window._storeDetailInitialized = true;
+
+ // Get store code from URL
+ const path = window.location.pathname;
+ const match = path.match(/\/admin\/stores\/([^\/]+)$/);
+
+ if (match) {
+ this.storeCode = match[1];
+ detailLog.info('Viewing store:', this.storeCode);
+ await this.loadStore();
+ // Load subscription after store is loaded
+ if (this.store?.id) {
+ await this.loadSubscription();
+ }
+ } else {
+ detailLog.error('No store code in URL');
+ this.error = 'Invalid store URL';
+ Utils.showToast(I18n.t('tenancy.messages.invalid_store_url'), 'error');
+ }
+
+ detailLog.info('=== STORE DETAIL PAGE INITIALIZATION COMPLETE ===');
+ },
+
+ // Load store data
+ async loadStore() {
+ detailLog.info('Loading store details...');
+ this.loading = true;
+ this.error = null;
+
+ try {
+ const url = `/admin/stores/${this.storeCode}`;
+ window.LogConfig.logApiCall('GET', url, null, 'request');
+
+ const startTime = performance.now();
+ const response = await apiClient.get(url);
+ const duration = performance.now() - startTime;
+
+ window.LogConfig.logApiCall('GET', url, response, 'response');
+ window.LogConfig.logPerformance('Load Store Details', duration);
+
+ this.store = response;
+
+ detailLog.info(`Store loaded in ${duration}ms`, {
+ store_code: this.store.store_code,
+ name: this.store.name,
+ is_verified: this.store.is_verified,
+ is_active: this.store.is_active
+ });
+ detailLog.debug('Full store data:', this.store);
+
+ } catch (error) {
+ window.LogConfig.logError(error, 'Load Store Details');
+ this.error = error.message || 'Failed to load store details';
+ Utils.showToast(I18n.t('tenancy.messages.failed_to_load_store_details'), 'error');
+ } finally {
+ this.loading = false;
+ }
+ },
+
+ // Format date (matches dashboard pattern)
+ formatDate(dateString) {
+ if (!dateString) {
+ detailLog.debug('formatDate called with empty dateString');
+ return '-';
+ }
+ const formatted = Utils.formatDate(dateString);
+ detailLog.debug(`Date formatted: ${dateString} -> ${formatted}`);
+ return formatted;
+ },
+
+ // Load subscription data for this store via convenience endpoint
+ async loadSubscription() {
+ if (!this.store?.id) {
+ detailLog.warn('Cannot load subscription: no store ID');
+ return;
+ }
+
+ detailLog.info('Loading subscription for store:', this.store.id);
+
+ try {
+ const url = `/admin/subscriptions/store/${this.store.id}`;
+ window.LogConfig.logApiCall('GET', url, null, 'request');
+
+ const response = await apiClient.get(url);
+ window.LogConfig.logApiCall('GET', url, response, 'response');
+
+ this.subscription = response.subscription;
+ this.subscriptionTier = response.tier;
+ this.usageMetrics = response.features || [];
+
+ detailLog.info('Subscription loaded:', {
+ tier: this.subscription?.tier,
+ status: this.subscription?.status,
+ features_count: this.usageMetrics.length
+ });
+
+ } catch (error) {
+ // 404 means no subscription exists - that's OK
+ if (error.status === 404) {
+ detailLog.info('No subscription found for store');
+ this.subscription = null;
+ this.usageMetrics = [];
+ } else {
+ detailLog.warn('Failed to load subscription:', error.message);
+ }
+ }
+ },
+
+ // Get usage bar color based on percentage
+ getUsageBarColor(current, limit) {
+ if (!limit || limit === 0) return 'bg-blue-500';
+ const percent = (current / limit) * 100;
+ if (percent >= 90) return 'bg-red-500';
+ if (percent >= 75) return 'bg-yellow-500';
+ return 'bg-green-500';
+ },
+
+ // Create a new subscription for this store
+ async createSubscription() {
+ if (!this.store?.id) {
+ Utils.showToast(I18n.t('tenancy.messages.no_store_loaded'), 'error');
+ return;
+ }
+
+ detailLog.info('Creating subscription for store:', this.store.id);
+
+ try {
+ // Create a trial subscription with default tier
+ const url = `/admin/subscriptions/${this.store.id}`;
+ const data = {
+ tier: 'essential',
+ status: 'trial',
+ trial_days: 14,
+ is_annual: false
+ };
+
+ window.LogConfig.logApiCall('POST', url, data, 'request');
+ const response = await apiClient.post(url, data);
+ window.LogConfig.logApiCall('POST', url, response, 'response');
+
+ this.subscription = response;
+ Utils.showToast(I18n.t('tenancy.messages.subscription_created_successfully'), 'success');
+ detailLog.info('Subscription created:', this.subscription);
+
+ } catch (error) {
+ window.LogConfig.logError(error, 'Create Subscription');
+ Utils.showToast(error.message || 'Failed to create subscription', 'error');
+ }
+ },
+
+ // Delete store
+ async deleteStore() {
+ detailLog.info('Delete store requested:', this.storeCode);
+
+ if (!confirm(`Are you sure you want to delete store "${this.store.name}"?\n\nThis action cannot be undone and will delete:\n- All products\n- All orders\n- All customers\n- All team members`)) {
+ detailLog.info('Delete cancelled by user');
+ return;
+ }
+
+ // Second confirmation for safety
+ if (!confirm(`FINAL CONFIRMATION\n\nType the store code to confirm: ${this.store.store_code}\n\nAre you absolutely sure?`)) {
+ detailLog.info('Delete cancelled by user (second confirmation)');
+ return;
+ }
+
+ try {
+ const url = `/admin/stores/${this.storeCode}?confirm=true`;
+ window.LogConfig.logApiCall('DELETE', url, null, 'request');
+
+ detailLog.info('Deleting store:', this.storeCode);
+ await apiClient.delete(url);
+
+ window.LogConfig.logApiCall('DELETE', url, null, 'response');
+
+ Utils.showToast(I18n.t('tenancy.messages.store_deleted_successfully'), 'success');
+ detailLog.info('Store deleted successfully');
+
+ // Redirect to stores list
+ setTimeout(() => window.location.href = '/admin/stores', 1500);
+
+ } catch (error) {
+ window.LogConfig.logError(error, 'Delete Store');
+ Utils.showToast(error.message || 'Failed to delete store', 'error');
+ }
+ },
+
+ // Refresh store data
+ async refresh() {
+ detailLog.info('=== STORE REFRESH TRIGGERED ===');
+ await this.loadStore();
+ Utils.showToast(I18n.t('tenancy.messages.store_details_refreshed'), 'success');
+ detailLog.info('=== STORE REFRESH COMPLETE ===');
+ }
+ };
+}
+
+detailLog.info('Store detail module loaded');
\ No newline at end of file
diff --git a/app/modules/tenancy/templates/tenancy/admin/store-detail.html b/app/modules/tenancy/templates/tenancy/admin/store-detail.html
new file mode 100644
index 00000000..55742138
--- /dev/null
+++ b/app/modules/tenancy/templates/tenancy/admin/store-detail.html
@@ -0,0 +1,388 @@
+{# app/templates/admin/store-detail.html #}
+{% extends "admin/base.html" %}
+{% from 'shared/macros/alerts.html' import loading_state, error_state %}
+{% from 'shared/macros/headers.html' import detail_page_header %}
+
+{% block title %}Store Details{% endblock %}
+
+{% block alpine_data %}adminStoreDetail(){% endblock %}
+
+{% block content %}
+{% call detail_page_header("store?.name || 'Store Details'", '/admin/stores', subtitle_show='store') %}
+
+ •
+
+{% endcall %}
+
+{{ loading_state('Loading store details...') }}
+
+{{ error_state('Error loading store') }}
+
+
+
+
+
+
+ Cancel Subscription+ +
+
+ + Are you sure you want to cancel your subscription? You'll continue to have access until the end of your current billing period. + +
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block extra_scripts %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/tests/unit/services/test_billing_service.py b/tests/unit/services/test_billing_service.py
index 1c09c710..1e7ebb0c 100644
--- a/tests/unit/services/test_billing_service.py
+++ b/tests/unit/services/test_billing_service.py
@@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch
import pytest
-from app.modules.tenancy.exceptions import VendorNotFoundException
+from app.modules.tenancy.exceptions import StoreNotFoundException
from app.modules.billing.services.billing_service import (
BillingService,
NoActiveSubscriptionError,
@@ -18,10 +18,10 @@ from app.modules.billing.services.billing_service import (
from app.modules.billing.models import (
AddOnProduct,
BillingHistory,
+ MerchantSubscription,
SubscriptionStatus,
SubscriptionTier,
- VendorAddOn,
- VendorSubscription,
+ StoreAddOn,
)
@@ -35,22 +35,22 @@ class TestBillingServiceSubscription:
self.service = BillingService()
def test_get_subscription_with_tier_creates_if_not_exists(
- self, db, test_vendor, test_subscription_tier
+ self, db, test_store, test_subscription_tier
):
"""Test get_subscription_with_tier creates subscription if needed."""
- subscription, tier = self.service.get_subscription_with_tier(db, test_vendor.id)
+ subscription, tier = self.service.get_subscription_with_tier(db, test_store.id)
assert subscription is not None
- assert subscription.vendor_id == test_vendor.id
+ assert subscription.store_id == test_store.id
assert tier is not None
assert tier.code == subscription.tier
def test_get_subscription_with_tier_returns_existing(
- self, db, test_vendor, test_subscription
+ self, db, test_store, test_subscription
):
"""Test get_subscription_with_tier returns existing subscription."""
# Note: test_subscription fixture already creates the tier
- subscription, tier = self.service.get_subscription_with_tier(db, test_vendor.id)
+ subscription, tier = self.service.get_subscription_with_tier(db, test_store.id)
assert subscription.id == test_subscription.id
assert tier.code == test_subscription.tier
@@ -109,7 +109,7 @@ class TestBillingServiceCheckout:
@patch("app.modules.billing.services.billing_service.stripe_service")
def test_create_checkout_session_stripe_not_configured(
- self, mock_stripe, db, test_vendor, test_subscription_tier
+ self, mock_stripe, db, test_store, test_subscription_tier
):
"""Test checkout fails when Stripe not configured."""
mock_stripe.is_configured = False
@@ -117,7 +117,7 @@ class TestBillingServiceCheckout:
with pytest.raises(PaymentSystemNotConfiguredError):
self.service.create_checkout_session(
db=db,
- vendor_id=test_vendor.id,
+ store_id=test_store.id,
tier_code="essential",
is_annual=False,
success_url="https://example.com/success",
@@ -126,7 +126,7 @@ class TestBillingServiceCheckout:
@patch("app.modules.billing.services.billing_service.stripe_service")
def test_create_checkout_session_success(
- self, mock_stripe, db, test_vendor, test_subscription_tier_with_stripe
+ self, mock_stripe, db, test_store, test_subscription_tier_with_stripe
):
"""Test successful checkout session creation."""
mock_stripe.is_configured = True
@@ -137,7 +137,7 @@ class TestBillingServiceCheckout:
result = self.service.create_checkout_session(
db=db,
- vendor_id=test_vendor.id,
+ store_id=test_store.id,
tier_code="essential",
is_annual=False,
success_url="https://example.com/success",
@@ -149,7 +149,7 @@ class TestBillingServiceCheckout:
@patch("app.modules.billing.services.billing_service.stripe_service")
def test_create_checkout_session_tier_not_found(
- self, mock_stripe, db, test_vendor
+ self, mock_stripe, db, test_store
):
"""Test checkout fails with invalid tier."""
mock_stripe.is_configured = True
@@ -157,7 +157,7 @@ class TestBillingServiceCheckout:
with pytest.raises(TierNotFoundError):
self.service.create_checkout_session(
db=db,
- vendor_id=test_vendor.id,
+ store_id=test_store.id,
tier_code="nonexistent",
is_annual=False,
success_url="https://example.com/success",
@@ -166,7 +166,7 @@ class TestBillingServiceCheckout:
@patch("app.modules.billing.services.billing_service.stripe_service")
def test_create_checkout_session_no_price(
- self, mock_stripe, db, test_vendor, test_subscription_tier
+ self, mock_stripe, db, test_store, test_subscription_tier
):
"""Test checkout fails when tier has no Stripe price."""
mock_stripe.is_configured = True
@@ -174,7 +174,7 @@ class TestBillingServiceCheckout:
with pytest.raises(StripePriceNotConfiguredError):
self.service.create_checkout_session(
db=db,
- vendor_id=test_vendor.id,
+ store_id=test_store.id,
tier_code="essential",
is_annual=False,
success_url="https://example.com/success",
@@ -192,32 +192,32 @@ class TestBillingServicePortal:
self.service = BillingService()
@patch("app.modules.billing.services.billing_service.stripe_service")
- def test_create_portal_session_stripe_not_configured(self, mock_stripe, db, test_vendor):
+ def test_create_portal_session_stripe_not_configured(self, mock_stripe, db, test_store):
"""Test portal fails when Stripe not configured."""
mock_stripe.is_configured = False
with pytest.raises(PaymentSystemNotConfiguredError):
self.service.create_portal_session(
db=db,
- vendor_id=test_vendor.id,
+ store_id=test_store.id,
return_url="https://example.com/billing",
)
@patch("app.modules.billing.services.billing_service.stripe_service")
- def test_create_portal_session_no_subscription(self, mock_stripe, db, test_vendor):
+ def test_create_portal_session_no_subscription(self, mock_stripe, db, test_store):
"""Test portal fails when no subscription exists."""
mock_stripe.is_configured = True
with pytest.raises(NoActiveSubscriptionError):
self.service.create_portal_session(
db=db,
- vendor_id=test_vendor.id,
+ store_id=test_store.id,
return_url="https://example.com/billing",
)
@patch("app.modules.billing.services.billing_service.stripe_service")
def test_create_portal_session_success(
- self, mock_stripe, db, test_vendor, test_active_subscription
+ self, mock_stripe, db, test_store, test_active_subscription
):
"""Test successful portal session creation."""
mock_stripe.is_configured = True
@@ -227,7 +227,7 @@ class TestBillingServicePortal:
result = self.service.create_portal_session(
db=db,
- vendor_id=test_vendor.id,
+ store_id=test_store.id,
return_url="https://example.com/billing",
)
@@ -243,30 +243,30 @@ class TestBillingServiceInvoices:
"""Initialize service instance before each test."""
self.service = BillingService()
- def test_get_invoices_empty(self, db, test_vendor):
+ def test_get_invoices_empty(self, db, test_store):
"""Test getting invoices when none exist."""
- invoices, total = self.service.get_invoices(db, test_vendor.id)
+ invoices, total = self.service.get_invoices(db, test_store.id)
assert invoices == []
assert total == 0
- def test_get_invoices_with_data(self, db, test_vendor, test_billing_history):
+ def test_get_invoices_with_data(self, db, test_store, test_billing_history):
"""Test getting invoices returns data."""
- invoices, total = self.service.get_invoices(db, test_vendor.id)
+ invoices, total = self.service.get_invoices(db, test_store.id)
assert len(invoices) == 1
assert total == 1
assert invoices[0].invoice_number == "INV-001"
- def test_get_invoices_pagination(self, db, test_vendor, test_multiple_invoices):
+ def test_get_invoices_pagination(self, db, test_store, test_multiple_invoices):
"""Test invoice pagination."""
# Get first page
- page1, total = self.service.get_invoices(db, test_vendor.id, skip=0, limit=2)
+ page1, total = self.service.get_invoices(db, test_store.id, skip=0, limit=2)
assert len(page1) == 2
assert total == 5
# Get second page
- page2, _ = self.service.get_invoices(db, test_vendor.id, skip=2, limit=2)
+ page2, _ = self.service.get_invoices(db, test_store.id, skip=2, limit=2)
assert len(page2) == 2
@@ -298,9 +298,9 @@ class TestBillingServiceAddons:
assert len(domain_addons) == 1
assert domain_addons[0].category == "domain"
- def test_get_vendor_addons_empty(self, db, test_vendor):
- """Test getting vendor addons when none purchased."""
- addons = self.service.get_vendor_addons(db, test_vendor.id)
+ def test_get_store_addons_empty(self, db, test_store):
+ """Test getting store addons when none purchased."""
+ addons = self.service.get_store_addons(db, test_store.id)
assert addons == []
@@ -315,7 +315,7 @@ class TestBillingServiceCancellation:
@patch("app.modules.billing.services.billing_service.stripe_service")
def test_cancel_subscription_no_subscription(
- self, mock_stripe, db, test_vendor
+ self, mock_stripe, db, test_store
):
"""Test cancel fails when no subscription."""
mock_stripe.is_configured = True
@@ -323,21 +323,21 @@ class TestBillingServiceCancellation:
with pytest.raises(NoActiveSubscriptionError):
self.service.cancel_subscription(
db=db,
- vendor_id=test_vendor.id,
+ store_id=test_store.id,
reason="Test reason",
immediately=False,
)
@patch("app.modules.billing.services.billing_service.stripe_service")
def test_cancel_subscription_success(
- self, mock_stripe, db, test_vendor, test_active_subscription
+ self, mock_stripe, db, test_store, test_active_subscription
):
"""Test successful subscription cancellation."""
mock_stripe.is_configured = True
result = self.service.cancel_subscription(
db=db,
- vendor_id=test_vendor.id,
+ store_id=test_store.id,
reason="Too expensive",
immediately=False,
)
@@ -348,22 +348,22 @@ class TestBillingServiceCancellation:
@patch("app.modules.billing.services.billing_service.stripe_service")
def test_reactivate_subscription_not_cancelled(
- self, mock_stripe, db, test_vendor, test_active_subscription
+ self, mock_stripe, db, test_store, test_active_subscription
):
"""Test reactivate fails when subscription not cancelled."""
mock_stripe.is_configured = True
with pytest.raises(SubscriptionNotCancelledError):
- self.service.reactivate_subscription(db, test_vendor.id)
+ self.service.reactivate_subscription(db, test_store.id)
@patch("app.modules.billing.services.billing_service.stripe_service")
def test_reactivate_subscription_success(
- self, mock_stripe, db, test_vendor, test_cancelled_subscription
+ self, mock_stripe, db, test_store, test_cancelled_subscription
):
"""Test successful subscription reactivation."""
mock_stripe.is_configured = True
- result = self.service.reactivate_subscription(db, test_vendor.id)
+ result = self.service.reactivate_subscription(db, test_store.id)
assert result["message"] == "Subscription reactivated successfully"
assert test_cancelled_subscription.cancelled_at is None
@@ -372,23 +372,23 @@ class TestBillingServiceCancellation:
@pytest.mark.unit
@pytest.mark.billing
-class TestBillingServiceVendor:
- """Test suite for BillingService vendor operations."""
+class TestBillingServiceStore:
+ """Test suite for BillingService store operations."""
def setup_method(self):
"""Initialize service instance before each test."""
self.service = BillingService()
- def test_get_vendor_success(self, db, test_vendor):
- """Test getting vendor by ID."""
- vendor = self.service.get_vendor(db, test_vendor.id)
+ def test_get_store_success(self, db, test_store):
+ """Test getting store by ID."""
+ store = self.service.get_store(db, test_store.id)
- assert vendor.id == test_vendor.id
+ assert store.id == test_store.id
- def test_get_vendor_not_found(self, db):
- """Test getting non-existent vendor raises error."""
- with pytest.raises(VendorNotFoundException):
- self.service.get_vendor(db, 99999)
+ def test_get_store_not_found(self, db):
+ """Test getting non-existent store raises error."""
+ with pytest.raises(StoreNotFoundException):
+ self.service.get_store(db, 99999)
# ==================== Fixtures ====================
@@ -480,7 +480,7 @@ def test_subscription_tiers(db):
@pytest.fixture
-def test_subscription(db, test_vendor):
+def test_subscription(db, test_store):
"""Create a basic subscription for testing."""
# Create tier first
tier = SubscriptionTier(
@@ -494,8 +494,8 @@ def test_subscription(db, test_vendor):
db.add(tier)
db.commit()
- subscription = VendorSubscription(
- vendor_id=test_vendor.id,
+ subscription = MerchantSubscription(
+ store_id=test_store.id,
tier="essential",
status=SubscriptionStatus.ACTIVE,
period_start=datetime.now(timezone.utc),
@@ -508,7 +508,7 @@ def test_subscription(db, test_vendor):
@pytest.fixture
-def test_active_subscription(db, test_vendor):
+def test_active_subscription(db, test_store):
"""Create an active subscription with Stripe IDs."""
# Create tier first if not exists
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first()
@@ -524,8 +524,8 @@ def test_active_subscription(db, test_vendor):
db.add(tier)
db.commit()
- subscription = VendorSubscription(
- vendor_id=test_vendor.id,
+ subscription = MerchantSubscription(
+ store_id=test_store.id,
tier="essential",
status=SubscriptionStatus.ACTIVE,
stripe_customer_id="cus_test123",
@@ -540,7 +540,7 @@ def test_active_subscription(db, test_vendor):
@pytest.fixture
-def test_cancelled_subscription(db, test_vendor):
+def test_cancelled_subscription(db, test_store):
"""Create a cancelled subscription."""
# Create tier first if not exists
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first()
@@ -556,8 +556,8 @@ def test_cancelled_subscription(db, test_vendor):
db.add(tier)
db.commit()
- subscription = VendorSubscription(
- vendor_id=test_vendor.id,
+ subscription = MerchantSubscription(
+ store_id=test_store.id,
tier="essential",
status=SubscriptionStatus.ACTIVE,
stripe_customer_id="cus_test123",
@@ -574,10 +574,10 @@ def test_cancelled_subscription(db, test_vendor):
@pytest.fixture
-def test_billing_history(db, test_vendor):
+def test_billing_history(db, test_store):
"""Create a billing history record."""
record = BillingHistory(
- vendor_id=test_vendor.id,
+ store_id=test_store.id,
stripe_invoice_id="in_test123",
invoice_number="INV-001",
invoice_date=datetime.now(timezone.utc),
@@ -595,12 +595,12 @@ def test_billing_history(db, test_vendor):
@pytest.fixture
-def test_multiple_invoices(db, test_vendor):
+def test_multiple_invoices(db, test_store):
"""Create multiple billing history records."""
records = []
for i in range(5):
record = BillingHistory(
- vendor_id=test_vendor.id,
+ store_id=test_store.id,
stripe_invoice_id=f"in_test{i}",
invoice_number=f"INV-{i:03d}",
invoice_date=datetime.now(timezone.utc),
diff --git a/tests/unit/services/test_feature_service.py b/tests/unit/services/test_feature_service.py
index 6b6ff54e..22df5e5e 100644
--- a/tests/unit/services/test_feature_service.py
+++ b/tests/unit/services/test_feature_service.py
@@ -5,8 +5,7 @@ import pytest
from app.modules.billing.exceptions import FeatureNotFoundError, InvalidFeatureCodesError, TierNotFoundError
from app.modules.billing.services.feature_service import FeatureService, feature_service
-from app.modules.billing.models import Feature
-from app.modules.billing.models import SubscriptionTier, VendorSubscription
+from app.modules.billing.models import SubscriptionTier, MerchantSubscription
@pytest.mark.unit
@@ -18,27 +17,27 @@ class TestFeatureServiceAvailability:
"""Initialize service instance before each test."""
self.service = FeatureService()
- def test_has_feature_true(self, db, test_vendor_with_subscription):
+ def test_has_feature_true(self, db, test_store_with_subscription):
"""Test has_feature returns True for available feature."""
- vendor_id = test_vendor_with_subscription.id
- result = self.service.has_feature(db, vendor_id, "basic_reports")
+ store_id = test_store_with_subscription.id
+ result = self.service.has_feature(db, store_id, "basic_reports")
assert result is True
- def test_has_feature_false(self, db, test_vendor_with_subscription):
+ def test_has_feature_false(self, db, test_store_with_subscription):
"""Test has_feature returns False for unavailable feature."""
- vendor_id = test_vendor_with_subscription.id
- result = self.service.has_feature(db, vendor_id, "api_access")
+ store_id = test_store_with_subscription.id
+ result = self.service.has_feature(db, store_id, "api_access")
assert result is False
- def test_has_feature_no_subscription(self, db, test_vendor):
- """Test has_feature returns False for vendor without subscription."""
- result = self.service.has_feature(db, test_vendor.id, "basic_reports")
+ def test_has_feature_no_subscription(self, db, test_store):
+ """Test has_feature returns False for store without subscription."""
+ result = self.service.has_feature(db, test_store.id, "basic_reports")
assert result is False
- def test_get_vendor_feature_codes(self, db, test_vendor_with_subscription):
- """Test getting all feature codes for vendor."""
- vendor_id = test_vendor_with_subscription.id
- features = self.service.get_vendor_feature_codes(db, vendor_id)
+ def test_get_store_feature_codes(self, db, test_store_with_subscription):
+ """Test getting all feature codes for store."""
+ store_id = test_store_with_subscription.id
+ features = self.service.get_store_feature_codes(db, store_id)
assert isinstance(features, set)
assert "basic_reports" in features
@@ -54,10 +53,10 @@ class TestFeatureServiceListing:
"""Initialize service instance before each test."""
self.service = FeatureService()
- def test_get_vendor_features(self, db, test_vendor_with_subscription, test_features):
+ def test_get_store_features(self, db, test_store_with_subscription, test_features):
"""Test getting all features with availability."""
- vendor_id = test_vendor_with_subscription.id
- features = self.service.get_vendor_features(db, vendor_id)
+ store_id = test_store_with_subscription.id
+ features = self.service.get_store_features(db, store_id)
assert len(features) > 0
basic_reports = next((f for f in features if f.code == "basic_reports"), None)
@@ -68,30 +67,30 @@ class TestFeatureServiceListing:
assert api_access is not None
assert api_access.is_available is False
- def test_get_vendor_features_by_category(
- self, db, test_vendor_with_subscription, test_features
+ def test_get_store_features_by_category(
+ self, db, test_store_with_subscription, test_features
):
"""Test filtering features by category."""
- vendor_id = test_vendor_with_subscription.id
- features = self.service.get_vendor_features(db, vendor_id, category="analytics")
+ store_id = test_store_with_subscription.id
+ features = self.service.get_store_features(db, store_id, category="analytics")
assert all(f.category == "analytics" for f in features)
- def test_get_vendor_features_available_only(
- self, db, test_vendor_with_subscription, test_features
+ def test_get_store_features_available_only(
+ self, db, test_store_with_subscription, test_features
):
"""Test getting only available features."""
- vendor_id = test_vendor_with_subscription.id
- features = self.service.get_vendor_features(
- db, vendor_id, include_unavailable=False
+ store_id = test_store_with_subscription.id
+ features = self.service.get_store_features(
+ db, store_id, include_unavailable=False
)
assert all(f.is_available for f in features)
- def test_get_available_feature_codes(self, db, test_vendor_with_subscription):
+ def test_get_available_feature_codes(self, db, test_store_with_subscription):
"""Test getting simple list of available codes."""
- vendor_id = test_vendor_with_subscription.id
- codes = self.service.get_available_feature_codes(db, vendor_id)
+ store_id = test_store_with_subscription.id
+ codes = self.service.get_available_feature_codes(db, store_id)
assert isinstance(codes, list)
assert "basic_reports" in codes
@@ -159,28 +158,28 @@ class TestFeatureServiceCache:
"""Initialize service instance before each test."""
self.service = FeatureService()
- def test_cache_invalidation(self, db, test_vendor_with_subscription):
- """Test cache invalidation for vendor."""
- vendor_id = test_vendor_with_subscription.id
+ def test_cache_invalidation(self, db, test_store_with_subscription):
+ """Test cache invalidation for store."""
+ store_id = test_store_with_subscription.id
# Prime the cache
- self.service.get_vendor_feature_codes(db, vendor_id)
- assert self.service._cache.get(vendor_id) is not None
+ self.service.get_store_feature_codes(db, store_id)
+ assert self.service._cache.get(store_id) is not None
# Invalidate
- self.service.invalidate_vendor_cache(vendor_id)
- assert self.service._cache.get(vendor_id) is None
+ self.service.invalidate_store_cache(store_id)
+ assert self.service._cache.get(store_id) is None
- def test_cache_invalidate_all(self, db, test_vendor_with_subscription):
+ def test_cache_invalidate_all(self, db, test_store_with_subscription):
"""Test invalidating entire cache."""
- vendor_id = test_vendor_with_subscription.id
+ store_id = test_store_with_subscription.id
# Prime the cache
- self.service.get_vendor_feature_codes(db, vendor_id)
+ self.service.get_store_feature_codes(db, store_id)
# Invalidate all
self.service.invalidate_all_cache()
- assert self.service._cache.get(vendor_id) is None
+ assert self.service._cache.get(store_id) is None
@pytest.mark.unit
@@ -301,14 +300,14 @@ def test_subscription_tiers(db):
@pytest.fixture
-def test_vendor_with_subscription(db, test_vendor, test_subscription_tiers):
- """Create a vendor with an active subscription."""
+def test_store_with_subscription(db, test_store, test_subscription_tiers):
+ """Create a store with an active subscription."""
from datetime import datetime, timezone
essential_tier = test_subscription_tiers[0] # Use the essential tier from tiers list
now = datetime.now(timezone.utc)
- subscription = VendorSubscription(
- vendor_id=test_vendor.id,
+ subscription = StoreSubscription(
+ store_id=test_store.id,
tier="essential",
tier_id=essential_tier.id,
status="active",
@@ -318,8 +317,8 @@ def test_vendor_with_subscription(db, test_vendor, test_subscription_tiers):
)
db.add(subscription)
db.commit()
- db.refresh(test_vendor)
- return test_vendor
+ db.refresh(test_store)
+ return test_store
@pytest.fixture
diff --git a/tests/unit/services/test_stripe_webhook_handler.py b/tests/unit/services/test_stripe_webhook_handler.py
index 11c14fe0..d035c772 100644
--- a/tests/unit/services/test_stripe_webhook_handler.py
+++ b/tests/unit/services/test_stripe_webhook_handler.py
@@ -9,10 +9,10 @@ import pytest
from app.handlers.stripe_webhook import StripeWebhookHandler
from app.modules.billing.models import (
BillingHistory,
+ MerchantSubscription,
StripeWebhookEvent,
SubscriptionStatus,
SubscriptionTier,
- VendorSubscription,
)
@@ -61,7 +61,7 @@ class TestStripeWebhookHandlerCheckout:
@patch("app.handlers.stripe_webhook.stripe.Subscription.retrieve")
def test_handle_checkout_completed_success(
- self, mock_stripe_retrieve, db, test_vendor, test_subscription, mock_checkout_event
+ self, mock_stripe_retrieve, db, test_store, test_subscription, mock_checkout_event
):
"""Test successful checkout completion."""
# Mock Stripe subscription retrieve
@@ -71,7 +71,7 @@ class TestStripeWebhookHandlerCheckout:
mock_stripe_sub.trial_end = None
mock_stripe_retrieve.return_value = mock_stripe_sub
- mock_checkout_event.data.object.metadata = {"vendor_id": str(test_vendor.id)}
+ mock_checkout_event.data.object.metadata = {"store_id": str(test_store.id)}
result = self.handler.handle_event(db, mock_checkout_event)
@@ -80,15 +80,15 @@ class TestStripeWebhookHandlerCheckout:
assert test_subscription.stripe_customer_id == "cus_test123"
assert test_subscription.status == SubscriptionStatus.ACTIVE
- def test_handle_checkout_completed_no_vendor_id(self, db, mock_checkout_event):
- """Test checkout with missing vendor_id is skipped."""
+ def test_handle_checkout_completed_no_store_id(self, db, mock_checkout_event):
+ """Test checkout with missing store_id is skipped."""
mock_checkout_event.data.object.metadata = {}
result = self.handler.handle_event(db, mock_checkout_event)
assert result["status"] == "processed"
assert result["result"]["action"] == "skipped"
- assert result["result"]["reason"] == "no vendor_id"
+ assert result["result"]["reason"] == "no store_id"
@pytest.mark.unit
@@ -101,7 +101,7 @@ class TestStripeWebhookHandlerSubscription:
self.handler = StripeWebhookHandler()
def test_handle_subscription_updated_status_change(
- self, db, test_vendor, test_active_subscription, mock_subscription_updated_event
+ self, db, test_store, test_active_subscription, mock_subscription_updated_event
):
"""Test subscription update changes status."""
result = self.handler.handle_event(db, mock_subscription_updated_event)
@@ -109,7 +109,7 @@ class TestStripeWebhookHandlerSubscription:
assert result["status"] == "processed"
def test_handle_subscription_deleted(
- self, db, test_vendor, test_active_subscription, mock_subscription_deleted_event
+ self, db, test_store, test_active_subscription, mock_subscription_deleted_event
):
"""Test subscription deletion."""
result = self.handler.handle_event(db, mock_subscription_deleted_event)
@@ -129,7 +129,7 @@ class TestStripeWebhookHandlerInvoice:
self.handler = StripeWebhookHandler()
def test_handle_invoice_paid_creates_billing_record(
- self, db, test_vendor, test_active_subscription, mock_invoice_paid_event
+ self, db, test_store, test_active_subscription, mock_invoice_paid_event
):
"""Test invoice.paid creates billing history record."""
result = self.handler.handle_event(db, mock_invoice_paid_event)
@@ -139,7 +139,7 @@ class TestStripeWebhookHandlerInvoice:
# Check billing record created
record = (
db.query(BillingHistory)
- .filter(BillingHistory.vendor_id == test_vendor.id)
+ .filter(BillingHistory.store_id == test_store.id)
.first()
)
assert record is not None
@@ -147,7 +147,7 @@ class TestStripeWebhookHandlerInvoice:
assert record.total_cents == 4900
def test_handle_invoice_paid_resets_counters(
- self, db, test_vendor, test_active_subscription, mock_invoice_paid_event
+ self, db, test_store, test_active_subscription, mock_invoice_paid_event
):
"""Test invoice.paid resets order counters."""
test_active_subscription.orders_this_period = 50
@@ -159,7 +159,7 @@ class TestStripeWebhookHandlerInvoice:
assert test_active_subscription.orders_this_period == 0
def test_handle_payment_failed_marks_past_due(
- self, db, test_vendor, test_active_subscription, mock_payment_failed_event
+ self, db, test_store, test_active_subscription, mock_payment_failed_event
):
"""Test payment failure marks subscription as past due."""
result = self.handler.handle_event(db, mock_payment_failed_event)
@@ -248,7 +248,7 @@ def test_subscription_tier(db):
@pytest.fixture
-def test_subscription(db, test_vendor):
+def test_subscription(db, test_store):
"""Create a basic subscription for testing."""
# Create tier first if not exists
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first()
@@ -264,8 +264,8 @@ def test_subscription(db, test_vendor):
db.add(tier)
db.commit()
- subscription = VendorSubscription(
- vendor_id=test_vendor.id,
+ subscription = MerchantSubscription(
+ store_id=test_store.id,
tier="essential",
status=SubscriptionStatus.TRIAL,
period_start=datetime.now(timezone.utc),
@@ -278,7 +278,7 @@ def test_subscription(db, test_vendor):
@pytest.fixture
-def test_active_subscription(db, test_vendor):
+def test_active_subscription(db, test_store):
"""Create an active subscription with Stripe IDs."""
# Create tier first if not exists
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first()
@@ -294,8 +294,8 @@ def test_active_subscription(db, test_vendor):
db.add(tier)
db.commit()
- subscription = VendorSubscription(
- vendor_id=test_vendor.id,
+ subscription = MerchantSubscription(
+ store_id=test_store.id,
tier="essential",
status=SubscriptionStatus.ACTIVE,
stripe_customer_id="cus_test123",
diff --git a/tests/unit/services/test_usage_service.py b/tests/unit/services/test_usage_service.py
index 21d95fce..f815235e 100644
--- a/tests/unit/services/test_usage_service.py
+++ b/tests/unit/services/test_usage_service.py
@@ -5,32 +5,32 @@ import pytest
from app.modules.analytics.services.usage_service import UsageService, usage_service
from app.modules.catalog.models import Product
-from app.modules.billing.models import SubscriptionTier, VendorSubscription
-from app.modules.tenancy.models import VendorUser
+from app.modules.billing.models import SubscriptionTier, MerchantSubscription
+from app.modules.tenancy.models import StoreUser
@pytest.mark.unit
@pytest.mark.usage
class TestUsageServiceGetUsage:
- """Test suite for get_vendor_usage operation."""
+ """Test suite for get_store_usage operation."""
def setup_method(self):
"""Initialize service instance before each test."""
self.service = UsageService()
- def test_get_vendor_usage_basic(self, db, test_vendor_with_subscription):
+ def test_get_store_usage_basic(self, db, test_store_with_subscription):
"""Test getting basic usage data."""
- vendor_id = test_vendor_with_subscription.id
- usage = self.service.get_vendor_usage(db, vendor_id)
+ store_id = test_store_with_subscription.id
+ usage = self.service.get_store_usage(db, store_id)
assert usage.tier.code == "essential"
assert usage.tier.name == "Essential"
assert len(usage.usage) == 3
- def test_get_vendor_usage_metrics(self, db, test_vendor_with_subscription):
+ def test_get_store_usage_metrics(self, db, test_store_with_subscription):
"""Test usage metrics are calculated correctly."""
- vendor_id = test_vendor_with_subscription.id
- usage = self.service.get_vendor_usage(db, vendor_id)
+ store_id = test_store_with_subscription.id
+ usage = self.service.get_store_usage(db, store_id)
orders_metric = next((m for m in usage.usage if m.name == "orders"), None)
assert orders_metric is not None
@@ -39,39 +39,39 @@ class TestUsageServiceGetUsage:
assert orders_metric.percentage == 10.0
assert orders_metric.is_unlimited is False
- def test_get_vendor_usage_at_limit(self, db, test_vendor_at_limit):
+ def test_get_store_usage_at_limit(self, db, test_store_at_limit):
"""Test usage shows at limit correctly."""
- vendor_id = test_vendor_at_limit.id
- usage = self.service.get_vendor_usage(db, vendor_id)
+ store_id = test_store_at_limit.id
+ usage = self.service.get_store_usage(db, store_id)
orders_metric = next((m for m in usage.usage if m.name == "orders"), None)
assert orders_metric.is_at_limit is True
assert usage.has_limits_reached is True
- def test_get_vendor_usage_approaching_limit(self, db, test_vendor_approaching_limit):
+ def test_get_store_usage_approaching_limit(self, db, test_store_approaching_limit):
"""Test usage shows approaching limit correctly."""
- vendor_id = test_vendor_approaching_limit.id
- usage = self.service.get_vendor_usage(db, vendor_id)
+ store_id = test_store_approaching_limit.id
+ usage = self.service.get_store_usage(db, store_id)
orders_metric = next((m for m in usage.usage if m.name == "orders"), None)
assert orders_metric.is_approaching_limit is True
assert usage.has_limits_approaching is True
- def test_get_vendor_usage_upgrade_available(
- self, db, test_vendor_with_subscription, test_professional_tier
+ def test_get_store_usage_upgrade_available(
+ self, db, test_store_with_subscription, test_professional_tier
):
"""Test upgrade info when not on highest tier."""
- vendor_id = test_vendor_with_subscription.id
- usage = self.service.get_vendor_usage(db, vendor_id)
+ store_id = test_store_with_subscription.id
+ usage = self.service.get_store_usage(db, store_id)
assert usage.upgrade_available is True
assert usage.upgrade_tier is not None
assert usage.upgrade_tier.code == "professional"
- def test_get_vendor_usage_highest_tier(self, db, test_vendor_on_professional):
+ def test_get_store_usage_highest_tier(self, db, test_store_on_professional):
"""Test no upgrade when on highest tier."""
- vendor_id = test_vendor_on_professional.id
- usage = self.service.get_vendor_usage(db, vendor_id)
+ store_id = test_store_on_professional.id
+ usage = self.service.get_store_usage(db, store_id)
assert usage.tier.is_highest_tier is True
assert usage.upgrade_available is False
@@ -87,28 +87,28 @@ class TestUsageServiceCheckLimit:
"""Initialize service instance before each test."""
self.service = UsageService()
- def test_check_orders_limit_can_proceed(self, db, test_vendor_with_subscription):
+ def test_check_orders_limit_can_proceed(self, db, test_store_with_subscription):
"""Test checking orders limit when under limit."""
- vendor_id = test_vendor_with_subscription.id
- result = self.service.check_limit(db, vendor_id, "orders")
+ store_id = test_store_with_subscription.id
+ result = self.service.check_limit(db, store_id, "orders")
assert result.can_proceed is True
assert result.current == 10
assert result.limit == 100
- def test_check_products_limit(self, db, test_vendor_with_products):
+ def test_check_products_limit(self, db, test_store_with_products):
"""Test checking products limit."""
- vendor_id = test_vendor_with_products.id
- result = self.service.check_limit(db, vendor_id, "products")
+ store_id = test_store_with_products.id
+ result = self.service.check_limit(db, store_id, "products")
assert result.can_proceed is True
assert result.current == 5
assert result.limit == 500
- def test_check_team_members_limit(self, db, test_vendor_with_team):
+ def test_check_team_members_limit(self, db, test_store_with_team):
"""Test checking team members limit when at limit."""
- vendor_id = test_vendor_with_team.id
- result = self.service.check_limit(db, vendor_id, "team_members")
+ store_id = test_store_with_team.id
+ result = self.service.check_limit(db, store_id, "team_members")
# At limit (2/2) - can_proceed should be False
assert result.can_proceed is False
@@ -116,18 +116,18 @@ class TestUsageServiceCheckLimit:
assert result.limit == 2
assert result.percentage == 100.0
- def test_check_unknown_limit_type(self, db, test_vendor_with_subscription):
+ def test_check_unknown_limit_type(self, db, test_store_with_subscription):
"""Test checking unknown limit type."""
- vendor_id = test_vendor_with_subscription.id
- result = self.service.check_limit(db, vendor_id, "unknown")
+ store_id = test_store_with_subscription.id
+ result = self.service.check_limit(db, store_id, "unknown")
assert result.can_proceed is True
assert "Unknown limit type" in result.message
- def test_check_limit_upgrade_info_when_blocked(self, db, test_vendor_at_limit):
+ def test_check_limit_upgrade_info_when_blocked(self, db, test_store_at_limit):
"""Test upgrade info is provided when at limit."""
- vendor_id = test_vendor_at_limit.id
- result = self.service.check_limit(db, vendor_id, "orders")
+ store_id = test_store_at_limit.id
+ result = self.service.check_limit(db, store_id, "orders")
assert result.can_proceed is False
assert result.upgrade_tier_code == "professional"
@@ -182,13 +182,13 @@ def test_professional_tier(db, test_essential_tier):
@pytest.fixture
-def test_vendor_with_subscription(db, test_vendor, test_essential_tier):
- """Create vendor with active subscription."""
+def test_store_with_subscription(db, test_store, test_essential_tier):
+ """Create store with active subscription."""
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
- subscription = VendorSubscription(
- vendor_id=test_vendor.id,
+ subscription = StoreSubscription(
+ store_id=test_store.id,
tier="essential",
tier_id=test_essential_tier.id,
status="active",
@@ -198,18 +198,18 @@ def test_vendor_with_subscription(db, test_vendor, test_essential_tier):
)
db.add(subscription)
db.commit()
- db.refresh(test_vendor)
- return test_vendor
+ db.refresh(test_store)
+ return test_store
@pytest.fixture
-def test_vendor_at_limit(db, test_vendor, test_essential_tier, test_professional_tier):
- """Create vendor at order limit."""
+def test_store_at_limit(db, test_store, test_essential_tier, test_professional_tier):
+ """Create store at order limit."""
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
- subscription = VendorSubscription(
- vendor_id=test_vendor.id,
+ subscription = StoreSubscription(
+ store_id=test_store.id,
tier="essential",
tier_id=test_essential_tier.id,
status="active",
@@ -219,18 +219,18 @@ def test_vendor_at_limit(db, test_vendor, test_essential_tier, test_professional
)
db.add(subscription)
db.commit()
- db.refresh(test_vendor)
- return test_vendor
+ db.refresh(test_store)
+ return test_store
@pytest.fixture
-def test_vendor_approaching_limit(db, test_vendor, test_essential_tier):
- """Create vendor approaching order limit (>=80%)."""
+def test_store_approaching_limit(db, test_store, test_essential_tier):
+ """Create store approaching order limit (>=80%)."""
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
- subscription = VendorSubscription(
- vendor_id=test_vendor.id,
+ subscription = StoreSubscription(
+ store_id=test_store.id,
tier="essential",
tier_id=test_essential_tier.id,
status="active",
@@ -240,18 +240,18 @@ def test_vendor_approaching_limit(db, test_vendor, test_essential_tier):
)
db.add(subscription)
db.commit()
- db.refresh(test_vendor)
- return test_vendor
+ db.refresh(test_store)
+ return test_store
@pytest.fixture
-def test_vendor_on_professional(db, test_vendor, test_professional_tier):
- """Create vendor on highest tier."""
+def test_store_on_professional(db, test_store, test_professional_tier):
+ """Create store on highest tier."""
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
- subscription = VendorSubscription(
- vendor_id=test_vendor.id,
+ subscription = StoreSubscription(
+ store_id=test_store.id,
tier="professional",
tier_id=test_professional_tier.id,
status="active",
@@ -261,48 +261,48 @@ def test_vendor_on_professional(db, test_vendor, test_professional_tier):
)
db.add(subscription)
db.commit()
- db.refresh(test_vendor)
- return test_vendor
+ db.refresh(test_store)
+ return test_store
@pytest.fixture
-def test_vendor_with_products(db, test_vendor_with_subscription, marketplace_product_factory):
- """Create vendor with products."""
+def test_store_with_products(db, test_store_with_subscription, marketplace_product_factory):
+ """Create store with products."""
for i in range(5):
# Create marketplace product first
mp = marketplace_product_factory(db, title=f"Test Product {i}")
product = Product(
- vendor_id=test_vendor_with_subscription.id,
+ store_id=test_store_with_subscription.id,
marketplace_product_id=mp.id,
price_cents=1000,
is_active=True,
)
db.add(product)
db.commit()
- return test_vendor_with_subscription
+ return test_store_with_subscription
@pytest.fixture
-def test_vendor_with_team(db, test_vendor_with_subscription, test_user, other_user):
- """Create vendor with team members (owner + team member = 2)."""
- from app.modules.tenancy.models import VendorUserType
+def test_store_with_team(db, test_store_with_subscription, test_user, other_user):
+ """Create store with team members (owner + team member = 2)."""
+ from app.modules.tenancy.models import StoreUserType
# Add owner
- owner = VendorUser(
- vendor_id=test_vendor_with_subscription.id,
+ owner = StoreUser(
+ store_id=test_store_with_subscription.id,
user_id=test_user.id,
- user_type=VendorUserType.OWNER.value,
+ user_type=StoreUserType.OWNER.value,
is_active=True,
)
db.add(owner)
# Add team member
- team_member = VendorUser(
- vendor_id=test_vendor_with_subscription.id,
+ team_member = StoreUser(
+ store_id=test_store_with_subscription.id,
user_id=other_user.id,
- user_type=VendorUserType.TEAM_MEMBER.value,
+ user_type=StoreUserType.TEAM_MEMBER.value,
is_active=True,
)
db.add(team_member)
db.commit()
- return test_vendor_with_subscription
+ return test_store_with_subscription
+
+
+
+ + Quick Actions ++
+
+
+ Edit Store
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + Verification + ++ - + +
+
+
+
+
+
+
+
+
+ + Status + ++ - + +
+
+
+
+
+
+
+
+
+ + Created + ++ - + +
+
+
+
+
+
+
+ + Last Updated + ++ - + +
+
+
+
+
+
+
+
+ + Subscription ++ +
+
+
+
+
+ Tier:
+
+
+
+
+ Status:
+
+
+
+
+
+ Annual
+
+
+
+
+
+
+
+ Period:
+
+ →
+
+
+
+
+ Trial ends:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /
+
+
+
+
+
+
+
+
+
+ No usage data available +
+
+
+
+
+
+
+
+
+
+ No Subscription Found +This store doesn't have a subscription yet. +
+
+
+
+
+
+
+
+
+ + Basic Information ++
+
+
+
+ Store Code +- +
+
+ Name +- +
+
+ Subdomain +- +
+
+ Description +- +
+
+ + Contact Information ++
+
+
+
+ Owner Email +- +Owner's authentication email +
+
+ Contact Email +- +Public business contact +
+
+
+ Phone +- +
+
+
+
+ + Business Details ++
+
+
+
+ Business Address +- +
+
+ Tax Number +- +
+
+
+
+
+
+
+ + Owner Information ++
+
+
+
+ Owner User ID +- +
+
+ Owner Username +- +
+
+ Owner Email +- +
+
++ More Actions ++
+
+
+
+ View Parent Merchant
+
+
+
+
+
+ Customize Theme
+
+
+ + + This store belongs to merchant: . + Contact info and ownership are managed at the merchant level. + + | |||||||||||||||||||||||||||||||||||||