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:
from app.modules.billing.models import (
VendorSubscription,
MerchantSubscription,
SubscriptionTier,
SubscriptionStatus,
TierCode,
Feature,
FeatureCode,
TierFeatureLimit,
MerchantFeatureOverride,
)
"""
from app.modules.billing.models.merchant_subscription import MerchantSubscription
from app.modules.billing.models.subscription import (
# Enums
TierCode,
SubscriptionStatus,
AddOnCategory,
BillingPeriod,
# Models
SubscriptionTier,
AddOnProduct,
VendorAddOn,
StripeWebhookEvent,
BillingHistory,
VendorSubscription,
BillingPeriod,
CapacitySnapshot,
# Legacy constants
TIER_LIMITS,
StoreAddOn,
StripeWebhookEvent,
SubscriptionStatus,
SubscriptionTier,
TierCode,
)
from app.modules.billing.models.feature import (
# Enums
FeatureCategory,
FeatureUILocation,
# Model
Feature,
# Constants
FeatureCode,
from app.modules.billing.models.tier_feature_limit import (
MerchantFeatureOverride,
TierFeatureLimit,
)
__all__ = [
@@ -52,18 +43,13 @@ __all__ = [
# Subscription Models
"SubscriptionTier",
"AddOnProduct",
"VendorAddOn",
"StoreAddOn",
"StripeWebhookEvent",
"BillingHistory",
"VendorSubscription",
"CapacitySnapshot",
# Legacy constants
"TIER_LIMITS",
# Feature Enums
"FeatureCategory",
"FeatureUILocation",
# Feature Model
"Feature",
# Feature Constants
"FeatureCode",
# Merchant Subscription
"MerchantSubscription",
# Feature Limits
"TierFeatureLimit",
"MerchantFeatureOverride",
]

View File

@@ -1,39 +1,38 @@
# app/modules/billing/routes/admin.py
# app/modules/billing/routes/api/admin.py
"""
Billing module admin routes.
This module wraps the existing admin subscription routes and adds
module-based access control. The actual route implementations remain
in app/api/v1/admin/subscriptions.py for now, but are accessed through
this module-aware router.
Future: Move all route implementations here for full module isolation.
Provides admin API endpoints for subscription and billing management:
- Subscription tier CRUD
- Merchant subscription listing and management
- Billing history
- Subscription statistics
"""
import logging
from fastapi import APIRouter, Depends, Path, Query
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api, require_module_access
from app.core.database import get_db
from app.modules.billing.services import admin_subscription_service, subscription_service
from app.modules.enums import FrontendType
from app.modules.tenancy.models import User
from app.modules.billing.schemas import (
BillingHistoryListResponse,
BillingHistoryWithVendor,
BillingHistoryWithMerchant,
MerchantSubscriptionAdminCreate,
MerchantSubscriptionAdminResponse,
MerchantSubscriptionAdminUpdate,
MerchantSubscriptionListResponse,
MerchantSubscriptionWithMerchant,
SubscriptionStatsResponse,
SubscriptionTierCreate,
SubscriptionTierListResponse,
SubscriptionTierResponse,
SubscriptionTierUpdate,
VendorSubscriptionCreate,
VendorSubscriptionListResponse,
VendorSubscriptionResponse,
VendorSubscriptionUpdate,
VendorSubscriptionWithVendor,
)
from models.schema.auth import UserContext
logger = logging.getLogger(__name__)
@@ -52,14 +51,10 @@ admin_router = APIRouter(
@admin_router.get("/tiers", response_model=SubscriptionTierListResponse)
def list_subscription_tiers(
include_inactive: bool = Query(False, description="Include inactive tiers"),
current_user: User = Depends(get_current_admin_api),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
List all subscription tiers.
Returns all tiers with their limits, features, and Stripe configuration.
"""
"""List all subscription tiers."""
tiers = admin_subscription_service.get_tiers(db, include_inactive=include_inactive)
return SubscriptionTierListResponse(
@@ -71,7 +66,7 @@ def list_subscription_tiers(
@admin_router.get("/tiers/{tier_code}", response_model=SubscriptionTierResponse)
def get_subscription_tier(
tier_code: str = Path(..., description="Tier code"),
current_user: User = Depends(get_current_admin_api),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get a specific subscription tier by code."""
@@ -82,7 +77,7 @@ def get_subscription_tier(
@admin_router.post("/tiers", response_model=SubscriptionTierResponse, status_code=201)
def create_subscription_tier(
tier_data: SubscriptionTierCreate,
current_user: User = Depends(get_current_admin_api),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Create a new subscription tier."""
@@ -96,7 +91,7 @@ def create_subscription_tier(
def update_subscription_tier(
tier_data: SubscriptionTierUpdate,
tier_code: str = Path(..., description="Tier code"),
current_user: User = Depends(get_current_admin_api),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Update a subscription tier."""
@@ -110,52 +105,48 @@ def update_subscription_tier(
@admin_router.delete("/tiers/{tier_code}", status_code=204)
def delete_subscription_tier(
tier_code: str = Path(..., description="Tier code"),
current_user: User = Depends(get_current_admin_api),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Soft-delete a subscription tier.
Sets is_active=False rather than deleting to preserve history.
"""
"""Soft-delete a subscription tier."""
admin_subscription_service.deactivate_tier(db, tier_code)
db.commit()
# ============================================================================
# Vendor Subscription Endpoints
# Merchant Subscription Endpoints
# ============================================================================
@admin_router.get("", response_model=VendorSubscriptionListResponse)
def list_vendor_subscriptions(
@admin_router.get("", response_model=MerchantSubscriptionListResponse)
def list_merchant_subscriptions(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
status: str | None = Query(None, description="Filter by status"),
tier: str | None = Query(None, description="Filter by tier"),
search: str | None = Query(None, description="Search vendor name"),
current_user: User = Depends(get_current_admin_api),
tier: str | None = Query(None, description="Filter by tier code"),
search: str | None = Query(None, description="Search merchant name"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
List all vendor subscriptions with filtering.
Includes vendor information for each subscription.
"""
"""List all merchant subscriptions with filtering."""
data = admin_subscription_service.list_subscriptions(
db, page=page, per_page=per_page, status=status, tier=tier, search=search
)
subscriptions = []
for sub, vendor in data["results"]:
sub_dict = {
**VendorSubscriptionResponse.model_validate(sub).model_dump(),
"vendor_name": vendor.name,
"vendor_code": vendor.subdomain,
}
subscriptions.append(VendorSubscriptionWithVendor(**sub_dict))
for sub, merchant in data["results"]:
sub_resp = MerchantSubscriptionAdminResponse.model_validate(sub)
tier_name = sub.tier.name if sub.tier else None
subscriptions.append(
MerchantSubscriptionWithMerchant(
**sub_resp.model_dump(),
merchant_name=merchant.name,
platform_name="", # Platform name can be resolved if needed
tier_name=tier_name,
)
)
return VendorSubscriptionListResponse(
return MerchantSubscriptionListResponse(
subscriptions=subscriptions,
total=data["total"],
page=data["page"],
@@ -164,6 +155,154 @@ def list_vendor_subscriptions(
)
@admin_router.post(
"/merchants/{merchant_id}/platforms/{platform_id}",
response_model=MerchantSubscriptionAdminResponse,
status_code=201,
)
def create_merchant_subscription(
create_data: MerchantSubscriptionAdminCreate,
merchant_id: int = Path(..., description="Merchant ID"),
platform_id: int = Path(..., description="Platform ID"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Create a subscription for a merchant on a platform."""
sub = subscription_service.get_or_create_subscription(
db,
merchant_id=merchant_id,
platform_id=platform_id,
tier_code=create_data.tier_code,
trial_days=create_data.trial_days,
)
# Update status if not trial
if create_data.status != "trial":
sub.status = create_data.status
sub.is_annual = create_data.is_annual
db.commit()
db.refresh(sub)
logger.info(
f"Admin created subscription for merchant {merchant_id} "
f"on platform {platform_id}: tier={create_data.tier_code}"
)
return MerchantSubscriptionAdminResponse.model_validate(sub)
@admin_router.get(
"/merchants/{merchant_id}/platforms/{platform_id}",
response_model=MerchantSubscriptionAdminResponse,
)
def get_merchant_subscription(
merchant_id: int = Path(..., description="Merchant ID"),
platform_id: int = Path(..., description="Platform ID"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get subscription details for a specific merchant on a platform."""
sub, merchant = admin_subscription_service.get_subscription(
db, merchant_id, platform_id
)
return MerchantSubscriptionAdminResponse.model_validate(sub)
@admin_router.patch(
"/merchants/{merchant_id}/platforms/{platform_id}",
response_model=MerchantSubscriptionAdminResponse,
)
def update_merchant_subscription(
update_data: MerchantSubscriptionAdminUpdate,
merchant_id: int = Path(..., description="Merchant ID"),
platform_id: int = Path(..., description="Platform ID"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Update a merchant's subscription."""
data = update_data.model_dump(exclude_unset=True)
sub, merchant = admin_subscription_service.update_subscription(
db, merchant_id, platform_id, data
)
db.commit()
db.refresh(sub)
return MerchantSubscriptionAdminResponse.model_validate(sub)
# ============================================================================
# Store Convenience Endpoint
# ============================================================================
@admin_router.get("/store/{store_id}")
def get_subscription_for_store(
store_id: int = Path(..., description="Store ID"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Get subscription + feature usage for a store (resolves to merchant).
Convenience endpoint for the admin store detail page. Resolves
store -> merchant -> subscription internally and returns subscription
info with feature usage metrics.
"""
from app.modules.billing.services.feature_service import feature_service
from app.modules.billing.schemas.subscription import FeatureSummaryResponse
# Resolve store to merchant
merchant_id, platform_id = feature_service._get_merchant_for_store(db, store_id)
if merchant_id is None or platform_id is None:
raise HTTPException(status_code=404, detail="Store not found or has no merchant association")
# Get subscription
try:
sub, merchant = admin_subscription_service.get_subscription(
db, merchant_id, platform_id
)
except Exception:
return {
"subscription": None,
"tier": None,
"features": [],
}
# Get feature summary
features_summary = feature_service.get_merchant_features_summary(db, merchant_id, platform_id)
# Build tier info
tier_info = None
if sub.tier:
tier_info = {
"code": sub.tier.code,
"name": sub.tier.name,
"feature_codes": [fl.feature_code for fl in (sub.tier.feature_limits or [])],
}
# Build usage metrics (quantitative features only)
usage_metrics = []
for fs in features_summary:
if fs.feature_type == "quantitative" and fs.enabled:
usage_metrics.append({
"name": fs.name_key.replace("_", " ").title(),
"current": fs.current or 0,
"limit": fs.limit,
"percentage": fs.percent_used or 0,
"is_unlimited": fs.limit is None,
"is_at_limit": fs.remaining == 0 if fs.remaining is not None else False,
"is_approaching_limit": (fs.percent_used or 0) >= 80,
})
return {
"subscription": MerchantSubscriptionAdminResponse.model_validate(sub).model_dump(),
"tier": tier_info,
"features": usage_metrics,
}
# ============================================================================
# Statistics Endpoints
# ============================================================================
@@ -171,7 +310,7 @@ def list_vendor_subscriptions(
@admin_router.get("/stats", response_model=SubscriptionStatsResponse)
def get_subscription_stats(
current_user: User = Depends(get_current_admin_api),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get subscription statistics for admin dashboard."""
@@ -188,39 +327,39 @@ def get_subscription_stats(
def list_billing_history(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
vendor_id: int | None = Query(None, description="Filter by vendor"),
merchant_id: int | None = Query(None, description="Filter by merchant"),
status: str | None = Query(None, description="Filter by status"),
current_user: User = Depends(get_current_admin_api),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""List billing history (invoices) across all vendors."""
"""List billing history (invoices) across all merchants."""
data = admin_subscription_service.list_billing_history(
db, page=page, per_page=per_page, vendor_id=vendor_id, status=status
db, page=page, per_page=per_page, merchant_id=merchant_id, status=status
)
invoices = []
for invoice, vendor in data["results"]:
invoice_dict = {
"id": invoice.id,
"vendor_id": invoice.vendor_id,
"stripe_invoice_id": invoice.stripe_invoice_id,
"invoice_number": invoice.invoice_number,
"invoice_date": invoice.invoice_date,
"due_date": invoice.due_date,
"subtotal_cents": invoice.subtotal_cents,
"tax_cents": invoice.tax_cents,
"total_cents": invoice.total_cents,
"amount_paid_cents": invoice.amount_paid_cents,
"currency": invoice.currency,
"status": invoice.status,
"invoice_pdf_url": invoice.invoice_pdf_url,
"hosted_invoice_url": invoice.hosted_invoice_url,
"description": invoice.description,
"created_at": invoice.created_at,
"vendor_name": vendor.name,
"vendor_code": vendor.subdomain,
}
invoices.append(BillingHistoryWithVendor(**invoice_dict))
for invoice, merchant in data["results"]:
invoices.append(
BillingHistoryWithMerchant(
id=invoice.id,
merchant_id=invoice.merchant_id,
stripe_invoice_id=invoice.stripe_invoice_id,
invoice_number=invoice.invoice_number,
invoice_date=invoice.invoice_date,
due_date=invoice.due_date,
subtotal_cents=invoice.subtotal_cents,
tax_cents=invoice.tax_cents,
total_cents=invoice.total_cents,
amount_paid_cents=invoice.amount_paid_cents,
currency=invoice.currency,
status=invoice.status,
invoice_pdf_url=invoice.invoice_pdf_url,
hosted_invoice_url=invoice.hosted_invoice_url,
description=invoice.description,
created_at=invoice.created_at,
merchant_name=merchant.name,
)
)
return BillingHistoryListResponse(
invoices=invoices,
@@ -231,112 +370,6 @@ def list_billing_history(
)
# ============================================================================
# Vendor Subscription Detail Endpoints
# ============================================================================
@admin_router.post("/{vendor_id}", response_model=VendorSubscriptionWithVendor, status_code=201)
def create_vendor_subscription(
create_data: VendorSubscriptionCreate,
vendor_id: int = Path(..., description="Vendor ID"),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Create a subscription for a vendor.
Creates a new subscription with the specified tier and status.
Defaults to Essential tier with trial status.
"""
# Verify vendor exists
vendor = admin_subscription_service.get_vendor(db, vendor_id)
# Create subscription using the subscription service
sub = subscription_service.get_or_create_subscription(
db,
vendor_id=vendor_id,
tier=create_data.tier,
trial_days=create_data.trial_days,
)
# Update status if not trial
if create_data.status != "trial":
sub.status = create_data.status
sub.is_annual = create_data.is_annual
db.commit()
db.refresh(sub)
# Get usage counts
usage = admin_subscription_service.get_vendor_usage_counts(db, vendor_id)
logger.info(f"Admin created subscription for vendor {vendor_id}: tier={create_data.tier}")
return VendorSubscriptionWithVendor(
**VendorSubscriptionResponse.model_validate(sub).model_dump(),
vendor_name=vendor.name,
vendor_code=vendor.subdomain,
products_count=usage["products_count"],
team_count=usage["team_count"],
)
@admin_router.get("/{vendor_id}", response_model=VendorSubscriptionWithVendor)
def get_vendor_subscription(
vendor_id: int = Path(..., description="Vendor ID"),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get subscription details for a specific vendor."""
sub, vendor = admin_subscription_service.get_subscription(db, vendor_id)
# Get usage counts
usage = admin_subscription_service.get_vendor_usage_counts(db, vendor_id)
return VendorSubscriptionWithVendor(
**VendorSubscriptionResponse.model_validate(sub).model_dump(),
vendor_name=vendor.name,
vendor_code=vendor.subdomain,
products_count=usage["products_count"],
team_count=usage["team_count"],
)
@admin_router.patch("/{vendor_id}", response_model=VendorSubscriptionWithVendor)
def update_vendor_subscription(
update_data: VendorSubscriptionUpdate,
vendor_id: int = Path(..., description="Vendor ID"),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Update a vendor's subscription.
Allows admins to:
- Change tier
- Update status
- Set custom limit overrides
- Extend trial period
"""
data = update_data.model_dump(exclude_unset=True)
sub, vendor = admin_subscription_service.update_subscription(db, vendor_id, data)
db.commit()
db.refresh(sub)
# Get usage counts
usage = admin_subscription_service.get_vendor_usage_counts(db, vendor_id)
return VendorSubscriptionWithVendor(
**VendorSubscriptionResponse.model_validate(sub).model_dump(),
vendor_name=vendor.name,
vendor_code=vendor.subdomain,
products_count=usage["products_count"],
team_count=usage["team_count"],
)
# ============================================================================
# Aggregate Feature Management Routes
# ============================================================================

View File

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

View File

@@ -1,8 +1,8 @@
# app/modules/billing/schemas/subscription.py
"""
Pydantic schemas for subscription operations.
Pydantic schemas for merchant-level subscription operations.
Supports subscription management and tier limit checks.
Supports subscription management, tier information, and feature summaries.
"""
from datetime import datetime
@@ -15,48 +15,23 @@ from pydantic import BaseModel, ConfigDict, Field
# ============================================================================
class TierFeatures(BaseModel):
"""Features included in a tier."""
class TierFeatureLimitResponse(BaseModel):
"""Feature limit entry for a tier."""
letzshop_sync: bool = True
inventory_basic: bool = True
inventory_locations: bool = False
inventory_purchase_orders: bool = False
invoice_lu: bool = True
invoice_eu_vat: bool = False
invoice_bulk: bool = False
customer_view: bool = True
customer_export: bool = False
analytics_dashboard: bool = False
accounting_export: bool = False
api_access: bool = False
automation_rules: bool = False
team_roles: bool = False
white_label: bool = False
multi_vendor: bool = False
custom_integrations: bool = False
sla_guarantee: bool = False
dedicated_support: bool = False
class TierLimits(BaseModel):
"""Limits for a subscription tier."""
orders_per_month: int | None = Field(None, description="None = unlimited")
products_limit: int | None = Field(None, description="None = unlimited")
team_members: int | None = Field(None, description="None = unlimited")
order_history_months: int | None = Field(None, description="None = unlimited")
feature_code: str
limit_value: int | None = Field(None, description="None = unlimited")
class TierInfo(BaseModel):
"""Full tier information."""
"""Full tier information with feature limits."""
code: str
name: str
description: str | None = None
price_monthly_cents: int
price_annual_cents: int | None
limits: TierLimits
features: list[str]
feature_codes: list[str] = Field(default_factory=list)
feature_limits: list[TierFeatureLimitResponse] = Field(default_factory=list)
# ============================================================================
@@ -64,47 +39,43 @@ class TierInfo(BaseModel):
# ============================================================================
class SubscriptionCreate(BaseModel):
"""Schema for creating a subscription (admin/internal use)."""
class MerchantSubscriptionCreate(BaseModel):
"""Schema for creating a merchant subscription."""
tier: str = Field(default="essential", pattern="^(essential|professional|business|enterprise)$")
tier_code: str = Field(default="essential")
is_annual: bool = False
trial_days: int = Field(default=14, ge=0, le=30)
class SubscriptionUpdate(BaseModel):
"""Schema for updating a subscription."""
class MerchantSubscriptionUpdate(BaseModel):
"""Schema for updating a merchant subscription."""
tier: str | None = Field(None, pattern="^(essential|professional|business|enterprise)$")
tier_code: str | None = None
status: str | None = Field(None, pattern="^(trial|active|past_due|cancelled|expired)$")
is_annual: bool | None = None
custom_orders_limit: int | None = None
custom_products_limit: int | None = None
custom_team_limit: int | None = None
class SubscriptionResponse(BaseModel):
"""Schema for subscription response."""
class MerchantSubscriptionResponse(BaseModel):
"""Schema for merchant subscription response."""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_id: int
tier: str
status: str
merchant_id: int
platform_id: int
tier_id: int | None
status: str
is_annual: bool
period_start: datetime
period_end: datetime
is_annual: bool
trial_ends_at: datetime | None
orders_this_period: int
orders_limit_reached_at: datetime | None
# Effective limits (with custom overrides applied)
orders_limit: int | None
products_limit: int | None
team_members_limit: int | None
# Stripe info (optional, may be hidden from client)
stripe_customer_id: str | None = None
# Cancellation
cancelled_at: datetime | None = None
# Computed properties
is_active: bool
@@ -115,47 +86,36 @@ class SubscriptionResponse(BaseModel):
updated_at: datetime
class SubscriptionUsage(BaseModel):
"""Current subscription usage statistics."""
orders_used: int
orders_limit: int | None
orders_remaining: int | None
orders_percent_used: float | None
products_used: int
products_limit: int | None
products_remaining: int | None
products_percent_used: float | None
team_members_used: int
team_members_limit: int | None
team_members_remaining: int | None
team_members_percent_used: float | None
# ============================================================================
# Feature Summary Schemas
# ============================================================================
class UsageSummary(BaseModel):
"""Usage summary for billing page display."""
class FeatureSummaryResponse(BaseModel):
"""Feature summary for merchant portal display."""
orders_this_period: int
orders_limit: int | None
orders_remaining: int | None
products_count: int
products_limit: int | None
products_remaining: int | None
team_count: int
team_limit: int | None
team_remaining: int | None
code: str
name_key: str
description_key: str
category: str
feature_type: str
scope: str
enabled: bool
limit: int | None = None
current: int | None = None
remaining: int | None = None
percent_used: float | None = None
is_override: bool = False
unit_key: str | None = None
ui_icon: str | None = None
class SubscriptionStatusResponse(BaseModel):
"""Subscription status with usage and limits."""
class MerchantSubscriptionStatusResponse(BaseModel):
"""Full subscription status with tier info and feature summary."""
subscription: SubscriptionResponse
usage: SubscriptionUsage
tier_info: TierInfo
subscription: MerchantSubscriptionResponse
tier: TierInfo | None = None
features: list[FeatureSummaryResponse] = Field(default_factory=list)
# ============================================================================
@@ -173,37 +133,11 @@ class LimitCheckResult(BaseModel):
message: str | None = None
class CanCreateOrderResponse(BaseModel):
"""Response for order creation check."""
allowed: bool
orders_this_period: int
orders_limit: int | None
message: str | None = None
class CanAddProductResponse(BaseModel):
"""Response for product addition check."""
allowed: bool
products_count: int
products_limit: int | None
message: str | None = None
class CanAddTeamMemberResponse(BaseModel):
"""Response for team member addition check."""
allowed: bool
team_count: int
team_limit: int | None
message: str | None = None
class FeatureCheckResponse(BaseModel):
"""Response for feature check."""
feature: str
feature_code: str
enabled: bool
tier_required: str | None = None
message: str | None = None

View File

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

View File

@@ -1,590 +1,438 @@
# app/modules/billing/services/feature_service.py
"""
Feature service for tier-based access control.
Feature-agnostic billing service for merchant-level access control.
Provides:
- Feature availability checking with caching
- Vendor feature listing for API/UI
- Feature metadata for upgrade prompts
- Cache invalidation on subscription changes
Zero knowledge of what features exist. Works with:
- TierFeatureLimit (tier -> feature mappings)
- MerchantFeatureOverride (per-merchant exceptions)
- FeatureAggregatorService (discovers features from modules)
Usage:
from app.modules.billing.services.feature_service import feature_service
# Check if vendor has feature
if feature_service.has_feature(db, vendor_id, FeatureCode.ANALYTICS_DASHBOARD):
# Check if merchant has feature
if feature_service.has_feature(db, merchant_id, platform_id, "analytics_dashboard"):
...
# Get all features available to vendor
features = feature_service.get_vendor_features(db, vendor_id)
# Check quantitative limit
allowed, msg = feature_service.check_resource_limit(
db, "products_limit", store_id=store_id
)
# Get feature info for upgrade prompt
info = feature_service.get_feature_upgrade_info(db, "analytics_dashboard")
# Get feature summary for merchant portal
summary = feature_service.get_merchant_features_summary(db, merchant_id, platform_id)
"""
import logging
import time
from dataclasses import dataclass
from functools import lru_cache
from sqlalchemy.orm import Session, joinedload
from app.modules.billing.exceptions import (
FeatureNotFoundError,
InvalidFeatureCodesError,
TierNotFoundError,
from app.modules.billing.models import (
MerchantFeatureOverride,
MerchantSubscription,
SubscriptionTier,
TierFeatureLimit,
)
from app.modules.billing.models import Feature, FeatureCode
from app.modules.billing.models import SubscriptionTier, VendorSubscription
from app.modules.contracts.features import FeatureScope, FeatureType
logger = logging.getLogger(__name__)
@dataclass
class FeatureInfo:
"""Feature information for API responses."""
class FeatureSummary:
"""Summary of a feature for merchant portal display."""
code: str
name: str
description: str | None
name_key: str
description_key: str
category: str
ui_location: str | None
feature_type: str # "binary" or "quantitative"
scope: str # "store" or "merchant"
enabled: bool
limit: int | None # For quantitative: effective limit
current: int | None # For quantitative: current usage
remaining: int | None # For quantitative: remaining capacity
percent_used: float | None # For quantitative: usage percentage
is_override: bool # Whether an override is applied
unit_key: str | None
ui_icon: str | None
ui_route: str | None
ui_badge_text: str | None
is_available: bool
minimum_tier_code: str | None
minimum_tier_name: str | None
@dataclass
class FeatureUpgradeInfo:
"""Information for upgrade prompts."""
feature_code: str
feature_name: str
feature_description: str | None
required_tier_code: str
required_tier_name: str
required_tier_price_monthly_cents: int
class FeatureCache:
"""
In-memory cache for vendor features.
In-memory cache for merchant features.
Caches vendor_id -> set of feature codes with TTL.
Invalidated when subscription changes.
Caches (merchant_id, platform_id) -> set of feature codes with TTL.
"""
def __init__(self, ttl_seconds: int = 300):
self._cache: dict[int, tuple[set[str], float]] = {}
self._cache: dict[tuple[int, int], tuple[set[str], float]] = {}
self._ttl = ttl_seconds
def get(self, vendor_id: int) -> set[str] | None:
"""Get cached features for vendor, or None if not cached/expired."""
if vendor_id not in self._cache:
def get(self, merchant_id: int, platform_id: int) -> set[str] | None:
key = (merchant_id, platform_id)
if key not in self._cache:
return None
features, timestamp = self._cache[vendor_id]
features, timestamp = self._cache[key]
if time.time() - timestamp > self._ttl:
del self._cache[vendor_id]
del self._cache[key]
return None
return features
def set(self, vendor_id: int, features: set[str]) -> None:
"""Cache features for vendor."""
self._cache[vendor_id] = (features, time.time())
def set(self, merchant_id: int, platform_id: int, features: set[str]) -> None:
self._cache[(merchant_id, platform_id)] = (features, time.time())
def invalidate(self, vendor_id: int) -> None:
"""Invalidate cache for vendor."""
self._cache.pop(vendor_id, None)
def invalidate(self, merchant_id: int, platform_id: int) -> None:
self._cache.pop((merchant_id, platform_id), None)
def invalidate_all(self) -> None:
"""Invalidate entire cache."""
self._cache.clear()
class FeatureService:
"""
Service for feature-based access control.
Feature-agnostic service for merchant-level billing.
Provides methods to check feature availability and get feature metadata.
Uses in-memory caching with TTL for performance.
Resolves feature access through:
1. MerchantSubscription -> SubscriptionTier -> TierFeatureLimit
2. MerchantFeatureOverride (admin-set exceptions)
3. FeatureAggregator (for usage counts and declarations)
"""
def __init__(self):
self._cache = FeatureCache(ttl_seconds=300) # 5 minute cache
self._feature_registry_cache: dict[str, Feature] | None = None
self._feature_registry_timestamp: float = 0
self._cache = FeatureCache(ttl_seconds=300)
# =========================================================================
# Store -> Merchant Resolution
# =========================================================================
def _get_merchant_for_store(self, db: Session, store_id: int) -> tuple[int | None, int | None]:
"""
Resolve store_id to (merchant_id, platform_id).
Returns:
Tuple of (merchant_id, platform_id), either may be None
"""
from app.modules.tenancy.models import Store
store = db.query(Store).filter(Store.id == store_id).first()
if not store:
return None, None
merchant_id = store.merchant_id
# Get platform_id from store's platform association
platform_id = getattr(store, "platform_id", None)
if platform_id is None:
# Try StorePlatform junction
from app.modules.tenancy.models import StorePlatform
sp = (
db.query(StorePlatform.platform_id)
.filter(StorePlatform.store_id == store_id)
.first()
)
platform_id = sp[0] if sp else None
return merchant_id, platform_id
def _get_subscription(
self, db: Session, merchant_id: int, platform_id: int
) -> MerchantSubscription | None:
"""Get merchant subscription for a platform."""
return (
db.query(MerchantSubscription)
.options(joinedload(MerchantSubscription.tier).joinedload(SubscriptionTier.feature_limits))
.filter(
MerchantSubscription.merchant_id == merchant_id,
MerchantSubscription.platform_id == platform_id,
)
.first()
)
# =========================================================================
# Feature Availability
# =========================================================================
def has_feature(self, db: Session, vendor_id: int, feature_code: str) -> bool:
def has_feature(
self, db: Session, merchant_id: int, platform_id: int, feature_code: str
) -> bool:
"""
Check if vendor has access to a specific feature.
Check if merchant has access to a specific feature.
Checks:
1. MerchantFeatureOverride (force enable/disable)
2. TierFeatureLimit (tier assignment)
Args:
db: Database session
vendor_id: Vendor ID
feature_code: Feature code (use FeatureCode constants)
merchant_id: Merchant ID
platform_id: Platform ID
feature_code: Feature code to check
Returns:
True if vendor has access to the feature
True if merchant has access to the feature
"""
vendor_features = self._get_vendor_feature_codes(db, vendor_id)
return feature_code in vendor_features
# Check override first
override = (
db.query(MerchantFeatureOverride)
.filter(
MerchantFeatureOverride.merchant_id == merchant_id,
MerchantFeatureOverride.platform_id == platform_id,
MerchantFeatureOverride.feature_code == feature_code,
)
.first()
)
if override is not None:
return override.is_enabled
def get_vendor_feature_codes(self, db: Session, vendor_id: int) -> set[str]:
# Check tier assignment
subscription = self._get_subscription(db, merchant_id, platform_id)
if not subscription or not subscription.is_active or not subscription.tier:
return False
return subscription.tier.has_feature(feature_code)
def has_feature_for_store(
self, db: Session, store_id: int, feature_code: str
) -> bool:
"""
Get set of feature codes available to vendor.
Check if a store has access to a feature (resolves store -> merchant).
Args:
db: Database session
vendor_id: Vendor ID
Returns:
Set of feature codes the vendor has access to
Convenience method for backwards compatibility.
"""
return self._get_vendor_feature_codes(db, vendor_id)
merchant_id, platform_id = self._get_merchant_for_store(db, store_id)
if merchant_id is None or platform_id is None:
return False
return self.has_feature(db, merchant_id, platform_id, feature_code)
def _get_vendor_feature_codes(self, db: Session, vendor_id: int) -> set[str]:
"""Internal method with caching."""
# Check cache first
cached = self._cache.get(vendor_id)
def get_merchant_feature_codes(
self, db: Session, merchant_id: int, platform_id: int
) -> set[str]:
"""Get all feature codes available to merchant on a platform."""
# Check cache
cached = self._cache.get(merchant_id, platform_id)
if cached is not None:
return cached
# Get subscription with tier relationship
subscription = (
db.query(VendorSubscription)
.options(joinedload(VendorSubscription.tier_obj))
.filter(VendorSubscription.vendor_id == vendor_id)
.first()
)
features: set[str] = set()
if not subscription:
logger.warning(f"No subscription found for vendor {vendor_id}")
return set()
# Get tier features
subscription = self._get_subscription(db, merchant_id, platform_id)
if subscription and subscription.is_active and subscription.tier:
features = subscription.tier.get_feature_codes()
# Get features from tier
tier = subscription.tier_obj
if tier and tier.features:
features = set(tier.features)
else:
# Fallback: query tier by code
tier = (
db.query(SubscriptionTier)
.filter(SubscriptionTier.code == subscription.tier)
.first()
# Apply overrides
overrides = (
db.query(MerchantFeatureOverride)
.filter(
MerchantFeatureOverride.merchant_id == merchant_id,
MerchantFeatureOverride.platform_id == platform_id,
)
features = set(tier.features) if tier and tier.features else set()
.all()
)
for override in overrides:
if override.is_enabled:
features.add(override.feature_code)
else:
features.discard(override.feature_code)
# Cache and return
self._cache.set(vendor_id, features)
self._cache.set(merchant_id, platform_id, features)
return features
# =========================================================================
# Feature Listing
# Effective Limits
# =========================================================================
def get_vendor_features(
def get_effective_limit(
self, db: Session, merchant_id: int, platform_id: int, feature_code: str
) -> int | None:
"""
Get the effective limit for a feature (override or tier default).
Returns:
Limit value, or None for unlimited
"""
# Check override first
override = (
db.query(MerchantFeatureOverride)
.filter(
MerchantFeatureOverride.merchant_id == merchant_id,
MerchantFeatureOverride.platform_id == platform_id,
MerchantFeatureOverride.feature_code == feature_code,
)
.first()
)
if override is not None:
return override.limit_value
# Get from tier
subscription = self._get_subscription(db, merchant_id, platform_id)
if not subscription or not subscription.tier:
return 0 # No subscription = no access
return subscription.tier.get_limit_for_feature(feature_code)
# =========================================================================
# Resource Limit Checks
# =========================================================================
def check_resource_limit(
self,
db: Session,
vendor_id: int,
category: str | None = None,
include_unavailable: bool = True,
) -> list[FeatureInfo]:
feature_code: str,
store_id: int | None = None,
merchant_id: int | None = None,
platform_id: int | None = None,
) -> tuple[bool, str | None]:
"""
Get all features with availability status for vendor.
Check if a resource limit allows adding more items.
Resolves store -> merchant if needed. Gets the declaration to
determine scope, then checks usage against limit.
Args:
db: Database session
vendor_id: Vendor ID
category: Optional category filter
include_unavailable: Include features not available to vendor
feature_code: Feature code (e.g., "products_limit")
store_id: Store ID (if checking per-store)
merchant_id: Merchant ID (if already known)
platform_id: Platform ID (if already known)
Returns:
List of FeatureInfo with is_available flag
(allowed, error_message) tuple
"""
vendor_features = self._get_vendor_feature_codes(db, vendor_id)
from app.modules.billing.services.feature_aggregator import feature_aggregator
# Query all active features
query = db.query(Feature).filter(Feature.is_active == True) # noqa: E712
# Resolve store -> merchant if needed
if merchant_id is None and store_id is not None:
merchant_id, platform_id = self._get_merchant_for_store(db, store_id)
if category:
query = query.filter(Feature.category == category)
if merchant_id is None or platform_id is None:
return False, "No subscription found"
if not include_unavailable:
# Only return features the vendor has
query = query.filter(Feature.code.in_(vendor_features))
# Check subscription is active
subscription = self._get_subscription(db, merchant_id, platform_id)
if not subscription or not subscription.is_active:
return False, "Subscription is not active"
features = (
query.options(joinedload(Feature.minimum_tier))
.order_by(Feature.category, Feature.display_order)
.all()
# Get feature declaration
decl = feature_aggregator.get_declaration(feature_code)
if decl is None:
logger.warning(f"Unknown feature code: {feature_code}")
return True, None # Unknown features are allowed by default
# Binary features: just check if enabled
if decl.feature_type == FeatureType.BINARY:
if self.has_feature(db, merchant_id, platform_id, feature_code):
return True, None
return False, f"Feature '{feature_code}' requires an upgrade"
# Quantitative: check usage against limit
limit = self.get_effective_limit(db, merchant_id, platform_id, feature_code)
if limit is None: # Unlimited
return True, None
# Get current usage based on scope
usage = feature_aggregator.get_usage_for_feature(
db, feature_code,
store_id=store_id,
merchant_id=merchant_id,
platform_id=platform_id,
)
current = usage.current_count if usage else 0
result = []
for feature in features:
result.append(
FeatureInfo(
code=feature.code,
name=feature.name,
description=feature.description,
category=feature.category,
ui_location=feature.ui_location,
ui_icon=feature.ui_icon,
ui_route=feature.ui_route,
ui_badge_text=feature.ui_badge_text,
is_available=feature.code in vendor_features,
minimum_tier_code=feature.minimum_tier.code if feature.minimum_tier else None,
minimum_tier_name=feature.minimum_tier.name if feature.minimum_tier else None,
)
if current >= limit:
return False, (
f"Limit reached ({current}/{limit} {decl.unit_key or feature_code}). "
f"Upgrade to increase your limit."
)
return result
def get_available_feature_codes(self, db: Session, vendor_id: int) -> list[str]:
"""
Get list of feature codes available to vendor (for frontend).
Simple list for x-feature directive checks.
"""
return list(self._get_vendor_feature_codes(db, vendor_id))
return True, None
# =========================================================================
# Feature Metadata
# Feature Summary
# =========================================================================
def get_feature_by_code(self, db: Session, feature_code: str) -> Feature | None:
"""Get feature by code."""
return (
db.query(Feature)
.options(joinedload(Feature.minimum_tier))
.filter(Feature.code == feature_code)
.first()
)
def get_feature_upgrade_info(
self, db: Session, feature_code: str
) -> FeatureUpgradeInfo | None:
def get_merchant_features_summary(
self, db: Session, merchant_id: int, platform_id: int
) -> list[FeatureSummary]:
"""
Get upgrade information for a feature.
Get complete feature summary for merchant portal display.
Used for upgrade prompts when a feature is not available.
Returns all features with current status, limits, and usage.
"""
feature = self.get_feature_by_code(db, feature_code)
from app.modules.billing.services.feature_aggregator import feature_aggregator
if not feature or not feature.minimum_tier:
return None
declarations = feature_aggregator.get_all_declarations()
merchant_features = self.get_merchant_feature_codes(db, merchant_id, platform_id)
tier = feature.minimum_tier
return FeatureUpgradeInfo(
feature_code=feature.code,
feature_name=feature.name,
feature_description=feature.description,
required_tier_code=tier.code,
required_tier_name=tier.name,
required_tier_price_monthly_cents=tier.price_monthly_cents,
)
# Preload overrides
overrides = {
o.feature_code: o
for o in db.query(MerchantFeatureOverride).filter(
MerchantFeatureOverride.merchant_id == merchant_id,
MerchantFeatureOverride.platform_id == platform_id,
).all()
}
def get_all_features(
self,
db: Session,
category: str | None = None,
active_only: bool = True,
) -> list[Feature]:
"""Get all features (for admin)."""
query = db.query(Feature).options(joinedload(Feature.minimum_tier))
# Get all usage at once
store_usage = {}
merchant_usage = feature_aggregator.get_merchant_usage(db, merchant_id, platform_id)
if active_only:
query = query.filter(Feature.is_active == True) # noqa: E712
summaries = []
for code, decl in sorted(declarations.items(), key=lambda x: (x[1].category, x[1].display_order)):
enabled = code in merchant_features
is_override = code in overrides
limit = self.get_effective_limit(db, merchant_id, platform_id, code) if decl.feature_type == FeatureType.QUANTITATIVE else None
if category:
query = query.filter(Feature.category == category)
current = None
remaining = None
percent_used = None
return query.order_by(Feature.category, Feature.display_order).all()
if decl.feature_type == FeatureType.QUANTITATIVE:
usage_data = merchant_usage.get(code)
current = usage_data.current_count if usage_data else 0
if limit is not None:
remaining = max(0, limit - current)
percent_used = min(100.0, (current / limit * 100)) if limit > 0 else 0.0
def get_features_by_tier(self, db: Session, tier_code: str) -> list[str]:
"""Get feature codes for a specific tier."""
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
summaries.append(FeatureSummary(
code=code,
name_key=decl.name_key,
description_key=decl.description_key,
category=decl.category,
feature_type=decl.feature_type.value,
scope=decl.scope.value,
enabled=enabled,
limit=limit,
current=current,
remaining=remaining,
percent_used=percent_used,
is_override=is_override,
unit_key=decl.unit_key,
ui_icon=decl.ui_icon,
))
if not tier or not tier.features:
return []
return tier.features
# =========================================================================
# Feature Categories
# =========================================================================
def get_categories(self, db: Session) -> list[str]:
"""Get all unique feature categories."""
result = (
db.query(Feature.category)
.filter(Feature.is_active == True) # noqa: E712
.distinct()
.order_by(Feature.category)
.all()
)
return [row[0] for row in result]
def get_features_grouped_by_category(
self, db: Session, vendor_id: int
) -> dict[str, list[FeatureInfo]]:
"""Get features grouped by category with availability."""
features = self.get_vendor_features(db, vendor_id, include_unavailable=True)
grouped: dict[str, list[FeatureInfo]] = {}
for feature in features:
if feature.category not in grouped:
grouped[feature.category] = []
grouped[feature.category].append(feature)
return grouped
return summaries
# =========================================================================
# Cache Management
# =========================================================================
def invalidate_vendor_cache(self, vendor_id: int) -> None:
"""
Invalidate cache for a specific vendor.
Call this when:
- Vendor's subscription tier changes
- Tier features are updated (for all vendors on that tier)
"""
self._cache.invalidate(vendor_id)
logger.debug(f"Invalidated feature cache for vendor {vendor_id}")
def invalidate_cache(self, merchant_id: int, platform_id: int) -> None:
"""Invalidate cache for a specific merchant/platform."""
self._cache.invalidate(merchant_id, platform_id)
def invalidate_all_cache(self) -> None:
"""
Invalidate entire cache.
Call this when tier features are modified in admin.
"""
"""Invalidate entire cache."""
self._cache.invalidate_all()
logger.debug("Invalidated all feature caches")
# =========================================================================
# Admin Operations
# =========================================================================
def get_all_tiers_with_features(self, db: Session) -> list[SubscriptionTier]:
"""Get all active tiers with their features for admin."""
return (
db.query(SubscriptionTier)
.filter(SubscriptionTier.is_active == True) # noqa: E712
.order_by(SubscriptionTier.display_order)
.all()
)
def get_tier_by_code(self, db: Session, tier_code: str) -> SubscriptionTier:
"""
Get tier by code, raising exception if not found.
Raises:
TierNotFoundError: If tier not found
"""
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
if not tier:
raise TierNotFoundError(tier_code)
return tier
def get_tier_features_with_details(
self, db: Session, tier_code: str
) -> tuple[SubscriptionTier, list[Feature]]:
"""
Get tier with full feature details.
Returns:
Tuple of (tier, list of Feature objects)
Raises:
TierNotFoundError: If tier not found
"""
tier = self.get_tier_by_code(db, tier_code)
feature_codes = tier.features or []
features = (
db.query(Feature)
.filter(Feature.code.in_(feature_codes))
.order_by(Feature.category, Feature.display_order)
.all()
)
return tier, features
def update_tier_features(
self, db: Session, tier_code: str, feature_codes: list[str]
) -> SubscriptionTier:
"""
Update features for a tier (admin operation).
Args:
db: Database session
tier_code: Tier code
feature_codes: List of feature codes to assign
Returns:
Updated tier
Raises:
TierNotFoundError: If tier not found
InvalidFeatureCodesError: If any feature codes are invalid
"""
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
if not tier:
raise TierNotFoundError(tier_code)
# Validate feature codes exist
# noqa: SVC-005 - Features are platform-level, not vendor-scoped
valid_codes = {
f.code for f in db.query(Feature.code).filter(Feature.is_active == True).all() # noqa: E712
}
invalid = set(feature_codes) - valid_codes
if invalid:
raise InvalidFeatureCodesError(invalid)
tier.features = feature_codes
# Invalidate all caches since tier features changed
self.invalidate_all_cache()
logger.info(f"Updated features for tier {tier_code}: {len(feature_codes)} features")
return tier
def update_feature(
self,
db: Session,
feature_code: str,
name: str | None = None,
description: str | None = None,
category: str | None = None,
ui_location: str | None = None,
ui_icon: str | None = None,
ui_route: str | None = None,
ui_badge_text: str | None = None,
minimum_tier_code: str | None = None,
is_active: bool | None = None,
is_visible: bool | None = None,
display_order: int | None = None,
) -> Feature:
"""
Update feature metadata.
Args:
db: Database session
feature_code: Feature code to update
... other optional fields to update
Returns:
Updated feature
Raises:
FeatureNotFoundError: If feature not found
TierNotFoundError: If minimum_tier_code provided but not found
"""
feature = (
db.query(Feature)
.options(joinedload(Feature.minimum_tier))
.filter(Feature.code == feature_code)
.first()
)
if not feature:
raise FeatureNotFoundError(feature_code)
# Update fields if provided
if name is not None:
feature.name = name
if description is not None:
feature.description = description
if category is not None:
feature.category = category
if ui_location is not None:
feature.ui_location = ui_location
if ui_icon is not None:
feature.ui_icon = ui_icon
if ui_route is not None:
feature.ui_route = ui_route
if ui_badge_text is not None:
feature.ui_badge_text = ui_badge_text
if is_active is not None:
feature.is_active = is_active
if is_visible is not None:
feature.is_visible = is_visible
if display_order is not None:
feature.display_order = display_order
# Update minimum tier if provided
if minimum_tier_code is not None:
if minimum_tier_code == "":
feature.minimum_tier_id = None
else:
tier = (
db.query(SubscriptionTier)
.filter(SubscriptionTier.code == minimum_tier_code)
.first()
)
if not tier:
raise TierNotFoundError(minimum_tier_code)
feature.minimum_tier_id = tier.id
logger.info(f"Updated feature {feature_code}")
return feature
def update_feature_minimum_tier(
self, db: Session, feature_code: str, tier_code: str | None
) -> Feature:
"""
Update minimum tier for a feature (for upgrade prompts).
Args:
db: Database session
feature_code: Feature code
tier_code: Tier code or None
Raises:
FeatureNotFoundError: If feature not found
TierNotFoundError: If tier_code provided but not found
"""
feature = db.query(Feature).filter(Feature.code == feature_code).first()
if not feature:
raise FeatureNotFoundError(feature_code)
if tier_code:
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
if not tier:
raise TierNotFoundError(tier_code)
feature.minimum_tier_id = tier.id
else:
feature.minimum_tier_id = None
logger.info(f"Updated minimum tier for feature {feature_code}: {tier_code}")
return feature
# Singleton instance
feature_service = FeatureService()
# ============================================================================
# Convenience Exports
# ============================================================================
# Re-export FeatureCode for easy imports
__all__ = [
"feature_service",
"FeatureService",
"FeatureInfo",
"FeatureUpgradeInfo",
"FeatureCode",
"FeatureSummary",
]

View File

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

View File

@@ -39,7 +39,7 @@ function adminSubscriptions() {
},
// Sorting
sortBy: 'vendor_name',
sortBy: 'store_name',
sortOrder: 'asc',
// Modal state
@@ -47,12 +47,14 @@ function adminSubscriptions() {
editingSub: null,
formData: {
tier: '',
status: '',
custom_orders_limit: null,
custom_products_limit: null,
custom_team_limit: null
status: ''
},
// Feature overrides
featureOverrides: [],
quantitativeFeatures: [],
loadingOverrides: false,
// Computed: Total pages
get totalPages() {
return this.pagination.pages;
@@ -203,16 +205,18 @@ function adminSubscriptions() {
}
},
openEditModal(sub) {
async openEditModal(sub) {
this.editingSub = sub;
this.formData = {
tier: sub.tier,
status: sub.status,
custom_orders_limit: sub.custom_orders_limit,
custom_products_limit: sub.custom_products_limit,
custom_team_limit: sub.custom_team_limit
status: sub.status
};
this.featureOverrides = [];
this.quantitativeFeatures = [];
this.showModal = true;
// Load feature catalog and merchant overrides
await this.loadFeatureOverrides(sub.merchant_id);
},
closeModal() {
@@ -220,6 +224,77 @@ function adminSubscriptions() {
this.editingSub = null;
},
async loadFeatureOverrides(merchantId) {
this.loadingOverrides = true;
try {
const [catalogData, overridesData] = await Promise.all([
apiClient.get('/admin/subscriptions/features/catalog'),
apiClient.get(`/admin/subscriptions/features/merchants/${merchantId}/overrides`),
]);
// Extract quantitative features from catalog
const allFeatures = [];
for (const [, features] of Object.entries(catalogData.features || {})) {
for (const f of features) {
if (f.feature_type === 'quantitative') {
allFeatures.push(f);
}
}
}
this.quantitativeFeatures = allFeatures;
// Map overrides by feature_code
this.featureOverrides = (overridesData || []).map(o => ({
feature_code: o.feature_code,
limit_value: o.limit_value,
is_enabled: o.is_enabled
}));
subsLog.info(`Loaded ${allFeatures.length} quantitative features and ${this.featureOverrides.length} overrides`);
} catch (error) {
subsLog.error('Failed to load feature overrides:', error);
} finally {
this.loadingOverrides = false;
}
},
getOverrideValue(featureCode) {
const override = this.featureOverrides.find(o => o.feature_code === featureCode);
return override?.limit_value ?? '';
},
setOverrideValue(featureCode, value) {
const numValue = value === '' ? null : parseInt(value, 10);
const existing = this.featureOverrides.find(o => o.feature_code === featureCode);
if (existing) {
existing.limit_value = numValue;
} else if (numValue !== null) {
this.featureOverrides.push({
feature_code: featureCode,
limit_value: numValue,
is_enabled: true
});
}
},
async saveFeatureOverrides(merchantId) {
// Only send overrides that have a limit_value set
const entries = this.featureOverrides
.filter(o => o.limit_value !== null && o.limit_value !== undefined)
.map(o => ({
feature_code: o.feature_code,
limit_value: o.limit_value,
is_enabled: true
}));
if (entries.length > 0) {
await apiClient.put(
`/admin/subscriptions/features/merchants/${merchantId}/overrides`,
entries
);
}
},
async saveSubscription() {
if (!this.editingSub) return;
@@ -227,14 +302,17 @@ function adminSubscriptions() {
this.error = null;
try {
// Clean up null values for empty strings
const payload = { ...this.formData };
if (payload.custom_orders_limit === '') payload.custom_orders_limit = null;
if (payload.custom_products_limit === '') payload.custom_products_limit = null;
if (payload.custom_team_limit === '') payload.custom_team_limit = null;
await apiClient.patch(`/admin/subscriptions/${this.editingSub.vendor_id}`, payload);
this.successMessage = `Subscription for "${this.editingSub.vendor_name}" updated`;
await apiClient.patch(
`/admin/subscriptions/merchants/${this.editingSub.merchant_id}/platforms/${this.editingSub.platform_id}`,
payload
);
// Save feature overrides
await this.saveFeatureOverrides(this.editingSub.merchant_id);
this.successMessage = `Subscription for "${this.editingSub.store_name || this.editingSub.merchant_name}" updated`;
this.closeModal();
await this.loadSubscriptions();

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 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-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">Status</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">
<template x-if="loading">
<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>
Loading tiers...
</td>
@@ -109,7 +106,7 @@
</template>
<template x-if="!loading && tiers.length === 0">
<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.
</td>
</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 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-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">
<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 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>
@@ -225,39 +219,6 @@
>
</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 -->
<div>
<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 class="flex items-center">
<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>
</div>
</div>
@@ -420,18 +381,32 @@
<!-- Features List -->
<div class="divide-y divide-gray-100 dark:divide-gray-700">
<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">
<input
type="checkbox"
:checked="isFeatureSelected(feature.code)"
@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="text-xs text-gray-500 dark:text-gray-400" x-text="feature.description"></div>
</div>
</label>
<div class="flex items-start px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30">
<label class="flex items-start cursor-pointer flex-1">
<input
type="checkbox"
:checked="isFeatureSelected(feature.code)"
@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_key || feature.name"></div>
<div class="text-xs text-gray-500 dark:text-gray-400" x-text="feature.description_key || feature.description"></div>
</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>
</div>
</div>

View File

@@ -5,12 +5,12 @@
{% from 'shared/macros/tables.html' import table_wrapper, table_header_custom, th_sortable %}
{% from 'shared/macros/pagination.html' import pagination %}
{% block title %}Vendor Subscriptions{% endblock %}
{% block title %}Store Subscriptions{% endblock %}
{% block alpine_data %}adminSubscriptions(){% endblock %}
{% block content %}
{{ page_header_refresh('Vendor Subscriptions') }}
{{ page_header_refresh('Store Subscriptions') }}
{{ alert_dynamic(type='success', title='Success', message_var='successMessage', show_condition='successMessage') }}
@@ -94,7 +94,7 @@
type="text"
x-model="filters.search"
@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"
>
</div>
@@ -140,19 +140,17 @@
{% call table_wrapper() %}
<table class="w-full whitespace-nowrap">
{% 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('status', 'Status', 'sortBy', 'sortOrder') }}
<th class="px-4 py-3 text-center">Orders</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">Period End</th>
<th class="px-4 py-3 text-right">Actions</th>
{% endcall %}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="loading">
<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>
Loading subscriptions...
</td>
@@ -160,7 +158,7 @@
</template>
<template x-if="!loading && subscriptions.length === 0">
<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.
</td>
</tr>
@@ -170,8 +168,8 @@
<td class="px-4 py-3">
<div class="flex items-center">
<div>
<p class="font-semibold text-gray-900 dark:text-gray-100" x-text="sub.vendor_name"></p>
<p class="text-xs text-gray-500" x-text="sub.vendor_code"></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.store_code"></p>
</div>
</div>
</td>
@@ -197,15 +195,8 @@
x-text="sub.status.replace('_', ' ').toUpperCase()"></span>
</td>
<td class="px-4 py-3 text-center">
<span x-text="sub.orders_this_period"></span>
<span class="text-gray-400">/</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>
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded"
x-text="(sub.feature_codes || []).length"></span>
</td>
<td class="px-4 py-3 text-sm" x-text="formatDate(sub.period_end)"></td>
<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">
<span x-html="$icon('pencil', 'w-4 h-4')"></span>
</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>
</a>
</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 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>
<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">
<!-- Tier -->
@@ -265,39 +256,35 @@
</select>
</div>
<!-- Custom Limits Section -->
<!-- Feature Overrides Section -->
<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>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-1">Orders/Month</label>
<input
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"
>
<template x-if="loadingOverrides">
<div class="flex items-center justify-center py-4">
<span x-html="$icon('refresh', 'w-5 h-5 animate-spin text-purple-600')"></span>
<span class="ml-2 text-sm text-gray-500">Loading features...</span>
</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>

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

View File

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

View File

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

View File

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