feat(billing): migrate frontend templates to feature provider system

Replace hardcoded subscription fields (orders_limit, products_limit,
team_members_limit) across 5 frontend pages with dynamic feature
provider APIs. Add admin convenience endpoint for store subscription
lookup. Remove legacy stubs (StoreSubscription, FeatureCode, Feature,
TIER_LIMITS, FeatureInfo, FeatureUpgradeInfo) and schema aliases.

Pages updated:
- Admin subscriptions: dynamic feature overrides editor
- Admin tiers: correct feature catalog/limits API URLs
- Store billing: usage metrics from /store/billing/usage
- Merchant subscription detail: tier.feature_limits rendering
- Admin store detail: new GET /admin/subscriptions/store/{id} endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 15:18:16 +01:00
parent 922616c9e3
commit 1db7e8a087
19 changed files with 2508 additions and 1205 deletions

View File

@@ -7,40 +7,31 @@ discovered and registered with SQLAlchemy's Base.metadata at startup.
Usage: Usage:
from app.modules.billing.models import ( from app.modules.billing.models import (
VendorSubscription, MerchantSubscription,
SubscriptionTier, SubscriptionTier,
SubscriptionStatus, SubscriptionStatus,
TierCode, TierCode,
Feature, TierFeatureLimit,
FeatureCode, MerchantFeatureOverride,
) )
""" """
from app.modules.billing.models.merchant_subscription import MerchantSubscription
from app.modules.billing.models.subscription import ( from app.modules.billing.models.subscription import (
# Enums
TierCode,
SubscriptionStatus,
AddOnCategory, AddOnCategory,
BillingPeriod,
# Models
SubscriptionTier,
AddOnProduct, AddOnProduct,
VendorAddOn,
StripeWebhookEvent,
BillingHistory, BillingHistory,
VendorSubscription, BillingPeriod,
CapacitySnapshot, CapacitySnapshot,
# Legacy constants StoreAddOn,
TIER_LIMITS, StripeWebhookEvent,
SubscriptionStatus,
SubscriptionTier,
TierCode,
) )
from app.modules.billing.models.feature import ( from app.modules.billing.models.tier_feature_limit import (
# Enums MerchantFeatureOverride,
FeatureCategory, TierFeatureLimit,
FeatureUILocation,
# Model
Feature,
# Constants
FeatureCode,
) )
__all__ = [ __all__ = [
@@ -52,18 +43,13 @@ __all__ = [
# Subscription Models # Subscription Models
"SubscriptionTier", "SubscriptionTier",
"AddOnProduct", "AddOnProduct",
"VendorAddOn", "StoreAddOn",
"StripeWebhookEvent", "StripeWebhookEvent",
"BillingHistory", "BillingHistory",
"VendorSubscription",
"CapacitySnapshot", "CapacitySnapshot",
# Legacy constants # Merchant Subscription
"TIER_LIMITS", "MerchantSubscription",
# Feature Enums # Feature Limits
"FeatureCategory", "TierFeatureLimit",
"FeatureUILocation", "MerchantFeatureOverride",
# Feature Model
"Feature",
# Feature Constants
"FeatureCode",
] ]

View File

@@ -1,39 +1,38 @@
# app/modules/billing/routes/admin.py # app/modules/billing/routes/api/admin.py
""" """
Billing module admin routes. Billing module admin routes.
This module wraps the existing admin subscription routes and adds Provides admin API endpoints for subscription and billing management:
module-based access control. The actual route implementations remain - Subscription tier CRUD
in app/api/v1/admin/subscriptions.py for now, but are accessed through - Merchant subscription listing and management
this module-aware router. - Billing history
- Subscription statistics
Future: Move all route implementations here for full module isolation.
""" """
import logging import logging
from fastapi import APIRouter, Depends, Path, Query from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api, require_module_access from app.api.deps import get_current_admin_api, require_module_access
from app.core.database import get_db from app.core.database import get_db
from app.modules.billing.services import admin_subscription_service, subscription_service from app.modules.billing.services import admin_subscription_service, subscription_service
from app.modules.enums import FrontendType from app.modules.enums import FrontendType
from app.modules.tenancy.models import User
from app.modules.billing.schemas import ( from app.modules.billing.schemas import (
BillingHistoryListResponse, BillingHistoryListResponse,
BillingHistoryWithVendor, BillingHistoryWithMerchant,
MerchantSubscriptionAdminCreate,
MerchantSubscriptionAdminResponse,
MerchantSubscriptionAdminUpdate,
MerchantSubscriptionListResponse,
MerchantSubscriptionWithMerchant,
SubscriptionStatsResponse, SubscriptionStatsResponse,
SubscriptionTierCreate, SubscriptionTierCreate,
SubscriptionTierListResponse, SubscriptionTierListResponse,
SubscriptionTierResponse, SubscriptionTierResponse,
SubscriptionTierUpdate, SubscriptionTierUpdate,
VendorSubscriptionCreate,
VendorSubscriptionListResponse,
VendorSubscriptionResponse,
VendorSubscriptionUpdate,
VendorSubscriptionWithVendor,
) )
from models.schema.auth import UserContext
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -52,14 +51,10 @@ admin_router = APIRouter(
@admin_router.get("/tiers", response_model=SubscriptionTierListResponse) @admin_router.get("/tiers", response_model=SubscriptionTierListResponse)
def list_subscription_tiers( def list_subscription_tiers(
include_inactive: bool = Query(False, description="Include inactive 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), db: Session = Depends(get_db),
): ):
""" """List all subscription tiers."""
List all subscription tiers.
Returns all tiers with their limits, features, and Stripe configuration.
"""
tiers = admin_subscription_service.get_tiers(db, include_inactive=include_inactive) tiers = admin_subscription_service.get_tiers(db, include_inactive=include_inactive)
return SubscriptionTierListResponse( return SubscriptionTierListResponse(
@@ -71,7 +66,7 @@ def list_subscription_tiers(
@admin_router.get("/tiers/{tier_code}", response_model=SubscriptionTierResponse) @admin_router.get("/tiers/{tier_code}", response_model=SubscriptionTierResponse)
def get_subscription_tier( def get_subscription_tier(
tier_code: str = Path(..., description="Tier code"), 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), db: Session = Depends(get_db),
): ):
"""Get a specific subscription tier by code.""" """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) @admin_router.post("/tiers", response_model=SubscriptionTierResponse, status_code=201)
def create_subscription_tier( def create_subscription_tier(
tier_data: SubscriptionTierCreate, tier_data: SubscriptionTierCreate,
current_user: User = Depends(get_current_admin_api), current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Create a new subscription tier.""" """Create a new subscription tier."""
@@ -96,7 +91,7 @@ def create_subscription_tier(
def update_subscription_tier( def update_subscription_tier(
tier_data: SubscriptionTierUpdate, tier_data: SubscriptionTierUpdate,
tier_code: str = Path(..., description="Tier code"), 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), db: Session = Depends(get_db),
): ):
"""Update a subscription tier.""" """Update a subscription tier."""
@@ -110,52 +105,48 @@ def update_subscription_tier(
@admin_router.delete("/tiers/{tier_code}", status_code=204) @admin_router.delete("/tiers/{tier_code}", status_code=204)
def delete_subscription_tier( def delete_subscription_tier(
tier_code: str = Path(..., description="Tier code"), 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), db: Session = Depends(get_db),
): ):
""" """Soft-delete a subscription tier."""
Soft-delete a subscription tier.
Sets is_active=False rather than deleting to preserve history.
"""
admin_subscription_service.deactivate_tier(db, tier_code) admin_subscription_service.deactivate_tier(db, tier_code)
db.commit() db.commit()
# ============================================================================ # ============================================================================
# Vendor Subscription Endpoints # Merchant Subscription Endpoints
# ============================================================================ # ============================================================================
@admin_router.get("", response_model=VendorSubscriptionListResponse) @admin_router.get("", response_model=MerchantSubscriptionListResponse)
def list_vendor_subscriptions( def list_merchant_subscriptions(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100), per_page: int = Query(20, ge=1, le=100),
status: str | None = Query(None, description="Filter by status"), status: str | None = Query(None, description="Filter by status"),
tier: str | None = Query(None, description="Filter by tier"), tier: str | None = Query(None, description="Filter by tier code"),
search: str | None = Query(None, description="Search vendor name"), search: str | None = Query(None, description="Search merchant name"),
current_user: User = Depends(get_current_admin_api), current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """List all merchant subscriptions with filtering."""
List all vendor subscriptions with filtering.
Includes vendor information for each subscription.
"""
data = admin_subscription_service.list_subscriptions( data = admin_subscription_service.list_subscriptions(
db, page=page, per_page=per_page, status=status, tier=tier, search=search db, page=page, per_page=per_page, status=status, tier=tier, search=search
) )
subscriptions = [] subscriptions = []
for sub, vendor in data["results"]: for sub, merchant in data["results"]:
sub_dict = { sub_resp = MerchantSubscriptionAdminResponse.model_validate(sub)
**VendorSubscriptionResponse.model_validate(sub).model_dump(), tier_name = sub.tier.name if sub.tier else None
"vendor_name": vendor.name, subscriptions.append(
"vendor_code": vendor.subdomain, MerchantSubscriptionWithMerchant(
} **sub_resp.model_dump(),
subscriptions.append(VendorSubscriptionWithVendor(**sub_dict)) merchant_name=merchant.name,
platform_name="", # Platform name can be resolved if needed
tier_name=tier_name,
)
)
return VendorSubscriptionListResponse( return MerchantSubscriptionListResponse(
subscriptions=subscriptions, subscriptions=subscriptions,
total=data["total"], total=data["total"],
page=data["page"], 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 # Statistics Endpoints
# ============================================================================ # ============================================================================
@@ -171,7 +310,7 @@ def list_vendor_subscriptions(
@admin_router.get("/stats", response_model=SubscriptionStatsResponse) @admin_router.get("/stats", response_model=SubscriptionStatsResponse)
def get_subscription_stats( 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), db: Session = Depends(get_db),
): ):
"""Get subscription statistics for admin dashboard.""" """Get subscription statistics for admin dashboard."""
@@ -188,39 +327,39 @@ def get_subscription_stats(
def list_billing_history( def list_billing_history(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100), 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"), 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), 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( 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 = [] invoices = []
for invoice, vendor in data["results"]: for invoice, merchant in data["results"]:
invoice_dict = { invoices.append(
"id": invoice.id, BillingHistoryWithMerchant(
"vendor_id": invoice.vendor_id, id=invoice.id,
"stripe_invoice_id": invoice.stripe_invoice_id, merchant_id=invoice.merchant_id,
"invoice_number": invoice.invoice_number, stripe_invoice_id=invoice.stripe_invoice_id,
"invoice_date": invoice.invoice_date, invoice_number=invoice.invoice_number,
"due_date": invoice.due_date, invoice_date=invoice.invoice_date,
"subtotal_cents": invoice.subtotal_cents, due_date=invoice.due_date,
"tax_cents": invoice.tax_cents, subtotal_cents=invoice.subtotal_cents,
"total_cents": invoice.total_cents, tax_cents=invoice.tax_cents,
"amount_paid_cents": invoice.amount_paid_cents, total_cents=invoice.total_cents,
"currency": invoice.currency, amount_paid_cents=invoice.amount_paid_cents,
"status": invoice.status, currency=invoice.currency,
"invoice_pdf_url": invoice.invoice_pdf_url, status=invoice.status,
"hosted_invoice_url": invoice.hosted_invoice_url, invoice_pdf_url=invoice.invoice_pdf_url,
"description": invoice.description, hosted_invoice_url=invoice.hosted_invoice_url,
"created_at": invoice.created_at, description=invoice.description,
"vendor_name": vendor.name, created_at=invoice.created_at,
"vendor_code": vendor.subdomain, merchant_name=merchant.name,
} )
invoices.append(BillingHistoryWithVendor(**invoice_dict)) )
return BillingHistoryListResponse( return BillingHistoryListResponse(
invoices=invoices, 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 # Aggregate Feature Management Routes
# ============================================================================ # ============================================================================

View File

@@ -6,48 +6,47 @@ This is the canonical location for billing schemas.
Usage: Usage:
from app.modules.billing.schemas import ( from app.modules.billing.schemas import (
SubscriptionCreate, MerchantSubscriptionCreate,
SubscriptionResponse, MerchantSubscriptionResponse,
TierInfo, TierInfo,
) )
""" """
from app.modules.billing.schemas.subscription import ( from app.modules.billing.schemas.subscription import (
# Tier schemas # Tier schemas
TierFeatures, TierFeatureLimitResponse,
TierLimits,
TierInfo, TierInfo,
# Subscription CRUD schemas # Subscription schemas
SubscriptionCreate, MerchantSubscriptionCreate,
SubscriptionUpdate, MerchantSubscriptionUpdate,
SubscriptionResponse, MerchantSubscriptionResponse,
# Usage schemas MerchantSubscriptionStatusResponse,
SubscriptionUsage, # Feature summary schemas
UsageSummary, FeatureSummaryResponse,
SubscriptionStatusResponse,
# Limit check schemas # Limit check schemas
LimitCheckResult, LimitCheckResult,
CanCreateOrderResponse,
CanAddProductResponse,
CanAddTeamMemberResponse,
FeatureCheckResponse, FeatureCheckResponse,
) )
from app.modules.billing.schemas.billing import ( from app.modules.billing.schemas.billing import (
# Subscription Tier Admin schemas # Subscription Tier Admin schemas
TierFeatureLimitEntry,
SubscriptionTierBase, SubscriptionTierBase,
SubscriptionTierCreate, SubscriptionTierCreate,
SubscriptionTierUpdate, SubscriptionTierUpdate,
SubscriptionTierResponse, SubscriptionTierResponse,
SubscriptionTierListResponse, SubscriptionTierListResponse,
# Vendor Subscription schemas # Merchant Subscription Admin schemas
VendorSubscriptionResponse, MerchantSubscriptionAdminResponse,
VendorSubscriptionWithVendor, MerchantSubscriptionWithMerchant,
VendorSubscriptionListResponse, MerchantSubscriptionListResponse,
VendorSubscriptionCreate, MerchantSubscriptionAdminCreate,
VendorSubscriptionUpdate, MerchantSubscriptionAdminUpdate,
# Merchant Feature Override schemas
MerchantFeatureOverrideEntry,
MerchantFeatureOverrideResponse,
# Billing History schemas # Billing History schemas
BillingHistoryResponse, BillingHistoryResponse,
BillingHistoryWithVendor, BillingHistoryWithMerchant,
BillingHistoryListResponse, BillingHistoryListResponse,
# Checkout & Portal schemas # Checkout & Portal schemas
CheckoutRequest, CheckoutRequest,
@@ -55,42 +54,44 @@ from app.modules.billing.schemas.billing import (
PortalSessionResponse, PortalSessionResponse,
# Stats schemas # Stats schemas
SubscriptionStatsResponse, SubscriptionStatsResponse,
# Feature Catalog schemas
FeatureDeclarationResponse,
FeatureCatalogResponse,
) )
__all__ = [ __all__ = [
# Tier schemas (subscription.py) # Tier schemas (subscription.py)
"TierFeatures", "TierFeatureLimitResponse",
"TierLimits",
"TierInfo", "TierInfo",
# Subscription CRUD schemas (subscription.py) # Subscription schemas (subscription.py)
"SubscriptionCreate", "MerchantSubscriptionCreate",
"SubscriptionUpdate", "MerchantSubscriptionUpdate",
"SubscriptionResponse", "MerchantSubscriptionResponse",
# Usage schemas (subscription.py) "MerchantSubscriptionStatusResponse",
"SubscriptionUsage", # Feature summary schemas (subscription.py)
"UsageSummary", "FeatureSummaryResponse",
"SubscriptionStatusResponse",
# Limit check schemas (subscription.py) # Limit check schemas (subscription.py)
"LimitCheckResult", "LimitCheckResult",
"CanCreateOrderResponse",
"CanAddProductResponse",
"CanAddTeamMemberResponse",
"FeatureCheckResponse", "FeatureCheckResponse",
# Subscription Tier Admin schemas (billing.py) # Subscription Tier Admin schemas (billing.py)
"TierFeatureLimitEntry",
"SubscriptionTierBase", "SubscriptionTierBase",
"SubscriptionTierCreate", "SubscriptionTierCreate",
"SubscriptionTierUpdate", "SubscriptionTierUpdate",
"SubscriptionTierResponse", "SubscriptionTierResponse",
"SubscriptionTierListResponse", "SubscriptionTierListResponse",
# Vendor Subscription schemas (billing.py) # Merchant Subscription Admin schemas (billing.py)
"VendorSubscriptionResponse", "MerchantSubscriptionAdminResponse",
"VendorSubscriptionWithVendor", "MerchantSubscriptionWithMerchant",
"VendorSubscriptionListResponse", "MerchantSubscriptionListResponse",
"VendorSubscriptionCreate", "MerchantSubscriptionAdminCreate",
"VendorSubscriptionUpdate", "MerchantSubscriptionAdminUpdate",
# Merchant Feature Override schemas (billing.py)
"MerchantFeatureOverrideEntry",
"MerchantFeatureOverrideResponse",
# Billing History schemas (billing.py) # Billing History schemas (billing.py)
"BillingHistoryResponse", "BillingHistoryResponse",
"BillingHistoryWithVendor", "BillingHistoryWithMerchant",
"BillingHistoryListResponse", "BillingHistoryListResponse",
# Checkout & Portal schemas (billing.py) # Checkout & Portal schemas (billing.py)
"CheckoutRequest", "CheckoutRequest",
@@ -98,4 +99,7 @@ __all__ = [
"PortalSessionResponse", "PortalSessionResponse",
# Stats schemas (billing.py) # Stats schemas (billing.py)
"SubscriptionStatsResponse", "SubscriptionStatsResponse",
# Feature Catalog schemas (billing.py)
"FeatureDeclarationResponse",
"FeatureCatalogResponse",
] ]

View File

@@ -1,8 +1,8 @@
# app/modules/billing/schemas/subscription.py # 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 from datetime import datetime
@@ -15,48 +15,23 @@ from pydantic import BaseModel, ConfigDict, Field
# ============================================================================ # ============================================================================
class TierFeatures(BaseModel): class TierFeatureLimitResponse(BaseModel):
"""Features included in a tier.""" """Feature limit entry for a tier."""
letzshop_sync: bool = True feature_code: str
inventory_basic: bool = True limit_value: int | None = Field(None, description="None = unlimited")
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")
class TierInfo(BaseModel): class TierInfo(BaseModel):
"""Full tier information.""" """Full tier information with feature limits."""
code: str code: str
name: str name: str
description: str | None = None
price_monthly_cents: int price_monthly_cents: int
price_annual_cents: int | None price_annual_cents: int | None
limits: TierLimits feature_codes: list[str] = Field(default_factory=list)
features: list[str] feature_limits: list[TierFeatureLimitResponse] = Field(default_factory=list)
# ============================================================================ # ============================================================================
@@ -64,47 +39,43 @@ class TierInfo(BaseModel):
# ============================================================================ # ============================================================================
class SubscriptionCreate(BaseModel): class MerchantSubscriptionCreate(BaseModel):
"""Schema for creating a subscription (admin/internal use).""" """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 is_annual: bool = False
trial_days: int = Field(default=14, ge=0, le=30) trial_days: int = Field(default=14, ge=0, le=30)
class SubscriptionUpdate(BaseModel): class MerchantSubscriptionUpdate(BaseModel):
"""Schema for updating a subscription.""" """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)$") status: str | None = Field(None, pattern="^(trial|active|past_due|cancelled|expired)$")
is_annual: bool | None = None 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): class MerchantSubscriptionResponse(BaseModel):
"""Schema for subscription response.""" """Schema for merchant subscription response."""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
vendor_id: int merchant_id: int
tier: str platform_id: int
status: str tier_id: int | None
status: str
is_annual: bool
period_start: datetime period_start: datetime
period_end: datetime period_end: datetime
is_annual: bool
trial_ends_at: datetime | None trial_ends_at: datetime | None
orders_this_period: int
orders_limit_reached_at: datetime | None
# Effective limits (with custom overrides applied) # Stripe info (optional, may be hidden from client)
orders_limit: int | None stripe_customer_id: str | None = None
products_limit: int | None
team_members_limit: int | None # Cancellation
cancelled_at: datetime | None = None
# Computed properties # Computed properties
is_active: bool is_active: bool
@@ -115,47 +86,36 @@ class SubscriptionResponse(BaseModel):
updated_at: datetime updated_at: datetime
class SubscriptionUsage(BaseModel): # ============================================================================
"""Current subscription usage statistics.""" # Feature Summary Schemas
# ============================================================================
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
class UsageSummary(BaseModel): class FeatureSummaryResponse(BaseModel):
"""Usage summary for billing page display.""" """Feature summary for merchant portal display."""
orders_this_period: int code: str
orders_limit: int | None name_key: str
orders_remaining: int | None description_key: str
category: str
products_count: int feature_type: str
products_limit: int | None scope: str
products_remaining: int | None enabled: bool
limit: int | None = None
team_count: int current: int | None = None
team_limit: int | None remaining: int | None = None
team_remaining: int | None percent_used: float | None = None
is_override: bool = False
unit_key: str | None = None
ui_icon: str | None = None
class SubscriptionStatusResponse(BaseModel): class MerchantSubscriptionStatusResponse(BaseModel):
"""Subscription status with usage and limits.""" """Full subscription status with tier info and feature summary."""
subscription: SubscriptionResponse subscription: MerchantSubscriptionResponse
usage: SubscriptionUsage tier: TierInfo | None = None
tier_info: TierInfo features: list[FeatureSummaryResponse] = Field(default_factory=list)
# ============================================================================ # ============================================================================
@@ -173,37 +133,11 @@ class LimitCheckResult(BaseModel):
message: str | None = None 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): class FeatureCheckResponse(BaseModel):
"""Response for feature check.""" """Response for feature check."""
feature: str feature_code: str
enabled: bool enabled: bool
tier_required: str | None = None
message: str | None = None message: str | None = None

View File

@@ -32,9 +32,6 @@ from app.modules.billing.exceptions import (
from app.modules.billing.services.feature_service import ( from app.modules.billing.services.feature_service import (
FeatureService, FeatureService,
feature_service, feature_service,
FeatureInfo,
FeatureUpgradeInfo,
FeatureCode,
) )
from app.modules.billing.services.capacity_forecast_service import ( from app.modules.billing.services.capacity_forecast_service import (
CapacityForecastService, CapacityForecastService,
@@ -62,9 +59,6 @@ __all__ = [
"SubscriptionNotCancelledError", "SubscriptionNotCancelledError",
"FeatureService", "FeatureService",
"feature_service", "feature_service",
"FeatureInfo",
"FeatureUpgradeInfo",
"FeatureCode",
"CapacityForecastService", "CapacityForecastService",
"capacity_forecast_service", "capacity_forecast_service",
"PlatformPricingService", "PlatformPricingService",

View File

@@ -1,590 +1,438 @@
# app/modules/billing/services/feature_service.py # app/modules/billing/services/feature_service.py
""" """
Feature service for tier-based access control. Feature-agnostic billing service for merchant-level access control.
Provides: Zero knowledge of what features exist. Works with:
- Feature availability checking with caching - TierFeatureLimit (tier -> feature mappings)
- Vendor feature listing for API/UI - MerchantFeatureOverride (per-merchant exceptions)
- Feature metadata for upgrade prompts - FeatureAggregatorService (discovers features from modules)
- Cache invalidation on subscription changes
Usage: Usage:
from app.modules.billing.services.feature_service import feature_service from app.modules.billing.services.feature_service import feature_service
# Check if vendor has feature # Check if merchant has feature
if feature_service.has_feature(db, vendor_id, FeatureCode.ANALYTICS_DASHBOARD): if feature_service.has_feature(db, merchant_id, platform_id, "analytics_dashboard"):
... ...
# Get all features available to vendor # Check quantitative limit
features = feature_service.get_vendor_features(db, vendor_id) allowed, msg = feature_service.check_resource_limit(
db, "products_limit", store_id=store_id
)
# Get feature info for upgrade prompt # Get feature summary for merchant portal
info = feature_service.get_feature_upgrade_info(db, "analytics_dashboard") summary = feature_service.get_merchant_features_summary(db, merchant_id, platform_id)
""" """
import logging import logging
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from app.modules.billing.exceptions import ( from app.modules.billing.models import (
FeatureNotFoundError, MerchantFeatureOverride,
InvalidFeatureCodesError, MerchantSubscription,
TierNotFoundError, SubscriptionTier,
TierFeatureLimit,
) )
from app.modules.billing.models import Feature, FeatureCode from app.modules.contracts.features import FeatureScope, FeatureType
from app.modules.billing.models import SubscriptionTier, VendorSubscription
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@dataclass @dataclass
class FeatureInfo: class FeatureSummary:
"""Feature information for API responses.""" """Summary of a feature for merchant portal display."""
code: str code: str
name: str name_key: str
description: str | None description_key: str
category: 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_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: class FeatureCache:
""" """
In-memory cache for vendor features. In-memory cache for merchant features.
Caches vendor_id -> set of feature codes with TTL. Caches (merchant_id, platform_id) -> set of feature codes with TTL.
Invalidated when subscription changes.
""" """
def __init__(self, ttl_seconds: int = 300): 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 self._ttl = ttl_seconds
def get(self, vendor_id: int) -> set[str] | None: def get(self, merchant_id: int, platform_id: int) -> set[str] | None:
"""Get cached features for vendor, or None if not cached/expired.""" key = (merchant_id, platform_id)
if vendor_id not in self._cache: if key not in self._cache:
return None return None
features, timestamp = self._cache[key]
features, timestamp = self._cache[vendor_id]
if time.time() - timestamp > self._ttl: if time.time() - timestamp > self._ttl:
del self._cache[vendor_id] del self._cache[key]
return None return None
return features return features
def set(self, vendor_id: int, features: set[str]) -> None: def set(self, merchant_id: int, platform_id: int, features: set[str]) -> None:
"""Cache features for vendor.""" self._cache[(merchant_id, platform_id)] = (features, time.time())
self._cache[vendor_id] = (features, time.time())
def invalidate(self, vendor_id: int) -> None: def invalidate(self, merchant_id: int, platform_id: int) -> None:
"""Invalidate cache for vendor.""" self._cache.pop((merchant_id, platform_id), None)
self._cache.pop(vendor_id, None)
def invalidate_all(self) -> None: def invalidate_all(self) -> None:
"""Invalidate entire cache."""
self._cache.clear() self._cache.clear()
class FeatureService: 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. Resolves feature access through:
Uses in-memory caching with TTL for performance. 1. MerchantSubscription -> SubscriptionTier -> TierFeatureLimit
2. MerchantFeatureOverride (admin-set exceptions)
3. FeatureAggregator (for usage counts and declarations)
""" """
def __init__(self): def __init__(self):
self._cache = FeatureCache(ttl_seconds=300) # 5 minute cache self._cache = FeatureCache(ttl_seconds=300)
self._feature_registry_cache: dict[str, Feature] | None = None
self._feature_registry_timestamp: float = 0 # =========================================================================
# 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 # 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: Args:
db: Database session db: Database session
vendor_id: Vendor ID merchant_id: Merchant ID
feature_code: Feature code (use FeatureCode constants) platform_id: Platform ID
feature_code: Feature code to check
Returns: 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) # Check override first
return feature_code in vendor_features 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: Convenience method for backwards compatibility.
db: Database session
vendor_id: Vendor ID
Returns:
Set of feature codes the vendor has access to
""" """
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]: def get_merchant_feature_codes(
"""Internal method with caching.""" self, db: Session, merchant_id: int, platform_id: int
# Check cache first ) -> set[str]:
cached = self._cache.get(vendor_id) """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: if cached is not None:
return cached return cached
# Get subscription with tier relationship features: set[str] = set()
subscription = (
db.query(VendorSubscription)
.options(joinedload(VendorSubscription.tier_obj))
.filter(VendorSubscription.vendor_id == vendor_id)
.first()
)
if not subscription: # Get tier features
logger.warning(f"No subscription found for vendor {vendor_id}") subscription = self._get_subscription(db, merchant_id, platform_id)
return set() if subscription and subscription.is_active and subscription.tier:
features = subscription.tier.get_feature_codes()
# Get features from tier # Apply overrides
tier = subscription.tier_obj overrides = (
if tier and tier.features: db.query(MerchantFeatureOverride)
features = set(tier.features) .filter(
else: MerchantFeatureOverride.merchant_id == merchant_id,
# Fallback: query tier by code MerchantFeatureOverride.platform_id == platform_id,
tier = (
db.query(SubscriptionTier)
.filter(SubscriptionTier.code == subscription.tier)
.first()
) )
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(merchant_id, platform_id, features)
self._cache.set(vendor_id, features)
return 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, self,
db: Session, db: Session,
vendor_id: int, feature_code: str,
category: str | None = None, store_id: int | None = None,
include_unavailable: bool = True, merchant_id: int | None = None,
) -> list[FeatureInfo]: 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: Args:
db: Database session db: Database session
vendor_id: Vendor ID feature_code: Feature code (e.g., "products_limit")
category: Optional category filter store_id: Store ID (if checking per-store)
include_unavailable: Include features not available to vendor merchant_id: Merchant ID (if already known)
platform_id: Platform ID (if already known)
Returns: 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 # Resolve store -> merchant if needed
query = db.query(Feature).filter(Feature.is_active == True) # noqa: E712 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: if merchant_id is None or platform_id is None:
query = query.filter(Feature.category == category) return False, "No subscription found"
if not include_unavailable: # Check subscription is active
# Only return features the vendor has subscription = self._get_subscription(db, merchant_id, platform_id)
query = query.filter(Feature.code.in_(vendor_features)) if not subscription or not subscription.is_active:
return False, "Subscription is not active"
features = ( # Get feature declaration
query.options(joinedload(Feature.minimum_tier)) decl = feature_aggregator.get_declaration(feature_code)
.order_by(Feature.category, Feature.display_order) if decl is None:
.all() 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 = [] if current >= limit:
for feature in features: return False, (
result.append( f"Limit reached ({current}/{limit} {decl.unit_key or feature_code}). "
FeatureInfo( f"Upgrade to increase your limit."
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,
)
) )
return result return True, None
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))
# ========================================================================= # =========================================================================
# Feature Metadata # Feature Summary
# ========================================================================= # =========================================================================
def get_feature_by_code(self, db: Session, feature_code: str) -> Feature | None: def get_merchant_features_summary(
"""Get feature by code.""" self, db: Session, merchant_id: int, platform_id: int
return ( ) -> list[FeatureSummary]:
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:
""" """
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: declarations = feature_aggregator.get_all_declarations()
return None merchant_features = self.get_merchant_feature_codes(db, merchant_id, platform_id)
tier = feature.minimum_tier # Preload overrides
return FeatureUpgradeInfo( overrides = {
feature_code=feature.code, o.feature_code: o
feature_name=feature.name, for o in db.query(MerchantFeatureOverride).filter(
feature_description=feature.description, MerchantFeatureOverride.merchant_id == merchant_id,
required_tier_code=tier.code, MerchantFeatureOverride.platform_id == platform_id,
required_tier_name=tier.name, ).all()
required_tier_price_monthly_cents=tier.price_monthly_cents, }
)
def get_all_features( # Get all usage at once
self, store_usage = {}
db: Session, merchant_usage = feature_aggregator.get_merchant_usage(db, merchant_id, platform_id)
category: str | None = None,
active_only: bool = True,
) -> list[Feature]:
"""Get all features (for admin)."""
query = db.query(Feature).options(joinedload(Feature.minimum_tier))
if active_only: summaries = []
query = query.filter(Feature.is_active == True) # noqa: E712 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: current = None
query = query.filter(Feature.category == category) 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]: summaries.append(FeatureSummary(
"""Get feature codes for a specific tier.""" code=code,
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first() 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 summaries
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
# ========================================================================= # =========================================================================
# Cache Management # Cache Management
# ========================================================================= # =========================================================================
def invalidate_vendor_cache(self, vendor_id: int) -> None: def invalidate_cache(self, merchant_id: int, platform_id: int) -> None:
""" """Invalidate cache for a specific merchant/platform."""
Invalidate cache for a specific vendor. self._cache.invalidate(merchant_id, platform_id)
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_all_cache(self) -> None: def invalidate_all_cache(self) -> None:
""" """Invalidate entire cache."""
Invalidate entire cache.
Call this when tier features are modified in admin.
"""
self._cache.invalidate_all() 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 # Singleton instance
feature_service = FeatureService() feature_service = FeatureService()
# ============================================================================
# Convenience Exports
# ============================================================================
# Re-export FeatureCode for easy imports
__all__ = [ __all__ = [
"feature_service", "feature_service",
"FeatureService", "FeatureService",
"FeatureInfo", "FeatureSummary",
"FeatureUpgradeInfo",
"FeatureCode",
] ]

View File

@@ -28,6 +28,7 @@ function adminSubscriptionTiers() {
categories: [], categories: [],
featuresGrouped: {}, featuresGrouped: {},
selectedFeatures: [], selectedFeatures: [],
featureLimits: {}, // { feature_code: limit_value }
selectedTierForFeatures: null, selectedTierForFeatures: null,
showFeaturePanel: false, showFeaturePanel: false,
loadingFeatures: false, loadingFeatures: false,
@@ -46,13 +47,9 @@ function adminSubscriptionTiers() {
description: '', description: '',
price_monthly_cents: 0, price_monthly_cents: 0,
price_annual_cents: null, price_annual_cents: null,
orders_per_month: null,
products_limit: null,
team_members: null,
display_order: 0, display_order: 0,
stripe_product_id: '', stripe_product_id: '',
stripe_price_monthly_id: '', stripe_price_monthly_id: '',
features: [],
is_active: true, is_active: true,
is_public: true is_public: true
}, },
@@ -70,8 +67,7 @@ function adminSubscriptionTiers() {
await Promise.all([ await Promise.all([
this.loadTiers(), this.loadTiers(),
this.loadStats(), this.loadStats(),
this.loadFeatures(), this.loadFeatures()
this.loadCategories()
]); ]);
tiersLog.info('=== SUBSCRIPTION TIERS PAGE INITIALIZED ==='); tiersLog.info('=== SUBSCRIPTION TIERS PAGE INITIALIZED ===');
} catch (error) { } catch (error) {
@@ -137,13 +133,9 @@ function adminSubscriptionTiers() {
description: '', description: '',
price_monthly_cents: 0, price_monthly_cents: 0,
price_annual_cents: null, price_annual_cents: null,
orders_per_month: null,
products_limit: null,
team_members: null,
display_order: this.tiers.length, display_order: this.tiers.length,
stripe_product_id: '', stripe_product_id: '',
stripe_price_monthly_id: '', stripe_price_monthly_id: '',
features: [],
is_active: true, is_active: true,
is_public: true is_public: true
}; };
@@ -158,13 +150,9 @@ function adminSubscriptionTiers() {
description: tier.description || '', description: tier.description || '',
price_monthly_cents: tier.price_monthly_cents, price_monthly_cents: tier.price_monthly_cents,
price_annual_cents: tier.price_annual_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, display_order: tier.display_order,
stripe_product_id: tier.stripe_product_id || '', stripe_product_id: tier.stripe_product_id || '',
stripe_price_monthly_id: tier.stripe_price_monthly_id || '', stripe_price_monthly_id: tier.stripe_price_monthly_id || '',
features: tier.features || [],
is_active: tier.is_active, is_active: tier.is_active,
is_public: tier.is_public is_public: tier.is_public
}; };
@@ -184,9 +172,6 @@ function adminSubscriptionTiers() {
// Clean up null values for empty strings // Clean up null values for empty strings
const payload = { ...this.formData }; const payload = { ...this.formData };
if (payload.price_annual_cents === '') payload.price_annual_cents = null; 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) { if (this.editingTier) {
// Update existing tier // Update existing tier
@@ -233,24 +218,22 @@ function adminSubscriptionTiers() {
async loadFeatures() { async loadFeatures() {
try { try {
const data = await apiClient.get('/admin/features'); const data = await apiClient.get('/admin/subscriptions/features/catalog');
this.features = data.features || []; // Parse grouped response: { features: { category: [FeatureDeclaration, ...] } }
tiersLog.info(`Loaded ${this.features.length} features`); 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) { } catch (error) {
tiersLog.error('Failed to load features:', 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() { groupFeaturesByCategory() {
this.featuresGrouped = {}; this.featuresGrouped = {};
for (const category of this.categories) { for (const category of this.categories) {
@@ -263,18 +246,22 @@ function adminSubscriptionTiers() {
this.selectedTierForFeatures = tier; this.selectedTierForFeatures = tier;
this.loadingFeatures = true; this.loadingFeatures = true;
this.showFeaturePanel = true; this.showFeaturePanel = true;
this.featureLimits = {};
try { try {
// Load tier's current features // Load tier's current feature limits
const data = await apiClient.get(`/admin/features/tiers/${tier.code}/features`); const data = await apiClient.get(`/admin/subscriptions/features/tiers/${tier.code}/limits`);
if (data.features) { // data is TierFeatureLimitEntry[]: [{feature_code, limit_value, enabled}]
this.selectedFeatures = data.features.map(f => f.code); this.selectedFeatures = [];
} else { for (const entry of (data || [])) {
this.selectedFeatures = tier.features || []; 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) { } catch (error) {
tiersLog.error('Failed to load tier features:', error); tiersLog.error('Failed to load tier features:', error);
this.selectedFeatures = tier.features || []; this.selectedFeatures = tier.feature_codes || tier.features || [];
} finally { } finally {
this.groupFeaturesByCategory(); this.groupFeaturesByCategory();
this.loadingFeatures = false; this.loadingFeatures = false;
@@ -285,6 +272,7 @@ function adminSubscriptionTiers() {
this.showFeaturePanel = false; this.showFeaturePanel = false;
this.selectedTierForFeatures = null; this.selectedTierForFeatures = null;
this.selectedFeatures = []; this.selectedFeatures = [];
this.featureLimits = {};
this.featuresGrouped = {}; this.featuresGrouped = {};
}, },
@@ -308,9 +296,16 @@ function adminSubscriptionTiers() {
this.savingFeatures = true; this.savingFeatures = true;
try { try {
// Build TierFeatureLimitEntry[] payload
const entries = this.selectedFeatures.map(code => ({
feature_code: code,
limit_value: this.featureLimits[code] ?? null,
enabled: true
}));
await apiClient.put( await apiClient.put(
`/admin/features/tiers/${this.selectedTierForFeatures.code}/features`, `/admin/subscriptions/features/tiers/${this.selectedTierForFeatures.code}/limits`,
{ feature_codes: this.selectedFeatures } entries
); );
this.successMessage = `Features updated for ${this.selectedTierForFeatures.name}`; this.successMessage = `Features updated for ${this.selectedTierForFeatures.name}`;
@@ -345,6 +340,18 @@ function adminSubscriptionTiers() {
return categoryFeatures.every(f => this.selectedFeatures.includes(f.code)); 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) { formatCategoryName(category) {
return category return category
.split('_') .split('_')

View File

@@ -39,7 +39,7 @@ function adminSubscriptions() {
}, },
// Sorting // Sorting
sortBy: 'vendor_name', sortBy: 'store_name',
sortOrder: 'asc', sortOrder: 'asc',
// Modal state // Modal state
@@ -47,12 +47,14 @@ function adminSubscriptions() {
editingSub: null, editingSub: null,
formData: { formData: {
tier: '', tier: '',
status: '', status: ''
custom_orders_limit: null,
custom_products_limit: null,
custom_team_limit: null
}, },
// Feature overrides
featureOverrides: [],
quantitativeFeatures: [],
loadingOverrides: false,
// Computed: Total pages // Computed: Total pages
get totalPages() { get totalPages() {
return this.pagination.pages; return this.pagination.pages;
@@ -203,16 +205,18 @@ function adminSubscriptions() {
} }
}, },
openEditModal(sub) { async openEditModal(sub) {
this.editingSub = sub; this.editingSub = sub;
this.formData = { this.formData = {
tier: sub.tier, tier: sub.tier,
status: sub.status, status: sub.status
custom_orders_limit: sub.custom_orders_limit,
custom_products_limit: sub.custom_products_limit,
custom_team_limit: sub.custom_team_limit
}; };
this.featureOverrides = [];
this.quantitativeFeatures = [];
this.showModal = true; this.showModal = true;
// Load feature catalog and merchant overrides
await this.loadFeatureOverrides(sub.merchant_id);
}, },
closeModal() { closeModal() {
@@ -220,6 +224,77 @@ function adminSubscriptions() {
this.editingSub = null; 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() { async saveSubscription() {
if (!this.editingSub) return; if (!this.editingSub) return;
@@ -227,14 +302,17 @@ function adminSubscriptions() {
this.error = null; this.error = null;
try { try {
// Clean up null values for empty strings
const payload = { ...this.formData }; 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); await apiClient.patch(
this.successMessage = `Subscription for "${this.editingSub.vendor_name}" updated`; `/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(); this.closeModal();
await this.loadSubscriptions(); await this.loadSubscriptions();

View File

@@ -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);
}
};
}

View File

@@ -91,9 +91,6 @@
{{ th_sortable('name', 'Name', 'sortBy', 'sortOrder') }} {{ th_sortable('name', 'Name', 'sortBy', 'sortOrder') }}
<th class="px-4 py-3 text-right">Monthly</th> <th class="px-4 py-3 text-right">Monthly</th>
<th class="px-4 py-3 text-right">Annual</th> <th class="px-4 py-3 text-right">Annual</th>
<th class="px-4 py-3 text-center">Orders/Mo</th>
<th class="px-4 py-3 text-center">Products</th>
<th class="px-4 py-3 text-center">Team</th>
<th class="px-4 py-3 text-center">Features</th> <th class="px-4 py-3 text-center">Features</th>
<th class="px-4 py-3 text-center">Status</th> <th class="px-4 py-3 text-center">Status</th>
<th class="px-4 py-3 text-right">Actions</th> <th class="px-4 py-3 text-right">Actions</th>
@@ -101,7 +98,7 @@
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800"> <tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="loading"> <template x-if="loading">
<tr> <tr>
<td colspan="11" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400"> <td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('refresh', 'inline w-6 h-6 animate-spin mr-2')"></span> <span x-html="$icon('refresh', 'inline w-6 h-6 animate-spin mr-2')"></span>
Loading tiers... Loading tiers...
</td> </td>
@@ -109,7 +106,7 @@
</template> </template>
<template x-if="!loading && tiers.length === 0"> <template x-if="!loading && tiers.length === 0">
<tr> <tr>
<td colspan="11" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400"> <td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
No subscription tiers found. No subscription tiers found.
</td> </td>
</tr> </tr>
@@ -131,11 +128,8 @@
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100" x-text="tier.name"></td> <td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100" x-text="tier.name"></td>
<td class="px-4 py-3 text-sm text-right font-mono" x-text="formatCurrency(tier.price_monthly_cents)"></td> <td class="px-4 py-3 text-sm text-right font-mono" x-text="formatCurrency(tier.price_monthly_cents)"></td>
<td class="px-4 py-3 text-sm text-right font-mono" x-text="tier.price_annual_cents ? formatCurrency(tier.price_annual_cents) : '-'"></td> <td class="px-4 py-3 text-sm text-right font-mono" x-text="tier.price_annual_cents ? formatCurrency(tier.price_annual_cents) : '-'"></td>
<td class="px-4 py-3 text-sm text-center" x-text="tier.orders_per_month || 'Unlimited'"></td>
<td class="px-4 py-3 text-sm text-center" x-text="tier.products_limit || 'Unlimited'"></td>
<td class="px-4 py-3 text-sm text-center" x-text="tier.team_members || 'Unlimited'"></td>
<td class="px-4 py-3 text-sm text-center"> <td class="px-4 py-3 text-sm text-center">
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded" x-text="(tier.features || []).length"></span> <span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded" x-text="(tier.feature_codes || tier.features || []).length"></span>
</td> </td>
<td class="px-4 py-3 text-center"> <td class="px-4 py-3 text-center">
<span x-show="tier.is_active && tier.is_public" class="px-2 py-1 text-xs font-medium text-green-700 bg-green-100 rounded-full dark:bg-green-900 dark:text-green-200">Active</span> <span x-show="tier.is_active && tier.is_public" class="px-2 py-1 text-xs font-medium text-green-700 bg-green-100 rounded-full dark:bg-green-900 dark:text-green-200">Active</span>
@@ -225,39 +219,6 @@
> >
</div> </div>
<!-- Orders per Month -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Orders/Month (empty = unlimited)</label>
<input
type="number"
x-model.number="formData.orders_per_month"
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="e.g., 100"
>
</div>
<!-- Products Limit -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Products Limit (empty = unlimited)</label>
<input
type="number"
x-model.number="formData.products_limit"
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="e.g., 200"
>
</div>
<!-- Team Members -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Team Members (empty = unlimited)</label>
<input
type="number"
x-model.number="formData.team_members"
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="e.g., 3"
>
</div>
<!-- Display Order --> <!-- Display Order -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Display Order</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Display Order</label>
@@ -310,7 +271,7 @@
</label> </label>
<label class="flex items-center"> <label class="flex items-center">
<input type="checkbox" x-model="formData.is_public" class="mr-2 rounded border-gray-300 dark:border-gray-600"> <input type="checkbox" x-model="formData.is_public" class="mr-2 rounded border-gray-300 dark:border-gray-600">
<span class="text-sm text-gray-700 dark:text-gray-300">Public (visible to vendors)</span> <span class="text-sm text-gray-700 dark:text-gray-300">Public (visible to stores)</span>
</label> </label>
</div> </div>
</div> </div>
@@ -420,18 +381,32 @@
<!-- Features List --> <!-- Features List -->
<div class="divide-y divide-gray-100 dark:divide-gray-700"> <div class="divide-y divide-gray-100 dark:divide-gray-700">
<template x-for="feature in featuresGrouped[category]" :key="feature.code"> <template x-for="feature in featuresGrouped[category]" :key="feature.code">
<label class="flex items-start px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30 cursor-pointer"> <div class="flex items-start px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30">
<input <label class="flex items-start cursor-pointer flex-1">
type="checkbox" <input
:checked="isFeatureSelected(feature.code)" type="checkbox"
@change="toggleFeature(feature.code)" :checked="isFeatureSelected(feature.code)"
class="mt-0.5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500" @change="toggleFeature(feature.code)"
> class="mt-0.5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
<div class="ml-3"> >
<div class="text-sm font-medium text-gray-900 dark:text-white" x-text="feature.name"></div> <div class="ml-3">
<div class="text-xs text-gray-500 dark:text-gray-400" x-text="feature.description"></div> <div class="text-sm font-medium text-gray-900 dark:text-white" x-text="feature.name_key || feature.name"></div>
</div> <div class="text-xs text-gray-500 dark:text-gray-400" x-text="feature.description_key || feature.description"></div>
</label> </div>
</label>
<template x-if="feature.feature_type === 'quantitative' && isFeatureSelected(feature.code)">
<div class="ml-2 flex-shrink-0">
<input
type="number"
:value="getFeatureLimitValue(feature.code)"
@input="setFeatureLimitValue(feature.code, $event.target.value)"
placeholder="Unlimited"
class="w-24 px-2 py-1 text-sm border border-gray-300 rounded dark:border-gray-600 dark:bg-gray-700 dark:text-white"
min="0"
>
</div>
</template>
</div>
</template> </template>
</div> </div>
</div> </div>

View File

@@ -5,12 +5,12 @@
{% from 'shared/macros/tables.html' import table_wrapper, table_header_custom, th_sortable %} {% from 'shared/macros/tables.html' import table_wrapper, table_header_custom, th_sortable %}
{% from 'shared/macros/pagination.html' import pagination %} {% from 'shared/macros/pagination.html' import pagination %}
{% block title %}Vendor Subscriptions{% endblock %} {% block title %}Store Subscriptions{% endblock %}
{% block alpine_data %}adminSubscriptions(){% endblock %} {% block alpine_data %}adminSubscriptions(){% endblock %}
{% block content %} {% block content %}
{{ page_header_refresh('Vendor Subscriptions') }} {{ page_header_refresh('Store Subscriptions') }}
{{ alert_dynamic(type='success', title='Success', message_var='successMessage', show_condition='successMessage') }} {{ alert_dynamic(type='success', title='Success', message_var='successMessage', show_condition='successMessage') }}
@@ -94,7 +94,7 @@
type="text" type="text"
x-model="filters.search" x-model="filters.search"
@input.debounce.300ms="loadSubscriptions()" @input.debounce.300ms="loadSubscriptions()"
placeholder="Search vendor name..." placeholder="Search store name..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white" class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
> >
</div> </div>
@@ -140,19 +140,17 @@
{% call table_wrapper() %} {% call table_wrapper() %}
<table class="w-full whitespace-nowrap"> <table class="w-full whitespace-nowrap">
{% call table_header_custom() %} {% call table_header_custom() %}
{{ th_sortable('vendor_name', 'Vendor', 'sortBy', 'sortOrder') }} {{ th_sortable('store_name', 'Store', 'sortBy', 'sortOrder') }}
{{ th_sortable('tier', 'Tier', 'sortBy', 'sortOrder') }} {{ th_sortable('tier', 'Tier', 'sortBy', 'sortOrder') }}
{{ th_sortable('status', 'Status', 'sortBy', 'sortOrder') }} {{ th_sortable('status', 'Status', 'sortBy', 'sortOrder') }}
<th class="px-4 py-3 text-center">Orders</th> <th class="px-4 py-3 text-center">Features</th>
<th class="px-4 py-3 text-center">Products</th>
<th class="px-4 py-3 text-center">Team</th>
<th class="px-4 py-3">Period End</th> <th class="px-4 py-3">Period End</th>
<th class="px-4 py-3 text-right">Actions</th> <th class="px-4 py-3 text-right">Actions</th>
{% endcall %} {% endcall %}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800"> <tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="loading"> <template x-if="loading">
<tr> <tr>
<td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400"> <td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('refresh', 'inline w-6 h-6 animate-spin mr-2')"></span> <span x-html="$icon('refresh', 'inline w-6 h-6 animate-spin mr-2')"></span>
Loading subscriptions... Loading subscriptions...
</td> </td>
@@ -160,7 +158,7 @@
</template> </template>
<template x-if="!loading && subscriptions.length === 0"> <template x-if="!loading && subscriptions.length === 0">
<tr> <tr>
<td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400"> <td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
No subscriptions found. No subscriptions found.
</td> </td>
</tr> </tr>
@@ -170,8 +168,8 @@
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex items-center"> <div class="flex items-center">
<div> <div>
<p class="font-semibold text-gray-900 dark:text-gray-100" x-text="sub.vendor_name"></p> <p class="font-semibold text-gray-900 dark:text-gray-100" x-text="sub.store_name"></p>
<p class="text-xs text-gray-500" x-text="sub.vendor_code"></p> <p class="text-xs text-gray-500" x-text="sub.store_code"></p>
</div> </div>
</div> </div>
</td> </td>
@@ -197,15 +195,8 @@
x-text="sub.status.replace('_', ' ').toUpperCase()"></span> x-text="sub.status.replace('_', ' ').toUpperCase()"></span>
</td> </td>
<td class="px-4 py-3 text-center"> <td class="px-4 py-3 text-center">
<span x-text="sub.orders_this_period"></span> <span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded"
<span class="text-gray-400">/</span> x-text="(sub.feature_codes || []).length"></span>
<span x-text="sub.orders_limit || '&infin;'"></span>
</td>
<td class="px-4 py-3 text-center">
<span x-text="sub.products_limit || '&infin;'"></span>
</td>
<td class="px-4 py-3 text-center">
<span x-text="sub.team_members_limit || '&infin;'"></span>
</td> </td>
<td class="px-4 py-3 text-sm" x-text="formatDate(sub.period_end)"></td> <td class="px-4 py-3 text-sm" x-text="formatDate(sub.period_end)"></td>
<td class="px-4 py-3 text-right"> <td class="px-4 py-3 text-right">
@@ -213,7 +204,7 @@
<button @click="openEditModal(sub)" class="p-2 text-gray-500 hover:text-purple-600 dark:hover:text-purple-400" title="Edit"> <button @click="openEditModal(sub)" class="p-2 text-gray-500 hover:text-purple-600 dark:hover:text-purple-400" title="Edit">
<span x-html="$icon('pencil', 'w-4 h-4')"></span> <span x-html="$icon('pencil', 'w-4 h-4')"></span>
</button> </button>
<a :href="'/admin/vendors/' + sub.vendor_code" class="p-2 text-gray-500 hover:text-blue-600 dark:hover:text-blue-400" title="View Vendor"> <a :href="'/admin/stores/' + sub.store_code" class="p-2 text-gray-500 hover:text-blue-600 dark:hover:text-blue-400" title="View Store">
<span x-html="$icon('external-link', 'w-4 h-4')"></span> <span x-html="$icon('external-link', 'w-4 h-4')"></span>
</a> </a>
</div> </div>
@@ -233,7 +224,7 @@
<div x-show="showModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center overflow-auto bg-black bg-opacity-50"> <div x-show="showModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center overflow-auto bg-black bg-opacity-50">
<div class="relative w-full max-w-lg p-6 mx-4 bg-white rounded-lg shadow-xl dark:bg-gray-800" @click.away="closeModal()"> <div class="relative w-full max-w-lg p-6 mx-4 bg-white rounded-lg shadow-xl dark:bg-gray-800" @click.away="closeModal()">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Edit Subscription</h3> <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Edit Subscription</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4" x-text="'Vendor: ' + (editingSub?.vendor_name || '')"></p> <p class="text-sm text-gray-500 dark:text-gray-400 mb-4" x-text="'Store: ' + (editingSub?.store_name || '')"></p>
<div class="space-y-4"> <div class="space-y-4">
<!-- Tier --> <!-- Tier -->
@@ -265,39 +256,35 @@
</select> </select>
</div> </div>
<!-- Custom Limits Section --> <!-- Feature Overrides Section -->
<div class="pt-4 border-t border-gray-200 dark:border-gray-700"> <div class="pt-4 border-t border-gray-200 dark:border-gray-700">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Custom Limit Overrides</h4> <h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Feature Overrides</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Leave empty to use tier defaults</p> <p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Leave empty to use tier defaults</p>
<div class="grid grid-cols-3 gap-3"> <template x-if="loadingOverrides">
<div> <div class="flex items-center justify-center py-4">
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-1">Orders/Month</label> <span x-html="$icon('refresh', 'w-5 h-5 animate-spin text-purple-600')"></span>
<input <span class="ml-2 text-sm text-gray-500">Loading features...</span>
type="number"
x-model.number="formData.custom_orders_limit"
placeholder="Tier default"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
</div>
<div>
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-1">Products</label>
<input
type="number"
x-model.number="formData.custom_products_limit"
placeholder="Tier default"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
</div>
<div>
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-1">Team Members</label>
<input
type="number"
x-model.number="formData.custom_team_limit"
placeholder="Tier default"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
</div> </div>
</template>
<div x-show="!loadingOverrides" class="space-y-3 max-h-48 overflow-y-auto">
<template x-for="feature in quantitativeFeatures" :key="feature.code">
<div class="flex items-center gap-3">
<label class="flex-1 text-xs text-gray-600 dark:text-gray-400"
x-text="feature.name_key.replace(/_/g, ' ')"></label>
<input
type="number"
:placeholder="'Tier default'"
:value="getOverrideValue(feature.code)"
@input="setOverrideValue(feature.code, $event.target.value)"
class="w-28 px-2 py-1.5 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
</div>
</template>
<template x-if="quantitativeFeatures.length === 0 && !loadingOverrides">
<p class="text-xs text-gray-400 text-center py-2">No quantitative features available</p>
</template>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,215 @@
{# app/modules/billing/templates/billing/merchant/subscription-detail.html #}
{% extends "merchant/base.html" %}
{% block title %}Subscription Details{% endblock %}
{% block content %}
<div x-data="merchantSubscriptionDetail()">
<!-- Back link and header -->
<div class="mb-8">
<a href="/merchants/billing/subscriptions" class="inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800 mb-4">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back to Subscriptions
</a>
<h2 class="text-2xl font-bold text-gray-900">Subscription Details</h2>
</div>
<!-- Loading -->
<div x-show="loading" class="text-center py-12 text-gray-500">
<svg class="inline w-6 h-6 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Loading...
</div>
<!-- Error -->
<div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<p class="text-sm text-red-800" x-text="error"></p>
</div>
<!-- Success Message -->
<div x-show="successMessage" x-cloak class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<p class="text-sm text-green-800" x-text="successMessage"></p>
</div>
<!-- Subscription Info -->
<div x-show="!loading && subscription" class="space-y-6">
<!-- Main Details Card -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900" x-text="subscription?.platform_name || subscription?.store_name || 'Subscription'"></h3>
<span class="px-3 py-1 text-sm font-semibold rounded-full"
:class="{
'bg-green-100 text-green-800': subscription?.status === 'active',
'bg-blue-100 text-blue-800': subscription?.status === 'trial',
'bg-yellow-100 text-yellow-800': subscription?.status === 'past_due',
'bg-red-100 text-red-800': subscription?.status === 'cancelled',
'bg-gray-100 text-gray-600': subscription?.status === 'expired'
}"
x-text="subscription?.status?.replace('_', ' ').toUpperCase()"></span>
</div>
<div class="p-6">
<dl class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div>
<dt class="text-sm font-medium text-gray-500">Tier</dt>
<dd class="mt-1 text-lg font-semibold text-gray-900" x-text="capitalize(subscription?.tier)"></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Billing Period</dt>
<dd class="mt-1 text-lg font-semibold text-gray-900" x-text="subscription?.is_annual ? 'Annual' : 'Monthly'"></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Period End</dt>
<dd class="mt-1 text-lg font-semibold text-gray-900" x-text="formatDate(subscription?.period_end)"></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Store Code</dt>
<dd class="mt-1 text-sm font-mono text-gray-700" x-text="subscription?.store_code || '-'"></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Created</dt>
<dd class="mt-1 text-sm text-gray-700" x-text="formatDate(subscription?.created_at)"></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Auto Renew</dt>
<dd class="mt-1 text-sm text-gray-700" x-text="subscription?.auto_renew !== false ? 'Yes' : 'No'"></dd>
</div>
</dl>
</div>
</div>
<!-- Feature Limits Card -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-900">Plan Features</h3>
</div>
<div class="p-6">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<template x-for="fl in (subscription?.tier?.feature_limits || subscription?.feature_limits || [])" :key="fl.feature_code">
<div class="p-4 bg-gray-50 rounded-lg">
<p class="text-sm text-gray-500" x-text="fl.feature_code.replace(/_/g, ' ')"></p>
<p class="text-xl font-bold text-gray-900" x-text="fl.limit_value || 'Unlimited'"></p>
</div>
</template>
<template x-if="!(subscription?.tier?.feature_limits || subscription?.feature_limits || []).length">
<div class="p-4 bg-gray-50 rounded-lg sm:col-span-3">
<p class="text-sm text-gray-500 text-center">No feature limits configured for this tier</p>
</div>
</template>
</div>
</div>
</div>
<!-- Upgrade Action -->
<div class="flex justify-end" x-show="subscription?.status === 'active' || subscription?.status === 'trial'">
<button @click="requestUpgrade()"
:disabled="upgrading"
class="inline-flex items-center px-5 py-2.5 text-sm font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"/>
</svg>
<span x-text="upgrading ? 'Processing...' : 'Upgrade Plan'"></span>
</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function merchantSubscriptionDetail() {
return {
loading: true,
error: null,
successMessage: null,
subscription: null,
upgrading: false,
init() {
this.loadSubscription();
},
getToken() {
const match = document.cookie.match(/(?:^|;\s*)merchant_token=([^;]*)/);
return match ? decodeURIComponent(match[1]) : null;
},
getSubscriptionId() {
// Extract ID from URL: /merchants/billing/subscriptions/{id}
const parts = window.location.pathname.split('/');
return parts[parts.length - 1];
},
async loadSubscription() {
const token = this.getToken();
if (!token) {
window.location.href = '/merchants/login';
return;
}
const subId = this.getSubscriptionId();
try {
const resp = await fetch(`/api/v1/merchants/billing/subscriptions/${subId}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (resp.status === 401) {
window.location.href = '/merchants/login';
return;
}
if (!resp.ok) throw new Error('Failed to load subscription');
this.subscription = await resp.json();
} catch (err) {
console.error('Error:', err);
this.error = 'Failed to load subscription details.';
} finally {
this.loading = false;
}
},
async requestUpgrade() {
this.upgrading = true;
this.error = null;
this.successMessage = null;
const token = this.getToken();
const subId = this.getSubscriptionId();
try {
const resp = await fetch(`/api/v1/merchants/billing/subscriptions/${subId}/upgrade`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!resp.ok) {
const data = await resp.json();
throw new Error(data.detail || 'Upgrade request failed');
}
this.successMessage = 'Upgrade request submitted. You will be contacted with available options.';
} catch (err) {
this.error = err.message;
} finally {
this.upgrading = false;
}
},
capitalize(str) {
if (!str) return '-';
return str.charAt(0).toUpperCase() + str.slice(1);
},
formatDate(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
}
};
}
</script>
{% endblock %}

View File

@@ -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') }}
<!-- Success/Cancel Messages -->
<template x-if="showSuccessMessage">
<div class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg flex items-center justify-between">
<div class="flex items-center">
<span x-html="$icon('check-circle', 'w-5 h-5 mr-2')"></span>
<span>Your subscription has been updated successfully!</span>
</div>
<button @click="showSuccessMessage = false" class="text-green-700 hover:text-green-900">
<span x-html="$icon('x-mark', 'w-5 h-5')"></span>
</button>
</div>
</template>
<template x-if="showCancelMessage">
<div class="mb-6 p-4 bg-yellow-100 border border-yellow-400 text-yellow-700 rounded-lg flex items-center justify-between">
<div class="flex items-center">
<span x-html="$icon('exclamation-triangle', 'w-5 h-5 mr-2')"></span>
<span>Checkout was cancelled. No changes were made to your subscription.</span>
</div>
<button @click="showCancelMessage = false" class="text-yellow-700 hover:text-yellow-900">
<span x-html="$icon('x-mark', 'w-5 h-5')"></span>
</button>
</div>
</template>
<template x-if="showAddonSuccessMessage">
<div class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg flex items-center justify-between">
<div class="flex items-center">
<span x-html="$icon('check-circle', 'w-5 h-5 mr-2')"></span>
<span>Add-on purchased successfully!</span>
</div>
<button @click="showAddonSuccessMessage = false" class="text-green-700 hover:text-green-900">
<span x-html="$icon('x-mark', 'w-5 h-5')"></span>
</button>
</div>
</template>
<!-- Loading State -->
<template x-if="loading">
<div class="flex justify-center items-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600"></div>
</div>
</template>
<template x-if="!loading">
<div class="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
<!-- Current Plan Card -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Current Plan</h3>
<span :class="{
'bg-green-100 text-green-800': subscription?.status === 'active',
'bg-yellow-100 text-yellow-800': subscription?.status === 'trial',
'bg-red-100 text-red-800': subscription?.status === 'past_due' || subscription?.status === 'cancelled',
'bg-gray-100 text-gray-800': !['active', 'trial', 'past_due', 'cancelled'].includes(subscription?.status)
}" class="px-2 py-1 text-xs font-semibold rounded-full">
<span x-text="subscription?.status?.replace('_', ' ')?.toUpperCase() || 'INACTIVE'"></span>
</span>
</div>
<div class="mb-4">
<div class="text-3xl font-bold text-purple-600 dark:text-purple-400" x-text="subscription?.tier_name || 'No Plan'"></div>
<template x-if="subscription?.is_trial">
<p class="text-sm text-yellow-600 dark:text-yellow-400 mt-1">
Trial ends <span x-text="formatDate(subscription?.trial_ends_at)"></span>
</p>
</template>
<template x-if="subscription?.cancelled_at">
<p class="text-sm text-red-600 dark:text-red-400 mt-1">
Cancels on <span x-text="formatDate(subscription?.period_end)"></span>
</p>
</template>
</div>
<div class="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<template x-if="subscription?.period_end && !subscription?.cancelled_at">
<p>
Next billing: <span class="font-medium text-gray-800 dark:text-gray-200" x-text="formatDate(subscription?.period_end)"></span>
</p>
</template>
</div>
<div class="mt-6 space-y-2">
<template x-if="subscription?.stripe_customer_id">
<button @click="openPortal()"
class="w-full px-4 py-2 text-sm font-medium text-purple-600 bg-purple-100 rounded-lg hover:bg-purple-200 dark:bg-purple-900 dark:text-purple-300">
Manage Payment Method
</button>
</template>
<template x-if="subscription?.cancelled_at">
<button @click="reactivate()"
class="w-full px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700">
Reactivate Subscription
</button>
</template>
<template x-if="!subscription?.cancelled_at && subscription?.status === 'active'">
<button @click="showCancelModal = true"
class="w-full px-4 py-2 text-sm font-medium text-red-600 bg-red-100 rounded-lg hover:bg-red-200 dark:bg-red-900 dark:text-red-300">
Cancel Subscription
</button>
</template>
</div>
</div>
<!-- Usage Summary Card -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">Usage This Period</h3>
<template x-if="usageMetrics.length === 0">
<p class="text-sm text-gray-500 dark:text-gray-400 text-center py-4">No usage data available</p>
</template>
<template x-for="metric in usageMetrics" :key="metric.name">
<div class="mb-4">
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-600 dark:text-gray-400" x-text="metric.name"></span>
<span class="font-medium text-gray-800 dark:text-gray-200">
<span x-text="metric.current"></span>
<span x-text="metric.is_unlimited ? ' (Unlimited)' : ` / ${metric.limit}`"></span>
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2 dark:bg-gray-700">
<div class="h-2 rounded-full transition-all duration-300"
:class="metric.percentage >= 90 ? 'bg-red-600' : metric.percentage >= 70 ? 'bg-yellow-600' : 'bg-purple-600'"
:style="`width: ${Math.min(100, metric.percentage || 0)}%`"></div>
</div>
</div>
</template>
<template x-if="subscription?.last_payment_error">
<div class="mt-4 p-3 bg-red-100 dark:bg-red-900 rounded-lg">
<p class="text-sm text-red-700 dark:text-red-300">
<span x-html="$icon('exclamation-circle', 'w-4 h-4 inline mr-1')"></span>
Payment issue: <span x-text="subscription.last_payment_error"></span>
</p>
</div>
</template>
</div>
<!-- Quick Actions Card -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">Quick Actions</h3>
<div class="space-y-3">
<button @click="showTiersModal = true"
class="w-full flex items-center justify-between px-4 py-3 text-left bg-gray-50 dark:bg-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors">
<div class="flex items-center">
<span x-html="$icon('arrow-trending-up', 'w-5 h-5 text-purple-600 mr-3')"></span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Change Plan</span>
</div>
<span x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
</button>
<button @click="showAddonsModal = true"
class="w-full flex items-center justify-between px-4 py-3 text-left bg-gray-50 dark:bg-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors">
<div class="flex items-center">
<span x-html="$icon('puzzle-piece', 'w-5 h-5 text-blue-600 mr-3')"></span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Add-ons</span>
</div>
<span x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
</button>
<a :href="`/store/${storeCode}/invoices`"
class="w-full flex items-center justify-between px-4 py-3 text-left bg-gray-50 dark:bg-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors">
<div class="flex items-center">
<span x-html="$icon('document-text', 'w-5 h-5 text-green-600 mr-3')"></span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">View Invoices</span>
</div>
<span x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
</a>
</div>
</div>
</div>
<!-- Invoice History Section -->
<div class="mt-8 bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">Recent Invoices</h3>
<template x-if="invoices.length === 0">
<p class="text-gray-500 dark:text-gray-400 text-center py-8">No invoices yet</p>
</template>
<template x-if="invoices.length > 0">
<div class="overflow-x-auto">
<table class="w-full whitespace-nowrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Invoice</th>
<th class="px-4 py-3">Date</th>
<th class="px-4 py-3">Amount</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="invoice in invoices.slice(0, 5)" :key="invoice.id">
<tr class="text-gray-700 dark:text-gray-400">
<td class="px-4 py-3 text-sm font-medium" x-text="invoice.invoice_number || `#${invoice.id}`"></td>
<td class="px-4 py-3 text-sm" x-text="formatDate(invoice.invoice_date)"></td>
<td class="px-4 py-3 text-sm font-medium" x-text="formatCurrency(invoice.total_cents, invoice.currency)"></td>
<td class="px-4 py-3 text-sm">
<span :class="{
'bg-green-100 text-green-800': invoice.status === 'paid',
'bg-yellow-100 text-yellow-800': invoice.status === 'open',
'bg-red-100 text-red-800': invoice.status === 'uncollectible'
}" class="px-2 py-1 text-xs font-semibold rounded-full" x-text="invoice.status.toUpperCase()"></span>
</td>
<td class="px-4 py-3 text-sm">
<template x-if="invoice.pdf_url">
<a :href="invoice.pdf_url" target="_blank" class="text-purple-600 hover:text-purple-800">
<span x-html="$icon('arrow-down-tray', 'w-5 h-5')"></span>
</a>
</template>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</div>
</template>
<!-- Tiers Modal -->
{% call modal_simple('tiersModal', 'Choose Your Plan', show_var='showTiersModal', size='xl') %}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<template x-for="tier in tiers" :key="tier.code">
<div :class="{'ring-2 ring-purple-600': tier.is_current}"
class="relative p-6 bg-gray-50 dark:bg-gray-700 rounded-lg">
<template x-if="tier.is_current">
<span class="absolute top-0 right-0 px-2 py-1 text-xs font-semibold text-white bg-purple-600 rounded-bl-lg rounded-tr-lg">Current</span>
</template>
<h4 class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="tier.name"></h4>
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
<span x-text="formatCurrency(tier.price_monthly_cents, 'EUR')"></span>
<span class="text-sm font-normal text-gray-500">/mo</span>
</p>
<ul class="mt-4 space-y-2 text-sm text-gray-600 dark:text-gray-400">
<template x-for="code in (tier.feature_codes || []).slice(0, 5)" :key="code">
<li class="flex items-center">
<span x-html="$icon('check', 'w-4 h-4 text-green-500 mr-2')"></span>
<span x-text="code.replace(/_/g, ' ')"></span>
</li>
</template>
<template x-if="(tier.feature_codes || []).length > 5">
<li class="text-xs text-gray-400">
+ <span x-text="(tier.feature_codes || []).length - 5"></span> more features
</li>
</template>
<template x-if="(tier.feature_codes || []).length === 0">
<li class="text-xs text-gray-400">No features listed</li>
</template>
</ul>
<button @click="selectTier(tier)"
:disabled="tier.is_current"
:class="tier.is_current ? 'bg-gray-300 cursor-not-allowed' : 'bg-purple-600 hover:bg-purple-700'"
class="w-full mt-4 px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors">
<span x-text="tier.is_current ? 'Current Plan' : (tier.can_upgrade ? 'Upgrade' : 'Downgrade')"></span>
</button>
</div>
</template>
</div>
{% endcall %}
<!-- Add-ons Modal -->
<div x-show="showAddonsModal"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-30 flex items-center justify-center overflow-auto bg-black bg-opacity-50"
@click.self="showAddonsModal = false">
<div class="w-full max-w-2xl mx-4 bg-white dark:bg-gray-800 rounded-lg shadow-xl max-h-[90vh] overflow-hidden flex flex-col">
<div class="flex items-center justify-between px-6 py-4 border-b dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Add-ons</h3>
<button @click="showAddonsModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x-mark', 'w-6 h-6')"></span>
</button>
</div>
<div class="p-6 overflow-y-auto">
<!-- My Active Add-ons -->
<template x-if="myAddons.length > 0">
<div class="mb-6">
<h4 class="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-3 uppercase tracking-wide">Your Active Add-ons</h4>
<div class="space-y-3">
<template x-for="addon in myAddons.filter(a => a.status === 'active')" :key="addon.id">
<div class="flex items-center justify-between p-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg">
<div>
<h4 class="font-medium text-gray-700 dark:text-gray-200" x-text="addon.addon_name"></h4>
<template x-if="addon.domain_name">
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="addon.domain_name"></p>
</template>
<p class="text-xs text-gray-400 mt-1">
<span x-text="addon.period_end ? `Renews ${formatDate(addon.period_end)}` : 'Active'"></span>
</p>
</div>
<button @click="cancelAddon(addon)"
class="px-3 py-1 text-sm font-medium text-red-600 bg-red-100 rounded-lg hover:bg-red-200 dark:bg-red-900/50 dark:text-red-400">
Cancel
</button>
</div>
</template>
</div>
</div>
</template>
<!-- Available Add-ons -->
<h4 class="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-3 uppercase tracking-wide">Available Add-ons</h4>
<template x-if="addons.length === 0">
<p class="text-gray-500 text-center py-8">No add-ons available</p>
</template>
<div class="space-y-3">
<template x-for="addon in addons" :key="addon.id">
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div>
<h4 class="font-medium text-gray-700 dark:text-gray-200" x-text="addon.name"></h4>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="addon.description"></p>
<p class="text-sm font-medium text-purple-600 mt-1">
<span x-text="formatCurrency(addon.price_cents, 'EUR')"></span>
<span x-text="`/${addon.billing_period}`"></span>
</p>
</div>
<button @click="purchaseAddon(addon)"
:disabled="isAddonPurchased(addon.code) || purchasingAddon === addon.code"
:class="isAddonPurchased(addon.code) ? 'bg-gray-200 text-gray-500 cursor-not-allowed' : 'bg-purple-100 text-purple-600 hover:bg-purple-200'"
class="px-4 py-2 text-sm font-medium rounded-lg transition-colors">
<template x-if="purchasingAddon === addon.code">
<span class="flex items-center">
<span class="-ml-1 mr-2 h-4 w-4" x-html="$icon('spinner', 'h-4 w-4 animate-spin')"></span>
Processing...
</span>
</template>
<template x-if="purchasingAddon !== addon.code">
<span x-text="isAddonPurchased(addon.code) ? 'Active' : 'Add'"></span>
</template>
</button>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- Cancel Subscription Modal -->
<div x-show="showCancelModal"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-30 flex items-center justify-center overflow-auto bg-black bg-opacity-50"
@click.self="showCancelModal = false">
<div class="w-full max-w-md mx-4 bg-white dark:bg-gray-800 rounded-lg shadow-xl">
<div class="flex items-center justify-between px-6 py-4 border-b dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Cancel Subscription</h3>
<button @click="showCancelModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x-mark', 'w-6 h-6')"></span>
</button>
</div>
<div class="p-6">
<p class="text-gray-600 dark:text-gray-400 mb-4">
Are you sure you want to cancel your subscription? You'll continue to have access until the end of your current billing period.
</p>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Reason for cancelling (optional)
</label>
<textarea x-model="cancelReason"
rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="Tell us why you're leaving..."></textarea>
</div>
<div class="flex justify-end space-x-3">
<button @click="showCancelModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300">
Keep Subscription
</button>
<button @click="cancelSubscription()"
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700">
Cancel Subscription
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="/static/modules/billing/store/js/billing.js"></script>
{% endblock %}

View File

@@ -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');

View File

@@ -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') %}
<span x-text="storeCode"></span>
<span class="text-gray-400 mx-2"></span>
<span x-text="store?.subdomain"></span>
{% endcall %}
{{ loading_state('Loading store details...') }}
{{ error_state('Error loading store') }}
<!-- Store Details -->
<div x-show="!loading && store">
<!-- Quick Actions Card -->
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Quick Actions
</h3>
<div class="flex flex-wrap items-center gap-3">
<a
:href="`/admin/stores/${storeCode}/edit`"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
<span x-html="$icon('edit', 'w-4 h-4 mr-2')"></span>
Edit Store
</a>
<button
@click="deleteStore()"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-red-600 border border-transparent rounded-lg hover:bg-red-700 focus:outline-none focus:shadow-outline-red">
<span x-html="$icon('delete', 'w-4 h-4 mr-2')"></span>
Delete Store
</button>
</div>
</div>
<!-- Status Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-4">
<!-- Verification Status -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 rounded-full"
:class="store?.is_verified ? 'text-green-500 bg-green-100 dark:text-green-100 dark:bg-green-500' : 'text-orange-500 bg-orange-100 dark:text-orange-100 dark:bg-orange-500'">
<span x-html="$icon(store?.is_verified ? 'badge-check' : 'clock', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Verification
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="store?.is_verified ? 'Verified' : 'Pending'">
-
</p>
</div>
</div>
<!-- Active Status -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 rounded-full"
:class="store?.is_active ? 'text-green-500 bg-green-100 dark:text-green-100 dark:bg-green-500' : 'text-red-500 bg-red-100 dark:text-red-100 dark:bg-red-500'">
<span x-html="$icon(store?.is_active ? 'check-circle' : 'x-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Status
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="store?.is_active ? 'Active' : 'Inactive'">
-
</p>
</div>
</div>
<!-- Created Date -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('calendar', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Created
</p>
<p class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="formatDate(store?.created_at)">
-
</p>
</div>
</div>
<!-- Updated Date -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
<span x-html="$icon('refresh', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Last Updated
</p>
<p class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="formatDate(store?.updated_at)">
-
</p>
</div>
</div>
</div>
<!-- Subscription Card -->
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800" x-show="subscription">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
Subscription
</h3>
<button
@click="showSubscriptionModal = true"
class="flex items-center px-3 py-1.5 text-sm font-medium text-purple-600 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300">
<span x-html="$icon('edit', 'w-4 h-4 mr-1')"></span>
Edit
</button>
</div>
<!-- Tier and Status -->
<div class="flex flex-wrap items-center gap-4 mb-4">
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600 dark:text-gray-400">Tier:</span>
<span class="px-2.5 py-0.5 text-sm font-medium rounded-full"
:class="{
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300': subscription?.tier === 'essential',
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': subscription?.tier === 'professional',
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300': subscription?.tier === 'business',
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300': subscription?.tier === 'enterprise'
}"
x-text="subscription?.tier ? subscription.tier.charAt(0).toUpperCase() + subscription.tier.slice(1) : '-'">
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600 dark:text-gray-400">Status:</span>
<span class="px-2.5 py-0.5 text-sm font-medium rounded-full"
:class="{
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300': subscription?.status === 'active',
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': subscription?.status === 'trial',
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300': subscription?.status === 'past_due',
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300': subscription?.status === 'cancelled' || subscription?.status === 'expired'
}"
x-text="subscription?.status ? subscription.status.replace('_', ' ').charAt(0).toUpperCase() + subscription.status.slice(1) : '-'">
</span>
</div>
<template x-if="subscription?.is_annual">
<span class="px-2.5 py-0.5 text-xs font-medium text-purple-800 bg-purple-100 rounded-full dark:bg-purple-900 dark:text-purple-300">
Annual
</span>
</template>
</div>
<!-- Period Info -->
<div class="flex flex-wrap gap-4 mb-4 text-sm">
<div>
<span class="text-gray-600 dark:text-gray-400">Period:</span>
<span class="ml-1 text-gray-700 dark:text-gray-300" x-text="formatDate(subscription?.period_start)"></span>
<span class="text-gray-400"></span>
<span class="text-gray-700 dark:text-gray-300" x-text="formatDate(subscription?.period_end)"></span>
</div>
<template x-if="subscription?.trial_ends_at">
<div>
<span class="text-gray-600 dark:text-gray-400">Trial ends:</span>
<span class="ml-1 text-gray-700 dark:text-gray-300" x-text="formatDate(subscription?.trial_ends_at)"></span>
</div>
</template>
</div>
<!-- Usage Meters -->
<div class="grid gap-4 md:grid-cols-3">
<template x-for="metric in usageMetrics" :key="metric.name">
<div class="p-3 bg-gray-50 rounded-lg dark:bg-gray-700">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 uppercase" x-text="metric.name"></span>
</div>
<div class="flex items-baseline gap-1">
<span class="text-xl font-bold text-gray-700 dark:text-gray-200" x-text="metric.current"></span>
<span class="text-sm text-gray-500 dark:text-gray-400">
/ <span x-text="metric.is_unlimited ? '∞' : metric.limit"></span>
</span>
</div>
<div class="mt-2 h-1.5 bg-gray-200 rounded-full dark:bg-gray-600" x-show="!metric.is_unlimited">
<div class="h-1.5 rounded-full transition-all"
:class="getUsageBarColor(metric.current, metric.limit)"
:style="`width: ${Math.min(100, metric.percentage || 0)}%`">
</div>
</div>
</div>
</template>
<template x-if="usageMetrics.length === 0">
<div class="p-3 bg-gray-50 rounded-lg dark:bg-gray-700 md:col-span-3">
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">No usage data available</p>
</div>
</template>
</div>
</div>
<!-- No Subscription Notice -->
<div class="px-4 py-3 mb-6 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800" x-show="!subscription && !loading">
<div class="flex items-center gap-3">
<span x-html="$icon('exclamation', 'w-5 h-5 text-yellow-600 dark:text-yellow-400')"></span>
<div>
<p class="text-sm font-medium text-yellow-800 dark:text-yellow-200">No Subscription Found</p>
<p class="text-sm text-yellow-700 dark:text-yellow-300">This store doesn't have a subscription yet.</p>
</div>
<button
@click="createSubscription()"
class="ml-auto px-3 py-1.5 text-sm font-medium text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">
Create Subscription
</button>
</div>
</div>
<!-- Main Info Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-2">
<!-- Basic Information -->
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Basic Information
</h3>
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Store Code</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="store?.store_code || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Name</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="store?.name || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Subdomain</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="store?.subdomain || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Description</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="store?.description || 'No description provided'">-</p>
</div>
</div>
</div>
<!-- Contact Information -->
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Contact Information
</h3>
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Owner Email</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="store?.owner_email || '-'">-</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Owner's authentication email</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Contact Email</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="store?.contact_email || '-'">-</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Public business contact</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Phone</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="store?.contact_phone || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Website</p>
<a
x-show="store?.website"
:href="store?.website"
target="_blank"
class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400"
x-text="store?.website">
</a>
<span x-show="!store?.website" class="text-sm text-gray-700 dark:text-gray-300">-</span>
</div>
</div>
</div>
</div>
<!-- Business Details -->
<div class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Business Details
</h3>
<div class="grid gap-6 md:grid-cols-2">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Business Address</p>
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-line" x-text="store?.business_address || 'No address provided'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Tax Number</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="store?.tax_number || 'Not provided'">-</p>
</div>
</div>
</div>
<!-- Owner Information -->
<div class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Owner Information
</h3>
<div class="grid gap-6 md:grid-cols-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Owner User ID</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="store?.owner_user_id || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Owner Username</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="store?.owner_username || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Owner Email</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="store?.owner_email || '-'">-</p>
</div>
</div>
</div>
<!-- Marketplace URLs -->
<div class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800" x-show="store?.letzshop_csv_url_fr || store?.letzshop_csv_url_en || store?.letzshop_csv_url_de">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Marketplace CSV URLs
</h3>
<div class="space-y-3">
<div x-show="store?.letzshop_csv_url_fr">
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-1">French (FR)</p>
<a
:href="store?.letzshop_csv_url_fr"
target="_blank"
class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400 break-all"
x-text="store?.letzshop_csv_url_fr">
</a>
</div>
<div x-show="store?.letzshop_csv_url_en">
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-1">English (EN)</p>
<a
:href="store?.letzshop_csv_url_en"
target="_blank"
class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400 break-all"
x-text="store?.letzshop_csv_url_en">
</a>
</div>
<div x-show="store?.letzshop_csv_url_de">
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-1">German (DE)</p>
<a
:href="store?.letzshop_csv_url_de"
target="_blank"
class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400 break-all"
x-text="store?.letzshop_csv_url_de">
</a>
</div>
</div>
</div>
<!-- More Actions -->
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
More Actions
</h3>
<div class="flex flex-wrap gap-3">
<!-- View Parent Merchant -->
<a
:href="'/admin/merchants/' + store?.merchant_id + '/edit'"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white transition-colors duration-150 bg-blue-600 border border-transparent rounded-lg hover:bg-blue-700 focus:outline-none focus:shadow-outline-blue"
>
<span x-html="$icon('office-building', 'w-4 h-4 mr-2')"></span>
View Parent Merchant
</a>
<!-- Customize Theme -->
<a
:href="`/admin/stores/${storeCode}/theme`"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
>
<span x-html="$icon('color-swatch', 'w-4 h-4 mr-2')"></span>
Customize Theme
</a>
</div>
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
<span x-html="$icon('information-circle', 'w-4 h-4 inline mr-1')"></span>
This store belongs to merchant: <strong x-text="store?.merchant_name"></strong>.
Contact info and ownership are managed at the merchant level.
</p>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('tenancy_static', path='admin/js/store-detail.js') }}"></script>
{% endblock %}

View File

@@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from app.modules.tenancy.exceptions import VendorNotFoundException from app.modules.tenancy.exceptions import StoreNotFoundException
from app.modules.billing.services.billing_service import ( from app.modules.billing.services.billing_service import (
BillingService, BillingService,
NoActiveSubscriptionError, NoActiveSubscriptionError,
@@ -18,10 +18,10 @@ from app.modules.billing.services.billing_service import (
from app.modules.billing.models import ( from app.modules.billing.models import (
AddOnProduct, AddOnProduct,
BillingHistory, BillingHistory,
MerchantSubscription,
SubscriptionStatus, SubscriptionStatus,
SubscriptionTier, SubscriptionTier,
VendorAddOn, StoreAddOn,
VendorSubscription,
) )
@@ -35,22 +35,22 @@ class TestBillingServiceSubscription:
self.service = BillingService() self.service = BillingService()
def test_get_subscription_with_tier_creates_if_not_exists( 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.""" """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 is not None
assert subscription.vendor_id == test_vendor.id assert subscription.store_id == test_store.id
assert tier is not None assert tier is not None
assert tier.code == subscription.tier assert tier.code == subscription.tier
def test_get_subscription_with_tier_returns_existing( 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.""" """Test get_subscription_with_tier returns existing subscription."""
# Note: test_subscription fixture already creates the tier # 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 subscription.id == test_subscription.id
assert tier.code == test_subscription.tier assert tier.code == test_subscription.tier
@@ -109,7 +109,7 @@ class TestBillingServiceCheckout:
@patch("app.modules.billing.services.billing_service.stripe_service") @patch("app.modules.billing.services.billing_service.stripe_service")
def test_create_checkout_session_stripe_not_configured( 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.""" """Test checkout fails when Stripe not configured."""
mock_stripe.is_configured = False mock_stripe.is_configured = False
@@ -117,7 +117,7 @@ class TestBillingServiceCheckout:
with pytest.raises(PaymentSystemNotConfiguredError): with pytest.raises(PaymentSystemNotConfiguredError):
self.service.create_checkout_session( self.service.create_checkout_session(
db=db, db=db,
vendor_id=test_vendor.id, store_id=test_store.id,
tier_code="essential", tier_code="essential",
is_annual=False, is_annual=False,
success_url="https://example.com/success", success_url="https://example.com/success",
@@ -126,7 +126,7 @@ class TestBillingServiceCheckout:
@patch("app.modules.billing.services.billing_service.stripe_service") @patch("app.modules.billing.services.billing_service.stripe_service")
def test_create_checkout_session_success( 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.""" """Test successful checkout session creation."""
mock_stripe.is_configured = True mock_stripe.is_configured = True
@@ -137,7 +137,7 @@ class TestBillingServiceCheckout:
result = self.service.create_checkout_session( result = self.service.create_checkout_session(
db=db, db=db,
vendor_id=test_vendor.id, store_id=test_store.id,
tier_code="essential", tier_code="essential",
is_annual=False, is_annual=False,
success_url="https://example.com/success", success_url="https://example.com/success",
@@ -149,7 +149,7 @@ class TestBillingServiceCheckout:
@patch("app.modules.billing.services.billing_service.stripe_service") @patch("app.modules.billing.services.billing_service.stripe_service")
def test_create_checkout_session_tier_not_found( 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.""" """Test checkout fails with invalid tier."""
mock_stripe.is_configured = True mock_stripe.is_configured = True
@@ -157,7 +157,7 @@ class TestBillingServiceCheckout:
with pytest.raises(TierNotFoundError): with pytest.raises(TierNotFoundError):
self.service.create_checkout_session( self.service.create_checkout_session(
db=db, db=db,
vendor_id=test_vendor.id, store_id=test_store.id,
tier_code="nonexistent", tier_code="nonexistent",
is_annual=False, is_annual=False,
success_url="https://example.com/success", success_url="https://example.com/success",
@@ -166,7 +166,7 @@ class TestBillingServiceCheckout:
@patch("app.modules.billing.services.billing_service.stripe_service") @patch("app.modules.billing.services.billing_service.stripe_service")
def test_create_checkout_session_no_price( 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.""" """Test checkout fails when tier has no Stripe price."""
mock_stripe.is_configured = True mock_stripe.is_configured = True
@@ -174,7 +174,7 @@ class TestBillingServiceCheckout:
with pytest.raises(StripePriceNotConfiguredError): with pytest.raises(StripePriceNotConfiguredError):
self.service.create_checkout_session( self.service.create_checkout_session(
db=db, db=db,
vendor_id=test_vendor.id, store_id=test_store.id,
tier_code="essential", tier_code="essential",
is_annual=False, is_annual=False,
success_url="https://example.com/success", success_url="https://example.com/success",
@@ -192,32 +192,32 @@ class TestBillingServicePortal:
self.service = BillingService() self.service = BillingService()
@patch("app.modules.billing.services.billing_service.stripe_service") @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.""" """Test portal fails when Stripe not configured."""
mock_stripe.is_configured = False mock_stripe.is_configured = False
with pytest.raises(PaymentSystemNotConfiguredError): with pytest.raises(PaymentSystemNotConfiguredError):
self.service.create_portal_session( self.service.create_portal_session(
db=db, db=db,
vendor_id=test_vendor.id, store_id=test_store.id,
return_url="https://example.com/billing", return_url="https://example.com/billing",
) )
@patch("app.modules.billing.services.billing_service.stripe_service") @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.""" """Test portal fails when no subscription exists."""
mock_stripe.is_configured = True mock_stripe.is_configured = True
with pytest.raises(NoActiveSubscriptionError): with pytest.raises(NoActiveSubscriptionError):
self.service.create_portal_session( self.service.create_portal_session(
db=db, db=db,
vendor_id=test_vendor.id, store_id=test_store.id,
return_url="https://example.com/billing", return_url="https://example.com/billing",
) )
@patch("app.modules.billing.services.billing_service.stripe_service") @patch("app.modules.billing.services.billing_service.stripe_service")
def test_create_portal_session_success( 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.""" """Test successful portal session creation."""
mock_stripe.is_configured = True mock_stripe.is_configured = True
@@ -227,7 +227,7 @@ class TestBillingServicePortal:
result = self.service.create_portal_session( result = self.service.create_portal_session(
db=db, db=db,
vendor_id=test_vendor.id, store_id=test_store.id,
return_url="https://example.com/billing", return_url="https://example.com/billing",
) )
@@ -243,30 +243,30 @@ class TestBillingServiceInvoices:
"""Initialize service instance before each test.""" """Initialize service instance before each test."""
self.service = BillingService() 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.""" """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 invoices == []
assert total == 0 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.""" """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 len(invoices) == 1
assert total == 1 assert total == 1
assert invoices[0].invoice_number == "INV-001" 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.""" """Test invoice pagination."""
# Get first page # 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 len(page1) == 2
assert total == 5 assert total == 5
# Get second page # 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 assert len(page2) == 2
@@ -298,9 +298,9 @@ class TestBillingServiceAddons:
assert len(domain_addons) == 1 assert len(domain_addons) == 1
assert domain_addons[0].category == "domain" assert domain_addons[0].category == "domain"
def test_get_vendor_addons_empty(self, db, test_vendor): def test_get_store_addons_empty(self, db, test_store):
"""Test getting vendor addons when none purchased.""" """Test getting store addons when none purchased."""
addons = self.service.get_vendor_addons(db, test_vendor.id) addons = self.service.get_store_addons(db, test_store.id)
assert addons == [] assert addons == []
@@ -315,7 +315,7 @@ class TestBillingServiceCancellation:
@patch("app.modules.billing.services.billing_service.stripe_service") @patch("app.modules.billing.services.billing_service.stripe_service")
def test_cancel_subscription_no_subscription( def test_cancel_subscription_no_subscription(
self, mock_stripe, db, test_vendor self, mock_stripe, db, test_store
): ):
"""Test cancel fails when no subscription.""" """Test cancel fails when no subscription."""
mock_stripe.is_configured = True mock_stripe.is_configured = True
@@ -323,21 +323,21 @@ class TestBillingServiceCancellation:
with pytest.raises(NoActiveSubscriptionError): with pytest.raises(NoActiveSubscriptionError):
self.service.cancel_subscription( self.service.cancel_subscription(
db=db, db=db,
vendor_id=test_vendor.id, store_id=test_store.id,
reason="Test reason", reason="Test reason",
immediately=False, immediately=False,
) )
@patch("app.modules.billing.services.billing_service.stripe_service") @patch("app.modules.billing.services.billing_service.stripe_service")
def test_cancel_subscription_success( 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.""" """Test successful subscription cancellation."""
mock_stripe.is_configured = True mock_stripe.is_configured = True
result = self.service.cancel_subscription( result = self.service.cancel_subscription(
db=db, db=db,
vendor_id=test_vendor.id, store_id=test_store.id,
reason="Too expensive", reason="Too expensive",
immediately=False, immediately=False,
) )
@@ -348,22 +348,22 @@ class TestBillingServiceCancellation:
@patch("app.modules.billing.services.billing_service.stripe_service") @patch("app.modules.billing.services.billing_service.stripe_service")
def test_reactivate_subscription_not_cancelled( 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.""" """Test reactivate fails when subscription not cancelled."""
mock_stripe.is_configured = True mock_stripe.is_configured = True
with pytest.raises(SubscriptionNotCancelledError): 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") @patch("app.modules.billing.services.billing_service.stripe_service")
def test_reactivate_subscription_success( 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.""" """Test successful subscription reactivation."""
mock_stripe.is_configured = True 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 result["message"] == "Subscription reactivated successfully"
assert test_cancelled_subscription.cancelled_at is None assert test_cancelled_subscription.cancelled_at is None
@@ -372,23 +372,23 @@ class TestBillingServiceCancellation:
@pytest.mark.unit @pytest.mark.unit
@pytest.mark.billing @pytest.mark.billing
class TestBillingServiceVendor: class TestBillingServiceStore:
"""Test suite for BillingService vendor operations.""" """Test suite for BillingService store operations."""
def setup_method(self): def setup_method(self):
"""Initialize service instance before each test.""" """Initialize service instance before each test."""
self.service = BillingService() self.service = BillingService()
def test_get_vendor_success(self, db, test_vendor): def test_get_store_success(self, db, test_store):
"""Test getting vendor by ID.""" """Test getting store by ID."""
vendor = self.service.get_vendor(db, test_vendor.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): def test_get_store_not_found(self, db):
"""Test getting non-existent vendor raises error.""" """Test getting non-existent store raises error."""
with pytest.raises(VendorNotFoundException): with pytest.raises(StoreNotFoundException):
self.service.get_vendor(db, 99999) self.service.get_store(db, 99999)
# ==================== Fixtures ==================== # ==================== Fixtures ====================
@@ -480,7 +480,7 @@ def test_subscription_tiers(db):
@pytest.fixture @pytest.fixture
def test_subscription(db, test_vendor): def test_subscription(db, test_store):
"""Create a basic subscription for testing.""" """Create a basic subscription for testing."""
# Create tier first # Create tier first
tier = SubscriptionTier( tier = SubscriptionTier(
@@ -494,8 +494,8 @@ def test_subscription(db, test_vendor):
db.add(tier) db.add(tier)
db.commit() db.commit()
subscription = VendorSubscription( subscription = MerchantSubscription(
vendor_id=test_vendor.id, store_id=test_store.id,
tier="essential", tier="essential",
status=SubscriptionStatus.ACTIVE, status=SubscriptionStatus.ACTIVE,
period_start=datetime.now(timezone.utc), period_start=datetime.now(timezone.utc),
@@ -508,7 +508,7 @@ def test_subscription(db, test_vendor):
@pytest.fixture @pytest.fixture
def test_active_subscription(db, test_vendor): def test_active_subscription(db, test_store):
"""Create an active subscription with Stripe IDs.""" """Create an active subscription with Stripe IDs."""
# Create tier first if not exists # Create tier first if not exists
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first() tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first()
@@ -524,8 +524,8 @@ def test_active_subscription(db, test_vendor):
db.add(tier) db.add(tier)
db.commit() db.commit()
subscription = VendorSubscription( subscription = MerchantSubscription(
vendor_id=test_vendor.id, store_id=test_store.id,
tier="essential", tier="essential",
status=SubscriptionStatus.ACTIVE, status=SubscriptionStatus.ACTIVE,
stripe_customer_id="cus_test123", stripe_customer_id="cus_test123",
@@ -540,7 +540,7 @@ def test_active_subscription(db, test_vendor):
@pytest.fixture @pytest.fixture
def test_cancelled_subscription(db, test_vendor): def test_cancelled_subscription(db, test_store):
"""Create a cancelled subscription.""" """Create a cancelled subscription."""
# Create tier first if not exists # Create tier first if not exists
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first() tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first()
@@ -556,8 +556,8 @@ def test_cancelled_subscription(db, test_vendor):
db.add(tier) db.add(tier)
db.commit() db.commit()
subscription = VendorSubscription( subscription = MerchantSubscription(
vendor_id=test_vendor.id, store_id=test_store.id,
tier="essential", tier="essential",
status=SubscriptionStatus.ACTIVE, status=SubscriptionStatus.ACTIVE,
stripe_customer_id="cus_test123", stripe_customer_id="cus_test123",
@@ -574,10 +574,10 @@ def test_cancelled_subscription(db, test_vendor):
@pytest.fixture @pytest.fixture
def test_billing_history(db, test_vendor): def test_billing_history(db, test_store):
"""Create a billing history record.""" """Create a billing history record."""
record = BillingHistory( record = BillingHistory(
vendor_id=test_vendor.id, store_id=test_store.id,
stripe_invoice_id="in_test123", stripe_invoice_id="in_test123",
invoice_number="INV-001", invoice_number="INV-001",
invoice_date=datetime.now(timezone.utc), invoice_date=datetime.now(timezone.utc),
@@ -595,12 +595,12 @@ def test_billing_history(db, test_vendor):
@pytest.fixture @pytest.fixture
def test_multiple_invoices(db, test_vendor): def test_multiple_invoices(db, test_store):
"""Create multiple billing history records.""" """Create multiple billing history records."""
records = [] records = []
for i in range(5): for i in range(5):
record = BillingHistory( record = BillingHistory(
vendor_id=test_vendor.id, store_id=test_store.id,
stripe_invoice_id=f"in_test{i}", stripe_invoice_id=f"in_test{i}",
invoice_number=f"INV-{i:03d}", invoice_number=f"INV-{i:03d}",
invoice_date=datetime.now(timezone.utc), invoice_date=datetime.now(timezone.utc),

View File

@@ -5,8 +5,7 @@ import pytest
from app.modules.billing.exceptions import FeatureNotFoundError, InvalidFeatureCodesError, TierNotFoundError from app.modules.billing.exceptions import FeatureNotFoundError, InvalidFeatureCodesError, TierNotFoundError
from app.modules.billing.services.feature_service import FeatureService, feature_service 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, MerchantSubscription
from app.modules.billing.models import SubscriptionTier, VendorSubscription
@pytest.mark.unit @pytest.mark.unit
@@ -18,27 +17,27 @@ class TestFeatureServiceAvailability:
"""Initialize service instance before each test.""" """Initialize service instance before each test."""
self.service = FeatureService() 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.""" """Test has_feature returns True for available feature."""
vendor_id = test_vendor_with_subscription.id store_id = test_store_with_subscription.id
result = self.service.has_feature(db, vendor_id, "basic_reports") result = self.service.has_feature(db, store_id, "basic_reports")
assert result is True 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.""" """Test has_feature returns False for unavailable feature."""
vendor_id = test_vendor_with_subscription.id store_id = test_store_with_subscription.id
result = self.service.has_feature(db, vendor_id, "api_access") result = self.service.has_feature(db, store_id, "api_access")
assert result is False assert result is False
def test_has_feature_no_subscription(self, db, test_vendor): def test_has_feature_no_subscription(self, db, test_store):
"""Test has_feature returns False for vendor without subscription.""" """Test has_feature returns False for store without subscription."""
result = self.service.has_feature(db, test_vendor.id, "basic_reports") result = self.service.has_feature(db, test_store.id, "basic_reports")
assert result is False assert result is False
def test_get_vendor_feature_codes(self, db, test_vendor_with_subscription): def test_get_store_feature_codes(self, db, test_store_with_subscription):
"""Test getting all feature codes for vendor.""" """Test getting all feature codes for store."""
vendor_id = test_vendor_with_subscription.id store_id = test_store_with_subscription.id
features = self.service.get_vendor_feature_codes(db, vendor_id) features = self.service.get_store_feature_codes(db, store_id)
assert isinstance(features, set) assert isinstance(features, set)
assert "basic_reports" in features assert "basic_reports" in features
@@ -54,10 +53,10 @@ class TestFeatureServiceListing:
"""Initialize service instance before each test.""" """Initialize service instance before each test."""
self.service = FeatureService() 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.""" """Test getting all features with availability."""
vendor_id = test_vendor_with_subscription.id store_id = test_store_with_subscription.id
features = self.service.get_vendor_features(db, vendor_id) features = self.service.get_store_features(db, store_id)
assert len(features) > 0 assert len(features) > 0
basic_reports = next((f for f in features if f.code == "basic_reports"), None) 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 not None
assert api_access.is_available is False assert api_access.is_available is False
def test_get_vendor_features_by_category( def test_get_store_features_by_category(
self, db, test_vendor_with_subscription, test_features self, db, test_store_with_subscription, test_features
): ):
"""Test filtering features by category.""" """Test filtering features by category."""
vendor_id = test_vendor_with_subscription.id store_id = test_store_with_subscription.id
features = self.service.get_vendor_features(db, vendor_id, category="analytics") features = self.service.get_store_features(db, store_id, category="analytics")
assert all(f.category == "analytics" for f in features) assert all(f.category == "analytics" for f in features)
def test_get_vendor_features_available_only( def test_get_store_features_available_only(
self, db, test_vendor_with_subscription, test_features self, db, test_store_with_subscription, test_features
): ):
"""Test getting only available features.""" """Test getting only available features."""
vendor_id = test_vendor_with_subscription.id store_id = test_store_with_subscription.id
features = self.service.get_vendor_features( features = self.service.get_store_features(
db, vendor_id, include_unavailable=False db, store_id, include_unavailable=False
) )
assert all(f.is_available for f in features) 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.""" """Test getting simple list of available codes."""
vendor_id = test_vendor_with_subscription.id store_id = test_store_with_subscription.id
codes = self.service.get_available_feature_codes(db, vendor_id) codes = self.service.get_available_feature_codes(db, store_id)
assert isinstance(codes, list) assert isinstance(codes, list)
assert "basic_reports" in codes assert "basic_reports" in codes
@@ -159,28 +158,28 @@ class TestFeatureServiceCache:
"""Initialize service instance before each test.""" """Initialize service instance before each test."""
self.service = FeatureService() self.service = FeatureService()
def test_cache_invalidation(self, db, test_vendor_with_subscription): def test_cache_invalidation(self, db, test_store_with_subscription):
"""Test cache invalidation for vendor.""" """Test cache invalidation for store."""
vendor_id = test_vendor_with_subscription.id store_id = test_store_with_subscription.id
# Prime the cache # Prime the cache
self.service.get_vendor_feature_codes(db, vendor_id) self.service.get_store_feature_codes(db, store_id)
assert self.service._cache.get(vendor_id) is not None assert self.service._cache.get(store_id) is not None
# Invalidate # Invalidate
self.service.invalidate_vendor_cache(vendor_id) self.service.invalidate_store_cache(store_id)
assert self.service._cache.get(vendor_id) is None 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.""" """Test invalidating entire cache."""
vendor_id = test_vendor_with_subscription.id store_id = test_store_with_subscription.id
# Prime the cache # Prime the cache
self.service.get_vendor_feature_codes(db, vendor_id) self.service.get_store_feature_codes(db, store_id)
# Invalidate all # Invalidate all
self.service.invalidate_all_cache() 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 @pytest.mark.unit
@@ -301,14 +300,14 @@ def test_subscription_tiers(db):
@pytest.fixture @pytest.fixture
def test_vendor_with_subscription(db, test_vendor, test_subscription_tiers): def test_store_with_subscription(db, test_store, test_subscription_tiers):
"""Create a vendor with an active subscription.""" """Create a store with an active subscription."""
from datetime import datetime, timezone from datetime import datetime, timezone
essential_tier = test_subscription_tiers[0] # Use the essential tier from tiers list essential_tier = test_subscription_tiers[0] # Use the essential tier from tiers list
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
subscription = VendorSubscription( subscription = StoreSubscription(
vendor_id=test_vendor.id, store_id=test_store.id,
tier="essential", tier="essential",
tier_id=essential_tier.id, tier_id=essential_tier.id,
status="active", status="active",
@@ -318,8 +317,8 @@ def test_vendor_with_subscription(db, test_vendor, test_subscription_tiers):
) )
db.add(subscription) db.add(subscription)
db.commit() db.commit()
db.refresh(test_vendor) db.refresh(test_store)
return test_vendor return test_store
@pytest.fixture @pytest.fixture

View File

@@ -9,10 +9,10 @@ import pytest
from app.handlers.stripe_webhook import StripeWebhookHandler from app.handlers.stripe_webhook import StripeWebhookHandler
from app.modules.billing.models import ( from app.modules.billing.models import (
BillingHistory, BillingHistory,
MerchantSubscription,
StripeWebhookEvent, StripeWebhookEvent,
SubscriptionStatus, SubscriptionStatus,
SubscriptionTier, SubscriptionTier,
VendorSubscription,
) )
@@ -61,7 +61,7 @@ class TestStripeWebhookHandlerCheckout:
@patch("app.handlers.stripe_webhook.stripe.Subscription.retrieve") @patch("app.handlers.stripe_webhook.stripe.Subscription.retrieve")
def test_handle_checkout_completed_success( 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.""" """Test successful checkout completion."""
# Mock Stripe subscription retrieve # Mock Stripe subscription retrieve
@@ -71,7 +71,7 @@ class TestStripeWebhookHandlerCheckout:
mock_stripe_sub.trial_end = None mock_stripe_sub.trial_end = None
mock_stripe_retrieve.return_value = mock_stripe_sub 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) 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.stripe_customer_id == "cus_test123"
assert test_subscription.status == SubscriptionStatus.ACTIVE assert test_subscription.status == SubscriptionStatus.ACTIVE
def test_handle_checkout_completed_no_vendor_id(self, db, mock_checkout_event): def test_handle_checkout_completed_no_store_id(self, db, mock_checkout_event):
"""Test checkout with missing vendor_id is skipped.""" """Test checkout with missing store_id is skipped."""
mock_checkout_event.data.object.metadata = {} mock_checkout_event.data.object.metadata = {}
result = self.handler.handle_event(db, mock_checkout_event) result = self.handler.handle_event(db, mock_checkout_event)
assert result["status"] == "processed" assert result["status"] == "processed"
assert result["result"]["action"] == "skipped" assert result["result"]["action"] == "skipped"
assert result["result"]["reason"] == "no vendor_id" assert result["result"]["reason"] == "no store_id"
@pytest.mark.unit @pytest.mark.unit
@@ -101,7 +101,7 @@ class TestStripeWebhookHandlerSubscription:
self.handler = StripeWebhookHandler() self.handler = StripeWebhookHandler()
def test_handle_subscription_updated_status_change( 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.""" """Test subscription update changes status."""
result = self.handler.handle_event(db, mock_subscription_updated_event) result = self.handler.handle_event(db, mock_subscription_updated_event)
@@ -109,7 +109,7 @@ class TestStripeWebhookHandlerSubscription:
assert result["status"] == "processed" assert result["status"] == "processed"
def test_handle_subscription_deleted( 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.""" """Test subscription deletion."""
result = self.handler.handle_event(db, mock_subscription_deleted_event) result = self.handler.handle_event(db, mock_subscription_deleted_event)
@@ -129,7 +129,7 @@ class TestStripeWebhookHandlerInvoice:
self.handler = StripeWebhookHandler() self.handler = StripeWebhookHandler()
def test_handle_invoice_paid_creates_billing_record( 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.""" """Test invoice.paid creates billing history record."""
result = self.handler.handle_event(db, mock_invoice_paid_event) result = self.handler.handle_event(db, mock_invoice_paid_event)
@@ -139,7 +139,7 @@ class TestStripeWebhookHandlerInvoice:
# Check billing record created # Check billing record created
record = ( record = (
db.query(BillingHistory) db.query(BillingHistory)
.filter(BillingHistory.vendor_id == test_vendor.id) .filter(BillingHistory.store_id == test_store.id)
.first() .first()
) )
assert record is not None assert record is not None
@@ -147,7 +147,7 @@ class TestStripeWebhookHandlerInvoice:
assert record.total_cents == 4900 assert record.total_cents == 4900
def test_handle_invoice_paid_resets_counters( 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 invoice.paid resets order counters."""
test_active_subscription.orders_this_period = 50 test_active_subscription.orders_this_period = 50
@@ -159,7 +159,7 @@ class TestStripeWebhookHandlerInvoice:
assert test_active_subscription.orders_this_period == 0 assert test_active_subscription.orders_this_period == 0
def test_handle_payment_failed_marks_past_due( 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.""" """Test payment failure marks subscription as past due."""
result = self.handler.handle_event(db, mock_payment_failed_event) result = self.handler.handle_event(db, mock_payment_failed_event)
@@ -248,7 +248,7 @@ def test_subscription_tier(db):
@pytest.fixture @pytest.fixture
def test_subscription(db, test_vendor): def test_subscription(db, test_store):
"""Create a basic subscription for testing.""" """Create a basic subscription for testing."""
# Create tier first if not exists # Create tier first if not exists
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first() tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first()
@@ -264,8 +264,8 @@ def test_subscription(db, test_vendor):
db.add(tier) db.add(tier)
db.commit() db.commit()
subscription = VendorSubscription( subscription = MerchantSubscription(
vendor_id=test_vendor.id, store_id=test_store.id,
tier="essential", tier="essential",
status=SubscriptionStatus.TRIAL, status=SubscriptionStatus.TRIAL,
period_start=datetime.now(timezone.utc), period_start=datetime.now(timezone.utc),
@@ -278,7 +278,7 @@ def test_subscription(db, test_vendor):
@pytest.fixture @pytest.fixture
def test_active_subscription(db, test_vendor): def test_active_subscription(db, test_store):
"""Create an active subscription with Stripe IDs.""" """Create an active subscription with Stripe IDs."""
# Create tier first if not exists # Create tier first if not exists
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first() tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first()
@@ -294,8 +294,8 @@ def test_active_subscription(db, test_vendor):
db.add(tier) db.add(tier)
db.commit() db.commit()
subscription = VendorSubscription( subscription = MerchantSubscription(
vendor_id=test_vendor.id, store_id=test_store.id,
tier="essential", tier="essential",
status=SubscriptionStatus.ACTIVE, status=SubscriptionStatus.ACTIVE,
stripe_customer_id="cus_test123", stripe_customer_id="cus_test123",

View File

@@ -5,32 +5,32 @@ import pytest
from app.modules.analytics.services.usage_service import UsageService, usage_service from app.modules.analytics.services.usage_service import UsageService, usage_service
from app.modules.catalog.models import Product from app.modules.catalog.models import Product
from app.modules.billing.models import SubscriptionTier, VendorSubscription from app.modules.billing.models import SubscriptionTier, MerchantSubscription
from app.modules.tenancy.models import VendorUser from app.modules.tenancy.models import StoreUser
@pytest.mark.unit @pytest.mark.unit
@pytest.mark.usage @pytest.mark.usage
class TestUsageServiceGetUsage: class TestUsageServiceGetUsage:
"""Test suite for get_vendor_usage operation.""" """Test suite for get_store_usage operation."""
def setup_method(self): def setup_method(self):
"""Initialize service instance before each test.""" """Initialize service instance before each test."""
self.service = UsageService() 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.""" """Test getting basic usage data."""
vendor_id = test_vendor_with_subscription.id store_id = test_store_with_subscription.id
usage = self.service.get_vendor_usage(db, vendor_id) usage = self.service.get_store_usage(db, store_id)
assert usage.tier.code == "essential" assert usage.tier.code == "essential"
assert usage.tier.name == "Essential" assert usage.tier.name == "Essential"
assert len(usage.usage) == 3 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.""" """Test usage metrics are calculated correctly."""
vendor_id = test_vendor_with_subscription.id store_id = test_store_with_subscription.id
usage = self.service.get_vendor_usage(db, vendor_id) usage = self.service.get_store_usage(db, store_id)
orders_metric = next((m for m in usage.usage if m.name == "orders"), None) orders_metric = next((m for m in usage.usage if m.name == "orders"), None)
assert orders_metric is not None assert orders_metric is not None
@@ -39,39 +39,39 @@ class TestUsageServiceGetUsage:
assert orders_metric.percentage == 10.0 assert orders_metric.percentage == 10.0
assert orders_metric.is_unlimited is False 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.""" """Test usage shows at limit correctly."""
vendor_id = test_vendor_at_limit.id store_id = test_store_at_limit.id
usage = self.service.get_vendor_usage(db, vendor_id) usage = self.service.get_store_usage(db, store_id)
orders_metric = next((m for m in usage.usage if m.name == "orders"), None) orders_metric = next((m for m in usage.usage if m.name == "orders"), None)
assert orders_metric.is_at_limit is True assert orders_metric.is_at_limit is True
assert usage.has_limits_reached 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.""" """Test usage shows approaching limit correctly."""
vendor_id = test_vendor_approaching_limit.id store_id = test_store_approaching_limit.id
usage = self.service.get_vendor_usage(db, vendor_id) usage = self.service.get_store_usage(db, store_id)
orders_metric = next((m for m in usage.usage if m.name == "orders"), None) orders_metric = next((m for m in usage.usage if m.name == "orders"), None)
assert orders_metric.is_approaching_limit is True assert orders_metric.is_approaching_limit is True
assert usage.has_limits_approaching is True assert usage.has_limits_approaching is True
def test_get_vendor_usage_upgrade_available( def test_get_store_usage_upgrade_available(
self, db, test_vendor_with_subscription, test_professional_tier self, db, test_store_with_subscription, test_professional_tier
): ):
"""Test upgrade info when not on highest tier.""" """Test upgrade info when not on highest tier."""
vendor_id = test_vendor_with_subscription.id store_id = test_store_with_subscription.id
usage = self.service.get_vendor_usage(db, vendor_id) usage = self.service.get_store_usage(db, store_id)
assert usage.upgrade_available is True assert usage.upgrade_available is True
assert usage.upgrade_tier is not None assert usage.upgrade_tier is not None
assert usage.upgrade_tier.code == "professional" 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.""" """Test no upgrade when on highest tier."""
vendor_id = test_vendor_on_professional.id store_id = test_store_on_professional.id
usage = self.service.get_vendor_usage(db, vendor_id) usage = self.service.get_store_usage(db, store_id)
assert usage.tier.is_highest_tier is True assert usage.tier.is_highest_tier is True
assert usage.upgrade_available is False assert usage.upgrade_available is False
@@ -87,28 +87,28 @@ class TestUsageServiceCheckLimit:
"""Initialize service instance before each test.""" """Initialize service instance before each test."""
self.service = UsageService() 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.""" """Test checking orders limit when under limit."""
vendor_id = test_vendor_with_subscription.id store_id = test_store_with_subscription.id
result = self.service.check_limit(db, vendor_id, "orders") result = self.service.check_limit(db, store_id, "orders")
assert result.can_proceed is True assert result.can_proceed is True
assert result.current == 10 assert result.current == 10
assert result.limit == 100 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.""" """Test checking products limit."""
vendor_id = test_vendor_with_products.id store_id = test_store_with_products.id
result = self.service.check_limit(db, vendor_id, "products") result = self.service.check_limit(db, store_id, "products")
assert result.can_proceed is True assert result.can_proceed is True
assert result.current == 5 assert result.current == 5
assert result.limit == 500 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.""" """Test checking team members limit when at limit."""
vendor_id = test_vendor_with_team.id store_id = test_store_with_team.id
result = self.service.check_limit(db, vendor_id, "team_members") result = self.service.check_limit(db, store_id, "team_members")
# At limit (2/2) - can_proceed should be False # At limit (2/2) - can_proceed should be False
assert result.can_proceed is False assert result.can_proceed is False
@@ -116,18 +116,18 @@ class TestUsageServiceCheckLimit:
assert result.limit == 2 assert result.limit == 2
assert result.percentage == 100.0 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.""" """Test checking unknown limit type."""
vendor_id = test_vendor_with_subscription.id store_id = test_store_with_subscription.id
result = self.service.check_limit(db, vendor_id, "unknown") result = self.service.check_limit(db, store_id, "unknown")
assert result.can_proceed is True assert result.can_proceed is True
assert "Unknown limit type" in result.message 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.""" """Test upgrade info is provided when at limit."""
vendor_id = test_vendor_at_limit.id store_id = test_store_at_limit.id
result = self.service.check_limit(db, vendor_id, "orders") result = self.service.check_limit(db, store_id, "orders")
assert result.can_proceed is False assert result.can_proceed is False
assert result.upgrade_tier_code == "professional" assert result.upgrade_tier_code == "professional"
@@ -182,13 +182,13 @@ def test_professional_tier(db, test_essential_tier):
@pytest.fixture @pytest.fixture
def test_vendor_with_subscription(db, test_vendor, test_essential_tier): def test_store_with_subscription(db, test_store, test_essential_tier):
"""Create vendor with active subscription.""" """Create store with active subscription."""
from datetime import datetime, timezone from datetime import datetime, timezone
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
subscription = VendorSubscription( subscription = StoreSubscription(
vendor_id=test_vendor.id, store_id=test_store.id,
tier="essential", tier="essential",
tier_id=test_essential_tier.id, tier_id=test_essential_tier.id,
status="active", status="active",
@@ -198,18 +198,18 @@ def test_vendor_with_subscription(db, test_vendor, test_essential_tier):
) )
db.add(subscription) db.add(subscription)
db.commit() db.commit()
db.refresh(test_vendor) db.refresh(test_store)
return test_vendor return test_store
@pytest.fixture @pytest.fixture
def test_vendor_at_limit(db, test_vendor, test_essential_tier, test_professional_tier): def test_store_at_limit(db, test_store, test_essential_tier, test_professional_tier):
"""Create vendor at order limit.""" """Create store at order limit."""
from datetime import datetime, timezone from datetime import datetime, timezone
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
subscription = VendorSubscription( subscription = StoreSubscription(
vendor_id=test_vendor.id, store_id=test_store.id,
tier="essential", tier="essential",
tier_id=test_essential_tier.id, tier_id=test_essential_tier.id,
status="active", status="active",
@@ -219,18 +219,18 @@ def test_vendor_at_limit(db, test_vendor, test_essential_tier, test_professional
) )
db.add(subscription) db.add(subscription)
db.commit() db.commit()
db.refresh(test_vendor) db.refresh(test_store)
return test_vendor return test_store
@pytest.fixture @pytest.fixture
def test_vendor_approaching_limit(db, test_vendor, test_essential_tier): def test_store_approaching_limit(db, test_store, test_essential_tier):
"""Create vendor approaching order limit (>=80%).""" """Create store approaching order limit (>=80%)."""
from datetime import datetime, timezone from datetime import datetime, timezone
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
subscription = VendorSubscription( subscription = StoreSubscription(
vendor_id=test_vendor.id, store_id=test_store.id,
tier="essential", tier="essential",
tier_id=test_essential_tier.id, tier_id=test_essential_tier.id,
status="active", status="active",
@@ -240,18 +240,18 @@ def test_vendor_approaching_limit(db, test_vendor, test_essential_tier):
) )
db.add(subscription) db.add(subscription)
db.commit() db.commit()
db.refresh(test_vendor) db.refresh(test_store)
return test_vendor return test_store
@pytest.fixture @pytest.fixture
def test_vendor_on_professional(db, test_vendor, test_professional_tier): def test_store_on_professional(db, test_store, test_professional_tier):
"""Create vendor on highest tier.""" """Create store on highest tier."""
from datetime import datetime, timezone from datetime import datetime, timezone
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
subscription = VendorSubscription( subscription = StoreSubscription(
vendor_id=test_vendor.id, store_id=test_store.id,
tier="professional", tier="professional",
tier_id=test_professional_tier.id, tier_id=test_professional_tier.id,
status="active", status="active",
@@ -261,48 +261,48 @@ def test_vendor_on_professional(db, test_vendor, test_professional_tier):
) )
db.add(subscription) db.add(subscription)
db.commit() db.commit()
db.refresh(test_vendor) db.refresh(test_store)
return test_vendor return test_store
@pytest.fixture @pytest.fixture
def test_vendor_with_products(db, test_vendor_with_subscription, marketplace_product_factory): def test_store_with_products(db, test_store_with_subscription, marketplace_product_factory):
"""Create vendor with products.""" """Create store with products."""
for i in range(5): for i in range(5):
# Create marketplace product first # Create marketplace product first
mp = marketplace_product_factory(db, title=f"Test Product {i}") mp = marketplace_product_factory(db, title=f"Test Product {i}")
product = Product( product = Product(
vendor_id=test_vendor_with_subscription.id, store_id=test_store_with_subscription.id,
marketplace_product_id=mp.id, marketplace_product_id=mp.id,
price_cents=1000, price_cents=1000,
is_active=True, is_active=True,
) )
db.add(product) db.add(product)
db.commit() db.commit()
return test_vendor_with_subscription return test_store_with_subscription
@pytest.fixture @pytest.fixture
def test_vendor_with_team(db, test_vendor_with_subscription, test_user, other_user): def test_store_with_team(db, test_store_with_subscription, test_user, other_user):
"""Create vendor with team members (owner + team member = 2).""" """Create store with team members (owner + team member = 2)."""
from app.modules.tenancy.models import VendorUserType from app.modules.tenancy.models import StoreUserType
# Add owner # Add owner
owner = VendorUser( owner = StoreUser(
vendor_id=test_vendor_with_subscription.id, store_id=test_store_with_subscription.id,
user_id=test_user.id, user_id=test_user.id,
user_type=VendorUserType.OWNER.value, user_type=StoreUserType.OWNER.value,
is_active=True, is_active=True,
) )
db.add(owner) db.add(owner)
# Add team member # Add team member
team_member = VendorUser( team_member = StoreUser(
vendor_id=test_vendor_with_subscription.id, store_id=test_store_with_subscription.id,
user_id=other_user.id, user_id=other_user.id,
user_type=VendorUserType.TEAM_MEMBER.value, user_type=StoreUserType.TEAM_MEMBER.value,
is_active=True, is_active=True,
) )
db.add(team_member) db.add(team_member)
db.commit() db.commit()
return test_vendor_with_subscription return test_store_with_subscription