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:
@@ -7,40 +7,31 @@ discovered and registered with SQLAlchemy's Base.metadata at startup.
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from app.modules.billing.models import (
|
from app.modules.billing.models import (
|
||||||
VendorSubscription,
|
MerchantSubscription,
|
||||||
SubscriptionTier,
|
SubscriptionTier,
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
TierCode,
|
TierCode,
|
||||||
Feature,
|
TierFeatureLimit,
|
||||||
FeatureCode,
|
MerchantFeatureOverride,
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from app.modules.billing.models.merchant_subscription import MerchantSubscription
|
||||||
from app.modules.billing.models.subscription import (
|
from app.modules.billing.models.subscription import (
|
||||||
# Enums
|
|
||||||
TierCode,
|
|
||||||
SubscriptionStatus,
|
|
||||||
AddOnCategory,
|
AddOnCategory,
|
||||||
BillingPeriod,
|
|
||||||
# Models
|
|
||||||
SubscriptionTier,
|
|
||||||
AddOnProduct,
|
AddOnProduct,
|
||||||
VendorAddOn,
|
|
||||||
StripeWebhookEvent,
|
|
||||||
BillingHistory,
|
BillingHistory,
|
||||||
VendorSubscription,
|
BillingPeriod,
|
||||||
CapacitySnapshot,
|
CapacitySnapshot,
|
||||||
# Legacy constants
|
StoreAddOn,
|
||||||
TIER_LIMITS,
|
StripeWebhookEvent,
|
||||||
|
SubscriptionStatus,
|
||||||
|
SubscriptionTier,
|
||||||
|
TierCode,
|
||||||
)
|
)
|
||||||
from app.modules.billing.models.feature import (
|
from app.modules.billing.models.tier_feature_limit import (
|
||||||
# Enums
|
MerchantFeatureOverride,
|
||||||
FeatureCategory,
|
TierFeatureLimit,
|
||||||
FeatureUILocation,
|
|
||||||
# Model
|
|
||||||
Feature,
|
|
||||||
# Constants
|
|
||||||
FeatureCode,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -52,18 +43,13 @@ __all__ = [
|
|||||||
# Subscription Models
|
# Subscription Models
|
||||||
"SubscriptionTier",
|
"SubscriptionTier",
|
||||||
"AddOnProduct",
|
"AddOnProduct",
|
||||||
"VendorAddOn",
|
"StoreAddOn",
|
||||||
"StripeWebhookEvent",
|
"StripeWebhookEvent",
|
||||||
"BillingHistory",
|
"BillingHistory",
|
||||||
"VendorSubscription",
|
|
||||||
"CapacitySnapshot",
|
"CapacitySnapshot",
|
||||||
# Legacy constants
|
# Merchant Subscription
|
||||||
"TIER_LIMITS",
|
"MerchantSubscription",
|
||||||
# Feature Enums
|
# Feature Limits
|
||||||
"FeatureCategory",
|
"TierFeatureLimit",
|
||||||
"FeatureUILocation",
|
"MerchantFeatureOverride",
|
||||||
# Feature Model
|
|
||||||
"Feature",
|
|
||||||
# Feature Constants
|
|
||||||
"FeatureCode",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,39 +1,38 @@
|
|||||||
# app/modules/billing/routes/admin.py
|
# app/modules/billing/routes/api/admin.py
|
||||||
"""
|
"""
|
||||||
Billing module admin routes.
|
Billing module admin routes.
|
||||||
|
|
||||||
This module wraps the existing admin subscription routes and adds
|
Provides admin API endpoints for subscription and billing management:
|
||||||
module-based access control. The actual route implementations remain
|
- Subscription tier CRUD
|
||||||
in app/api/v1/admin/subscriptions.py for now, but are accessed through
|
- Merchant subscription listing and management
|
||||||
this module-aware router.
|
- Billing history
|
||||||
|
- Subscription statistics
|
||||||
Future: Move all route implementations here for full module isolation.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Path, Query
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_admin_api, require_module_access
|
from app.api.deps import get_current_admin_api, require_module_access
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.modules.billing.services import admin_subscription_service, subscription_service
|
from app.modules.billing.services import admin_subscription_service, subscription_service
|
||||||
from app.modules.enums import FrontendType
|
from app.modules.enums import FrontendType
|
||||||
from app.modules.tenancy.models import User
|
|
||||||
from app.modules.billing.schemas import (
|
from app.modules.billing.schemas import (
|
||||||
BillingHistoryListResponse,
|
BillingHistoryListResponse,
|
||||||
BillingHistoryWithVendor,
|
BillingHistoryWithMerchant,
|
||||||
|
MerchantSubscriptionAdminCreate,
|
||||||
|
MerchantSubscriptionAdminResponse,
|
||||||
|
MerchantSubscriptionAdminUpdate,
|
||||||
|
MerchantSubscriptionListResponse,
|
||||||
|
MerchantSubscriptionWithMerchant,
|
||||||
SubscriptionStatsResponse,
|
SubscriptionStatsResponse,
|
||||||
SubscriptionTierCreate,
|
SubscriptionTierCreate,
|
||||||
SubscriptionTierListResponse,
|
SubscriptionTierListResponse,
|
||||||
SubscriptionTierResponse,
|
SubscriptionTierResponse,
|
||||||
SubscriptionTierUpdate,
|
SubscriptionTierUpdate,
|
||||||
VendorSubscriptionCreate,
|
|
||||||
VendorSubscriptionListResponse,
|
|
||||||
VendorSubscriptionResponse,
|
|
||||||
VendorSubscriptionUpdate,
|
|
||||||
VendorSubscriptionWithVendor,
|
|
||||||
)
|
)
|
||||||
|
from models.schema.auth import UserContext
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -52,14 +51,10 @@ admin_router = APIRouter(
|
|||||||
@admin_router.get("/tiers", response_model=SubscriptionTierListResponse)
|
@admin_router.get("/tiers", response_model=SubscriptionTierListResponse)
|
||||||
def list_subscription_tiers(
|
def list_subscription_tiers(
|
||||||
include_inactive: bool = Query(False, description="Include inactive tiers"),
|
include_inactive: bool = Query(False, description="Include inactive tiers"),
|
||||||
current_user: User = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""List all subscription tiers."""
|
||||||
List all subscription tiers.
|
|
||||||
|
|
||||||
Returns all tiers with their limits, features, and Stripe configuration.
|
|
||||||
"""
|
|
||||||
tiers = admin_subscription_service.get_tiers(db, include_inactive=include_inactive)
|
tiers = admin_subscription_service.get_tiers(db, include_inactive=include_inactive)
|
||||||
|
|
||||||
return SubscriptionTierListResponse(
|
return SubscriptionTierListResponse(
|
||||||
@@ -71,7 +66,7 @@ def list_subscription_tiers(
|
|||||||
@admin_router.get("/tiers/{tier_code}", response_model=SubscriptionTierResponse)
|
@admin_router.get("/tiers/{tier_code}", response_model=SubscriptionTierResponse)
|
||||||
def get_subscription_tier(
|
def get_subscription_tier(
|
||||||
tier_code: str = Path(..., description="Tier code"),
|
tier_code: str = Path(..., description="Tier code"),
|
||||||
current_user: User = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Get a specific subscription tier by code."""
|
"""Get a specific subscription tier by code."""
|
||||||
@@ -82,7 +77,7 @@ def get_subscription_tier(
|
|||||||
@admin_router.post("/tiers", response_model=SubscriptionTierResponse, status_code=201)
|
@admin_router.post("/tiers", response_model=SubscriptionTierResponse, status_code=201)
|
||||||
def create_subscription_tier(
|
def create_subscription_tier(
|
||||||
tier_data: SubscriptionTierCreate,
|
tier_data: SubscriptionTierCreate,
|
||||||
current_user: User = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Create a new subscription tier."""
|
"""Create a new subscription tier."""
|
||||||
@@ -96,7 +91,7 @@ def create_subscription_tier(
|
|||||||
def update_subscription_tier(
|
def update_subscription_tier(
|
||||||
tier_data: SubscriptionTierUpdate,
|
tier_data: SubscriptionTierUpdate,
|
||||||
tier_code: str = Path(..., description="Tier code"),
|
tier_code: str = Path(..., description="Tier code"),
|
||||||
current_user: User = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Update a subscription tier."""
|
"""Update a subscription tier."""
|
||||||
@@ -110,52 +105,48 @@ def update_subscription_tier(
|
|||||||
@admin_router.delete("/tiers/{tier_code}", status_code=204)
|
@admin_router.delete("/tiers/{tier_code}", status_code=204)
|
||||||
def delete_subscription_tier(
|
def delete_subscription_tier(
|
||||||
tier_code: str = Path(..., description="Tier code"),
|
tier_code: str = Path(..., description="Tier code"),
|
||||||
current_user: User = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""Soft-delete a subscription tier."""
|
||||||
Soft-delete a subscription tier.
|
|
||||||
|
|
||||||
Sets is_active=False rather than deleting to preserve history.
|
|
||||||
"""
|
|
||||||
admin_subscription_service.deactivate_tier(db, tier_code)
|
admin_subscription_service.deactivate_tier(db, tier_code)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Vendor Subscription Endpoints
|
# Merchant Subscription Endpoints
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("", response_model=VendorSubscriptionListResponse)
|
@admin_router.get("", response_model=MerchantSubscriptionListResponse)
|
||||||
def list_vendor_subscriptions(
|
def list_merchant_subscriptions(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
per_page: int = Query(20, ge=1, le=100),
|
per_page: int = Query(20, ge=1, le=100),
|
||||||
status: str | None = Query(None, description="Filter by status"),
|
status: str | None = Query(None, description="Filter by status"),
|
||||||
tier: str | None = Query(None, description="Filter by tier"),
|
tier: str | None = Query(None, description="Filter by tier code"),
|
||||||
search: str | None = Query(None, description="Search vendor name"),
|
search: str | None = Query(None, description="Search merchant name"),
|
||||||
current_user: User = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""List all merchant subscriptions with filtering."""
|
||||||
List all vendor subscriptions with filtering.
|
|
||||||
|
|
||||||
Includes vendor information for each subscription.
|
|
||||||
"""
|
|
||||||
data = admin_subscription_service.list_subscriptions(
|
data = admin_subscription_service.list_subscriptions(
|
||||||
db, page=page, per_page=per_page, status=status, tier=tier, search=search
|
db, page=page, per_page=per_page, status=status, tier=tier, search=search
|
||||||
)
|
)
|
||||||
|
|
||||||
subscriptions = []
|
subscriptions = []
|
||||||
for sub, vendor in data["results"]:
|
for sub, merchant in data["results"]:
|
||||||
sub_dict = {
|
sub_resp = MerchantSubscriptionAdminResponse.model_validate(sub)
|
||||||
**VendorSubscriptionResponse.model_validate(sub).model_dump(),
|
tier_name = sub.tier.name if sub.tier else None
|
||||||
"vendor_name": vendor.name,
|
subscriptions.append(
|
||||||
"vendor_code": vendor.subdomain,
|
MerchantSubscriptionWithMerchant(
|
||||||
}
|
**sub_resp.model_dump(),
|
||||||
subscriptions.append(VendorSubscriptionWithVendor(**sub_dict))
|
merchant_name=merchant.name,
|
||||||
|
platform_name="", # Platform name can be resolved if needed
|
||||||
|
tier_name=tier_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return VendorSubscriptionListResponse(
|
return MerchantSubscriptionListResponse(
|
||||||
subscriptions=subscriptions,
|
subscriptions=subscriptions,
|
||||||
total=data["total"],
|
total=data["total"],
|
||||||
page=data["page"],
|
page=data["page"],
|
||||||
@@ -164,6 +155,154 @@ def list_vendor_subscriptions(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.post(
|
||||||
|
"/merchants/{merchant_id}/platforms/{platform_id}",
|
||||||
|
response_model=MerchantSubscriptionAdminResponse,
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
def create_merchant_subscription(
|
||||||
|
create_data: MerchantSubscriptionAdminCreate,
|
||||||
|
merchant_id: int = Path(..., description="Merchant ID"),
|
||||||
|
platform_id: int = Path(..., description="Platform ID"),
|
||||||
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a subscription for a merchant on a platform."""
|
||||||
|
sub = subscription_service.get_or_create_subscription(
|
||||||
|
db,
|
||||||
|
merchant_id=merchant_id,
|
||||||
|
platform_id=platform_id,
|
||||||
|
tier_code=create_data.tier_code,
|
||||||
|
trial_days=create_data.trial_days,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update status if not trial
|
||||||
|
if create_data.status != "trial":
|
||||||
|
sub.status = create_data.status
|
||||||
|
|
||||||
|
sub.is_annual = create_data.is_annual
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(sub)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Admin created subscription for merchant {merchant_id} "
|
||||||
|
f"on platform {platform_id}: tier={create_data.tier_code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return MerchantSubscriptionAdminResponse.model_validate(sub)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get(
|
||||||
|
"/merchants/{merchant_id}/platforms/{platform_id}",
|
||||||
|
response_model=MerchantSubscriptionAdminResponse,
|
||||||
|
)
|
||||||
|
def get_merchant_subscription(
|
||||||
|
merchant_id: int = Path(..., description="Merchant ID"),
|
||||||
|
platform_id: int = Path(..., description="Platform ID"),
|
||||||
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get subscription details for a specific merchant on a platform."""
|
||||||
|
sub, merchant = admin_subscription_service.get_subscription(
|
||||||
|
db, merchant_id, platform_id
|
||||||
|
)
|
||||||
|
return MerchantSubscriptionAdminResponse.model_validate(sub)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.patch(
|
||||||
|
"/merchants/{merchant_id}/platforms/{platform_id}",
|
||||||
|
response_model=MerchantSubscriptionAdminResponse,
|
||||||
|
)
|
||||||
|
def update_merchant_subscription(
|
||||||
|
update_data: MerchantSubscriptionAdminUpdate,
|
||||||
|
merchant_id: int = Path(..., description="Merchant ID"),
|
||||||
|
platform_id: int = Path(..., description="Platform ID"),
|
||||||
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update a merchant's subscription."""
|
||||||
|
data = update_data.model_dump(exclude_unset=True)
|
||||||
|
sub, merchant = admin_subscription_service.update_subscription(
|
||||||
|
db, merchant_id, platform_id, data
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(sub)
|
||||||
|
|
||||||
|
return MerchantSubscriptionAdminResponse.model_validate(sub)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Store Convenience Endpoint
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get("/store/{store_id}")
|
||||||
|
def get_subscription_for_store(
|
||||||
|
store_id: int = Path(..., description="Store ID"),
|
||||||
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get subscription + feature usage for a store (resolves to merchant).
|
||||||
|
|
||||||
|
Convenience endpoint for the admin store detail page. Resolves
|
||||||
|
store -> merchant -> subscription internally and returns subscription
|
||||||
|
info with feature usage metrics.
|
||||||
|
"""
|
||||||
|
from app.modules.billing.services.feature_service import feature_service
|
||||||
|
from app.modules.billing.schemas.subscription import FeatureSummaryResponse
|
||||||
|
|
||||||
|
# Resolve store to merchant
|
||||||
|
merchant_id, platform_id = feature_service._get_merchant_for_store(db, store_id)
|
||||||
|
if merchant_id is None or platform_id is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Store not found or has no merchant association")
|
||||||
|
|
||||||
|
# Get subscription
|
||||||
|
try:
|
||||||
|
sub, merchant = admin_subscription_service.get_subscription(
|
||||||
|
db, merchant_id, platform_id
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return {
|
||||||
|
"subscription": None,
|
||||||
|
"tier": None,
|
||||||
|
"features": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get feature summary
|
||||||
|
features_summary = feature_service.get_merchant_features_summary(db, merchant_id, platform_id)
|
||||||
|
|
||||||
|
# Build tier info
|
||||||
|
tier_info = None
|
||||||
|
if sub.tier:
|
||||||
|
tier_info = {
|
||||||
|
"code": sub.tier.code,
|
||||||
|
"name": sub.tier.name,
|
||||||
|
"feature_codes": [fl.feature_code for fl in (sub.tier.feature_limits or [])],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build usage metrics (quantitative features only)
|
||||||
|
usage_metrics = []
|
||||||
|
for fs in features_summary:
|
||||||
|
if fs.feature_type == "quantitative" and fs.enabled:
|
||||||
|
usage_metrics.append({
|
||||||
|
"name": fs.name_key.replace("_", " ").title(),
|
||||||
|
"current": fs.current or 0,
|
||||||
|
"limit": fs.limit,
|
||||||
|
"percentage": fs.percent_used or 0,
|
||||||
|
"is_unlimited": fs.limit is None,
|
||||||
|
"is_at_limit": fs.remaining == 0 if fs.remaining is not None else False,
|
||||||
|
"is_approaching_limit": (fs.percent_used or 0) >= 80,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"subscription": MerchantSubscriptionAdminResponse.model_validate(sub).model_dump(),
|
||||||
|
"tier": tier_info,
|
||||||
|
"features": usage_metrics,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Statistics Endpoints
|
# Statistics Endpoints
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -171,7 +310,7 @@ def list_vendor_subscriptions(
|
|||||||
|
|
||||||
@admin_router.get("/stats", response_model=SubscriptionStatsResponse)
|
@admin_router.get("/stats", response_model=SubscriptionStatsResponse)
|
||||||
def get_subscription_stats(
|
def get_subscription_stats(
|
||||||
current_user: User = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Get subscription statistics for admin dashboard."""
|
"""Get subscription statistics for admin dashboard."""
|
||||||
@@ -188,39 +327,39 @@ def get_subscription_stats(
|
|||||||
def list_billing_history(
|
def list_billing_history(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
per_page: int = Query(20, ge=1, le=100),
|
per_page: int = Query(20, ge=1, le=100),
|
||||||
vendor_id: int | None = Query(None, description="Filter by vendor"),
|
merchant_id: int | None = Query(None, description="Filter by merchant"),
|
||||||
status: str | None = Query(None, description="Filter by status"),
|
status: str | None = Query(None, description="Filter by status"),
|
||||||
current_user: User = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""List billing history (invoices) across all vendors."""
|
"""List billing history (invoices) across all merchants."""
|
||||||
data = admin_subscription_service.list_billing_history(
|
data = admin_subscription_service.list_billing_history(
|
||||||
db, page=page, per_page=per_page, vendor_id=vendor_id, status=status
|
db, page=page, per_page=per_page, merchant_id=merchant_id, status=status
|
||||||
)
|
)
|
||||||
|
|
||||||
invoices = []
|
invoices = []
|
||||||
for invoice, vendor in data["results"]:
|
for invoice, merchant in data["results"]:
|
||||||
invoice_dict = {
|
invoices.append(
|
||||||
"id": invoice.id,
|
BillingHistoryWithMerchant(
|
||||||
"vendor_id": invoice.vendor_id,
|
id=invoice.id,
|
||||||
"stripe_invoice_id": invoice.stripe_invoice_id,
|
merchant_id=invoice.merchant_id,
|
||||||
"invoice_number": invoice.invoice_number,
|
stripe_invoice_id=invoice.stripe_invoice_id,
|
||||||
"invoice_date": invoice.invoice_date,
|
invoice_number=invoice.invoice_number,
|
||||||
"due_date": invoice.due_date,
|
invoice_date=invoice.invoice_date,
|
||||||
"subtotal_cents": invoice.subtotal_cents,
|
due_date=invoice.due_date,
|
||||||
"tax_cents": invoice.tax_cents,
|
subtotal_cents=invoice.subtotal_cents,
|
||||||
"total_cents": invoice.total_cents,
|
tax_cents=invoice.tax_cents,
|
||||||
"amount_paid_cents": invoice.amount_paid_cents,
|
total_cents=invoice.total_cents,
|
||||||
"currency": invoice.currency,
|
amount_paid_cents=invoice.amount_paid_cents,
|
||||||
"status": invoice.status,
|
currency=invoice.currency,
|
||||||
"invoice_pdf_url": invoice.invoice_pdf_url,
|
status=invoice.status,
|
||||||
"hosted_invoice_url": invoice.hosted_invoice_url,
|
invoice_pdf_url=invoice.invoice_pdf_url,
|
||||||
"description": invoice.description,
|
hosted_invoice_url=invoice.hosted_invoice_url,
|
||||||
"created_at": invoice.created_at,
|
description=invoice.description,
|
||||||
"vendor_name": vendor.name,
|
created_at=invoice.created_at,
|
||||||
"vendor_code": vendor.subdomain,
|
merchant_name=merchant.name,
|
||||||
}
|
)
|
||||||
invoices.append(BillingHistoryWithVendor(**invoice_dict))
|
)
|
||||||
|
|
||||||
return BillingHistoryListResponse(
|
return BillingHistoryListResponse(
|
||||||
invoices=invoices,
|
invoices=invoices,
|
||||||
@@ -231,112 +370,6 @@ def list_billing_history(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Vendor Subscription Detail Endpoints
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
@admin_router.post("/{vendor_id}", response_model=VendorSubscriptionWithVendor, status_code=201)
|
|
||||||
def create_vendor_subscription(
|
|
||||||
create_data: VendorSubscriptionCreate,
|
|
||||||
vendor_id: int = Path(..., description="Vendor ID"),
|
|
||||||
current_user: User = Depends(get_current_admin_api),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Create a subscription for a vendor.
|
|
||||||
|
|
||||||
Creates a new subscription with the specified tier and status.
|
|
||||||
Defaults to Essential tier with trial status.
|
|
||||||
"""
|
|
||||||
# Verify vendor exists
|
|
||||||
vendor = admin_subscription_service.get_vendor(db, vendor_id)
|
|
||||||
|
|
||||||
# Create subscription using the subscription service
|
|
||||||
sub = subscription_service.get_or_create_subscription(
|
|
||||||
db,
|
|
||||||
vendor_id=vendor_id,
|
|
||||||
tier=create_data.tier,
|
|
||||||
trial_days=create_data.trial_days,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update status if not trial
|
|
||||||
if create_data.status != "trial":
|
|
||||||
sub.status = create_data.status
|
|
||||||
|
|
||||||
sub.is_annual = create_data.is_annual
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
db.refresh(sub)
|
|
||||||
|
|
||||||
# Get usage counts
|
|
||||||
usage = admin_subscription_service.get_vendor_usage_counts(db, vendor_id)
|
|
||||||
|
|
||||||
logger.info(f"Admin created subscription for vendor {vendor_id}: tier={create_data.tier}")
|
|
||||||
|
|
||||||
return VendorSubscriptionWithVendor(
|
|
||||||
**VendorSubscriptionResponse.model_validate(sub).model_dump(),
|
|
||||||
vendor_name=vendor.name,
|
|
||||||
vendor_code=vendor.subdomain,
|
|
||||||
products_count=usage["products_count"],
|
|
||||||
team_count=usage["team_count"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("/{vendor_id}", response_model=VendorSubscriptionWithVendor)
|
|
||||||
def get_vendor_subscription(
|
|
||||||
vendor_id: int = Path(..., description="Vendor ID"),
|
|
||||||
current_user: User = Depends(get_current_admin_api),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Get subscription details for a specific vendor."""
|
|
||||||
sub, vendor = admin_subscription_service.get_subscription(db, vendor_id)
|
|
||||||
|
|
||||||
# Get usage counts
|
|
||||||
usage = admin_subscription_service.get_vendor_usage_counts(db, vendor_id)
|
|
||||||
|
|
||||||
return VendorSubscriptionWithVendor(
|
|
||||||
**VendorSubscriptionResponse.model_validate(sub).model_dump(),
|
|
||||||
vendor_name=vendor.name,
|
|
||||||
vendor_code=vendor.subdomain,
|
|
||||||
products_count=usage["products_count"],
|
|
||||||
team_count=usage["team_count"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@admin_router.patch("/{vendor_id}", response_model=VendorSubscriptionWithVendor)
|
|
||||||
def update_vendor_subscription(
|
|
||||||
update_data: VendorSubscriptionUpdate,
|
|
||||||
vendor_id: int = Path(..., description="Vendor ID"),
|
|
||||||
current_user: User = Depends(get_current_admin_api),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Update a vendor's subscription.
|
|
||||||
|
|
||||||
Allows admins to:
|
|
||||||
- Change tier
|
|
||||||
- Update status
|
|
||||||
- Set custom limit overrides
|
|
||||||
- Extend trial period
|
|
||||||
"""
|
|
||||||
data = update_data.model_dump(exclude_unset=True)
|
|
||||||
sub, vendor = admin_subscription_service.update_subscription(db, vendor_id, data)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(sub)
|
|
||||||
|
|
||||||
# Get usage counts
|
|
||||||
usage = admin_subscription_service.get_vendor_usage_counts(db, vendor_id)
|
|
||||||
|
|
||||||
return VendorSubscriptionWithVendor(
|
|
||||||
**VendorSubscriptionResponse.model_validate(sub).model_dump(),
|
|
||||||
vendor_name=vendor.name,
|
|
||||||
vendor_code=vendor.subdomain,
|
|
||||||
products_count=usage["products_count"],
|
|
||||||
team_count=usage["team_count"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Aggregate Feature Management Routes
|
# Aggregate Feature Management Routes
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -6,48 +6,47 @@ This is the canonical location for billing schemas.
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from app.modules.billing.schemas import (
|
from app.modules.billing.schemas import (
|
||||||
SubscriptionCreate,
|
MerchantSubscriptionCreate,
|
||||||
SubscriptionResponse,
|
MerchantSubscriptionResponse,
|
||||||
TierInfo,
|
TierInfo,
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from app.modules.billing.schemas.subscription import (
|
from app.modules.billing.schemas.subscription import (
|
||||||
# Tier schemas
|
# Tier schemas
|
||||||
TierFeatures,
|
TierFeatureLimitResponse,
|
||||||
TierLimits,
|
|
||||||
TierInfo,
|
TierInfo,
|
||||||
# Subscription CRUD schemas
|
# Subscription schemas
|
||||||
SubscriptionCreate,
|
MerchantSubscriptionCreate,
|
||||||
SubscriptionUpdate,
|
MerchantSubscriptionUpdate,
|
||||||
SubscriptionResponse,
|
MerchantSubscriptionResponse,
|
||||||
# Usage schemas
|
MerchantSubscriptionStatusResponse,
|
||||||
SubscriptionUsage,
|
# Feature summary schemas
|
||||||
UsageSummary,
|
FeatureSummaryResponse,
|
||||||
SubscriptionStatusResponse,
|
|
||||||
# Limit check schemas
|
# Limit check schemas
|
||||||
LimitCheckResult,
|
LimitCheckResult,
|
||||||
CanCreateOrderResponse,
|
|
||||||
CanAddProductResponse,
|
|
||||||
CanAddTeamMemberResponse,
|
|
||||||
FeatureCheckResponse,
|
FeatureCheckResponse,
|
||||||
)
|
)
|
||||||
from app.modules.billing.schemas.billing import (
|
from app.modules.billing.schemas.billing import (
|
||||||
# Subscription Tier Admin schemas
|
# Subscription Tier Admin schemas
|
||||||
|
TierFeatureLimitEntry,
|
||||||
SubscriptionTierBase,
|
SubscriptionTierBase,
|
||||||
SubscriptionTierCreate,
|
SubscriptionTierCreate,
|
||||||
SubscriptionTierUpdate,
|
SubscriptionTierUpdate,
|
||||||
SubscriptionTierResponse,
|
SubscriptionTierResponse,
|
||||||
SubscriptionTierListResponse,
|
SubscriptionTierListResponse,
|
||||||
# Vendor Subscription schemas
|
# Merchant Subscription Admin schemas
|
||||||
VendorSubscriptionResponse,
|
MerchantSubscriptionAdminResponse,
|
||||||
VendorSubscriptionWithVendor,
|
MerchantSubscriptionWithMerchant,
|
||||||
VendorSubscriptionListResponse,
|
MerchantSubscriptionListResponse,
|
||||||
VendorSubscriptionCreate,
|
MerchantSubscriptionAdminCreate,
|
||||||
VendorSubscriptionUpdate,
|
MerchantSubscriptionAdminUpdate,
|
||||||
|
# Merchant Feature Override schemas
|
||||||
|
MerchantFeatureOverrideEntry,
|
||||||
|
MerchantFeatureOverrideResponse,
|
||||||
# Billing History schemas
|
# Billing History schemas
|
||||||
BillingHistoryResponse,
|
BillingHistoryResponse,
|
||||||
BillingHistoryWithVendor,
|
BillingHistoryWithMerchant,
|
||||||
BillingHistoryListResponse,
|
BillingHistoryListResponse,
|
||||||
# Checkout & Portal schemas
|
# Checkout & Portal schemas
|
||||||
CheckoutRequest,
|
CheckoutRequest,
|
||||||
@@ -55,42 +54,44 @@ from app.modules.billing.schemas.billing import (
|
|||||||
PortalSessionResponse,
|
PortalSessionResponse,
|
||||||
# Stats schemas
|
# Stats schemas
|
||||||
SubscriptionStatsResponse,
|
SubscriptionStatsResponse,
|
||||||
|
# Feature Catalog schemas
|
||||||
|
FeatureDeclarationResponse,
|
||||||
|
FeatureCatalogResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Tier schemas (subscription.py)
|
# Tier schemas (subscription.py)
|
||||||
"TierFeatures",
|
"TierFeatureLimitResponse",
|
||||||
"TierLimits",
|
|
||||||
"TierInfo",
|
"TierInfo",
|
||||||
# Subscription CRUD schemas (subscription.py)
|
# Subscription schemas (subscription.py)
|
||||||
"SubscriptionCreate",
|
"MerchantSubscriptionCreate",
|
||||||
"SubscriptionUpdate",
|
"MerchantSubscriptionUpdate",
|
||||||
"SubscriptionResponse",
|
"MerchantSubscriptionResponse",
|
||||||
# Usage schemas (subscription.py)
|
"MerchantSubscriptionStatusResponse",
|
||||||
"SubscriptionUsage",
|
# Feature summary schemas (subscription.py)
|
||||||
"UsageSummary",
|
"FeatureSummaryResponse",
|
||||||
"SubscriptionStatusResponse",
|
|
||||||
# Limit check schemas (subscription.py)
|
# Limit check schemas (subscription.py)
|
||||||
"LimitCheckResult",
|
"LimitCheckResult",
|
||||||
"CanCreateOrderResponse",
|
|
||||||
"CanAddProductResponse",
|
|
||||||
"CanAddTeamMemberResponse",
|
|
||||||
"FeatureCheckResponse",
|
"FeatureCheckResponse",
|
||||||
# Subscription Tier Admin schemas (billing.py)
|
# Subscription Tier Admin schemas (billing.py)
|
||||||
|
"TierFeatureLimitEntry",
|
||||||
"SubscriptionTierBase",
|
"SubscriptionTierBase",
|
||||||
"SubscriptionTierCreate",
|
"SubscriptionTierCreate",
|
||||||
"SubscriptionTierUpdate",
|
"SubscriptionTierUpdate",
|
||||||
"SubscriptionTierResponse",
|
"SubscriptionTierResponse",
|
||||||
"SubscriptionTierListResponse",
|
"SubscriptionTierListResponse",
|
||||||
# Vendor Subscription schemas (billing.py)
|
# Merchant Subscription Admin schemas (billing.py)
|
||||||
"VendorSubscriptionResponse",
|
"MerchantSubscriptionAdminResponse",
|
||||||
"VendorSubscriptionWithVendor",
|
"MerchantSubscriptionWithMerchant",
|
||||||
"VendorSubscriptionListResponse",
|
"MerchantSubscriptionListResponse",
|
||||||
"VendorSubscriptionCreate",
|
"MerchantSubscriptionAdminCreate",
|
||||||
"VendorSubscriptionUpdate",
|
"MerchantSubscriptionAdminUpdate",
|
||||||
|
# Merchant Feature Override schemas (billing.py)
|
||||||
|
"MerchantFeatureOverrideEntry",
|
||||||
|
"MerchantFeatureOverrideResponse",
|
||||||
# Billing History schemas (billing.py)
|
# Billing History schemas (billing.py)
|
||||||
"BillingHistoryResponse",
|
"BillingHistoryResponse",
|
||||||
"BillingHistoryWithVendor",
|
"BillingHistoryWithMerchant",
|
||||||
"BillingHistoryListResponse",
|
"BillingHistoryListResponse",
|
||||||
# Checkout & Portal schemas (billing.py)
|
# Checkout & Portal schemas (billing.py)
|
||||||
"CheckoutRequest",
|
"CheckoutRequest",
|
||||||
@@ -98,4 +99,7 @@ __all__ = [
|
|||||||
"PortalSessionResponse",
|
"PortalSessionResponse",
|
||||||
# Stats schemas (billing.py)
|
# Stats schemas (billing.py)
|
||||||
"SubscriptionStatsResponse",
|
"SubscriptionStatsResponse",
|
||||||
|
# Feature Catalog schemas (billing.py)
|
||||||
|
"FeatureDeclarationResponse",
|
||||||
|
"FeatureCatalogResponse",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# app/modules/billing/schemas/subscription.py
|
# app/modules/billing/schemas/subscription.py
|
||||||
"""
|
"""
|
||||||
Pydantic schemas for subscription operations.
|
Pydantic schemas for merchant-level subscription operations.
|
||||||
|
|
||||||
Supports subscription management and tier limit checks.
|
Supports subscription management, tier information, and feature summaries.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -15,48 +15,23 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
class TierFeatures(BaseModel):
|
class TierFeatureLimitResponse(BaseModel):
|
||||||
"""Features included in a tier."""
|
"""Feature limit entry for a tier."""
|
||||||
|
|
||||||
letzshop_sync: bool = True
|
feature_code: str
|
||||||
inventory_basic: bool = True
|
limit_value: int | None = Field(None, description="None = unlimited")
|
||||||
inventory_locations: bool = False
|
|
||||||
inventory_purchase_orders: bool = False
|
|
||||||
invoice_lu: bool = True
|
|
||||||
invoice_eu_vat: bool = False
|
|
||||||
invoice_bulk: bool = False
|
|
||||||
customer_view: bool = True
|
|
||||||
customer_export: bool = False
|
|
||||||
analytics_dashboard: bool = False
|
|
||||||
accounting_export: bool = False
|
|
||||||
api_access: bool = False
|
|
||||||
automation_rules: bool = False
|
|
||||||
team_roles: bool = False
|
|
||||||
white_label: bool = False
|
|
||||||
multi_vendor: bool = False
|
|
||||||
custom_integrations: bool = False
|
|
||||||
sla_guarantee: bool = False
|
|
||||||
dedicated_support: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class TierLimits(BaseModel):
|
|
||||||
"""Limits for a subscription tier."""
|
|
||||||
|
|
||||||
orders_per_month: int | None = Field(None, description="None = unlimited")
|
|
||||||
products_limit: int | None = Field(None, description="None = unlimited")
|
|
||||||
team_members: int | None = Field(None, description="None = unlimited")
|
|
||||||
order_history_months: int | None = Field(None, description="None = unlimited")
|
|
||||||
|
|
||||||
|
|
||||||
class TierInfo(BaseModel):
|
class TierInfo(BaseModel):
|
||||||
"""Full tier information."""
|
"""Full tier information with feature limits."""
|
||||||
|
|
||||||
code: str
|
code: str
|
||||||
name: str
|
name: str
|
||||||
|
description: str | None = None
|
||||||
price_monthly_cents: int
|
price_monthly_cents: int
|
||||||
price_annual_cents: int | None
|
price_annual_cents: int | None
|
||||||
limits: TierLimits
|
feature_codes: list[str] = Field(default_factory=list)
|
||||||
features: list[str]
|
feature_limits: list[TierFeatureLimitResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -64,47 +39,43 @@ class TierInfo(BaseModel):
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionCreate(BaseModel):
|
class MerchantSubscriptionCreate(BaseModel):
|
||||||
"""Schema for creating a subscription (admin/internal use)."""
|
"""Schema for creating a merchant subscription."""
|
||||||
|
|
||||||
tier: str = Field(default="essential", pattern="^(essential|professional|business|enterprise)$")
|
tier_code: str = Field(default="essential")
|
||||||
is_annual: bool = False
|
is_annual: bool = False
|
||||||
trial_days: int = Field(default=14, ge=0, le=30)
|
trial_days: int = Field(default=14, ge=0, le=30)
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionUpdate(BaseModel):
|
class MerchantSubscriptionUpdate(BaseModel):
|
||||||
"""Schema for updating a subscription."""
|
"""Schema for updating a merchant subscription."""
|
||||||
|
|
||||||
tier: str | None = Field(None, pattern="^(essential|professional|business|enterprise)$")
|
tier_code: str | None = None
|
||||||
status: str | None = Field(None, pattern="^(trial|active|past_due|cancelled|expired)$")
|
status: str | None = Field(None, pattern="^(trial|active|past_due|cancelled|expired)$")
|
||||||
is_annual: bool | None = None
|
is_annual: bool | None = None
|
||||||
custom_orders_limit: int | None = None
|
|
||||||
custom_products_limit: int | None = None
|
|
||||||
custom_team_limit: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionResponse(BaseModel):
|
class MerchantSubscriptionResponse(BaseModel):
|
||||||
"""Schema for subscription response."""
|
"""Schema for merchant subscription response."""
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
vendor_id: int
|
merchant_id: int
|
||||||
tier: str
|
platform_id: int
|
||||||
status: str
|
tier_id: int | None
|
||||||
|
|
||||||
|
status: str
|
||||||
|
is_annual: bool
|
||||||
period_start: datetime
|
period_start: datetime
|
||||||
period_end: datetime
|
period_end: datetime
|
||||||
is_annual: bool
|
|
||||||
|
|
||||||
trial_ends_at: datetime | None
|
trial_ends_at: datetime | None
|
||||||
orders_this_period: int
|
|
||||||
orders_limit_reached_at: datetime | None
|
|
||||||
|
|
||||||
# Effective limits (with custom overrides applied)
|
# Stripe info (optional, may be hidden from client)
|
||||||
orders_limit: int | None
|
stripe_customer_id: str | None = None
|
||||||
products_limit: int | None
|
|
||||||
team_members_limit: int | None
|
# Cancellation
|
||||||
|
cancelled_at: datetime | None = None
|
||||||
|
|
||||||
# Computed properties
|
# Computed properties
|
||||||
is_active: bool
|
is_active: bool
|
||||||
@@ -115,47 +86,36 @@ class SubscriptionResponse(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionUsage(BaseModel):
|
# ============================================================================
|
||||||
"""Current subscription usage statistics."""
|
# Feature Summary Schemas
|
||||||
|
# ============================================================================
|
||||||
orders_used: int
|
|
||||||
orders_limit: int | None
|
|
||||||
orders_remaining: int | None
|
|
||||||
orders_percent_used: float | None
|
|
||||||
|
|
||||||
products_used: int
|
|
||||||
products_limit: int | None
|
|
||||||
products_remaining: int | None
|
|
||||||
products_percent_used: float | None
|
|
||||||
|
|
||||||
team_members_used: int
|
|
||||||
team_members_limit: int | None
|
|
||||||
team_members_remaining: int | None
|
|
||||||
team_members_percent_used: float | None
|
|
||||||
|
|
||||||
|
|
||||||
class UsageSummary(BaseModel):
|
class FeatureSummaryResponse(BaseModel):
|
||||||
"""Usage summary for billing page display."""
|
"""Feature summary for merchant portal display."""
|
||||||
|
|
||||||
orders_this_period: int
|
code: str
|
||||||
orders_limit: int | None
|
name_key: str
|
||||||
orders_remaining: int | None
|
description_key: str
|
||||||
|
category: str
|
||||||
products_count: int
|
feature_type: str
|
||||||
products_limit: int | None
|
scope: str
|
||||||
products_remaining: int | None
|
enabled: bool
|
||||||
|
limit: int | None = None
|
||||||
team_count: int
|
current: int | None = None
|
||||||
team_limit: int | None
|
remaining: int | None = None
|
||||||
team_remaining: int | None
|
percent_used: float | None = None
|
||||||
|
is_override: bool = False
|
||||||
|
unit_key: str | None = None
|
||||||
|
ui_icon: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionStatusResponse(BaseModel):
|
class MerchantSubscriptionStatusResponse(BaseModel):
|
||||||
"""Subscription status with usage and limits."""
|
"""Full subscription status with tier info and feature summary."""
|
||||||
|
|
||||||
subscription: SubscriptionResponse
|
subscription: MerchantSubscriptionResponse
|
||||||
usage: SubscriptionUsage
|
tier: TierInfo | None = None
|
||||||
tier_info: TierInfo
|
features: list[FeatureSummaryResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -173,37 +133,11 @@ class LimitCheckResult(BaseModel):
|
|||||||
message: str | None = None
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class CanCreateOrderResponse(BaseModel):
|
|
||||||
"""Response for order creation check."""
|
|
||||||
|
|
||||||
allowed: bool
|
|
||||||
orders_this_period: int
|
|
||||||
orders_limit: int | None
|
|
||||||
message: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class CanAddProductResponse(BaseModel):
|
|
||||||
"""Response for product addition check."""
|
|
||||||
|
|
||||||
allowed: bool
|
|
||||||
products_count: int
|
|
||||||
products_limit: int | None
|
|
||||||
message: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class CanAddTeamMemberResponse(BaseModel):
|
|
||||||
"""Response for team member addition check."""
|
|
||||||
|
|
||||||
allowed: bool
|
|
||||||
team_count: int
|
|
||||||
team_limit: int | None
|
|
||||||
message: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class FeatureCheckResponse(BaseModel):
|
class FeatureCheckResponse(BaseModel):
|
||||||
"""Response for feature check."""
|
"""Response for feature check."""
|
||||||
|
|
||||||
feature: str
|
feature_code: str
|
||||||
enabled: bool
|
enabled: bool
|
||||||
tier_required: str | None = None
|
|
||||||
message: str | None = None
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -32,9 +32,6 @@ from app.modules.billing.exceptions import (
|
|||||||
from app.modules.billing.services.feature_service import (
|
from app.modules.billing.services.feature_service import (
|
||||||
FeatureService,
|
FeatureService,
|
||||||
feature_service,
|
feature_service,
|
||||||
FeatureInfo,
|
|
||||||
FeatureUpgradeInfo,
|
|
||||||
FeatureCode,
|
|
||||||
)
|
)
|
||||||
from app.modules.billing.services.capacity_forecast_service import (
|
from app.modules.billing.services.capacity_forecast_service import (
|
||||||
CapacityForecastService,
|
CapacityForecastService,
|
||||||
@@ -62,9 +59,6 @@ __all__ = [
|
|||||||
"SubscriptionNotCancelledError",
|
"SubscriptionNotCancelledError",
|
||||||
"FeatureService",
|
"FeatureService",
|
||||||
"feature_service",
|
"feature_service",
|
||||||
"FeatureInfo",
|
|
||||||
"FeatureUpgradeInfo",
|
|
||||||
"FeatureCode",
|
|
||||||
"CapacityForecastService",
|
"CapacityForecastService",
|
||||||
"capacity_forecast_service",
|
"capacity_forecast_service",
|
||||||
"PlatformPricingService",
|
"PlatformPricingService",
|
||||||
|
|||||||
@@ -1,590 +1,438 @@
|
|||||||
# app/modules/billing/services/feature_service.py
|
# app/modules/billing/services/feature_service.py
|
||||||
"""
|
"""
|
||||||
Feature service for tier-based access control.
|
Feature-agnostic billing service for merchant-level access control.
|
||||||
|
|
||||||
Provides:
|
Zero knowledge of what features exist. Works with:
|
||||||
- Feature availability checking with caching
|
- TierFeatureLimit (tier -> feature mappings)
|
||||||
- Vendor feature listing for API/UI
|
- MerchantFeatureOverride (per-merchant exceptions)
|
||||||
- Feature metadata for upgrade prompts
|
- FeatureAggregatorService (discovers features from modules)
|
||||||
- Cache invalidation on subscription changes
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from app.modules.billing.services.feature_service import feature_service
|
from app.modules.billing.services.feature_service import feature_service
|
||||||
|
|
||||||
# Check if vendor has feature
|
# Check if merchant has feature
|
||||||
if feature_service.has_feature(db, vendor_id, FeatureCode.ANALYTICS_DASHBOARD):
|
if feature_service.has_feature(db, merchant_id, platform_id, "analytics_dashboard"):
|
||||||
...
|
...
|
||||||
|
|
||||||
# Get all features available to vendor
|
# Check quantitative limit
|
||||||
features = feature_service.get_vendor_features(db, vendor_id)
|
allowed, msg = feature_service.check_resource_limit(
|
||||||
|
db, "products_limit", store_id=store_id
|
||||||
|
)
|
||||||
|
|
||||||
# Get feature info for upgrade prompt
|
# Get feature summary for merchant portal
|
||||||
info = feature_service.get_feature_upgrade_info(db, "analytics_dashboard")
|
summary = feature_service.get_merchant_features_summary(db, merchant_id, platform_id)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import lru_cache
|
|
||||||
|
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
from app.modules.billing.exceptions import (
|
from app.modules.billing.models import (
|
||||||
FeatureNotFoundError,
|
MerchantFeatureOverride,
|
||||||
InvalidFeatureCodesError,
|
MerchantSubscription,
|
||||||
TierNotFoundError,
|
SubscriptionTier,
|
||||||
|
TierFeatureLimit,
|
||||||
)
|
)
|
||||||
from app.modules.billing.models import Feature, FeatureCode
|
from app.modules.contracts.features import FeatureScope, FeatureType
|
||||||
from app.modules.billing.models import SubscriptionTier, VendorSubscription
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class FeatureInfo:
|
class FeatureSummary:
|
||||||
"""Feature information for API responses."""
|
"""Summary of a feature for merchant portal display."""
|
||||||
|
|
||||||
code: str
|
code: str
|
||||||
name: str
|
name_key: str
|
||||||
description: str | None
|
description_key: str
|
||||||
category: str
|
category: str
|
||||||
ui_location: str | None
|
feature_type: str # "binary" or "quantitative"
|
||||||
|
scope: str # "store" or "merchant"
|
||||||
|
enabled: bool
|
||||||
|
limit: int | None # For quantitative: effective limit
|
||||||
|
current: int | None # For quantitative: current usage
|
||||||
|
remaining: int | None # For quantitative: remaining capacity
|
||||||
|
percent_used: float | None # For quantitative: usage percentage
|
||||||
|
is_override: bool # Whether an override is applied
|
||||||
|
unit_key: str | None
|
||||||
ui_icon: str | None
|
ui_icon: str | None
|
||||||
ui_route: str | None
|
|
||||||
ui_badge_text: str | None
|
|
||||||
is_available: bool
|
|
||||||
minimum_tier_code: str | None
|
|
||||||
minimum_tier_name: str | None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FeatureUpgradeInfo:
|
|
||||||
"""Information for upgrade prompts."""
|
|
||||||
|
|
||||||
feature_code: str
|
|
||||||
feature_name: str
|
|
||||||
feature_description: str | None
|
|
||||||
required_tier_code: str
|
|
||||||
required_tier_name: str
|
|
||||||
required_tier_price_monthly_cents: int
|
|
||||||
|
|
||||||
|
|
||||||
class FeatureCache:
|
class FeatureCache:
|
||||||
"""
|
"""
|
||||||
In-memory cache for vendor features.
|
In-memory cache for merchant features.
|
||||||
|
|
||||||
Caches vendor_id -> set of feature codes with TTL.
|
Caches (merchant_id, platform_id) -> set of feature codes with TTL.
|
||||||
Invalidated when subscription changes.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, ttl_seconds: int = 300):
|
def __init__(self, ttl_seconds: int = 300):
|
||||||
self._cache: dict[int, tuple[set[str], float]] = {}
|
self._cache: dict[tuple[int, int], tuple[set[str], float]] = {}
|
||||||
self._ttl = ttl_seconds
|
self._ttl = ttl_seconds
|
||||||
|
|
||||||
def get(self, vendor_id: int) -> set[str] | None:
|
def get(self, merchant_id: int, platform_id: int) -> set[str] | None:
|
||||||
"""Get cached features for vendor, or None if not cached/expired."""
|
key = (merchant_id, platform_id)
|
||||||
if vendor_id not in self._cache:
|
if key not in self._cache:
|
||||||
return None
|
return None
|
||||||
|
features, timestamp = self._cache[key]
|
||||||
features, timestamp = self._cache[vendor_id]
|
|
||||||
if time.time() - timestamp > self._ttl:
|
if time.time() - timestamp > self._ttl:
|
||||||
del self._cache[vendor_id]
|
del self._cache[key]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return features
|
return features
|
||||||
|
|
||||||
def set(self, vendor_id: int, features: set[str]) -> None:
|
def set(self, merchant_id: int, platform_id: int, features: set[str]) -> None:
|
||||||
"""Cache features for vendor."""
|
self._cache[(merchant_id, platform_id)] = (features, time.time())
|
||||||
self._cache[vendor_id] = (features, time.time())
|
|
||||||
|
|
||||||
def invalidate(self, vendor_id: int) -> None:
|
def invalidate(self, merchant_id: int, platform_id: int) -> None:
|
||||||
"""Invalidate cache for vendor."""
|
self._cache.pop((merchant_id, platform_id), None)
|
||||||
self._cache.pop(vendor_id, None)
|
|
||||||
|
|
||||||
def invalidate_all(self) -> None:
|
def invalidate_all(self) -> None:
|
||||||
"""Invalidate entire cache."""
|
|
||||||
self._cache.clear()
|
self._cache.clear()
|
||||||
|
|
||||||
|
|
||||||
class FeatureService:
|
class FeatureService:
|
||||||
"""
|
"""
|
||||||
Service for feature-based access control.
|
Feature-agnostic service for merchant-level billing.
|
||||||
|
|
||||||
Provides methods to check feature availability and get feature metadata.
|
Resolves feature access through:
|
||||||
Uses in-memory caching with TTL for performance.
|
1. MerchantSubscription -> SubscriptionTier -> TierFeatureLimit
|
||||||
|
2. MerchantFeatureOverride (admin-set exceptions)
|
||||||
|
3. FeatureAggregator (for usage counts and declarations)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._cache = FeatureCache(ttl_seconds=300) # 5 minute cache
|
self._cache = FeatureCache(ttl_seconds=300)
|
||||||
self._feature_registry_cache: dict[str, Feature] | None = None
|
|
||||||
self._feature_registry_timestamp: float = 0
|
# =========================================================================
|
||||||
|
# Store -> Merchant Resolution
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def _get_merchant_for_store(self, db: Session, store_id: int) -> tuple[int | None, int | None]:
|
||||||
|
"""
|
||||||
|
Resolve store_id to (merchant_id, platform_id).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (merchant_id, platform_id), either may be None
|
||||||
|
"""
|
||||||
|
from app.modules.tenancy.models import Store
|
||||||
|
|
||||||
|
store = db.query(Store).filter(Store.id == store_id).first()
|
||||||
|
if not store:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
merchant_id = store.merchant_id
|
||||||
|
# Get platform_id from store's platform association
|
||||||
|
platform_id = getattr(store, "platform_id", None)
|
||||||
|
if platform_id is None:
|
||||||
|
# Try StorePlatform junction
|
||||||
|
from app.modules.tenancy.models import StorePlatform
|
||||||
|
sp = (
|
||||||
|
db.query(StorePlatform.platform_id)
|
||||||
|
.filter(StorePlatform.store_id == store_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
platform_id = sp[0] if sp else None
|
||||||
|
|
||||||
|
return merchant_id, platform_id
|
||||||
|
|
||||||
|
def _get_subscription(
|
||||||
|
self, db: Session, merchant_id: int, platform_id: int
|
||||||
|
) -> MerchantSubscription | None:
|
||||||
|
"""Get merchant subscription for a platform."""
|
||||||
|
return (
|
||||||
|
db.query(MerchantSubscription)
|
||||||
|
.options(joinedload(MerchantSubscription.tier).joinedload(SubscriptionTier.feature_limits))
|
||||||
|
.filter(
|
||||||
|
MerchantSubscription.merchant_id == merchant_id,
|
||||||
|
MerchantSubscription.platform_id == platform_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Feature Availability
|
# Feature Availability
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
def has_feature(self, db: Session, vendor_id: int, feature_code: str) -> bool:
|
def has_feature(
|
||||||
|
self, db: Session, merchant_id: int, platform_id: int, feature_code: str
|
||||||
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if vendor has access to a specific feature.
|
Check if merchant has access to a specific feature.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
1. MerchantFeatureOverride (force enable/disable)
|
||||||
|
2. TierFeatureLimit (tier assignment)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
vendor_id: Vendor ID
|
merchant_id: Merchant ID
|
||||||
feature_code: Feature code (use FeatureCode constants)
|
platform_id: Platform ID
|
||||||
|
feature_code: Feature code to check
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if vendor has access to the feature
|
True if merchant has access to the feature
|
||||||
"""
|
"""
|
||||||
vendor_features = self._get_vendor_feature_codes(db, vendor_id)
|
# Check override first
|
||||||
return feature_code in vendor_features
|
override = (
|
||||||
|
db.query(MerchantFeatureOverride)
|
||||||
|
.filter(
|
||||||
|
MerchantFeatureOverride.merchant_id == merchant_id,
|
||||||
|
MerchantFeatureOverride.platform_id == platform_id,
|
||||||
|
MerchantFeatureOverride.feature_code == feature_code,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if override is not None:
|
||||||
|
return override.is_enabled
|
||||||
|
|
||||||
def get_vendor_feature_codes(self, db: Session, vendor_id: int) -> set[str]:
|
# Check tier assignment
|
||||||
|
subscription = self._get_subscription(db, merchant_id, platform_id)
|
||||||
|
if not subscription or not subscription.is_active or not subscription.tier:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return subscription.tier.has_feature(feature_code)
|
||||||
|
|
||||||
|
def has_feature_for_store(
|
||||||
|
self, db: Session, store_id: int, feature_code: str
|
||||||
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Get set of feature codes available to vendor.
|
Check if a store has access to a feature (resolves store -> merchant).
|
||||||
|
|
||||||
Args:
|
Convenience method for backwards compatibility.
|
||||||
db: Database session
|
|
||||||
vendor_id: Vendor ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Set of feature codes the vendor has access to
|
|
||||||
"""
|
"""
|
||||||
return self._get_vendor_feature_codes(db, vendor_id)
|
merchant_id, platform_id = self._get_merchant_for_store(db, store_id)
|
||||||
|
if merchant_id is None or platform_id is None:
|
||||||
|
return False
|
||||||
|
return self.has_feature(db, merchant_id, platform_id, feature_code)
|
||||||
|
|
||||||
def _get_vendor_feature_codes(self, db: Session, vendor_id: int) -> set[str]:
|
def get_merchant_feature_codes(
|
||||||
"""Internal method with caching."""
|
self, db: Session, merchant_id: int, platform_id: int
|
||||||
# Check cache first
|
) -> set[str]:
|
||||||
cached = self._cache.get(vendor_id)
|
"""Get all feature codes available to merchant on a platform."""
|
||||||
|
# Check cache
|
||||||
|
cached = self._cache.get(merchant_id, platform_id)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
# Get subscription with tier relationship
|
features: set[str] = set()
|
||||||
subscription = (
|
|
||||||
db.query(VendorSubscription)
|
|
||||||
.options(joinedload(VendorSubscription.tier_obj))
|
|
||||||
.filter(VendorSubscription.vendor_id == vendor_id)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
if not subscription:
|
# Get tier features
|
||||||
logger.warning(f"No subscription found for vendor {vendor_id}")
|
subscription = self._get_subscription(db, merchant_id, platform_id)
|
||||||
return set()
|
if subscription and subscription.is_active and subscription.tier:
|
||||||
|
features = subscription.tier.get_feature_codes()
|
||||||
|
|
||||||
# Get features from tier
|
# Apply overrides
|
||||||
tier = subscription.tier_obj
|
overrides = (
|
||||||
if tier and tier.features:
|
db.query(MerchantFeatureOverride)
|
||||||
features = set(tier.features)
|
.filter(
|
||||||
else:
|
MerchantFeatureOverride.merchant_id == merchant_id,
|
||||||
# Fallback: query tier by code
|
MerchantFeatureOverride.platform_id == platform_id,
|
||||||
tier = (
|
|
||||||
db.query(SubscriptionTier)
|
|
||||||
.filter(SubscriptionTier.code == subscription.tier)
|
|
||||||
.first()
|
|
||||||
)
|
)
|
||||||
features = set(tier.features) if tier and tier.features else set()
|
.all()
|
||||||
|
)
|
||||||
|
for override in overrides:
|
||||||
|
if override.is_enabled:
|
||||||
|
features.add(override.feature_code)
|
||||||
|
else:
|
||||||
|
features.discard(override.feature_code)
|
||||||
|
|
||||||
# Cache and return
|
self._cache.set(merchant_id, platform_id, features)
|
||||||
self._cache.set(vendor_id, features)
|
|
||||||
return features
|
return features
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Feature Listing
|
# Effective Limits
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
def get_vendor_features(
|
def get_effective_limit(
|
||||||
|
self, db: Session, merchant_id: int, platform_id: int, feature_code: str
|
||||||
|
) -> int | None:
|
||||||
|
"""
|
||||||
|
Get the effective limit for a feature (override or tier default).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Limit value, or None for unlimited
|
||||||
|
"""
|
||||||
|
# Check override first
|
||||||
|
override = (
|
||||||
|
db.query(MerchantFeatureOverride)
|
||||||
|
.filter(
|
||||||
|
MerchantFeatureOverride.merchant_id == merchant_id,
|
||||||
|
MerchantFeatureOverride.platform_id == platform_id,
|
||||||
|
MerchantFeatureOverride.feature_code == feature_code,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if override is not None:
|
||||||
|
return override.limit_value
|
||||||
|
|
||||||
|
# Get from tier
|
||||||
|
subscription = self._get_subscription(db, merchant_id, platform_id)
|
||||||
|
if not subscription or not subscription.tier:
|
||||||
|
return 0 # No subscription = no access
|
||||||
|
|
||||||
|
return subscription.tier.get_limit_for_feature(feature_code)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Resource Limit Checks
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def check_resource_limit(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
vendor_id: int,
|
feature_code: str,
|
||||||
category: str | None = None,
|
store_id: int | None = None,
|
||||||
include_unavailable: bool = True,
|
merchant_id: int | None = None,
|
||||||
) -> list[FeatureInfo]:
|
platform_id: int | None = None,
|
||||||
|
) -> tuple[bool, str | None]:
|
||||||
"""
|
"""
|
||||||
Get all features with availability status for vendor.
|
Check if a resource limit allows adding more items.
|
||||||
|
|
||||||
|
Resolves store -> merchant if needed. Gets the declaration to
|
||||||
|
determine scope, then checks usage against limit.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
vendor_id: Vendor ID
|
feature_code: Feature code (e.g., "products_limit")
|
||||||
category: Optional category filter
|
store_id: Store ID (if checking per-store)
|
||||||
include_unavailable: Include features not available to vendor
|
merchant_id: Merchant ID (if already known)
|
||||||
|
platform_id: Platform ID (if already known)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of FeatureInfo with is_available flag
|
(allowed, error_message) tuple
|
||||||
"""
|
"""
|
||||||
vendor_features = self._get_vendor_feature_codes(db, vendor_id)
|
from app.modules.billing.services.feature_aggregator import feature_aggregator
|
||||||
|
|
||||||
# Query all active features
|
# Resolve store -> merchant if needed
|
||||||
query = db.query(Feature).filter(Feature.is_active == True) # noqa: E712
|
if merchant_id is None and store_id is not None:
|
||||||
|
merchant_id, platform_id = self._get_merchant_for_store(db, store_id)
|
||||||
|
|
||||||
if category:
|
if merchant_id is None or platform_id is None:
|
||||||
query = query.filter(Feature.category == category)
|
return False, "No subscription found"
|
||||||
|
|
||||||
if not include_unavailable:
|
# Check subscription is active
|
||||||
# Only return features the vendor has
|
subscription = self._get_subscription(db, merchant_id, platform_id)
|
||||||
query = query.filter(Feature.code.in_(vendor_features))
|
if not subscription or not subscription.is_active:
|
||||||
|
return False, "Subscription is not active"
|
||||||
|
|
||||||
features = (
|
# Get feature declaration
|
||||||
query.options(joinedload(Feature.minimum_tier))
|
decl = feature_aggregator.get_declaration(feature_code)
|
||||||
.order_by(Feature.category, Feature.display_order)
|
if decl is None:
|
||||||
.all()
|
logger.warning(f"Unknown feature code: {feature_code}")
|
||||||
|
return True, None # Unknown features are allowed by default
|
||||||
|
|
||||||
|
# Binary features: just check if enabled
|
||||||
|
if decl.feature_type == FeatureType.BINARY:
|
||||||
|
if self.has_feature(db, merchant_id, platform_id, feature_code):
|
||||||
|
return True, None
|
||||||
|
return False, f"Feature '{feature_code}' requires an upgrade"
|
||||||
|
|
||||||
|
# Quantitative: check usage against limit
|
||||||
|
limit = self.get_effective_limit(db, merchant_id, platform_id, feature_code)
|
||||||
|
if limit is None: # Unlimited
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
# Get current usage based on scope
|
||||||
|
usage = feature_aggregator.get_usage_for_feature(
|
||||||
|
db, feature_code,
|
||||||
|
store_id=store_id,
|
||||||
|
merchant_id=merchant_id,
|
||||||
|
platform_id=platform_id,
|
||||||
)
|
)
|
||||||
|
current = usage.current_count if usage else 0
|
||||||
|
|
||||||
result = []
|
if current >= limit:
|
||||||
for feature in features:
|
return False, (
|
||||||
result.append(
|
f"Limit reached ({current}/{limit} {decl.unit_key or feature_code}). "
|
||||||
FeatureInfo(
|
f"Upgrade to increase your limit."
|
||||||
code=feature.code,
|
|
||||||
name=feature.name,
|
|
||||||
description=feature.description,
|
|
||||||
category=feature.category,
|
|
||||||
ui_location=feature.ui_location,
|
|
||||||
ui_icon=feature.ui_icon,
|
|
||||||
ui_route=feature.ui_route,
|
|
||||||
ui_badge_text=feature.ui_badge_text,
|
|
||||||
is_available=feature.code in vendor_features,
|
|
||||||
minimum_tier_code=feature.minimum_tier.code if feature.minimum_tier else None,
|
|
||||||
minimum_tier_name=feature.minimum_tier.name if feature.minimum_tier else None,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return result
|
return True, None
|
||||||
|
|
||||||
def get_available_feature_codes(self, db: Session, vendor_id: int) -> list[str]:
|
|
||||||
"""
|
|
||||||
Get list of feature codes available to vendor (for frontend).
|
|
||||||
|
|
||||||
Simple list for x-feature directive checks.
|
|
||||||
"""
|
|
||||||
return list(self._get_vendor_feature_codes(db, vendor_id))
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Feature Metadata
|
# Feature Summary
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
def get_feature_by_code(self, db: Session, feature_code: str) -> Feature | None:
|
def get_merchant_features_summary(
|
||||||
"""Get feature by code."""
|
self, db: Session, merchant_id: int, platform_id: int
|
||||||
return (
|
) -> list[FeatureSummary]:
|
||||||
db.query(Feature)
|
|
||||||
.options(joinedload(Feature.minimum_tier))
|
|
||||||
.filter(Feature.code == feature_code)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_feature_upgrade_info(
|
|
||||||
self, db: Session, feature_code: str
|
|
||||||
) -> FeatureUpgradeInfo | None:
|
|
||||||
"""
|
"""
|
||||||
Get upgrade information for a feature.
|
Get complete feature summary for merchant portal display.
|
||||||
|
|
||||||
Used for upgrade prompts when a feature is not available.
|
Returns all features with current status, limits, and usage.
|
||||||
"""
|
"""
|
||||||
feature = self.get_feature_by_code(db, feature_code)
|
from app.modules.billing.services.feature_aggregator import feature_aggregator
|
||||||
|
|
||||||
if not feature or not feature.minimum_tier:
|
declarations = feature_aggregator.get_all_declarations()
|
||||||
return None
|
merchant_features = self.get_merchant_feature_codes(db, merchant_id, platform_id)
|
||||||
|
|
||||||
tier = feature.minimum_tier
|
# Preload overrides
|
||||||
return FeatureUpgradeInfo(
|
overrides = {
|
||||||
feature_code=feature.code,
|
o.feature_code: o
|
||||||
feature_name=feature.name,
|
for o in db.query(MerchantFeatureOverride).filter(
|
||||||
feature_description=feature.description,
|
MerchantFeatureOverride.merchant_id == merchant_id,
|
||||||
required_tier_code=tier.code,
|
MerchantFeatureOverride.platform_id == platform_id,
|
||||||
required_tier_name=tier.name,
|
).all()
|
||||||
required_tier_price_monthly_cents=tier.price_monthly_cents,
|
}
|
||||||
)
|
|
||||||
|
|
||||||
def get_all_features(
|
# Get all usage at once
|
||||||
self,
|
store_usage = {}
|
||||||
db: Session,
|
merchant_usage = feature_aggregator.get_merchant_usage(db, merchant_id, platform_id)
|
||||||
category: str | None = None,
|
|
||||||
active_only: bool = True,
|
|
||||||
) -> list[Feature]:
|
|
||||||
"""Get all features (for admin)."""
|
|
||||||
query = db.query(Feature).options(joinedload(Feature.minimum_tier))
|
|
||||||
|
|
||||||
if active_only:
|
summaries = []
|
||||||
query = query.filter(Feature.is_active == True) # noqa: E712
|
for code, decl in sorted(declarations.items(), key=lambda x: (x[1].category, x[1].display_order)):
|
||||||
|
enabled = code in merchant_features
|
||||||
|
is_override = code in overrides
|
||||||
|
limit = self.get_effective_limit(db, merchant_id, platform_id, code) if decl.feature_type == FeatureType.QUANTITATIVE else None
|
||||||
|
|
||||||
if category:
|
current = None
|
||||||
query = query.filter(Feature.category == category)
|
remaining = None
|
||||||
|
percent_used = None
|
||||||
|
|
||||||
return query.order_by(Feature.category, Feature.display_order).all()
|
if decl.feature_type == FeatureType.QUANTITATIVE:
|
||||||
|
usage_data = merchant_usage.get(code)
|
||||||
|
current = usage_data.current_count if usage_data else 0
|
||||||
|
if limit is not None:
|
||||||
|
remaining = max(0, limit - current)
|
||||||
|
percent_used = min(100.0, (current / limit * 100)) if limit > 0 else 0.0
|
||||||
|
|
||||||
def get_features_by_tier(self, db: Session, tier_code: str) -> list[str]:
|
summaries.append(FeatureSummary(
|
||||||
"""Get feature codes for a specific tier."""
|
code=code,
|
||||||
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
|
name_key=decl.name_key,
|
||||||
|
description_key=decl.description_key,
|
||||||
|
category=decl.category,
|
||||||
|
feature_type=decl.feature_type.value,
|
||||||
|
scope=decl.scope.value,
|
||||||
|
enabled=enabled,
|
||||||
|
limit=limit,
|
||||||
|
current=current,
|
||||||
|
remaining=remaining,
|
||||||
|
percent_used=percent_used,
|
||||||
|
is_override=is_override,
|
||||||
|
unit_key=decl.unit_key,
|
||||||
|
ui_icon=decl.ui_icon,
|
||||||
|
))
|
||||||
|
|
||||||
if not tier or not tier.features:
|
return summaries
|
||||||
return []
|
|
||||||
|
|
||||||
return tier.features
|
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# Feature Categories
|
|
||||||
# =========================================================================
|
|
||||||
|
|
||||||
def get_categories(self, db: Session) -> list[str]:
|
|
||||||
"""Get all unique feature categories."""
|
|
||||||
result = (
|
|
||||||
db.query(Feature.category)
|
|
||||||
.filter(Feature.is_active == True) # noqa: E712
|
|
||||||
.distinct()
|
|
||||||
.order_by(Feature.category)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
return [row[0] for row in result]
|
|
||||||
|
|
||||||
def get_features_grouped_by_category(
|
|
||||||
self, db: Session, vendor_id: int
|
|
||||||
) -> dict[str, list[FeatureInfo]]:
|
|
||||||
"""Get features grouped by category with availability."""
|
|
||||||
features = self.get_vendor_features(db, vendor_id, include_unavailable=True)
|
|
||||||
|
|
||||||
grouped: dict[str, list[FeatureInfo]] = {}
|
|
||||||
for feature in features:
|
|
||||||
if feature.category not in grouped:
|
|
||||||
grouped[feature.category] = []
|
|
||||||
grouped[feature.category].append(feature)
|
|
||||||
|
|
||||||
return grouped
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Cache Management
|
# Cache Management
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
def invalidate_vendor_cache(self, vendor_id: int) -> None:
|
def invalidate_cache(self, merchant_id: int, platform_id: int) -> None:
|
||||||
"""
|
"""Invalidate cache for a specific merchant/platform."""
|
||||||
Invalidate cache for a specific vendor.
|
self._cache.invalidate(merchant_id, platform_id)
|
||||||
|
|
||||||
Call this when:
|
|
||||||
- Vendor's subscription tier changes
|
|
||||||
- Tier features are updated (for all vendors on that tier)
|
|
||||||
"""
|
|
||||||
self._cache.invalidate(vendor_id)
|
|
||||||
logger.debug(f"Invalidated feature cache for vendor {vendor_id}")
|
|
||||||
|
|
||||||
def invalidate_all_cache(self) -> None:
|
def invalidate_all_cache(self) -> None:
|
||||||
"""
|
"""Invalidate entire cache."""
|
||||||
Invalidate entire cache.
|
|
||||||
|
|
||||||
Call this when tier features are modified in admin.
|
|
||||||
"""
|
|
||||||
self._cache.invalidate_all()
|
self._cache.invalidate_all()
|
||||||
logger.debug("Invalidated all feature caches")
|
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# Admin Operations
|
|
||||||
# =========================================================================
|
|
||||||
|
|
||||||
def get_all_tiers_with_features(self, db: Session) -> list[SubscriptionTier]:
|
|
||||||
"""Get all active tiers with their features for admin."""
|
|
||||||
return (
|
|
||||||
db.query(SubscriptionTier)
|
|
||||||
.filter(SubscriptionTier.is_active == True) # noqa: E712
|
|
||||||
.order_by(SubscriptionTier.display_order)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_tier_by_code(self, db: Session, tier_code: str) -> SubscriptionTier:
|
|
||||||
"""
|
|
||||||
Get tier by code, raising exception if not found.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TierNotFoundError: If tier not found
|
|
||||||
"""
|
|
||||||
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
|
|
||||||
if not tier:
|
|
||||||
raise TierNotFoundError(tier_code)
|
|
||||||
return tier
|
|
||||||
|
|
||||||
def get_tier_features_with_details(
|
|
||||||
self, db: Session, tier_code: str
|
|
||||||
) -> tuple[SubscriptionTier, list[Feature]]:
|
|
||||||
"""
|
|
||||||
Get tier with full feature details.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (tier, list of Feature objects)
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TierNotFoundError: If tier not found
|
|
||||||
"""
|
|
||||||
tier = self.get_tier_by_code(db, tier_code)
|
|
||||||
feature_codes = tier.features or []
|
|
||||||
|
|
||||||
features = (
|
|
||||||
db.query(Feature)
|
|
||||||
.filter(Feature.code.in_(feature_codes))
|
|
||||||
.order_by(Feature.category, Feature.display_order)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
return tier, features
|
|
||||||
|
|
||||||
def update_tier_features(
|
|
||||||
self, db: Session, tier_code: str, feature_codes: list[str]
|
|
||||||
) -> SubscriptionTier:
|
|
||||||
"""
|
|
||||||
Update features for a tier (admin operation).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Database session
|
|
||||||
tier_code: Tier code
|
|
||||||
feature_codes: List of feature codes to assign
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Updated tier
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TierNotFoundError: If tier not found
|
|
||||||
InvalidFeatureCodesError: If any feature codes are invalid
|
|
||||||
"""
|
|
||||||
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
|
|
||||||
|
|
||||||
if not tier:
|
|
||||||
raise TierNotFoundError(tier_code)
|
|
||||||
|
|
||||||
# Validate feature codes exist
|
|
||||||
# noqa: SVC-005 - Features are platform-level, not vendor-scoped
|
|
||||||
valid_codes = {
|
|
||||||
f.code for f in db.query(Feature.code).filter(Feature.is_active == True).all() # noqa: E712
|
|
||||||
}
|
|
||||||
invalid = set(feature_codes) - valid_codes
|
|
||||||
if invalid:
|
|
||||||
raise InvalidFeatureCodesError(invalid)
|
|
||||||
|
|
||||||
tier.features = feature_codes
|
|
||||||
|
|
||||||
# Invalidate all caches since tier features changed
|
|
||||||
self.invalidate_all_cache()
|
|
||||||
|
|
||||||
logger.info(f"Updated features for tier {tier_code}: {len(feature_codes)} features")
|
|
||||||
return tier
|
|
||||||
|
|
||||||
def update_feature(
|
|
||||||
self,
|
|
||||||
db: Session,
|
|
||||||
feature_code: str,
|
|
||||||
name: str | None = None,
|
|
||||||
description: str | None = None,
|
|
||||||
category: str | None = None,
|
|
||||||
ui_location: str | None = None,
|
|
||||||
ui_icon: str | None = None,
|
|
||||||
ui_route: str | None = None,
|
|
||||||
ui_badge_text: str | None = None,
|
|
||||||
minimum_tier_code: str | None = None,
|
|
||||||
is_active: bool | None = None,
|
|
||||||
is_visible: bool | None = None,
|
|
||||||
display_order: int | None = None,
|
|
||||||
) -> Feature:
|
|
||||||
"""
|
|
||||||
Update feature metadata.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Database session
|
|
||||||
feature_code: Feature code to update
|
|
||||||
... other optional fields to update
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Updated feature
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
FeatureNotFoundError: If feature not found
|
|
||||||
TierNotFoundError: If minimum_tier_code provided but not found
|
|
||||||
"""
|
|
||||||
feature = (
|
|
||||||
db.query(Feature)
|
|
||||||
.options(joinedload(Feature.minimum_tier))
|
|
||||||
.filter(Feature.code == feature_code)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
if not feature:
|
|
||||||
raise FeatureNotFoundError(feature_code)
|
|
||||||
|
|
||||||
# Update fields if provided
|
|
||||||
if name is not None:
|
|
||||||
feature.name = name
|
|
||||||
if description is not None:
|
|
||||||
feature.description = description
|
|
||||||
if category is not None:
|
|
||||||
feature.category = category
|
|
||||||
if ui_location is not None:
|
|
||||||
feature.ui_location = ui_location
|
|
||||||
if ui_icon is not None:
|
|
||||||
feature.ui_icon = ui_icon
|
|
||||||
if ui_route is not None:
|
|
||||||
feature.ui_route = ui_route
|
|
||||||
if ui_badge_text is not None:
|
|
||||||
feature.ui_badge_text = ui_badge_text
|
|
||||||
if is_active is not None:
|
|
||||||
feature.is_active = is_active
|
|
||||||
if is_visible is not None:
|
|
||||||
feature.is_visible = is_visible
|
|
||||||
if display_order is not None:
|
|
||||||
feature.display_order = display_order
|
|
||||||
|
|
||||||
# Update minimum tier if provided
|
|
||||||
if minimum_tier_code is not None:
|
|
||||||
if minimum_tier_code == "":
|
|
||||||
feature.minimum_tier_id = None
|
|
||||||
else:
|
|
||||||
tier = (
|
|
||||||
db.query(SubscriptionTier)
|
|
||||||
.filter(SubscriptionTier.code == minimum_tier_code)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if not tier:
|
|
||||||
raise TierNotFoundError(minimum_tier_code)
|
|
||||||
feature.minimum_tier_id = tier.id
|
|
||||||
|
|
||||||
logger.info(f"Updated feature {feature_code}")
|
|
||||||
return feature
|
|
||||||
|
|
||||||
def update_feature_minimum_tier(
|
|
||||||
self, db: Session, feature_code: str, tier_code: str | None
|
|
||||||
) -> Feature:
|
|
||||||
"""
|
|
||||||
Update minimum tier for a feature (for upgrade prompts).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Database session
|
|
||||||
feature_code: Feature code
|
|
||||||
tier_code: Tier code or None
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
FeatureNotFoundError: If feature not found
|
|
||||||
TierNotFoundError: If tier_code provided but not found
|
|
||||||
"""
|
|
||||||
feature = db.query(Feature).filter(Feature.code == feature_code).first()
|
|
||||||
|
|
||||||
if not feature:
|
|
||||||
raise FeatureNotFoundError(feature_code)
|
|
||||||
|
|
||||||
if tier_code:
|
|
||||||
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
|
|
||||||
if not tier:
|
|
||||||
raise TierNotFoundError(tier_code)
|
|
||||||
feature.minimum_tier_id = tier.id
|
|
||||||
else:
|
|
||||||
feature.minimum_tier_id = None
|
|
||||||
|
|
||||||
logger.info(f"Updated minimum tier for feature {feature_code}: {tier_code}")
|
|
||||||
return feature
|
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance
|
# Singleton instance
|
||||||
feature_service = FeatureService()
|
feature_service = FeatureService()
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Convenience Exports
|
|
||||||
# ============================================================================
|
|
||||||
# Re-export FeatureCode for easy imports
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"feature_service",
|
"feature_service",
|
||||||
"FeatureService",
|
"FeatureService",
|
||||||
"FeatureInfo",
|
"FeatureSummary",
|
||||||
"FeatureUpgradeInfo",
|
|
||||||
"FeatureCode",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ function adminSubscriptionTiers() {
|
|||||||
categories: [],
|
categories: [],
|
||||||
featuresGrouped: {},
|
featuresGrouped: {},
|
||||||
selectedFeatures: [],
|
selectedFeatures: [],
|
||||||
|
featureLimits: {}, // { feature_code: limit_value }
|
||||||
selectedTierForFeatures: null,
|
selectedTierForFeatures: null,
|
||||||
showFeaturePanel: false,
|
showFeaturePanel: false,
|
||||||
loadingFeatures: false,
|
loadingFeatures: false,
|
||||||
@@ -46,13 +47,9 @@ function adminSubscriptionTiers() {
|
|||||||
description: '',
|
description: '',
|
||||||
price_monthly_cents: 0,
|
price_monthly_cents: 0,
|
||||||
price_annual_cents: null,
|
price_annual_cents: null,
|
||||||
orders_per_month: null,
|
|
||||||
products_limit: null,
|
|
||||||
team_members: null,
|
|
||||||
display_order: 0,
|
display_order: 0,
|
||||||
stripe_product_id: '',
|
stripe_product_id: '',
|
||||||
stripe_price_monthly_id: '',
|
stripe_price_monthly_id: '',
|
||||||
features: [],
|
|
||||||
is_active: true,
|
is_active: true,
|
||||||
is_public: true
|
is_public: true
|
||||||
},
|
},
|
||||||
@@ -70,8 +67,7 @@ function adminSubscriptionTiers() {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.loadTiers(),
|
this.loadTiers(),
|
||||||
this.loadStats(),
|
this.loadStats(),
|
||||||
this.loadFeatures(),
|
this.loadFeatures()
|
||||||
this.loadCategories()
|
|
||||||
]);
|
]);
|
||||||
tiersLog.info('=== SUBSCRIPTION TIERS PAGE INITIALIZED ===');
|
tiersLog.info('=== SUBSCRIPTION TIERS PAGE INITIALIZED ===');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -137,13 +133,9 @@ function adminSubscriptionTiers() {
|
|||||||
description: '',
|
description: '',
|
||||||
price_monthly_cents: 0,
|
price_monthly_cents: 0,
|
||||||
price_annual_cents: null,
|
price_annual_cents: null,
|
||||||
orders_per_month: null,
|
|
||||||
products_limit: null,
|
|
||||||
team_members: null,
|
|
||||||
display_order: this.tiers.length,
|
display_order: this.tiers.length,
|
||||||
stripe_product_id: '',
|
stripe_product_id: '',
|
||||||
stripe_price_monthly_id: '',
|
stripe_price_monthly_id: '',
|
||||||
features: [],
|
|
||||||
is_active: true,
|
is_active: true,
|
||||||
is_public: true
|
is_public: true
|
||||||
};
|
};
|
||||||
@@ -158,13 +150,9 @@ function adminSubscriptionTiers() {
|
|||||||
description: tier.description || '',
|
description: tier.description || '',
|
||||||
price_monthly_cents: tier.price_monthly_cents,
|
price_monthly_cents: tier.price_monthly_cents,
|
||||||
price_annual_cents: tier.price_annual_cents,
|
price_annual_cents: tier.price_annual_cents,
|
||||||
orders_per_month: tier.orders_per_month,
|
|
||||||
products_limit: tier.products_limit,
|
|
||||||
team_members: tier.team_members,
|
|
||||||
display_order: tier.display_order,
|
display_order: tier.display_order,
|
||||||
stripe_product_id: tier.stripe_product_id || '',
|
stripe_product_id: tier.stripe_product_id || '',
|
||||||
stripe_price_monthly_id: tier.stripe_price_monthly_id || '',
|
stripe_price_monthly_id: tier.stripe_price_monthly_id || '',
|
||||||
features: tier.features || [],
|
|
||||||
is_active: tier.is_active,
|
is_active: tier.is_active,
|
||||||
is_public: tier.is_public
|
is_public: tier.is_public
|
||||||
};
|
};
|
||||||
@@ -184,9 +172,6 @@ function adminSubscriptionTiers() {
|
|||||||
// Clean up null values for empty strings
|
// Clean up null values for empty strings
|
||||||
const payload = { ...this.formData };
|
const payload = { ...this.formData };
|
||||||
if (payload.price_annual_cents === '') payload.price_annual_cents = null;
|
if (payload.price_annual_cents === '') payload.price_annual_cents = null;
|
||||||
if (payload.orders_per_month === '') payload.orders_per_month = null;
|
|
||||||
if (payload.products_limit === '') payload.products_limit = null;
|
|
||||||
if (payload.team_members === '') payload.team_members = null;
|
|
||||||
|
|
||||||
if (this.editingTier) {
|
if (this.editingTier) {
|
||||||
// Update existing tier
|
// Update existing tier
|
||||||
@@ -233,24 +218,22 @@ function adminSubscriptionTiers() {
|
|||||||
|
|
||||||
async loadFeatures() {
|
async loadFeatures() {
|
||||||
try {
|
try {
|
||||||
const data = await apiClient.get('/admin/features');
|
const data = await apiClient.get('/admin/subscriptions/features/catalog');
|
||||||
this.features = data.features || [];
|
// Parse grouped response: { features: { category: [FeatureDeclaration, ...] } }
|
||||||
tiersLog.info(`Loaded ${this.features.length} features`);
|
this.features = [];
|
||||||
|
this.categories = [];
|
||||||
|
for (const [category, featureList] of Object.entries(data.features || {})) {
|
||||||
|
this.categories.push(category);
|
||||||
|
for (const f of featureList) {
|
||||||
|
this.features.push({ ...f, category });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tiersLog.info(`Loaded ${this.features.length} features in ${this.categories.length} categories`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
tiersLog.error('Failed to load features:', error);
|
tiersLog.error('Failed to load features:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadCategories() {
|
|
||||||
try {
|
|
||||||
const data = await apiClient.get('/admin/features/categories');
|
|
||||||
this.categories = data.categories || [];
|
|
||||||
tiersLog.info(`Loaded ${this.categories.length} categories`);
|
|
||||||
} catch (error) {
|
|
||||||
tiersLog.error('Failed to load categories:', error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
groupFeaturesByCategory() {
|
groupFeaturesByCategory() {
|
||||||
this.featuresGrouped = {};
|
this.featuresGrouped = {};
|
||||||
for (const category of this.categories) {
|
for (const category of this.categories) {
|
||||||
@@ -263,18 +246,22 @@ function adminSubscriptionTiers() {
|
|||||||
this.selectedTierForFeatures = tier;
|
this.selectedTierForFeatures = tier;
|
||||||
this.loadingFeatures = true;
|
this.loadingFeatures = true;
|
||||||
this.showFeaturePanel = true;
|
this.showFeaturePanel = true;
|
||||||
|
this.featureLimits = {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load tier's current features
|
// Load tier's current feature limits
|
||||||
const data = await apiClient.get(`/admin/features/tiers/${tier.code}/features`);
|
const data = await apiClient.get(`/admin/subscriptions/features/tiers/${tier.code}/limits`);
|
||||||
if (data.features) {
|
// data is TierFeatureLimitEntry[]: [{feature_code, limit_value, enabled}]
|
||||||
this.selectedFeatures = data.features.map(f => f.code);
|
this.selectedFeatures = [];
|
||||||
} else {
|
for (const entry of (data || [])) {
|
||||||
this.selectedFeatures = tier.features || [];
|
this.selectedFeatures.push(entry.feature_code);
|
||||||
|
if (entry.limit_value !== null && entry.limit_value !== undefined) {
|
||||||
|
this.featureLimits[entry.feature_code] = entry.limit_value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
tiersLog.error('Failed to load tier features:', error);
|
tiersLog.error('Failed to load tier features:', error);
|
||||||
this.selectedFeatures = tier.features || [];
|
this.selectedFeatures = tier.feature_codes || tier.features || [];
|
||||||
} finally {
|
} finally {
|
||||||
this.groupFeaturesByCategory();
|
this.groupFeaturesByCategory();
|
||||||
this.loadingFeatures = false;
|
this.loadingFeatures = false;
|
||||||
@@ -285,6 +272,7 @@ function adminSubscriptionTiers() {
|
|||||||
this.showFeaturePanel = false;
|
this.showFeaturePanel = false;
|
||||||
this.selectedTierForFeatures = null;
|
this.selectedTierForFeatures = null;
|
||||||
this.selectedFeatures = [];
|
this.selectedFeatures = [];
|
||||||
|
this.featureLimits = {};
|
||||||
this.featuresGrouped = {};
|
this.featuresGrouped = {};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -308,9 +296,16 @@ function adminSubscriptionTiers() {
|
|||||||
this.savingFeatures = true;
|
this.savingFeatures = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Build TierFeatureLimitEntry[] payload
|
||||||
|
const entries = this.selectedFeatures.map(code => ({
|
||||||
|
feature_code: code,
|
||||||
|
limit_value: this.featureLimits[code] ?? null,
|
||||||
|
enabled: true
|
||||||
|
}));
|
||||||
|
|
||||||
await apiClient.put(
|
await apiClient.put(
|
||||||
`/admin/features/tiers/${this.selectedTierForFeatures.code}/features`,
|
`/admin/subscriptions/features/tiers/${this.selectedTierForFeatures.code}/limits`,
|
||||||
{ feature_codes: this.selectedFeatures }
|
entries
|
||||||
);
|
);
|
||||||
|
|
||||||
this.successMessage = `Features updated for ${this.selectedTierForFeatures.name}`;
|
this.successMessage = `Features updated for ${this.selectedTierForFeatures.name}`;
|
||||||
@@ -345,6 +340,18 @@ function adminSubscriptionTiers() {
|
|||||||
return categoryFeatures.every(f => this.selectedFeatures.includes(f.code));
|
return categoryFeatures.every(f => this.selectedFeatures.includes(f.code));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getFeatureLimitValue(featureCode) {
|
||||||
|
return this.featureLimits[featureCode] ?? '';
|
||||||
|
},
|
||||||
|
|
||||||
|
setFeatureLimitValue(featureCode, value) {
|
||||||
|
if (value === '' || value === null || value === undefined) {
|
||||||
|
delete this.featureLimits[featureCode];
|
||||||
|
} else {
|
||||||
|
this.featureLimits[featureCode] = parseInt(value, 10);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
formatCategoryName(category) {
|
formatCategoryName(category) {
|
||||||
return category
|
return category
|
||||||
.split('_')
|
.split('_')
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function adminSubscriptions() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Sorting
|
// Sorting
|
||||||
sortBy: 'vendor_name',
|
sortBy: 'store_name',
|
||||||
sortOrder: 'asc',
|
sortOrder: 'asc',
|
||||||
|
|
||||||
// Modal state
|
// Modal state
|
||||||
@@ -47,12 +47,14 @@ function adminSubscriptions() {
|
|||||||
editingSub: null,
|
editingSub: null,
|
||||||
formData: {
|
formData: {
|
||||||
tier: '',
|
tier: '',
|
||||||
status: '',
|
status: ''
|
||||||
custom_orders_limit: null,
|
|
||||||
custom_products_limit: null,
|
|
||||||
custom_team_limit: null
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Feature overrides
|
||||||
|
featureOverrides: [],
|
||||||
|
quantitativeFeatures: [],
|
||||||
|
loadingOverrides: false,
|
||||||
|
|
||||||
// Computed: Total pages
|
// Computed: Total pages
|
||||||
get totalPages() {
|
get totalPages() {
|
||||||
return this.pagination.pages;
|
return this.pagination.pages;
|
||||||
@@ -203,16 +205,18 @@ function adminSubscriptions() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
openEditModal(sub) {
|
async openEditModal(sub) {
|
||||||
this.editingSub = sub;
|
this.editingSub = sub;
|
||||||
this.formData = {
|
this.formData = {
|
||||||
tier: sub.tier,
|
tier: sub.tier,
|
||||||
status: sub.status,
|
status: sub.status
|
||||||
custom_orders_limit: sub.custom_orders_limit,
|
|
||||||
custom_products_limit: sub.custom_products_limit,
|
|
||||||
custom_team_limit: sub.custom_team_limit
|
|
||||||
};
|
};
|
||||||
|
this.featureOverrides = [];
|
||||||
|
this.quantitativeFeatures = [];
|
||||||
this.showModal = true;
|
this.showModal = true;
|
||||||
|
|
||||||
|
// Load feature catalog and merchant overrides
|
||||||
|
await this.loadFeatureOverrides(sub.merchant_id);
|
||||||
},
|
},
|
||||||
|
|
||||||
closeModal() {
|
closeModal() {
|
||||||
@@ -220,6 +224,77 @@ function adminSubscriptions() {
|
|||||||
this.editingSub = null;
|
this.editingSub = null;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async loadFeatureOverrides(merchantId) {
|
||||||
|
this.loadingOverrides = true;
|
||||||
|
try {
|
||||||
|
const [catalogData, overridesData] = await Promise.all([
|
||||||
|
apiClient.get('/admin/subscriptions/features/catalog'),
|
||||||
|
apiClient.get(`/admin/subscriptions/features/merchants/${merchantId}/overrides`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Extract quantitative features from catalog
|
||||||
|
const allFeatures = [];
|
||||||
|
for (const [, features] of Object.entries(catalogData.features || {})) {
|
||||||
|
for (const f of features) {
|
||||||
|
if (f.feature_type === 'quantitative') {
|
||||||
|
allFeatures.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.quantitativeFeatures = allFeatures;
|
||||||
|
|
||||||
|
// Map overrides by feature_code
|
||||||
|
this.featureOverrides = (overridesData || []).map(o => ({
|
||||||
|
feature_code: o.feature_code,
|
||||||
|
limit_value: o.limit_value,
|
||||||
|
is_enabled: o.is_enabled
|
||||||
|
}));
|
||||||
|
|
||||||
|
subsLog.info(`Loaded ${allFeatures.length} quantitative features and ${this.featureOverrides.length} overrides`);
|
||||||
|
} catch (error) {
|
||||||
|
subsLog.error('Failed to load feature overrides:', error);
|
||||||
|
} finally {
|
||||||
|
this.loadingOverrides = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getOverrideValue(featureCode) {
|
||||||
|
const override = this.featureOverrides.find(o => o.feature_code === featureCode);
|
||||||
|
return override?.limit_value ?? '';
|
||||||
|
},
|
||||||
|
|
||||||
|
setOverrideValue(featureCode, value) {
|
||||||
|
const numValue = value === '' ? null : parseInt(value, 10);
|
||||||
|
const existing = this.featureOverrides.find(o => o.feature_code === featureCode);
|
||||||
|
if (existing) {
|
||||||
|
existing.limit_value = numValue;
|
||||||
|
} else if (numValue !== null) {
|
||||||
|
this.featureOverrides.push({
|
||||||
|
feature_code: featureCode,
|
||||||
|
limit_value: numValue,
|
||||||
|
is_enabled: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveFeatureOverrides(merchantId) {
|
||||||
|
// Only send overrides that have a limit_value set
|
||||||
|
const entries = this.featureOverrides
|
||||||
|
.filter(o => o.limit_value !== null && o.limit_value !== undefined)
|
||||||
|
.map(o => ({
|
||||||
|
feature_code: o.feature_code,
|
||||||
|
limit_value: o.limit_value,
|
||||||
|
is_enabled: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (entries.length > 0) {
|
||||||
|
await apiClient.put(
|
||||||
|
`/admin/subscriptions/features/merchants/${merchantId}/overrides`,
|
||||||
|
entries
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async saveSubscription() {
|
async saveSubscription() {
|
||||||
if (!this.editingSub) return;
|
if (!this.editingSub) return;
|
||||||
|
|
||||||
@@ -227,14 +302,17 @@ function adminSubscriptions() {
|
|||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Clean up null values for empty strings
|
|
||||||
const payload = { ...this.formData };
|
const payload = { ...this.formData };
|
||||||
if (payload.custom_orders_limit === '') payload.custom_orders_limit = null;
|
|
||||||
if (payload.custom_products_limit === '') payload.custom_products_limit = null;
|
|
||||||
if (payload.custom_team_limit === '') payload.custom_team_limit = null;
|
|
||||||
|
|
||||||
await apiClient.patch(`/admin/subscriptions/${this.editingSub.vendor_id}`, payload);
|
await apiClient.patch(
|
||||||
this.successMessage = `Subscription for "${this.editingSub.vendor_name}" updated`;
|
`/admin/subscriptions/merchants/${this.editingSub.merchant_id}/platforms/${this.editingSub.platform_id}`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save feature overrides
|
||||||
|
await this.saveFeatureOverrides(this.editingSub.merchant_id);
|
||||||
|
|
||||||
|
this.successMessage = `Subscription for "${this.editingSub.store_name || this.editingSub.merchant_name}" updated`;
|
||||||
|
|
||||||
this.closeModal();
|
this.closeModal();
|
||||||
await this.loadSubscriptions();
|
await this.loadSubscriptions();
|
||||||
|
|||||||
217
app/modules/billing/static/store/js/billing.js
Normal file
217
app/modules/billing/static/store/js/billing.js
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -91,9 +91,6 @@
|
|||||||
{{ th_sortable('name', 'Name', 'sortBy', 'sortOrder') }}
|
{{ th_sortable('name', 'Name', 'sortBy', 'sortOrder') }}
|
||||||
<th class="px-4 py-3 text-right">Monthly</th>
|
<th class="px-4 py-3 text-right">Monthly</th>
|
||||||
<th class="px-4 py-3 text-right">Annual</th>
|
<th class="px-4 py-3 text-right">Annual</th>
|
||||||
<th class="px-4 py-3 text-center">Orders/Mo</th>
|
|
||||||
<th class="px-4 py-3 text-center">Products</th>
|
|
||||||
<th class="px-4 py-3 text-center">Team</th>
|
|
||||||
<th class="px-4 py-3 text-center">Features</th>
|
<th class="px-4 py-3 text-center">Features</th>
|
||||||
<th class="px-4 py-3 text-center">Status</th>
|
<th class="px-4 py-3 text-center">Status</th>
|
||||||
<th class="px-4 py-3 text-right">Actions</th>
|
<th class="px-4 py-3 text-right">Actions</th>
|
||||||
@@ -101,7 +98,7 @@
|
|||||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||||
<template x-if="loading">
|
<template x-if="loading">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="11" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
<td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
<span x-html="$icon('refresh', 'inline w-6 h-6 animate-spin mr-2')"></span>
|
<span x-html="$icon('refresh', 'inline w-6 h-6 animate-spin mr-2')"></span>
|
||||||
Loading tiers...
|
Loading tiers...
|
||||||
</td>
|
</td>
|
||||||
@@ -109,7 +106,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<template x-if="!loading && tiers.length === 0">
|
<template x-if="!loading && tiers.length === 0">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="11" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
<td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
No subscription tiers found.
|
No subscription tiers found.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -131,11 +128,8 @@
|
|||||||
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100" x-text="tier.name"></td>
|
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100" x-text="tier.name"></td>
|
||||||
<td class="px-4 py-3 text-sm text-right font-mono" x-text="formatCurrency(tier.price_monthly_cents)"></td>
|
<td class="px-4 py-3 text-sm text-right font-mono" x-text="formatCurrency(tier.price_monthly_cents)"></td>
|
||||||
<td class="px-4 py-3 text-sm text-right font-mono" x-text="tier.price_annual_cents ? formatCurrency(tier.price_annual_cents) : '-'"></td>
|
<td class="px-4 py-3 text-sm text-right font-mono" x-text="tier.price_annual_cents ? formatCurrency(tier.price_annual_cents) : '-'"></td>
|
||||||
<td class="px-4 py-3 text-sm text-center" x-text="tier.orders_per_month || 'Unlimited'"></td>
|
|
||||||
<td class="px-4 py-3 text-sm text-center" x-text="tier.products_limit || 'Unlimited'"></td>
|
|
||||||
<td class="px-4 py-3 text-sm text-center" x-text="tier.team_members || 'Unlimited'"></td>
|
|
||||||
<td class="px-4 py-3 text-sm text-center">
|
<td class="px-4 py-3 text-sm text-center">
|
||||||
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded" x-text="(tier.features || []).length"></span>
|
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded" x-text="(tier.feature_codes || tier.features || []).length"></span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-center">
|
<td class="px-4 py-3 text-center">
|
||||||
<span x-show="tier.is_active && tier.is_public" class="px-2 py-1 text-xs font-medium text-green-700 bg-green-100 rounded-full dark:bg-green-900 dark:text-green-200">Active</span>
|
<span x-show="tier.is_active && tier.is_public" class="px-2 py-1 text-xs font-medium text-green-700 bg-green-100 rounded-full dark:bg-green-900 dark:text-green-200">Active</span>
|
||||||
@@ -225,39 +219,6 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Orders per Month -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Orders/Month (empty = unlimited)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
x-model.number="formData.orders_per_month"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
|
||||||
placeholder="e.g., 100"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Products Limit -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Products Limit (empty = unlimited)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
x-model.number="formData.products_limit"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
|
||||||
placeholder="e.g., 200"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Team Members -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Team Members (empty = unlimited)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
x-model.number="formData.team_members"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
|
||||||
placeholder="e.g., 3"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Display Order -->
|
<!-- Display Order -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Display Order</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Display Order</label>
|
||||||
@@ -310,7 +271,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<label class="flex items-center">
|
<label class="flex items-center">
|
||||||
<input type="checkbox" x-model="formData.is_public" class="mr-2 rounded border-gray-300 dark:border-gray-600">
|
<input type="checkbox" x-model="formData.is_public" class="mr-2 rounded border-gray-300 dark:border-gray-600">
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Public (visible to vendors)</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Public (visible to stores)</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -420,18 +381,32 @@
|
|||||||
<!-- Features List -->
|
<!-- Features List -->
|
||||||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
<template x-for="feature in featuresGrouped[category]" :key="feature.code">
|
<template x-for="feature in featuresGrouped[category]" :key="feature.code">
|
||||||
<label class="flex items-start px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30 cursor-pointer">
|
<div class="flex items-start px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||||
<input
|
<label class="flex items-start cursor-pointer flex-1">
|
||||||
type="checkbox"
|
<input
|
||||||
:checked="isFeatureSelected(feature.code)"
|
type="checkbox"
|
||||||
@change="toggleFeature(feature.code)"
|
:checked="isFeatureSelected(feature.code)"
|
||||||
class="mt-0.5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
|
@change="toggleFeature(feature.code)"
|
||||||
>
|
class="mt-0.5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
|
||||||
<div class="ml-3">
|
>
|
||||||
<div class="text-sm font-medium text-gray-900 dark:text-white" x-text="feature.name"></div>
|
<div class="ml-3">
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400" x-text="feature.description"></div>
|
<div class="text-sm font-medium text-gray-900 dark:text-white" x-text="feature.name_key || feature.name"></div>
|
||||||
</div>
|
<div class="text-xs text-gray-500 dark:text-gray-400" x-text="feature.description_key || feature.description"></div>
|
||||||
</label>
|
</div>
|
||||||
|
</label>
|
||||||
|
<template x-if="feature.feature_type === 'quantitative' && isFeatureSelected(feature.code)">
|
||||||
|
<div class="ml-2 flex-shrink-0">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="getFeatureLimitValue(feature.code)"
|
||||||
|
@input="setFeatureLimitValue(feature.code, $event.target.value)"
|
||||||
|
placeholder="Unlimited"
|
||||||
|
class="w-24 px-2 py-1 text-sm border border-gray-300 rounded dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
min="0"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
{% from 'shared/macros/tables.html' import table_wrapper, table_header_custom, th_sortable %}
|
{% from 'shared/macros/tables.html' import table_wrapper, table_header_custom, th_sortable %}
|
||||||
{% from 'shared/macros/pagination.html' import pagination %}
|
{% from 'shared/macros/pagination.html' import pagination %}
|
||||||
|
|
||||||
{% block title %}Vendor Subscriptions{% endblock %}
|
{% block title %}Store Subscriptions{% endblock %}
|
||||||
|
|
||||||
{% block alpine_data %}adminSubscriptions(){% endblock %}
|
{% block alpine_data %}adminSubscriptions(){% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ page_header_refresh('Vendor Subscriptions') }}
|
{{ page_header_refresh('Store Subscriptions') }}
|
||||||
|
|
||||||
{{ alert_dynamic(type='success', title='Success', message_var='successMessage', show_condition='successMessage') }}
|
{{ alert_dynamic(type='success', title='Success', message_var='successMessage', show_condition='successMessage') }}
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
x-model="filters.search"
|
x-model="filters.search"
|
||||||
@input.debounce.300ms="loadSubscriptions()"
|
@input.debounce.300ms="loadSubscriptions()"
|
||||||
placeholder="Search vendor name..."
|
placeholder="Search store name..."
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,19 +140,17 @@
|
|||||||
{% call table_wrapper() %}
|
{% call table_wrapper() %}
|
||||||
<table class="w-full whitespace-nowrap">
|
<table class="w-full whitespace-nowrap">
|
||||||
{% call table_header_custom() %}
|
{% call table_header_custom() %}
|
||||||
{{ th_sortable('vendor_name', 'Vendor', 'sortBy', 'sortOrder') }}
|
{{ th_sortable('store_name', 'Store', 'sortBy', 'sortOrder') }}
|
||||||
{{ th_sortable('tier', 'Tier', 'sortBy', 'sortOrder') }}
|
{{ th_sortable('tier', 'Tier', 'sortBy', 'sortOrder') }}
|
||||||
{{ th_sortable('status', 'Status', 'sortBy', 'sortOrder') }}
|
{{ th_sortable('status', 'Status', 'sortBy', 'sortOrder') }}
|
||||||
<th class="px-4 py-3 text-center">Orders</th>
|
<th class="px-4 py-3 text-center">Features</th>
|
||||||
<th class="px-4 py-3 text-center">Products</th>
|
|
||||||
<th class="px-4 py-3 text-center">Team</th>
|
|
||||||
<th class="px-4 py-3">Period End</th>
|
<th class="px-4 py-3">Period End</th>
|
||||||
<th class="px-4 py-3 text-right">Actions</th>
|
<th class="px-4 py-3 text-right">Actions</th>
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||||
<template x-if="loading">
|
<template x-if="loading">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
<span x-html="$icon('refresh', 'inline w-6 h-6 animate-spin mr-2')"></span>
|
<span x-html="$icon('refresh', 'inline w-6 h-6 animate-spin mr-2')"></span>
|
||||||
Loading subscriptions...
|
Loading subscriptions...
|
||||||
</td>
|
</td>
|
||||||
@@ -160,7 +158,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<template x-if="!loading && subscriptions.length === 0">
|
<template x-if="!loading && subscriptions.length === 0">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
No subscriptions found.
|
No subscriptions found.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -170,8 +168,8 @@
|
|||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-semibold text-gray-900 dark:text-gray-100" x-text="sub.vendor_name"></p>
|
<p class="font-semibold text-gray-900 dark:text-gray-100" x-text="sub.store_name"></p>
|
||||||
<p class="text-xs text-gray-500" x-text="sub.vendor_code"></p>
|
<p class="text-xs text-gray-500" x-text="sub.store_code"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -197,15 +195,8 @@
|
|||||||
x-text="sub.status.replace('_', ' ').toUpperCase()"></span>
|
x-text="sub.status.replace('_', ' ').toUpperCase()"></span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-center">
|
<td class="px-4 py-3 text-center">
|
||||||
<span x-text="sub.orders_this_period"></span>
|
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded"
|
||||||
<span class="text-gray-400">/</span>
|
x-text="(sub.feature_codes || []).length"></span>
|
||||||
<span x-text="sub.orders_limit || '∞'"></span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-center">
|
|
||||||
<span x-text="sub.products_limit || '∞'"></span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-center">
|
|
||||||
<span x-text="sub.team_members_limit || '∞'"></span>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-sm" x-text="formatDate(sub.period_end)"></td>
|
<td class="px-4 py-3 text-sm" x-text="formatDate(sub.period_end)"></td>
|
||||||
<td class="px-4 py-3 text-right">
|
<td class="px-4 py-3 text-right">
|
||||||
@@ -213,7 +204,7 @@
|
|||||||
<button @click="openEditModal(sub)" class="p-2 text-gray-500 hover:text-purple-600 dark:hover:text-purple-400" title="Edit">
|
<button @click="openEditModal(sub)" class="p-2 text-gray-500 hover:text-purple-600 dark:hover:text-purple-400" title="Edit">
|
||||||
<span x-html="$icon('pencil', 'w-4 h-4')"></span>
|
<span x-html="$icon('pencil', 'w-4 h-4')"></span>
|
||||||
</button>
|
</button>
|
||||||
<a :href="'/admin/vendors/' + sub.vendor_code" class="p-2 text-gray-500 hover:text-blue-600 dark:hover:text-blue-400" title="View Vendor">
|
<a :href="'/admin/stores/' + sub.store_code" class="p-2 text-gray-500 hover:text-blue-600 dark:hover:text-blue-400" title="View Store">
|
||||||
<span x-html="$icon('external-link', 'w-4 h-4')"></span>
|
<span x-html="$icon('external-link', 'w-4 h-4')"></span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,7 +224,7 @@
|
|||||||
<div x-show="showModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center overflow-auto bg-black bg-opacity-50">
|
<div x-show="showModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center overflow-auto bg-black bg-opacity-50">
|
||||||
<div class="relative w-full max-w-lg p-6 mx-4 bg-white rounded-lg shadow-xl dark:bg-gray-800" @click.away="closeModal()">
|
<div class="relative w-full max-w-lg p-6 mx-4 bg-white rounded-lg shadow-xl dark:bg-gray-800" @click.away="closeModal()">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Edit Subscription</h3>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Edit Subscription</h3>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4" x-text="'Vendor: ' + (editingSub?.vendor_name || '')"></p>
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4" x-text="'Store: ' + (editingSub?.store_name || '')"></p>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<!-- Tier -->
|
<!-- Tier -->
|
||||||
@@ -265,39 +256,35 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Custom Limits Section -->
|
<!-- Feature Overrides Section -->
|
||||||
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
|
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Custom Limit Overrides</h4>
|
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Feature Overrides</h4>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Leave empty to use tier defaults</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Leave empty to use tier defaults</p>
|
||||||
|
|
||||||
<div class="grid grid-cols-3 gap-3">
|
<template x-if="loadingOverrides">
|
||||||
<div>
|
<div class="flex items-center justify-center py-4">
|
||||||
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-1">Orders/Month</label>
|
<span x-html="$icon('refresh', 'w-5 h-5 animate-spin text-purple-600')"></span>
|
||||||
<input
|
<span class="ml-2 text-sm text-gray-500">Loading features...</span>
|
||||||
type="number"
|
|
||||||
x-model.number="formData.custom_orders_limit"
|
|
||||||
placeholder="Tier default"
|
|
||||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-1">Products</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
x-model.number="formData.custom_products_limit"
|
|
||||||
placeholder="Tier default"
|
|
||||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-1">Team Members</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
x-model.number="formData.custom_team_limit"
|
|
||||||
placeholder="Tier default"
|
|
||||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div x-show="!loadingOverrides" class="space-y-3 max-h-48 overflow-y-auto">
|
||||||
|
<template x-for="feature in quantitativeFeatures" :key="feature.code">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<label class="flex-1 text-xs text-gray-600 dark:text-gray-400"
|
||||||
|
x-text="feature.name_key.replace(/_/g, ' ')"></label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:placeholder="'Tier default'"
|
||||||
|
:value="getOverrideValue(feature.code)"
|
||||||
|
@input="setOverrideValue(feature.code, $event.target.value)"
|
||||||
|
class="w-28 px-2 py-1.5 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="quantitativeFeatures.length === 0 && !loadingOverrides">
|
||||||
|
<p class="text-xs text-gray-400 text-center py-2">No quantitative features available</p>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
406
app/modules/billing/templates/billing/store/billing.html
Normal file
406
app/modules/billing/templates/billing/store/billing.html
Normal 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 %}
|
||||||
232
app/modules/tenancy/static/admin/js/store-detail.js
Normal file
232
app/modules/tenancy/static/admin/js/store-detail.js
Normal 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');
|
||||||
388
app/modules/tenancy/templates/tenancy/admin/store-detail.html
Normal file
388
app/modules/tenancy/templates/tenancy/admin/store-detail.html
Normal 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 %}
|
||||||
@@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.modules.tenancy.exceptions import VendorNotFoundException
|
from app.modules.tenancy.exceptions import StoreNotFoundException
|
||||||
from app.modules.billing.services.billing_service import (
|
from app.modules.billing.services.billing_service import (
|
||||||
BillingService,
|
BillingService,
|
||||||
NoActiveSubscriptionError,
|
NoActiveSubscriptionError,
|
||||||
@@ -18,10 +18,10 @@ from app.modules.billing.services.billing_service import (
|
|||||||
from app.modules.billing.models import (
|
from app.modules.billing.models import (
|
||||||
AddOnProduct,
|
AddOnProduct,
|
||||||
BillingHistory,
|
BillingHistory,
|
||||||
|
MerchantSubscription,
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
SubscriptionTier,
|
SubscriptionTier,
|
||||||
VendorAddOn,
|
StoreAddOn,
|
||||||
VendorSubscription,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -35,22 +35,22 @@ class TestBillingServiceSubscription:
|
|||||||
self.service = BillingService()
|
self.service = BillingService()
|
||||||
|
|
||||||
def test_get_subscription_with_tier_creates_if_not_exists(
|
def test_get_subscription_with_tier_creates_if_not_exists(
|
||||||
self, db, test_vendor, test_subscription_tier
|
self, db, test_store, test_subscription_tier
|
||||||
):
|
):
|
||||||
"""Test get_subscription_with_tier creates subscription if needed."""
|
"""Test get_subscription_with_tier creates subscription if needed."""
|
||||||
subscription, tier = self.service.get_subscription_with_tier(db, test_vendor.id)
|
subscription, tier = self.service.get_subscription_with_tier(db, test_store.id)
|
||||||
|
|
||||||
assert subscription is not None
|
assert subscription is not None
|
||||||
assert subscription.vendor_id == test_vendor.id
|
assert subscription.store_id == test_store.id
|
||||||
assert tier is not None
|
assert tier is not None
|
||||||
assert tier.code == subscription.tier
|
assert tier.code == subscription.tier
|
||||||
|
|
||||||
def test_get_subscription_with_tier_returns_existing(
|
def test_get_subscription_with_tier_returns_existing(
|
||||||
self, db, test_vendor, test_subscription
|
self, db, test_store, test_subscription
|
||||||
):
|
):
|
||||||
"""Test get_subscription_with_tier returns existing subscription."""
|
"""Test get_subscription_with_tier returns existing subscription."""
|
||||||
# Note: test_subscription fixture already creates the tier
|
# Note: test_subscription fixture already creates the tier
|
||||||
subscription, tier = self.service.get_subscription_with_tier(db, test_vendor.id)
|
subscription, tier = self.service.get_subscription_with_tier(db, test_store.id)
|
||||||
|
|
||||||
assert subscription.id == test_subscription.id
|
assert subscription.id == test_subscription.id
|
||||||
assert tier.code == test_subscription.tier
|
assert tier.code == test_subscription.tier
|
||||||
@@ -109,7 +109,7 @@ class TestBillingServiceCheckout:
|
|||||||
|
|
||||||
@patch("app.modules.billing.services.billing_service.stripe_service")
|
@patch("app.modules.billing.services.billing_service.stripe_service")
|
||||||
def test_create_checkout_session_stripe_not_configured(
|
def test_create_checkout_session_stripe_not_configured(
|
||||||
self, mock_stripe, db, test_vendor, test_subscription_tier
|
self, mock_stripe, db, test_store, test_subscription_tier
|
||||||
):
|
):
|
||||||
"""Test checkout fails when Stripe not configured."""
|
"""Test checkout fails when Stripe not configured."""
|
||||||
mock_stripe.is_configured = False
|
mock_stripe.is_configured = False
|
||||||
@@ -117,7 +117,7 @@ class TestBillingServiceCheckout:
|
|||||||
with pytest.raises(PaymentSystemNotConfiguredError):
|
with pytest.raises(PaymentSystemNotConfiguredError):
|
||||||
self.service.create_checkout_session(
|
self.service.create_checkout_session(
|
||||||
db=db,
|
db=db,
|
||||||
vendor_id=test_vendor.id,
|
store_id=test_store.id,
|
||||||
tier_code="essential",
|
tier_code="essential",
|
||||||
is_annual=False,
|
is_annual=False,
|
||||||
success_url="https://example.com/success",
|
success_url="https://example.com/success",
|
||||||
@@ -126,7 +126,7 @@ class TestBillingServiceCheckout:
|
|||||||
|
|
||||||
@patch("app.modules.billing.services.billing_service.stripe_service")
|
@patch("app.modules.billing.services.billing_service.stripe_service")
|
||||||
def test_create_checkout_session_success(
|
def test_create_checkout_session_success(
|
||||||
self, mock_stripe, db, test_vendor, test_subscription_tier_with_stripe
|
self, mock_stripe, db, test_store, test_subscription_tier_with_stripe
|
||||||
):
|
):
|
||||||
"""Test successful checkout session creation."""
|
"""Test successful checkout session creation."""
|
||||||
mock_stripe.is_configured = True
|
mock_stripe.is_configured = True
|
||||||
@@ -137,7 +137,7 @@ class TestBillingServiceCheckout:
|
|||||||
|
|
||||||
result = self.service.create_checkout_session(
|
result = self.service.create_checkout_session(
|
||||||
db=db,
|
db=db,
|
||||||
vendor_id=test_vendor.id,
|
store_id=test_store.id,
|
||||||
tier_code="essential",
|
tier_code="essential",
|
||||||
is_annual=False,
|
is_annual=False,
|
||||||
success_url="https://example.com/success",
|
success_url="https://example.com/success",
|
||||||
@@ -149,7 +149,7 @@ class TestBillingServiceCheckout:
|
|||||||
|
|
||||||
@patch("app.modules.billing.services.billing_service.stripe_service")
|
@patch("app.modules.billing.services.billing_service.stripe_service")
|
||||||
def test_create_checkout_session_tier_not_found(
|
def test_create_checkout_session_tier_not_found(
|
||||||
self, mock_stripe, db, test_vendor
|
self, mock_stripe, db, test_store
|
||||||
):
|
):
|
||||||
"""Test checkout fails with invalid tier."""
|
"""Test checkout fails with invalid tier."""
|
||||||
mock_stripe.is_configured = True
|
mock_stripe.is_configured = True
|
||||||
@@ -157,7 +157,7 @@ class TestBillingServiceCheckout:
|
|||||||
with pytest.raises(TierNotFoundError):
|
with pytest.raises(TierNotFoundError):
|
||||||
self.service.create_checkout_session(
|
self.service.create_checkout_session(
|
||||||
db=db,
|
db=db,
|
||||||
vendor_id=test_vendor.id,
|
store_id=test_store.id,
|
||||||
tier_code="nonexistent",
|
tier_code="nonexistent",
|
||||||
is_annual=False,
|
is_annual=False,
|
||||||
success_url="https://example.com/success",
|
success_url="https://example.com/success",
|
||||||
@@ -166,7 +166,7 @@ class TestBillingServiceCheckout:
|
|||||||
|
|
||||||
@patch("app.modules.billing.services.billing_service.stripe_service")
|
@patch("app.modules.billing.services.billing_service.stripe_service")
|
||||||
def test_create_checkout_session_no_price(
|
def test_create_checkout_session_no_price(
|
||||||
self, mock_stripe, db, test_vendor, test_subscription_tier
|
self, mock_stripe, db, test_store, test_subscription_tier
|
||||||
):
|
):
|
||||||
"""Test checkout fails when tier has no Stripe price."""
|
"""Test checkout fails when tier has no Stripe price."""
|
||||||
mock_stripe.is_configured = True
|
mock_stripe.is_configured = True
|
||||||
@@ -174,7 +174,7 @@ class TestBillingServiceCheckout:
|
|||||||
with pytest.raises(StripePriceNotConfiguredError):
|
with pytest.raises(StripePriceNotConfiguredError):
|
||||||
self.service.create_checkout_session(
|
self.service.create_checkout_session(
|
||||||
db=db,
|
db=db,
|
||||||
vendor_id=test_vendor.id,
|
store_id=test_store.id,
|
||||||
tier_code="essential",
|
tier_code="essential",
|
||||||
is_annual=False,
|
is_annual=False,
|
||||||
success_url="https://example.com/success",
|
success_url="https://example.com/success",
|
||||||
@@ -192,32 +192,32 @@ class TestBillingServicePortal:
|
|||||||
self.service = BillingService()
|
self.service = BillingService()
|
||||||
|
|
||||||
@patch("app.modules.billing.services.billing_service.stripe_service")
|
@patch("app.modules.billing.services.billing_service.stripe_service")
|
||||||
def test_create_portal_session_stripe_not_configured(self, mock_stripe, db, test_vendor):
|
def test_create_portal_session_stripe_not_configured(self, mock_stripe, db, test_store):
|
||||||
"""Test portal fails when Stripe not configured."""
|
"""Test portal fails when Stripe not configured."""
|
||||||
mock_stripe.is_configured = False
|
mock_stripe.is_configured = False
|
||||||
|
|
||||||
with pytest.raises(PaymentSystemNotConfiguredError):
|
with pytest.raises(PaymentSystemNotConfiguredError):
|
||||||
self.service.create_portal_session(
|
self.service.create_portal_session(
|
||||||
db=db,
|
db=db,
|
||||||
vendor_id=test_vendor.id,
|
store_id=test_store.id,
|
||||||
return_url="https://example.com/billing",
|
return_url="https://example.com/billing",
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch("app.modules.billing.services.billing_service.stripe_service")
|
@patch("app.modules.billing.services.billing_service.stripe_service")
|
||||||
def test_create_portal_session_no_subscription(self, mock_stripe, db, test_vendor):
|
def test_create_portal_session_no_subscription(self, mock_stripe, db, test_store):
|
||||||
"""Test portal fails when no subscription exists."""
|
"""Test portal fails when no subscription exists."""
|
||||||
mock_stripe.is_configured = True
|
mock_stripe.is_configured = True
|
||||||
|
|
||||||
with pytest.raises(NoActiveSubscriptionError):
|
with pytest.raises(NoActiveSubscriptionError):
|
||||||
self.service.create_portal_session(
|
self.service.create_portal_session(
|
||||||
db=db,
|
db=db,
|
||||||
vendor_id=test_vendor.id,
|
store_id=test_store.id,
|
||||||
return_url="https://example.com/billing",
|
return_url="https://example.com/billing",
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch("app.modules.billing.services.billing_service.stripe_service")
|
@patch("app.modules.billing.services.billing_service.stripe_service")
|
||||||
def test_create_portal_session_success(
|
def test_create_portal_session_success(
|
||||||
self, mock_stripe, db, test_vendor, test_active_subscription
|
self, mock_stripe, db, test_store, test_active_subscription
|
||||||
):
|
):
|
||||||
"""Test successful portal session creation."""
|
"""Test successful portal session creation."""
|
||||||
mock_stripe.is_configured = True
|
mock_stripe.is_configured = True
|
||||||
@@ -227,7 +227,7 @@ class TestBillingServicePortal:
|
|||||||
|
|
||||||
result = self.service.create_portal_session(
|
result = self.service.create_portal_session(
|
||||||
db=db,
|
db=db,
|
||||||
vendor_id=test_vendor.id,
|
store_id=test_store.id,
|
||||||
return_url="https://example.com/billing",
|
return_url="https://example.com/billing",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -243,30 +243,30 @@ class TestBillingServiceInvoices:
|
|||||||
"""Initialize service instance before each test."""
|
"""Initialize service instance before each test."""
|
||||||
self.service = BillingService()
|
self.service = BillingService()
|
||||||
|
|
||||||
def test_get_invoices_empty(self, db, test_vendor):
|
def test_get_invoices_empty(self, db, test_store):
|
||||||
"""Test getting invoices when none exist."""
|
"""Test getting invoices when none exist."""
|
||||||
invoices, total = self.service.get_invoices(db, test_vendor.id)
|
invoices, total = self.service.get_invoices(db, test_store.id)
|
||||||
|
|
||||||
assert invoices == []
|
assert invoices == []
|
||||||
assert total == 0
|
assert total == 0
|
||||||
|
|
||||||
def test_get_invoices_with_data(self, db, test_vendor, test_billing_history):
|
def test_get_invoices_with_data(self, db, test_store, test_billing_history):
|
||||||
"""Test getting invoices returns data."""
|
"""Test getting invoices returns data."""
|
||||||
invoices, total = self.service.get_invoices(db, test_vendor.id)
|
invoices, total = self.service.get_invoices(db, test_store.id)
|
||||||
|
|
||||||
assert len(invoices) == 1
|
assert len(invoices) == 1
|
||||||
assert total == 1
|
assert total == 1
|
||||||
assert invoices[0].invoice_number == "INV-001"
|
assert invoices[0].invoice_number == "INV-001"
|
||||||
|
|
||||||
def test_get_invoices_pagination(self, db, test_vendor, test_multiple_invoices):
|
def test_get_invoices_pagination(self, db, test_store, test_multiple_invoices):
|
||||||
"""Test invoice pagination."""
|
"""Test invoice pagination."""
|
||||||
# Get first page
|
# Get first page
|
||||||
page1, total = self.service.get_invoices(db, test_vendor.id, skip=0, limit=2)
|
page1, total = self.service.get_invoices(db, test_store.id, skip=0, limit=2)
|
||||||
assert len(page1) == 2
|
assert len(page1) == 2
|
||||||
assert total == 5
|
assert total == 5
|
||||||
|
|
||||||
# Get second page
|
# Get second page
|
||||||
page2, _ = self.service.get_invoices(db, test_vendor.id, skip=2, limit=2)
|
page2, _ = self.service.get_invoices(db, test_store.id, skip=2, limit=2)
|
||||||
assert len(page2) == 2
|
assert len(page2) == 2
|
||||||
|
|
||||||
|
|
||||||
@@ -298,9 +298,9 @@ class TestBillingServiceAddons:
|
|||||||
assert len(domain_addons) == 1
|
assert len(domain_addons) == 1
|
||||||
assert domain_addons[0].category == "domain"
|
assert domain_addons[0].category == "domain"
|
||||||
|
|
||||||
def test_get_vendor_addons_empty(self, db, test_vendor):
|
def test_get_store_addons_empty(self, db, test_store):
|
||||||
"""Test getting vendor addons when none purchased."""
|
"""Test getting store addons when none purchased."""
|
||||||
addons = self.service.get_vendor_addons(db, test_vendor.id)
|
addons = self.service.get_store_addons(db, test_store.id)
|
||||||
assert addons == []
|
assert addons == []
|
||||||
|
|
||||||
|
|
||||||
@@ -315,7 +315,7 @@ class TestBillingServiceCancellation:
|
|||||||
|
|
||||||
@patch("app.modules.billing.services.billing_service.stripe_service")
|
@patch("app.modules.billing.services.billing_service.stripe_service")
|
||||||
def test_cancel_subscription_no_subscription(
|
def test_cancel_subscription_no_subscription(
|
||||||
self, mock_stripe, db, test_vendor
|
self, mock_stripe, db, test_store
|
||||||
):
|
):
|
||||||
"""Test cancel fails when no subscription."""
|
"""Test cancel fails when no subscription."""
|
||||||
mock_stripe.is_configured = True
|
mock_stripe.is_configured = True
|
||||||
@@ -323,21 +323,21 @@ class TestBillingServiceCancellation:
|
|||||||
with pytest.raises(NoActiveSubscriptionError):
|
with pytest.raises(NoActiveSubscriptionError):
|
||||||
self.service.cancel_subscription(
|
self.service.cancel_subscription(
|
||||||
db=db,
|
db=db,
|
||||||
vendor_id=test_vendor.id,
|
store_id=test_store.id,
|
||||||
reason="Test reason",
|
reason="Test reason",
|
||||||
immediately=False,
|
immediately=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch("app.modules.billing.services.billing_service.stripe_service")
|
@patch("app.modules.billing.services.billing_service.stripe_service")
|
||||||
def test_cancel_subscription_success(
|
def test_cancel_subscription_success(
|
||||||
self, mock_stripe, db, test_vendor, test_active_subscription
|
self, mock_stripe, db, test_store, test_active_subscription
|
||||||
):
|
):
|
||||||
"""Test successful subscription cancellation."""
|
"""Test successful subscription cancellation."""
|
||||||
mock_stripe.is_configured = True
|
mock_stripe.is_configured = True
|
||||||
|
|
||||||
result = self.service.cancel_subscription(
|
result = self.service.cancel_subscription(
|
||||||
db=db,
|
db=db,
|
||||||
vendor_id=test_vendor.id,
|
store_id=test_store.id,
|
||||||
reason="Too expensive",
|
reason="Too expensive",
|
||||||
immediately=False,
|
immediately=False,
|
||||||
)
|
)
|
||||||
@@ -348,22 +348,22 @@ class TestBillingServiceCancellation:
|
|||||||
|
|
||||||
@patch("app.modules.billing.services.billing_service.stripe_service")
|
@patch("app.modules.billing.services.billing_service.stripe_service")
|
||||||
def test_reactivate_subscription_not_cancelled(
|
def test_reactivate_subscription_not_cancelled(
|
||||||
self, mock_stripe, db, test_vendor, test_active_subscription
|
self, mock_stripe, db, test_store, test_active_subscription
|
||||||
):
|
):
|
||||||
"""Test reactivate fails when subscription not cancelled."""
|
"""Test reactivate fails when subscription not cancelled."""
|
||||||
mock_stripe.is_configured = True
|
mock_stripe.is_configured = True
|
||||||
|
|
||||||
with pytest.raises(SubscriptionNotCancelledError):
|
with pytest.raises(SubscriptionNotCancelledError):
|
||||||
self.service.reactivate_subscription(db, test_vendor.id)
|
self.service.reactivate_subscription(db, test_store.id)
|
||||||
|
|
||||||
@patch("app.modules.billing.services.billing_service.stripe_service")
|
@patch("app.modules.billing.services.billing_service.stripe_service")
|
||||||
def test_reactivate_subscription_success(
|
def test_reactivate_subscription_success(
|
||||||
self, mock_stripe, db, test_vendor, test_cancelled_subscription
|
self, mock_stripe, db, test_store, test_cancelled_subscription
|
||||||
):
|
):
|
||||||
"""Test successful subscription reactivation."""
|
"""Test successful subscription reactivation."""
|
||||||
mock_stripe.is_configured = True
|
mock_stripe.is_configured = True
|
||||||
|
|
||||||
result = self.service.reactivate_subscription(db, test_vendor.id)
|
result = self.service.reactivate_subscription(db, test_store.id)
|
||||||
|
|
||||||
assert result["message"] == "Subscription reactivated successfully"
|
assert result["message"] == "Subscription reactivated successfully"
|
||||||
assert test_cancelled_subscription.cancelled_at is None
|
assert test_cancelled_subscription.cancelled_at is None
|
||||||
@@ -372,23 +372,23 @@ class TestBillingServiceCancellation:
|
|||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@pytest.mark.billing
|
@pytest.mark.billing
|
||||||
class TestBillingServiceVendor:
|
class TestBillingServiceStore:
|
||||||
"""Test suite for BillingService vendor operations."""
|
"""Test suite for BillingService store operations."""
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
"""Initialize service instance before each test."""
|
"""Initialize service instance before each test."""
|
||||||
self.service = BillingService()
|
self.service = BillingService()
|
||||||
|
|
||||||
def test_get_vendor_success(self, db, test_vendor):
|
def test_get_store_success(self, db, test_store):
|
||||||
"""Test getting vendor by ID."""
|
"""Test getting store by ID."""
|
||||||
vendor = self.service.get_vendor(db, test_vendor.id)
|
store = self.service.get_store(db, test_store.id)
|
||||||
|
|
||||||
assert vendor.id == test_vendor.id
|
assert store.id == test_store.id
|
||||||
|
|
||||||
def test_get_vendor_not_found(self, db):
|
def test_get_store_not_found(self, db):
|
||||||
"""Test getting non-existent vendor raises error."""
|
"""Test getting non-existent store raises error."""
|
||||||
with pytest.raises(VendorNotFoundException):
|
with pytest.raises(StoreNotFoundException):
|
||||||
self.service.get_vendor(db, 99999)
|
self.service.get_store(db, 99999)
|
||||||
|
|
||||||
|
|
||||||
# ==================== Fixtures ====================
|
# ==================== Fixtures ====================
|
||||||
@@ -480,7 +480,7 @@ def test_subscription_tiers(db):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_subscription(db, test_vendor):
|
def test_subscription(db, test_store):
|
||||||
"""Create a basic subscription for testing."""
|
"""Create a basic subscription for testing."""
|
||||||
# Create tier first
|
# Create tier first
|
||||||
tier = SubscriptionTier(
|
tier = SubscriptionTier(
|
||||||
@@ -494,8 +494,8 @@ def test_subscription(db, test_vendor):
|
|||||||
db.add(tier)
|
db.add(tier)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
subscription = VendorSubscription(
|
subscription = MerchantSubscription(
|
||||||
vendor_id=test_vendor.id,
|
store_id=test_store.id,
|
||||||
tier="essential",
|
tier="essential",
|
||||||
status=SubscriptionStatus.ACTIVE,
|
status=SubscriptionStatus.ACTIVE,
|
||||||
period_start=datetime.now(timezone.utc),
|
period_start=datetime.now(timezone.utc),
|
||||||
@@ -508,7 +508,7 @@ def test_subscription(db, test_vendor):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_active_subscription(db, test_vendor):
|
def test_active_subscription(db, test_store):
|
||||||
"""Create an active subscription with Stripe IDs."""
|
"""Create an active subscription with Stripe IDs."""
|
||||||
# Create tier first if not exists
|
# Create tier first if not exists
|
||||||
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first()
|
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first()
|
||||||
@@ -524,8 +524,8 @@ def test_active_subscription(db, test_vendor):
|
|||||||
db.add(tier)
|
db.add(tier)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
subscription = VendorSubscription(
|
subscription = MerchantSubscription(
|
||||||
vendor_id=test_vendor.id,
|
store_id=test_store.id,
|
||||||
tier="essential",
|
tier="essential",
|
||||||
status=SubscriptionStatus.ACTIVE,
|
status=SubscriptionStatus.ACTIVE,
|
||||||
stripe_customer_id="cus_test123",
|
stripe_customer_id="cus_test123",
|
||||||
@@ -540,7 +540,7 @@ def test_active_subscription(db, test_vendor):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_cancelled_subscription(db, test_vendor):
|
def test_cancelled_subscription(db, test_store):
|
||||||
"""Create a cancelled subscription."""
|
"""Create a cancelled subscription."""
|
||||||
# Create tier first if not exists
|
# Create tier first if not exists
|
||||||
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first()
|
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first()
|
||||||
@@ -556,8 +556,8 @@ def test_cancelled_subscription(db, test_vendor):
|
|||||||
db.add(tier)
|
db.add(tier)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
subscription = VendorSubscription(
|
subscription = MerchantSubscription(
|
||||||
vendor_id=test_vendor.id,
|
store_id=test_store.id,
|
||||||
tier="essential",
|
tier="essential",
|
||||||
status=SubscriptionStatus.ACTIVE,
|
status=SubscriptionStatus.ACTIVE,
|
||||||
stripe_customer_id="cus_test123",
|
stripe_customer_id="cus_test123",
|
||||||
@@ -574,10 +574,10 @@ def test_cancelled_subscription(db, test_vendor):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_billing_history(db, test_vendor):
|
def test_billing_history(db, test_store):
|
||||||
"""Create a billing history record."""
|
"""Create a billing history record."""
|
||||||
record = BillingHistory(
|
record = BillingHistory(
|
||||||
vendor_id=test_vendor.id,
|
store_id=test_store.id,
|
||||||
stripe_invoice_id="in_test123",
|
stripe_invoice_id="in_test123",
|
||||||
invoice_number="INV-001",
|
invoice_number="INV-001",
|
||||||
invoice_date=datetime.now(timezone.utc),
|
invoice_date=datetime.now(timezone.utc),
|
||||||
@@ -595,12 +595,12 @@ def test_billing_history(db, test_vendor):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_multiple_invoices(db, test_vendor):
|
def test_multiple_invoices(db, test_store):
|
||||||
"""Create multiple billing history records."""
|
"""Create multiple billing history records."""
|
||||||
records = []
|
records = []
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
record = BillingHistory(
|
record = BillingHistory(
|
||||||
vendor_id=test_vendor.id,
|
store_id=test_store.id,
|
||||||
stripe_invoice_id=f"in_test{i}",
|
stripe_invoice_id=f"in_test{i}",
|
||||||
invoice_number=f"INV-{i:03d}",
|
invoice_number=f"INV-{i:03d}",
|
||||||
invoice_date=datetime.now(timezone.utc),
|
invoice_date=datetime.now(timezone.utc),
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import pytest
|
|||||||
|
|
||||||
from app.modules.billing.exceptions import FeatureNotFoundError, InvalidFeatureCodesError, TierNotFoundError
|
from app.modules.billing.exceptions import FeatureNotFoundError, InvalidFeatureCodesError, TierNotFoundError
|
||||||
from app.modules.billing.services.feature_service import FeatureService, feature_service
|
from app.modules.billing.services.feature_service import FeatureService, feature_service
|
||||||
from app.modules.billing.models import Feature
|
from app.modules.billing.models import SubscriptionTier, MerchantSubscription
|
||||||
from app.modules.billing.models import SubscriptionTier, VendorSubscription
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@@ -18,27 +17,27 @@ class TestFeatureServiceAvailability:
|
|||||||
"""Initialize service instance before each test."""
|
"""Initialize service instance before each test."""
|
||||||
self.service = FeatureService()
|
self.service = FeatureService()
|
||||||
|
|
||||||
def test_has_feature_true(self, db, test_vendor_with_subscription):
|
def test_has_feature_true(self, db, test_store_with_subscription):
|
||||||
"""Test has_feature returns True for available feature."""
|
"""Test has_feature returns True for available feature."""
|
||||||
vendor_id = test_vendor_with_subscription.id
|
store_id = test_store_with_subscription.id
|
||||||
result = self.service.has_feature(db, vendor_id, "basic_reports")
|
result = self.service.has_feature(db, store_id, "basic_reports")
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
def test_has_feature_false(self, db, test_vendor_with_subscription):
|
def test_has_feature_false(self, db, test_store_with_subscription):
|
||||||
"""Test has_feature returns False for unavailable feature."""
|
"""Test has_feature returns False for unavailable feature."""
|
||||||
vendor_id = test_vendor_with_subscription.id
|
store_id = test_store_with_subscription.id
|
||||||
result = self.service.has_feature(db, vendor_id, "api_access")
|
result = self.service.has_feature(db, store_id, "api_access")
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
def test_has_feature_no_subscription(self, db, test_vendor):
|
def test_has_feature_no_subscription(self, db, test_store):
|
||||||
"""Test has_feature returns False for vendor without subscription."""
|
"""Test has_feature returns False for store without subscription."""
|
||||||
result = self.service.has_feature(db, test_vendor.id, "basic_reports")
|
result = self.service.has_feature(db, test_store.id, "basic_reports")
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
def test_get_vendor_feature_codes(self, db, test_vendor_with_subscription):
|
def test_get_store_feature_codes(self, db, test_store_with_subscription):
|
||||||
"""Test getting all feature codes for vendor."""
|
"""Test getting all feature codes for store."""
|
||||||
vendor_id = test_vendor_with_subscription.id
|
store_id = test_store_with_subscription.id
|
||||||
features = self.service.get_vendor_feature_codes(db, vendor_id)
|
features = self.service.get_store_feature_codes(db, store_id)
|
||||||
|
|
||||||
assert isinstance(features, set)
|
assert isinstance(features, set)
|
||||||
assert "basic_reports" in features
|
assert "basic_reports" in features
|
||||||
@@ -54,10 +53,10 @@ class TestFeatureServiceListing:
|
|||||||
"""Initialize service instance before each test."""
|
"""Initialize service instance before each test."""
|
||||||
self.service = FeatureService()
|
self.service = FeatureService()
|
||||||
|
|
||||||
def test_get_vendor_features(self, db, test_vendor_with_subscription, test_features):
|
def test_get_store_features(self, db, test_store_with_subscription, test_features):
|
||||||
"""Test getting all features with availability."""
|
"""Test getting all features with availability."""
|
||||||
vendor_id = test_vendor_with_subscription.id
|
store_id = test_store_with_subscription.id
|
||||||
features = self.service.get_vendor_features(db, vendor_id)
|
features = self.service.get_store_features(db, store_id)
|
||||||
|
|
||||||
assert len(features) > 0
|
assert len(features) > 0
|
||||||
basic_reports = next((f for f in features if f.code == "basic_reports"), None)
|
basic_reports = next((f for f in features if f.code == "basic_reports"), None)
|
||||||
@@ -68,30 +67,30 @@ class TestFeatureServiceListing:
|
|||||||
assert api_access is not None
|
assert api_access is not None
|
||||||
assert api_access.is_available is False
|
assert api_access.is_available is False
|
||||||
|
|
||||||
def test_get_vendor_features_by_category(
|
def test_get_store_features_by_category(
|
||||||
self, db, test_vendor_with_subscription, test_features
|
self, db, test_store_with_subscription, test_features
|
||||||
):
|
):
|
||||||
"""Test filtering features by category."""
|
"""Test filtering features by category."""
|
||||||
vendor_id = test_vendor_with_subscription.id
|
store_id = test_store_with_subscription.id
|
||||||
features = self.service.get_vendor_features(db, vendor_id, category="analytics")
|
features = self.service.get_store_features(db, store_id, category="analytics")
|
||||||
|
|
||||||
assert all(f.category == "analytics" for f in features)
|
assert all(f.category == "analytics" for f in features)
|
||||||
|
|
||||||
def test_get_vendor_features_available_only(
|
def test_get_store_features_available_only(
|
||||||
self, db, test_vendor_with_subscription, test_features
|
self, db, test_store_with_subscription, test_features
|
||||||
):
|
):
|
||||||
"""Test getting only available features."""
|
"""Test getting only available features."""
|
||||||
vendor_id = test_vendor_with_subscription.id
|
store_id = test_store_with_subscription.id
|
||||||
features = self.service.get_vendor_features(
|
features = self.service.get_store_features(
|
||||||
db, vendor_id, include_unavailable=False
|
db, store_id, include_unavailable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
assert all(f.is_available for f in features)
|
assert all(f.is_available for f in features)
|
||||||
|
|
||||||
def test_get_available_feature_codes(self, db, test_vendor_with_subscription):
|
def test_get_available_feature_codes(self, db, test_store_with_subscription):
|
||||||
"""Test getting simple list of available codes."""
|
"""Test getting simple list of available codes."""
|
||||||
vendor_id = test_vendor_with_subscription.id
|
store_id = test_store_with_subscription.id
|
||||||
codes = self.service.get_available_feature_codes(db, vendor_id)
|
codes = self.service.get_available_feature_codes(db, store_id)
|
||||||
|
|
||||||
assert isinstance(codes, list)
|
assert isinstance(codes, list)
|
||||||
assert "basic_reports" in codes
|
assert "basic_reports" in codes
|
||||||
@@ -159,28 +158,28 @@ class TestFeatureServiceCache:
|
|||||||
"""Initialize service instance before each test."""
|
"""Initialize service instance before each test."""
|
||||||
self.service = FeatureService()
|
self.service = FeatureService()
|
||||||
|
|
||||||
def test_cache_invalidation(self, db, test_vendor_with_subscription):
|
def test_cache_invalidation(self, db, test_store_with_subscription):
|
||||||
"""Test cache invalidation for vendor."""
|
"""Test cache invalidation for store."""
|
||||||
vendor_id = test_vendor_with_subscription.id
|
store_id = test_store_with_subscription.id
|
||||||
|
|
||||||
# Prime the cache
|
# Prime the cache
|
||||||
self.service.get_vendor_feature_codes(db, vendor_id)
|
self.service.get_store_feature_codes(db, store_id)
|
||||||
assert self.service._cache.get(vendor_id) is not None
|
assert self.service._cache.get(store_id) is not None
|
||||||
|
|
||||||
# Invalidate
|
# Invalidate
|
||||||
self.service.invalidate_vendor_cache(vendor_id)
|
self.service.invalidate_store_cache(store_id)
|
||||||
assert self.service._cache.get(vendor_id) is None
|
assert self.service._cache.get(store_id) is None
|
||||||
|
|
||||||
def test_cache_invalidate_all(self, db, test_vendor_with_subscription):
|
def test_cache_invalidate_all(self, db, test_store_with_subscription):
|
||||||
"""Test invalidating entire cache."""
|
"""Test invalidating entire cache."""
|
||||||
vendor_id = test_vendor_with_subscription.id
|
store_id = test_store_with_subscription.id
|
||||||
|
|
||||||
# Prime the cache
|
# Prime the cache
|
||||||
self.service.get_vendor_feature_codes(db, vendor_id)
|
self.service.get_store_feature_codes(db, store_id)
|
||||||
|
|
||||||
# Invalidate all
|
# Invalidate all
|
||||||
self.service.invalidate_all_cache()
|
self.service.invalidate_all_cache()
|
||||||
assert self.service._cache.get(vendor_id) is None
|
assert self.service._cache.get(store_id) is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@@ -301,14 +300,14 @@ def test_subscription_tiers(db):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_vendor_with_subscription(db, test_vendor, test_subscription_tiers):
|
def test_store_with_subscription(db, test_store, test_subscription_tiers):
|
||||||
"""Create a vendor with an active subscription."""
|
"""Create a store with an active subscription."""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
essential_tier = test_subscription_tiers[0] # Use the essential tier from tiers list
|
essential_tier = test_subscription_tiers[0] # Use the essential tier from tiers list
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
subscription = VendorSubscription(
|
subscription = StoreSubscription(
|
||||||
vendor_id=test_vendor.id,
|
store_id=test_store.id,
|
||||||
tier="essential",
|
tier="essential",
|
||||||
tier_id=essential_tier.id,
|
tier_id=essential_tier.id,
|
||||||
status="active",
|
status="active",
|
||||||
@@ -318,8 +317,8 @@ def test_vendor_with_subscription(db, test_vendor, test_subscription_tiers):
|
|||||||
)
|
)
|
||||||
db.add(subscription)
|
db.add(subscription)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(test_vendor)
|
db.refresh(test_store)
|
||||||
return test_vendor
|
return test_store
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import pytest
|
|||||||
from app.handlers.stripe_webhook import StripeWebhookHandler
|
from app.handlers.stripe_webhook import StripeWebhookHandler
|
||||||
from app.modules.billing.models import (
|
from app.modules.billing.models import (
|
||||||
BillingHistory,
|
BillingHistory,
|
||||||
|
MerchantSubscription,
|
||||||
StripeWebhookEvent,
|
StripeWebhookEvent,
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
SubscriptionTier,
|
SubscriptionTier,
|
||||||
VendorSubscription,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ class TestStripeWebhookHandlerCheckout:
|
|||||||
|
|
||||||
@patch("app.handlers.stripe_webhook.stripe.Subscription.retrieve")
|
@patch("app.handlers.stripe_webhook.stripe.Subscription.retrieve")
|
||||||
def test_handle_checkout_completed_success(
|
def test_handle_checkout_completed_success(
|
||||||
self, mock_stripe_retrieve, db, test_vendor, test_subscription, mock_checkout_event
|
self, mock_stripe_retrieve, db, test_store, test_subscription, mock_checkout_event
|
||||||
):
|
):
|
||||||
"""Test successful checkout completion."""
|
"""Test successful checkout completion."""
|
||||||
# Mock Stripe subscription retrieve
|
# Mock Stripe subscription retrieve
|
||||||
@@ -71,7 +71,7 @@ class TestStripeWebhookHandlerCheckout:
|
|||||||
mock_stripe_sub.trial_end = None
|
mock_stripe_sub.trial_end = None
|
||||||
mock_stripe_retrieve.return_value = mock_stripe_sub
|
mock_stripe_retrieve.return_value = mock_stripe_sub
|
||||||
|
|
||||||
mock_checkout_event.data.object.metadata = {"vendor_id": str(test_vendor.id)}
|
mock_checkout_event.data.object.metadata = {"store_id": str(test_store.id)}
|
||||||
|
|
||||||
result = self.handler.handle_event(db, mock_checkout_event)
|
result = self.handler.handle_event(db, mock_checkout_event)
|
||||||
|
|
||||||
@@ -80,15 +80,15 @@ class TestStripeWebhookHandlerCheckout:
|
|||||||
assert test_subscription.stripe_customer_id == "cus_test123"
|
assert test_subscription.stripe_customer_id == "cus_test123"
|
||||||
assert test_subscription.status == SubscriptionStatus.ACTIVE
|
assert test_subscription.status == SubscriptionStatus.ACTIVE
|
||||||
|
|
||||||
def test_handle_checkout_completed_no_vendor_id(self, db, mock_checkout_event):
|
def test_handle_checkout_completed_no_store_id(self, db, mock_checkout_event):
|
||||||
"""Test checkout with missing vendor_id is skipped."""
|
"""Test checkout with missing store_id is skipped."""
|
||||||
mock_checkout_event.data.object.metadata = {}
|
mock_checkout_event.data.object.metadata = {}
|
||||||
|
|
||||||
result = self.handler.handle_event(db, mock_checkout_event)
|
result = self.handler.handle_event(db, mock_checkout_event)
|
||||||
|
|
||||||
assert result["status"] == "processed"
|
assert result["status"] == "processed"
|
||||||
assert result["result"]["action"] == "skipped"
|
assert result["result"]["action"] == "skipped"
|
||||||
assert result["result"]["reason"] == "no vendor_id"
|
assert result["result"]["reason"] == "no store_id"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@@ -101,7 +101,7 @@ class TestStripeWebhookHandlerSubscription:
|
|||||||
self.handler = StripeWebhookHandler()
|
self.handler = StripeWebhookHandler()
|
||||||
|
|
||||||
def test_handle_subscription_updated_status_change(
|
def test_handle_subscription_updated_status_change(
|
||||||
self, db, test_vendor, test_active_subscription, mock_subscription_updated_event
|
self, db, test_store, test_active_subscription, mock_subscription_updated_event
|
||||||
):
|
):
|
||||||
"""Test subscription update changes status."""
|
"""Test subscription update changes status."""
|
||||||
result = self.handler.handle_event(db, mock_subscription_updated_event)
|
result = self.handler.handle_event(db, mock_subscription_updated_event)
|
||||||
@@ -109,7 +109,7 @@ class TestStripeWebhookHandlerSubscription:
|
|||||||
assert result["status"] == "processed"
|
assert result["status"] == "processed"
|
||||||
|
|
||||||
def test_handle_subscription_deleted(
|
def test_handle_subscription_deleted(
|
||||||
self, db, test_vendor, test_active_subscription, mock_subscription_deleted_event
|
self, db, test_store, test_active_subscription, mock_subscription_deleted_event
|
||||||
):
|
):
|
||||||
"""Test subscription deletion."""
|
"""Test subscription deletion."""
|
||||||
result = self.handler.handle_event(db, mock_subscription_deleted_event)
|
result = self.handler.handle_event(db, mock_subscription_deleted_event)
|
||||||
@@ -129,7 +129,7 @@ class TestStripeWebhookHandlerInvoice:
|
|||||||
self.handler = StripeWebhookHandler()
|
self.handler = StripeWebhookHandler()
|
||||||
|
|
||||||
def test_handle_invoice_paid_creates_billing_record(
|
def test_handle_invoice_paid_creates_billing_record(
|
||||||
self, db, test_vendor, test_active_subscription, mock_invoice_paid_event
|
self, db, test_store, test_active_subscription, mock_invoice_paid_event
|
||||||
):
|
):
|
||||||
"""Test invoice.paid creates billing history record."""
|
"""Test invoice.paid creates billing history record."""
|
||||||
result = self.handler.handle_event(db, mock_invoice_paid_event)
|
result = self.handler.handle_event(db, mock_invoice_paid_event)
|
||||||
@@ -139,7 +139,7 @@ class TestStripeWebhookHandlerInvoice:
|
|||||||
# Check billing record created
|
# Check billing record created
|
||||||
record = (
|
record = (
|
||||||
db.query(BillingHistory)
|
db.query(BillingHistory)
|
||||||
.filter(BillingHistory.vendor_id == test_vendor.id)
|
.filter(BillingHistory.store_id == test_store.id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
assert record is not None
|
assert record is not None
|
||||||
@@ -147,7 +147,7 @@ class TestStripeWebhookHandlerInvoice:
|
|||||||
assert record.total_cents == 4900
|
assert record.total_cents == 4900
|
||||||
|
|
||||||
def test_handle_invoice_paid_resets_counters(
|
def test_handle_invoice_paid_resets_counters(
|
||||||
self, db, test_vendor, test_active_subscription, mock_invoice_paid_event
|
self, db, test_store, test_active_subscription, mock_invoice_paid_event
|
||||||
):
|
):
|
||||||
"""Test invoice.paid resets order counters."""
|
"""Test invoice.paid resets order counters."""
|
||||||
test_active_subscription.orders_this_period = 50
|
test_active_subscription.orders_this_period = 50
|
||||||
@@ -159,7 +159,7 @@ class TestStripeWebhookHandlerInvoice:
|
|||||||
assert test_active_subscription.orders_this_period == 0
|
assert test_active_subscription.orders_this_period == 0
|
||||||
|
|
||||||
def test_handle_payment_failed_marks_past_due(
|
def test_handle_payment_failed_marks_past_due(
|
||||||
self, db, test_vendor, test_active_subscription, mock_payment_failed_event
|
self, db, test_store, test_active_subscription, mock_payment_failed_event
|
||||||
):
|
):
|
||||||
"""Test payment failure marks subscription as past due."""
|
"""Test payment failure marks subscription as past due."""
|
||||||
result = self.handler.handle_event(db, mock_payment_failed_event)
|
result = self.handler.handle_event(db, mock_payment_failed_event)
|
||||||
@@ -248,7 +248,7 @@ def test_subscription_tier(db):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_subscription(db, test_vendor):
|
def test_subscription(db, test_store):
|
||||||
"""Create a basic subscription for testing."""
|
"""Create a basic subscription for testing."""
|
||||||
# Create tier first if not exists
|
# Create tier first if not exists
|
||||||
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first()
|
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first()
|
||||||
@@ -264,8 +264,8 @@ def test_subscription(db, test_vendor):
|
|||||||
db.add(tier)
|
db.add(tier)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
subscription = VendorSubscription(
|
subscription = MerchantSubscription(
|
||||||
vendor_id=test_vendor.id,
|
store_id=test_store.id,
|
||||||
tier="essential",
|
tier="essential",
|
||||||
status=SubscriptionStatus.TRIAL,
|
status=SubscriptionStatus.TRIAL,
|
||||||
period_start=datetime.now(timezone.utc),
|
period_start=datetime.now(timezone.utc),
|
||||||
@@ -278,7 +278,7 @@ def test_subscription(db, test_vendor):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_active_subscription(db, test_vendor):
|
def test_active_subscription(db, test_store):
|
||||||
"""Create an active subscription with Stripe IDs."""
|
"""Create an active subscription with Stripe IDs."""
|
||||||
# Create tier first if not exists
|
# Create tier first if not exists
|
||||||
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first()
|
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first()
|
||||||
@@ -294,8 +294,8 @@ def test_active_subscription(db, test_vendor):
|
|||||||
db.add(tier)
|
db.add(tier)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
subscription = VendorSubscription(
|
subscription = MerchantSubscription(
|
||||||
vendor_id=test_vendor.id,
|
store_id=test_store.id,
|
||||||
tier="essential",
|
tier="essential",
|
||||||
status=SubscriptionStatus.ACTIVE,
|
status=SubscriptionStatus.ACTIVE,
|
||||||
stripe_customer_id="cus_test123",
|
stripe_customer_id="cus_test123",
|
||||||
|
|||||||
@@ -5,32 +5,32 @@ import pytest
|
|||||||
|
|
||||||
from app.modules.analytics.services.usage_service import UsageService, usage_service
|
from app.modules.analytics.services.usage_service import UsageService, usage_service
|
||||||
from app.modules.catalog.models import Product
|
from app.modules.catalog.models import Product
|
||||||
from app.modules.billing.models import SubscriptionTier, VendorSubscription
|
from app.modules.billing.models import SubscriptionTier, MerchantSubscription
|
||||||
from app.modules.tenancy.models import VendorUser
|
from app.modules.tenancy.models import StoreUser
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@pytest.mark.usage
|
@pytest.mark.usage
|
||||||
class TestUsageServiceGetUsage:
|
class TestUsageServiceGetUsage:
|
||||||
"""Test suite for get_vendor_usage operation."""
|
"""Test suite for get_store_usage operation."""
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
"""Initialize service instance before each test."""
|
"""Initialize service instance before each test."""
|
||||||
self.service = UsageService()
|
self.service = UsageService()
|
||||||
|
|
||||||
def test_get_vendor_usage_basic(self, db, test_vendor_with_subscription):
|
def test_get_store_usage_basic(self, db, test_store_with_subscription):
|
||||||
"""Test getting basic usage data."""
|
"""Test getting basic usage data."""
|
||||||
vendor_id = test_vendor_with_subscription.id
|
store_id = test_store_with_subscription.id
|
||||||
usage = self.service.get_vendor_usage(db, vendor_id)
|
usage = self.service.get_store_usage(db, store_id)
|
||||||
|
|
||||||
assert usage.tier.code == "essential"
|
assert usage.tier.code == "essential"
|
||||||
assert usage.tier.name == "Essential"
|
assert usage.tier.name == "Essential"
|
||||||
assert len(usage.usage) == 3
|
assert len(usage.usage) == 3
|
||||||
|
|
||||||
def test_get_vendor_usage_metrics(self, db, test_vendor_with_subscription):
|
def test_get_store_usage_metrics(self, db, test_store_with_subscription):
|
||||||
"""Test usage metrics are calculated correctly."""
|
"""Test usage metrics are calculated correctly."""
|
||||||
vendor_id = test_vendor_with_subscription.id
|
store_id = test_store_with_subscription.id
|
||||||
usage = self.service.get_vendor_usage(db, vendor_id)
|
usage = self.service.get_store_usage(db, store_id)
|
||||||
|
|
||||||
orders_metric = next((m for m in usage.usage if m.name == "orders"), None)
|
orders_metric = next((m for m in usage.usage if m.name == "orders"), None)
|
||||||
assert orders_metric is not None
|
assert orders_metric is not None
|
||||||
@@ -39,39 +39,39 @@ class TestUsageServiceGetUsage:
|
|||||||
assert orders_metric.percentage == 10.0
|
assert orders_metric.percentage == 10.0
|
||||||
assert orders_metric.is_unlimited is False
|
assert orders_metric.is_unlimited is False
|
||||||
|
|
||||||
def test_get_vendor_usage_at_limit(self, db, test_vendor_at_limit):
|
def test_get_store_usage_at_limit(self, db, test_store_at_limit):
|
||||||
"""Test usage shows at limit correctly."""
|
"""Test usage shows at limit correctly."""
|
||||||
vendor_id = test_vendor_at_limit.id
|
store_id = test_store_at_limit.id
|
||||||
usage = self.service.get_vendor_usage(db, vendor_id)
|
usage = self.service.get_store_usage(db, store_id)
|
||||||
|
|
||||||
orders_metric = next((m for m in usage.usage if m.name == "orders"), None)
|
orders_metric = next((m for m in usage.usage if m.name == "orders"), None)
|
||||||
assert orders_metric.is_at_limit is True
|
assert orders_metric.is_at_limit is True
|
||||||
assert usage.has_limits_reached is True
|
assert usage.has_limits_reached is True
|
||||||
|
|
||||||
def test_get_vendor_usage_approaching_limit(self, db, test_vendor_approaching_limit):
|
def test_get_store_usage_approaching_limit(self, db, test_store_approaching_limit):
|
||||||
"""Test usage shows approaching limit correctly."""
|
"""Test usage shows approaching limit correctly."""
|
||||||
vendor_id = test_vendor_approaching_limit.id
|
store_id = test_store_approaching_limit.id
|
||||||
usage = self.service.get_vendor_usage(db, vendor_id)
|
usage = self.service.get_store_usage(db, store_id)
|
||||||
|
|
||||||
orders_metric = next((m for m in usage.usage if m.name == "orders"), None)
|
orders_metric = next((m for m in usage.usage if m.name == "orders"), None)
|
||||||
assert orders_metric.is_approaching_limit is True
|
assert orders_metric.is_approaching_limit is True
|
||||||
assert usage.has_limits_approaching is True
|
assert usage.has_limits_approaching is True
|
||||||
|
|
||||||
def test_get_vendor_usage_upgrade_available(
|
def test_get_store_usage_upgrade_available(
|
||||||
self, db, test_vendor_with_subscription, test_professional_tier
|
self, db, test_store_with_subscription, test_professional_tier
|
||||||
):
|
):
|
||||||
"""Test upgrade info when not on highest tier."""
|
"""Test upgrade info when not on highest tier."""
|
||||||
vendor_id = test_vendor_with_subscription.id
|
store_id = test_store_with_subscription.id
|
||||||
usage = self.service.get_vendor_usage(db, vendor_id)
|
usage = self.service.get_store_usage(db, store_id)
|
||||||
|
|
||||||
assert usage.upgrade_available is True
|
assert usage.upgrade_available is True
|
||||||
assert usage.upgrade_tier is not None
|
assert usage.upgrade_tier is not None
|
||||||
assert usage.upgrade_tier.code == "professional"
|
assert usage.upgrade_tier.code == "professional"
|
||||||
|
|
||||||
def test_get_vendor_usage_highest_tier(self, db, test_vendor_on_professional):
|
def test_get_store_usage_highest_tier(self, db, test_store_on_professional):
|
||||||
"""Test no upgrade when on highest tier."""
|
"""Test no upgrade when on highest tier."""
|
||||||
vendor_id = test_vendor_on_professional.id
|
store_id = test_store_on_professional.id
|
||||||
usage = self.service.get_vendor_usage(db, vendor_id)
|
usage = self.service.get_store_usage(db, store_id)
|
||||||
|
|
||||||
assert usage.tier.is_highest_tier is True
|
assert usage.tier.is_highest_tier is True
|
||||||
assert usage.upgrade_available is False
|
assert usage.upgrade_available is False
|
||||||
@@ -87,28 +87,28 @@ class TestUsageServiceCheckLimit:
|
|||||||
"""Initialize service instance before each test."""
|
"""Initialize service instance before each test."""
|
||||||
self.service = UsageService()
|
self.service = UsageService()
|
||||||
|
|
||||||
def test_check_orders_limit_can_proceed(self, db, test_vendor_with_subscription):
|
def test_check_orders_limit_can_proceed(self, db, test_store_with_subscription):
|
||||||
"""Test checking orders limit when under limit."""
|
"""Test checking orders limit when under limit."""
|
||||||
vendor_id = test_vendor_with_subscription.id
|
store_id = test_store_with_subscription.id
|
||||||
result = self.service.check_limit(db, vendor_id, "orders")
|
result = self.service.check_limit(db, store_id, "orders")
|
||||||
|
|
||||||
assert result.can_proceed is True
|
assert result.can_proceed is True
|
||||||
assert result.current == 10
|
assert result.current == 10
|
||||||
assert result.limit == 100
|
assert result.limit == 100
|
||||||
|
|
||||||
def test_check_products_limit(self, db, test_vendor_with_products):
|
def test_check_products_limit(self, db, test_store_with_products):
|
||||||
"""Test checking products limit."""
|
"""Test checking products limit."""
|
||||||
vendor_id = test_vendor_with_products.id
|
store_id = test_store_with_products.id
|
||||||
result = self.service.check_limit(db, vendor_id, "products")
|
result = self.service.check_limit(db, store_id, "products")
|
||||||
|
|
||||||
assert result.can_proceed is True
|
assert result.can_proceed is True
|
||||||
assert result.current == 5
|
assert result.current == 5
|
||||||
assert result.limit == 500
|
assert result.limit == 500
|
||||||
|
|
||||||
def test_check_team_members_limit(self, db, test_vendor_with_team):
|
def test_check_team_members_limit(self, db, test_store_with_team):
|
||||||
"""Test checking team members limit when at limit."""
|
"""Test checking team members limit when at limit."""
|
||||||
vendor_id = test_vendor_with_team.id
|
store_id = test_store_with_team.id
|
||||||
result = self.service.check_limit(db, vendor_id, "team_members")
|
result = self.service.check_limit(db, store_id, "team_members")
|
||||||
|
|
||||||
# At limit (2/2) - can_proceed should be False
|
# At limit (2/2) - can_proceed should be False
|
||||||
assert result.can_proceed is False
|
assert result.can_proceed is False
|
||||||
@@ -116,18 +116,18 @@ class TestUsageServiceCheckLimit:
|
|||||||
assert result.limit == 2
|
assert result.limit == 2
|
||||||
assert result.percentage == 100.0
|
assert result.percentage == 100.0
|
||||||
|
|
||||||
def test_check_unknown_limit_type(self, db, test_vendor_with_subscription):
|
def test_check_unknown_limit_type(self, db, test_store_with_subscription):
|
||||||
"""Test checking unknown limit type."""
|
"""Test checking unknown limit type."""
|
||||||
vendor_id = test_vendor_with_subscription.id
|
store_id = test_store_with_subscription.id
|
||||||
result = self.service.check_limit(db, vendor_id, "unknown")
|
result = self.service.check_limit(db, store_id, "unknown")
|
||||||
|
|
||||||
assert result.can_proceed is True
|
assert result.can_proceed is True
|
||||||
assert "Unknown limit type" in result.message
|
assert "Unknown limit type" in result.message
|
||||||
|
|
||||||
def test_check_limit_upgrade_info_when_blocked(self, db, test_vendor_at_limit):
|
def test_check_limit_upgrade_info_when_blocked(self, db, test_store_at_limit):
|
||||||
"""Test upgrade info is provided when at limit."""
|
"""Test upgrade info is provided when at limit."""
|
||||||
vendor_id = test_vendor_at_limit.id
|
store_id = test_store_at_limit.id
|
||||||
result = self.service.check_limit(db, vendor_id, "orders")
|
result = self.service.check_limit(db, store_id, "orders")
|
||||||
|
|
||||||
assert result.can_proceed is False
|
assert result.can_proceed is False
|
||||||
assert result.upgrade_tier_code == "professional"
|
assert result.upgrade_tier_code == "professional"
|
||||||
@@ -182,13 +182,13 @@ def test_professional_tier(db, test_essential_tier):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_vendor_with_subscription(db, test_vendor, test_essential_tier):
|
def test_store_with_subscription(db, test_store, test_essential_tier):
|
||||||
"""Create vendor with active subscription."""
|
"""Create store with active subscription."""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
subscription = VendorSubscription(
|
subscription = StoreSubscription(
|
||||||
vendor_id=test_vendor.id,
|
store_id=test_store.id,
|
||||||
tier="essential",
|
tier="essential",
|
||||||
tier_id=test_essential_tier.id,
|
tier_id=test_essential_tier.id,
|
||||||
status="active",
|
status="active",
|
||||||
@@ -198,18 +198,18 @@ def test_vendor_with_subscription(db, test_vendor, test_essential_tier):
|
|||||||
)
|
)
|
||||||
db.add(subscription)
|
db.add(subscription)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(test_vendor)
|
db.refresh(test_store)
|
||||||
return test_vendor
|
return test_store
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_vendor_at_limit(db, test_vendor, test_essential_tier, test_professional_tier):
|
def test_store_at_limit(db, test_store, test_essential_tier, test_professional_tier):
|
||||||
"""Create vendor at order limit."""
|
"""Create store at order limit."""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
subscription = VendorSubscription(
|
subscription = StoreSubscription(
|
||||||
vendor_id=test_vendor.id,
|
store_id=test_store.id,
|
||||||
tier="essential",
|
tier="essential",
|
||||||
tier_id=test_essential_tier.id,
|
tier_id=test_essential_tier.id,
|
||||||
status="active",
|
status="active",
|
||||||
@@ -219,18 +219,18 @@ def test_vendor_at_limit(db, test_vendor, test_essential_tier, test_professional
|
|||||||
)
|
)
|
||||||
db.add(subscription)
|
db.add(subscription)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(test_vendor)
|
db.refresh(test_store)
|
||||||
return test_vendor
|
return test_store
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_vendor_approaching_limit(db, test_vendor, test_essential_tier):
|
def test_store_approaching_limit(db, test_store, test_essential_tier):
|
||||||
"""Create vendor approaching order limit (>=80%)."""
|
"""Create store approaching order limit (>=80%)."""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
subscription = VendorSubscription(
|
subscription = StoreSubscription(
|
||||||
vendor_id=test_vendor.id,
|
store_id=test_store.id,
|
||||||
tier="essential",
|
tier="essential",
|
||||||
tier_id=test_essential_tier.id,
|
tier_id=test_essential_tier.id,
|
||||||
status="active",
|
status="active",
|
||||||
@@ -240,18 +240,18 @@ def test_vendor_approaching_limit(db, test_vendor, test_essential_tier):
|
|||||||
)
|
)
|
||||||
db.add(subscription)
|
db.add(subscription)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(test_vendor)
|
db.refresh(test_store)
|
||||||
return test_vendor
|
return test_store
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_vendor_on_professional(db, test_vendor, test_professional_tier):
|
def test_store_on_professional(db, test_store, test_professional_tier):
|
||||||
"""Create vendor on highest tier."""
|
"""Create store on highest tier."""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
subscription = VendorSubscription(
|
subscription = StoreSubscription(
|
||||||
vendor_id=test_vendor.id,
|
store_id=test_store.id,
|
||||||
tier="professional",
|
tier="professional",
|
||||||
tier_id=test_professional_tier.id,
|
tier_id=test_professional_tier.id,
|
||||||
status="active",
|
status="active",
|
||||||
@@ -261,48 +261,48 @@ def test_vendor_on_professional(db, test_vendor, test_professional_tier):
|
|||||||
)
|
)
|
||||||
db.add(subscription)
|
db.add(subscription)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(test_vendor)
|
db.refresh(test_store)
|
||||||
return test_vendor
|
return test_store
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_vendor_with_products(db, test_vendor_with_subscription, marketplace_product_factory):
|
def test_store_with_products(db, test_store_with_subscription, marketplace_product_factory):
|
||||||
"""Create vendor with products."""
|
"""Create store with products."""
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
# Create marketplace product first
|
# Create marketplace product first
|
||||||
mp = marketplace_product_factory(db, title=f"Test Product {i}")
|
mp = marketplace_product_factory(db, title=f"Test Product {i}")
|
||||||
product = Product(
|
product = Product(
|
||||||
vendor_id=test_vendor_with_subscription.id,
|
store_id=test_store_with_subscription.id,
|
||||||
marketplace_product_id=mp.id,
|
marketplace_product_id=mp.id,
|
||||||
price_cents=1000,
|
price_cents=1000,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
db.add(product)
|
db.add(product)
|
||||||
db.commit()
|
db.commit()
|
||||||
return test_vendor_with_subscription
|
return test_store_with_subscription
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_vendor_with_team(db, test_vendor_with_subscription, test_user, other_user):
|
def test_store_with_team(db, test_store_with_subscription, test_user, other_user):
|
||||||
"""Create vendor with team members (owner + team member = 2)."""
|
"""Create store with team members (owner + team member = 2)."""
|
||||||
from app.modules.tenancy.models import VendorUserType
|
from app.modules.tenancy.models import StoreUserType
|
||||||
|
|
||||||
# Add owner
|
# Add owner
|
||||||
owner = VendorUser(
|
owner = StoreUser(
|
||||||
vendor_id=test_vendor_with_subscription.id,
|
store_id=test_store_with_subscription.id,
|
||||||
user_id=test_user.id,
|
user_id=test_user.id,
|
||||||
user_type=VendorUserType.OWNER.value,
|
user_type=StoreUserType.OWNER.value,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
db.add(owner)
|
db.add(owner)
|
||||||
|
|
||||||
# Add team member
|
# Add team member
|
||||||
team_member = VendorUser(
|
team_member = StoreUser(
|
||||||
vendor_id=test_vendor_with_subscription.id,
|
store_id=test_store_with_subscription.id,
|
||||||
user_id=other_user.id,
|
user_id=other_user.id,
|
||||||
user_type=VendorUserType.TEAM_MEMBER.value,
|
user_type=StoreUserType.TEAM_MEMBER.value,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
db.add(team_member)
|
db.add(team_member)
|
||||||
db.commit()
|
db.commit()
|
||||||
return test_vendor_with_subscription
|
return test_store_with_subscription
|
||||||
|
|||||||
Reference in New Issue
Block a user