feat: complete subscription billing system phases 6-10
Phase 6 - Database-driven tiers: - Update subscription_service to query database first with legacy fallback - Add get_tier_info() db parameter and _get_tier_from_legacy() method Phase 7 - Platform health integration: - Add get_subscription_capacity() for theoretical vs actual capacity - Include subscription capacity in full health report Phase 8 - Background subscription tasks: - Add reset_period_counters() for billing period resets - Add check_trial_expirations() for trial management - Add sync_stripe_status() for Stripe synchronization - Add cleanup_stale_subscriptions() for maintenance - Add capture_capacity_snapshot() for daily metrics Phase 10 - Capacity planning & forecasting: - Add CapacitySnapshot model for historical tracking - Create capacity_forecast_service with growth trends - Add /subscription-capacity, /trends, /recommendations endpoints - Add /snapshot endpoint for manual captures Also includes billing API enhancements from phase 4: - Add upcoming-invoice, change-tier, addon purchase/cancel endpoints - Add UsageSummary schema for billing page - Enhance billing.js with addon management functions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
|||||||
|
"""Add capacity_snapshots table
|
||||||
|
|
||||||
|
Revision ID: l0a1b2c3d4e5
|
||||||
|
Revises: k9f0a1b2c3d4
|
||||||
|
Create Date: 2025-12-26
|
||||||
|
|
||||||
|
Adds table for tracking daily platform capacity metrics for growth forecasting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "l0a1b2c3d4e5"
|
||||||
|
down_revision = "k9f0a1b2c3d4"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"capacity_snapshots",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("snapshot_date", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
# Vendor metrics
|
||||||
|
sa.Column("total_vendors", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("active_vendors", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("trial_vendors", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
# Subscription metrics
|
||||||
|
sa.Column("total_subscriptions", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("active_subscriptions", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
# Resource metrics
|
||||||
|
sa.Column("total_products", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("total_orders_month", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("total_team_members", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
# Storage metrics
|
||||||
|
sa.Column("storage_used_gb", sa.Numeric(10, 2), nullable=False, server_default="0"),
|
||||||
|
sa.Column("db_size_mb", sa.Numeric(10, 2), nullable=False, server_default="0"),
|
||||||
|
# Capacity metrics
|
||||||
|
sa.Column("theoretical_products_limit", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("theoretical_orders_limit", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("theoretical_team_limit", sa.Integer(), nullable=True),
|
||||||
|
# Tier distribution
|
||||||
|
sa.Column("tier_distribution", sa.JSON(), nullable=True),
|
||||||
|
# Performance metrics
|
||||||
|
sa.Column("avg_response_ms", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("peak_cpu_percent", sa.Numeric(5, 2), nullable=True),
|
||||||
|
sa.Column("peak_memory_percent", sa.Numeric(5, 2), nullable=True),
|
||||||
|
# Timestamps
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
# Primary key
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes
|
||||||
|
op.create_index("ix_capacity_snapshots_id", "capacity_snapshots", ["id"], unique=False)
|
||||||
|
op.create_index("ix_capacity_snapshots_date", "capacity_snapshots", ["snapshot_date"], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_capacity_snapshots_date", table_name="capacity_snapshots")
|
||||||
|
op.drop_index("ix_capacity_snapshots_id", table_name="capacity_snapshots")
|
||||||
|
op.drop_table("capacity_snapshots")
|
||||||
@@ -144,3 +144,71 @@ async def get_capacity_metrics(
|
|||||||
"""Get capacity-focused metrics for planning."""
|
"""Get capacity-focused metrics for planning."""
|
||||||
metrics = platform_health_service.get_capacity_metrics(db)
|
metrics = platform_health_service.get_capacity_metrics(db)
|
||||||
return CapacityMetricsResponse(**metrics)
|
return CapacityMetricsResponse(**metrics)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/subscription-capacity")
|
||||||
|
async def get_subscription_capacity(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get subscription-based capacity metrics.
|
||||||
|
|
||||||
|
Shows theoretical vs actual capacity based on all vendor subscriptions.
|
||||||
|
"""
|
||||||
|
return platform_health_service.get_subscription_capacity(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trends")
|
||||||
|
async def get_growth_trends(
|
||||||
|
days: int = 30,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get growth trends over the specified period.
|
||||||
|
|
||||||
|
Returns growth rates and projections for key metrics.
|
||||||
|
"""
|
||||||
|
from app.services.capacity_forecast_service import capacity_forecast_service
|
||||||
|
|
||||||
|
return capacity_forecast_service.get_growth_trends(db, days=days)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recommendations")
|
||||||
|
async def get_scaling_recommendations(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get scaling recommendations based on current capacity and growth.
|
||||||
|
|
||||||
|
Returns prioritized list of recommendations.
|
||||||
|
"""
|
||||||
|
from app.services.capacity_forecast_service import capacity_forecast_service
|
||||||
|
|
||||||
|
return capacity_forecast_service.get_scaling_recommendations(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/snapshot")
|
||||||
|
async def capture_snapshot(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Manually capture a capacity snapshot.
|
||||||
|
|
||||||
|
Normally run automatically by daily background job.
|
||||||
|
"""
|
||||||
|
from app.services.capacity_forecast_service import capacity_forecast_service
|
||||||
|
|
||||||
|
snapshot = capacity_forecast_service.capture_daily_snapshot(db)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": snapshot.id,
|
||||||
|
"snapshot_date": snapshot.snapshot_date.isoformat(),
|
||||||
|
"total_vendors": snapshot.total_vendors,
|
||||||
|
"total_products": snapshot.total_products,
|
||||||
|
"message": "Snapshot captured successfully",
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,12 +12,16 @@ Provides endpoints for platform administrators to manage:
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Path, Query
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_admin_api
|
from app.api.deps import get_current_admin_api
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.services.admin_subscription_service import admin_subscription_service
|
from app.services.admin_subscription_service import admin_subscription_service
|
||||||
|
from models.database.product import Product
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
|
from models.database.vendor import VendorUser
|
||||||
|
from app.services.subscription_service import subscription_service
|
||||||
from models.schema.billing import (
|
from models.schema.billing import (
|
||||||
BillingHistoryListResponse,
|
BillingHistoryListResponse,
|
||||||
BillingHistoryWithVendor,
|
BillingHistoryWithVendor,
|
||||||
@@ -26,6 +30,7 @@ from models.schema.billing import (
|
|||||||
SubscriptionTierListResponse,
|
SubscriptionTierListResponse,
|
||||||
SubscriptionTierResponse,
|
SubscriptionTierResponse,
|
||||||
SubscriptionTierUpdate,
|
SubscriptionTierUpdate,
|
||||||
|
VendorSubscriptionCreate,
|
||||||
VendorSubscriptionListResponse,
|
VendorSubscriptionListResponse,
|
||||||
VendorSubscriptionResponse,
|
VendorSubscriptionResponse,
|
||||||
VendorSubscriptionUpdate,
|
VendorSubscriptionUpdate,
|
||||||
@@ -144,9 +149,6 @@ def list_vendor_subscriptions(
|
|||||||
**VendorSubscriptionResponse.model_validate(sub).model_dump(),
|
**VendorSubscriptionResponse.model_validate(sub).model_dump(),
|
||||||
"vendor_name": vendor.name,
|
"vendor_name": vendor.name,
|
||||||
"vendor_code": vendor.subdomain,
|
"vendor_code": vendor.subdomain,
|
||||||
"orders_limit": sub.orders_limit,
|
|
||||||
"products_limit": sub.products_limit,
|
|
||||||
"team_members_limit": sub.team_members_limit,
|
|
||||||
}
|
}
|
||||||
subscriptions.append(VendorSubscriptionWithVendor(**sub_dict))
|
subscriptions.append(VendorSubscriptionWithVendor(**sub_dict))
|
||||||
|
|
||||||
@@ -231,6 +233,73 @@ def list_billing_history(
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@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.
|
||||||
|
"""
|
||||||
|
from models.database.vendor import Vendor
|
||||||
|
|
||||||
|
# Verify vendor exists
|
||||||
|
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
||||||
|
if not vendor:
|
||||||
|
from app.exceptions import ResourceNotFoundException
|
||||||
|
raise ResourceNotFoundException("Vendor", str(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
|
||||||
|
products_count = (
|
||||||
|
db.query(func.count(Product.id))
|
||||||
|
.filter(Product.vendor_id == vendor_id)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
team_count = (
|
||||||
|
db.query(func.count(VendorUser.id))
|
||||||
|
.filter(
|
||||||
|
VendorUser.vendor_id == vendor_id,
|
||||||
|
VendorUser.is_active == True, # noqa: E712
|
||||||
|
)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
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=products_count,
|
||||||
|
team_count=team_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{vendor_id}", response_model=VendorSubscriptionWithVendor)
|
@router.get("/{vendor_id}", response_model=VendorSubscriptionWithVendor)
|
||||||
def get_vendor_subscription(
|
def get_vendor_subscription(
|
||||||
vendor_id: int = Path(..., description="Vendor ID"),
|
vendor_id: int = Path(..., description="Vendor ID"),
|
||||||
@@ -240,13 +309,30 @@ def get_vendor_subscription(
|
|||||||
"""Get subscription details for a specific vendor."""
|
"""Get subscription details for a specific vendor."""
|
||||||
sub, vendor = admin_subscription_service.get_subscription(db, vendor_id)
|
sub, vendor = admin_subscription_service.get_subscription(db, vendor_id)
|
||||||
|
|
||||||
|
# Get usage counts
|
||||||
|
products_count = (
|
||||||
|
db.query(func.count(Product.id))
|
||||||
|
.filter(Product.vendor_id == vendor_id)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
team_count = (
|
||||||
|
db.query(func.count(VendorUser.id))
|
||||||
|
.filter(
|
||||||
|
VendorUser.vendor_id == vendor_id,
|
||||||
|
VendorUser.is_active == True, # noqa: E712
|
||||||
|
)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
return VendorSubscriptionWithVendor(
|
return VendorSubscriptionWithVendor(
|
||||||
**VendorSubscriptionResponse.model_validate(sub).model_dump(),
|
**VendorSubscriptionResponse.model_validate(sub).model_dump(),
|
||||||
vendor_name=vendor.name,
|
vendor_name=vendor.name,
|
||||||
vendor_code=vendor.subdomain,
|
vendor_code=vendor.subdomain,
|
||||||
orders_limit=sub.orders_limit,
|
products_count=products_count,
|
||||||
products_limit=sub.products_limit,
|
team_count=team_count,
|
||||||
team_members_limit=sub.team_members_limit,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -271,11 +357,28 @@ def update_vendor_subscription(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(sub)
|
db.refresh(sub)
|
||||||
|
|
||||||
|
# Get usage counts
|
||||||
|
products_count = (
|
||||||
|
db.query(func.count(Product.id))
|
||||||
|
.filter(Product.vendor_id == vendor_id)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
team_count = (
|
||||||
|
db.query(func.count(VendorUser.id))
|
||||||
|
.filter(
|
||||||
|
VendorUser.vendor_id == vendor_id,
|
||||||
|
VendorUser.is_active == True, # noqa: E712
|
||||||
|
)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
return VendorSubscriptionWithVendor(
|
return VendorSubscriptionWithVendor(
|
||||||
**VendorSubscriptionResponse.model_validate(sub).model_dump(),
|
**VendorSubscriptionResponse.model_validate(sub).model_dump(),
|
||||||
vendor_name=vendor.name,
|
vendor_name=vendor.name,
|
||||||
vendor_code=vendor.subdomain,
|
vendor_code=vendor.subdomain,
|
||||||
orders_limit=sub.orders_limit,
|
products_count=products_count,
|
||||||
products_limit=sub.products_limit,
|
team_count=team_count,
|
||||||
team_members_limit=sub.team_members_limit,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.api.deps import get_current_admin_api
|
from app.api.deps import get_current_admin_api
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.services.subscription_service import subscription_service
|
||||||
from app.services.vendor_product_service import vendor_product_service
|
from app.services.vendor_product_service import vendor_product_service
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
from models.schema.vendor_product import (
|
from models.schema.vendor_product import (
|
||||||
@@ -119,6 +120,9 @@ def create_vendor_product(
|
|||||||
current_admin: User = Depends(get_current_admin_api),
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
):
|
):
|
||||||
"""Create a new vendor product."""
|
"""Create a new vendor product."""
|
||||||
|
# Check product limit before creating
|
||||||
|
subscription_service.check_product_limit(db, data.vendor_id)
|
||||||
|
|
||||||
product = vendor_product_service.create_product(db, data.model_dump())
|
product = vendor_product_service.create_product(db, data.model_dump())
|
||||||
db.commit() # ✅ ARCH: Commit at API level for transaction control
|
db.commit() # ✅ ARCH: Commit at API level for transaction control
|
||||||
return VendorProductCreateResponse(
|
return VendorProductCreateResponse(
|
||||||
|
|||||||
120
app/api/v1/vendor/billing.py
vendored
120
app/api/v1/vendor/billing.py
vendored
@@ -179,6 +179,37 @@ class CancelResponse(BaseModel):
|
|||||||
effective_date: str
|
effective_date: str
|
||||||
|
|
||||||
|
|
||||||
|
class UpcomingInvoiceResponse(BaseModel):
|
||||||
|
"""Upcoming invoice preview."""
|
||||||
|
|
||||||
|
amount_due_cents: int
|
||||||
|
currency: str
|
||||||
|
next_payment_date: str | None = None
|
||||||
|
line_items: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeTierRequest(BaseModel):
|
||||||
|
"""Request to change subscription tier."""
|
||||||
|
|
||||||
|
tier_code: str
|
||||||
|
is_annual: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeTierResponse(BaseModel):
|
||||||
|
"""Response for tier change."""
|
||||||
|
|
||||||
|
message: str
|
||||||
|
new_tier: str
|
||||||
|
effective_immediately: bool
|
||||||
|
|
||||||
|
|
||||||
|
class AddOnCancelResponse(BaseModel):
|
||||||
|
"""Response for add-on cancellation."""
|
||||||
|
|
||||||
|
message: str
|
||||||
|
addon_code: str
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Endpoints
|
# Endpoints
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -403,3 +434,92 @@ def reactivate_subscription(
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/upcoming-invoice", response_model=UpcomingInvoiceResponse)
|
||||||
|
def get_upcoming_invoice(
|
||||||
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Preview the upcoming invoice."""
|
||||||
|
vendor_id = current_user.token_vendor_id
|
||||||
|
|
||||||
|
result = billing_service.get_upcoming_invoice(db, vendor_id)
|
||||||
|
|
||||||
|
return UpcomingInvoiceResponse(
|
||||||
|
amount_due_cents=result.get("amount_due_cents", 0),
|
||||||
|
currency=result.get("currency", "EUR"),
|
||||||
|
next_payment_date=result.get("next_payment_date"),
|
||||||
|
line_items=result.get("line_items", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-tier", response_model=ChangeTierResponse)
|
||||||
|
def change_tier(
|
||||||
|
request: ChangeTierRequest,
|
||||||
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Change subscription tier (upgrade/downgrade)."""
|
||||||
|
vendor_id = current_user.token_vendor_id
|
||||||
|
|
||||||
|
result = billing_service.change_tier(
|
||||||
|
db=db,
|
||||||
|
vendor_id=vendor_id,
|
||||||
|
new_tier_code=request.tier_code,
|
||||||
|
is_annual=request.is_annual,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return ChangeTierResponse(
|
||||||
|
message=result["message"],
|
||||||
|
new_tier=result["new_tier"],
|
||||||
|
effective_immediately=result["effective_immediately"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/addons/purchase")
|
||||||
|
def purchase_addon(
|
||||||
|
request: AddOnPurchaseRequest,
|
||||||
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Purchase an add-on product."""
|
||||||
|
vendor_id = current_user.token_vendor_id
|
||||||
|
vendor = billing_service.get_vendor(db, vendor_id)
|
||||||
|
|
||||||
|
# Build URLs
|
||||||
|
base_url = f"https://{settings.platform_domain}"
|
||||||
|
success_url = f"{base_url}/vendor/{vendor.vendor_code}/billing?addon_success=true"
|
||||||
|
cancel_url = f"{base_url}/vendor/{vendor.vendor_code}/billing?addon_cancelled=true"
|
||||||
|
|
||||||
|
result = billing_service.purchase_addon(
|
||||||
|
db=db,
|
||||||
|
vendor_id=vendor_id,
|
||||||
|
addon_code=request.addon_code,
|
||||||
|
domain_name=request.domain_name,
|
||||||
|
quantity=request.quantity,
|
||||||
|
success_url=success_url,
|
||||||
|
cancel_url=cancel_url,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/addons/{addon_id}", response_model=AddOnCancelResponse)
|
||||||
|
def cancel_addon(
|
||||||
|
addon_id: int,
|
||||||
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Cancel a purchased add-on."""
|
||||||
|
vendor_id = current_user.token_vendor_id
|
||||||
|
|
||||||
|
result = billing_service.cancel_addon(db, vendor_id, addon_id)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return AddOnCancelResponse(
|
||||||
|
message=result["message"],
|
||||||
|
addon_code=result["addon_code"],
|
||||||
|
)
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ class AdminSubscriptionService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
raise ResourceNotFoundException(f"Subscription for vendor {vendor_id} not found")
|
raise ResourceNotFoundException("Subscription", str(vendor_id))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -365,6 +365,224 @@ class BillingService:
|
|||||||
|
|
||||||
return {"message": "Subscription reactivated successfully"}
|
return {"message": "Subscription reactivated successfully"}
|
||||||
|
|
||||||
|
def get_upcoming_invoice(self, db: Session, vendor_id: int) -> dict:
|
||||||
|
"""
|
||||||
|
Get upcoming invoice preview.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with amount_due_cents, currency, next_payment_date, line_items
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NoActiveSubscriptionError: If no subscription with customer ID
|
||||||
|
"""
|
||||||
|
subscription = subscription_service.get_subscription(db, vendor_id)
|
||||||
|
|
||||||
|
if not subscription or not subscription.stripe_customer_id:
|
||||||
|
raise NoActiveSubscriptionError()
|
||||||
|
|
||||||
|
if not stripe_service.is_configured:
|
||||||
|
# Return empty preview if Stripe not configured
|
||||||
|
return {
|
||||||
|
"amount_due_cents": 0,
|
||||||
|
"currency": "EUR",
|
||||||
|
"next_payment_date": None,
|
||||||
|
"line_items": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
invoice = stripe_service.get_upcoming_invoice(subscription.stripe_customer_id)
|
||||||
|
|
||||||
|
if not invoice:
|
||||||
|
return {
|
||||||
|
"amount_due_cents": 0,
|
||||||
|
"currency": "EUR",
|
||||||
|
"next_payment_date": None,
|
||||||
|
"line_items": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
line_items = []
|
||||||
|
if invoice.lines and invoice.lines.data:
|
||||||
|
for line in invoice.lines.data:
|
||||||
|
line_items.append({
|
||||||
|
"description": line.description or "",
|
||||||
|
"amount_cents": line.amount,
|
||||||
|
"quantity": line.quantity or 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"amount_due_cents": invoice.amount_due,
|
||||||
|
"currency": invoice.currency.upper(),
|
||||||
|
"next_payment_date": datetime.fromtimestamp(invoice.next_payment_attempt).isoformat()
|
||||||
|
if invoice.next_payment_attempt
|
||||||
|
else None,
|
||||||
|
"line_items": line_items,
|
||||||
|
}
|
||||||
|
|
||||||
|
def change_tier(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
vendor_id: int,
|
||||||
|
new_tier_code: str,
|
||||||
|
is_annual: bool,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Change subscription tier (upgrade/downgrade).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with message, new_tier, effective_immediately
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TierNotFoundError: If tier doesn't exist
|
||||||
|
NoActiveSubscriptionError: If no subscription
|
||||||
|
StripePriceNotConfiguredError: If price not configured
|
||||||
|
"""
|
||||||
|
subscription = subscription_service.get_subscription(db, vendor_id)
|
||||||
|
|
||||||
|
if not subscription or not subscription.stripe_subscription_id:
|
||||||
|
raise NoActiveSubscriptionError()
|
||||||
|
|
||||||
|
tier = self.get_tier_by_code(db, new_tier_code)
|
||||||
|
|
||||||
|
price_id = (
|
||||||
|
tier.stripe_price_annual_id
|
||||||
|
if is_annual and tier.stripe_price_annual_id
|
||||||
|
else tier.stripe_price_monthly_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not price_id:
|
||||||
|
raise StripePriceNotConfiguredError(new_tier_code)
|
||||||
|
|
||||||
|
# Update in Stripe
|
||||||
|
if stripe_service.is_configured:
|
||||||
|
stripe_service.update_subscription(
|
||||||
|
subscription_id=subscription.stripe_subscription_id,
|
||||||
|
new_price_id=price_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update local subscription
|
||||||
|
old_tier = subscription.tier
|
||||||
|
subscription.tier = new_tier_code
|
||||||
|
subscription.tier_id = tier.id
|
||||||
|
subscription.is_annual = is_annual
|
||||||
|
subscription.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
is_upgrade = self._is_upgrade(db, old_tier, new_tier_code)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"Subscription {'upgraded' if is_upgrade else 'changed'} to {tier.name}",
|
||||||
|
"new_tier": new_tier_code,
|
||||||
|
"effective_immediately": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _is_upgrade(self, db: Session, old_tier: str, new_tier: str) -> bool:
|
||||||
|
"""Check if tier change is an upgrade."""
|
||||||
|
old = db.query(SubscriptionTier).filter(SubscriptionTier.code == old_tier).first()
|
||||||
|
new = db.query(SubscriptionTier).filter(SubscriptionTier.code == new_tier).first()
|
||||||
|
|
||||||
|
if not old or not new:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return new.display_order > old.display_order
|
||||||
|
|
||||||
|
def purchase_addon(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
vendor_id: int,
|
||||||
|
addon_code: str,
|
||||||
|
domain_name: str | None,
|
||||||
|
quantity: int,
|
||||||
|
success_url: str,
|
||||||
|
cancel_url: str,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Create checkout session for add-on purchase.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with checkout_url and session_id
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PaymentSystemNotConfiguredError: If Stripe not configured
|
||||||
|
AddonNotFoundError: If addon doesn't exist
|
||||||
|
"""
|
||||||
|
if not stripe_service.is_configured:
|
||||||
|
raise PaymentSystemNotConfiguredError()
|
||||||
|
|
||||||
|
addon = (
|
||||||
|
db.query(AddOnProduct)
|
||||||
|
.filter(
|
||||||
|
AddOnProduct.code == addon_code,
|
||||||
|
AddOnProduct.is_active == True, # noqa: E712
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not addon:
|
||||||
|
raise BillingServiceError(f"Add-on '{addon_code}' not found")
|
||||||
|
|
||||||
|
if not addon.stripe_price_id:
|
||||||
|
raise BillingServiceError(f"Stripe price not configured for add-on '{addon_code}'")
|
||||||
|
|
||||||
|
vendor = self.get_vendor(db, vendor_id)
|
||||||
|
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
|
||||||
|
|
||||||
|
# Create checkout session for add-on
|
||||||
|
session = stripe_service.create_checkout_session(
|
||||||
|
db=db,
|
||||||
|
vendor=vendor,
|
||||||
|
price_id=addon.stripe_price_id,
|
||||||
|
success_url=success_url,
|
||||||
|
cancel_url=cancel_url,
|
||||||
|
quantity=quantity,
|
||||||
|
metadata={
|
||||||
|
"addon_code": addon_code,
|
||||||
|
"domain_name": domain_name or "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"checkout_url": session.url,
|
||||||
|
"session_id": session.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
def cancel_addon(self, db: Session, vendor_id: int, addon_id: int) -> dict:
|
||||||
|
"""
|
||||||
|
Cancel a purchased add-on.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with message and addon_code
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BillingServiceError: If addon not found or not owned by vendor
|
||||||
|
"""
|
||||||
|
vendor_addon = (
|
||||||
|
db.query(VendorAddOn)
|
||||||
|
.filter(
|
||||||
|
VendorAddOn.id == addon_id,
|
||||||
|
VendorAddOn.vendor_id == vendor_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not vendor_addon:
|
||||||
|
raise BillingServiceError("Add-on not found")
|
||||||
|
|
||||||
|
addon_code = vendor_addon.addon_product.code
|
||||||
|
|
||||||
|
# Cancel in Stripe if applicable
|
||||||
|
if stripe_service.is_configured and vendor_addon.stripe_subscription_item_id:
|
||||||
|
try:
|
||||||
|
stripe_service.cancel_subscription_item(vendor_addon.stripe_subscription_item_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to cancel addon in Stripe: {e}")
|
||||||
|
|
||||||
|
# Mark as cancelled
|
||||||
|
vendor_addon.status = "cancelled"
|
||||||
|
vendor_addon.cancelled_at = datetime.utcnow()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Add-on cancelled successfully",
|
||||||
|
"addon_code": addon_code,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Create service instance
|
# Create service instance
|
||||||
billing_service = BillingService()
|
billing_service = BillingService()
|
||||||
|
|||||||
321
app/services/capacity_forecast_service.py
Normal file
321
app/services/capacity_forecast_service.py
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
# app/services/capacity_forecast_service.py
|
||||||
|
"""
|
||||||
|
Capacity forecasting service for growth trends and scaling recommendations.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- Historical capacity trend analysis
|
||||||
|
- Growth rate calculations
|
||||||
|
- Days-until-threshold projections
|
||||||
|
- Scaling recommendations based on growth patterns
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from models.database.product import Product
|
||||||
|
from models.database.subscription import (
|
||||||
|
CapacitySnapshot,
|
||||||
|
SubscriptionStatus,
|
||||||
|
VendorSubscription,
|
||||||
|
)
|
||||||
|
from models.database.vendor import Vendor, VendorUser
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Scaling thresholds based on capacity-planning.md
|
||||||
|
INFRASTRUCTURE_SCALING = [
|
||||||
|
{"name": "Starter", "max_vendors": 50, "max_products": 10_000, "cost_monthly": 30},
|
||||||
|
{"name": "Small", "max_vendors": 100, "max_products": 30_000, "cost_monthly": 80},
|
||||||
|
{"name": "Medium", "max_vendors": 300, "max_products": 100_000, "cost_monthly": 150},
|
||||||
|
{"name": "Large", "max_vendors": 500, "max_products": 250_000, "cost_monthly": 350},
|
||||||
|
{"name": "Scale", "max_vendors": 1000, "max_products": 500_000, "cost_monthly": 700},
|
||||||
|
{"name": "Enterprise", "max_vendors": None, "max_products": None, "cost_monthly": 1500},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CapacityForecastService:
|
||||||
|
"""Service for capacity forecasting and trend analysis."""
|
||||||
|
|
||||||
|
def capture_daily_snapshot(self, db: Session) -> CapacitySnapshot:
|
||||||
|
"""
|
||||||
|
Capture a daily snapshot of platform capacity metrics.
|
||||||
|
|
||||||
|
Should be called by a daily background job.
|
||||||
|
"""
|
||||||
|
from app.services.image_service import image_service
|
||||||
|
from app.services.platform_health_service import platform_health_service
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
# Check if snapshot already exists for today
|
||||||
|
existing = (
|
||||||
|
db.query(CapacitySnapshot)
|
||||||
|
.filter(CapacitySnapshot.snapshot_date == today)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
logger.info(f"Snapshot already exists for {today}")
|
||||||
|
return existing
|
||||||
|
|
||||||
|
# Gather metrics
|
||||||
|
total_vendors = db.query(func.count(Vendor.id)).scalar() or 0
|
||||||
|
active_vendors = (
|
||||||
|
db.query(func.count(Vendor.id))
|
||||||
|
.filter(Vendor.is_active == True) # noqa: E712
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Subscription metrics
|
||||||
|
total_subs = db.query(func.count(VendorSubscription.id)).scalar() or 0
|
||||||
|
active_subs = (
|
||||||
|
db.query(func.count(VendorSubscription.id))
|
||||||
|
.filter(VendorSubscription.status.in_(["active", "trial"]))
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
trial_vendors = (
|
||||||
|
db.query(func.count(VendorSubscription.id))
|
||||||
|
.filter(VendorSubscription.status == SubscriptionStatus.TRIAL.value)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resource metrics
|
||||||
|
total_products = db.query(func.count(Product.id)).scalar() or 0
|
||||||
|
total_team = (
|
||||||
|
db.query(func.count(VendorUser.id))
|
||||||
|
.filter(VendorUser.is_active == True) # noqa: E712
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Orders this month
|
||||||
|
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
total_orders = sum(
|
||||||
|
s.orders_this_period
|
||||||
|
for s in db.query(VendorSubscription).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Storage metrics
|
||||||
|
try:
|
||||||
|
image_stats = image_service.get_storage_stats()
|
||||||
|
storage_gb = image_stats.get("total_size_gb", 0)
|
||||||
|
except Exception:
|
||||||
|
storage_gb = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
db_size = platform_health_service._get_database_size(db)
|
||||||
|
except Exception:
|
||||||
|
db_size = 0
|
||||||
|
|
||||||
|
# Theoretical capacity from subscriptions
|
||||||
|
capacity = platform_health_service.get_subscription_capacity(db)
|
||||||
|
theoretical_products = capacity["products"].get("theoretical_limit", 0)
|
||||||
|
theoretical_orders = capacity["orders_monthly"].get("theoretical_limit", 0)
|
||||||
|
theoretical_team = capacity["team_members"].get("theoretical_limit", 0)
|
||||||
|
|
||||||
|
# Tier distribution
|
||||||
|
tier_distribution = capacity.get("tier_distribution", {})
|
||||||
|
|
||||||
|
# Create snapshot
|
||||||
|
snapshot = CapacitySnapshot(
|
||||||
|
snapshot_date=today,
|
||||||
|
total_vendors=total_vendors,
|
||||||
|
active_vendors=active_vendors,
|
||||||
|
trial_vendors=trial_vendors,
|
||||||
|
total_subscriptions=total_subs,
|
||||||
|
active_subscriptions=active_subs,
|
||||||
|
total_products=total_products,
|
||||||
|
total_orders_month=total_orders,
|
||||||
|
total_team_members=total_team,
|
||||||
|
storage_used_gb=Decimal(str(storage_gb)),
|
||||||
|
db_size_mb=Decimal(str(db_size)),
|
||||||
|
theoretical_products_limit=theoretical_products,
|
||||||
|
theoretical_orders_limit=theoretical_orders,
|
||||||
|
theoretical_team_limit=theoretical_team,
|
||||||
|
tier_distribution=tier_distribution,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(snapshot)
|
||||||
|
db.flush()
|
||||||
|
db.refresh(snapshot)
|
||||||
|
|
||||||
|
logger.info(f"Captured capacity snapshot for {today}")
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
def get_growth_trends(self, db: Session, days: int = 30) -> dict:
|
||||||
|
"""
|
||||||
|
Calculate growth trends over the specified period.
|
||||||
|
|
||||||
|
Returns growth rates and projections for key metrics.
|
||||||
|
"""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
start_date = now - timedelta(days=days)
|
||||||
|
|
||||||
|
# Get snapshots for the period
|
||||||
|
snapshots = (
|
||||||
|
db.query(CapacitySnapshot)
|
||||||
|
.filter(CapacitySnapshot.snapshot_date >= start_date)
|
||||||
|
.order_by(CapacitySnapshot.snapshot_date)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(snapshots) < 2:
|
||||||
|
return {
|
||||||
|
"period_days": days,
|
||||||
|
"snapshots_available": len(snapshots),
|
||||||
|
"trends": {},
|
||||||
|
"message": "Insufficient data for trend analysis",
|
||||||
|
}
|
||||||
|
|
||||||
|
first = snapshots[0]
|
||||||
|
last = snapshots[-1]
|
||||||
|
period_days = (last.snapshot_date - first.snapshot_date).days or 1
|
||||||
|
|
||||||
|
def calc_growth(metric: str) -> dict:
|
||||||
|
start_val = getattr(first, metric) or 0
|
||||||
|
end_val = getattr(last, metric) or 0
|
||||||
|
change = end_val - start_val
|
||||||
|
|
||||||
|
if start_val > 0:
|
||||||
|
growth_rate = (change / start_val) * 100
|
||||||
|
daily_rate = growth_rate / period_days
|
||||||
|
monthly_rate = daily_rate * 30
|
||||||
|
else:
|
||||||
|
growth_rate = 0 if end_val == 0 else 100
|
||||||
|
daily_rate = 0
|
||||||
|
monthly_rate = 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"start_value": start_val,
|
||||||
|
"current_value": end_val,
|
||||||
|
"change": change,
|
||||||
|
"growth_rate_percent": round(growth_rate, 2),
|
||||||
|
"daily_growth_rate": round(daily_rate, 3),
|
||||||
|
"monthly_projection": round(end_val * (1 + monthly_rate / 100), 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
trends = {
|
||||||
|
"vendors": calc_growth("active_vendors"),
|
||||||
|
"products": calc_growth("total_products"),
|
||||||
|
"orders": calc_growth("total_orders_month"),
|
||||||
|
"team_members": calc_growth("total_team_members"),
|
||||||
|
"storage_gb": {
|
||||||
|
"start_value": float(first.storage_used_gb or 0),
|
||||||
|
"current_value": float(last.storage_used_gb or 0),
|
||||||
|
"change": float((last.storage_used_gb or 0) - (first.storage_used_gb or 0)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"period_days": period_days,
|
||||||
|
"snapshots_available": len(snapshots),
|
||||||
|
"start_date": first.snapshot_date.isoformat(),
|
||||||
|
"end_date": last.snapshot_date.isoformat(),
|
||||||
|
"trends": trends,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_scaling_recommendations(self, db: Session) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Generate scaling recommendations based on current capacity and growth.
|
||||||
|
|
||||||
|
Returns prioritized list of recommendations.
|
||||||
|
"""
|
||||||
|
from app.services.platform_health_service import platform_health_service
|
||||||
|
|
||||||
|
recommendations = []
|
||||||
|
|
||||||
|
# Get current capacity
|
||||||
|
capacity = platform_health_service.get_subscription_capacity(db)
|
||||||
|
health = platform_health_service.get_full_health_report(db)
|
||||||
|
trends = self.get_growth_trends(db, days=30)
|
||||||
|
|
||||||
|
# Check product capacity
|
||||||
|
products = capacity["products"]
|
||||||
|
if products.get("utilization_percent") and products["utilization_percent"] > 80:
|
||||||
|
recommendations.append({
|
||||||
|
"category": "capacity",
|
||||||
|
"severity": "warning",
|
||||||
|
"title": "Product capacity approaching limit",
|
||||||
|
"description": f"Currently at {products['utilization_percent']:.0f}% of theoretical product capacity",
|
||||||
|
"action": "Consider upgrading vendor tiers or adding capacity",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check infrastructure tier
|
||||||
|
current_tier = health.get("infrastructure_tier", {})
|
||||||
|
next_trigger = health.get("next_tier_trigger")
|
||||||
|
if next_trigger:
|
||||||
|
recommendations.append({
|
||||||
|
"category": "infrastructure",
|
||||||
|
"severity": "info",
|
||||||
|
"title": f"Current tier: {current_tier.get('name', 'Unknown')}",
|
||||||
|
"description": f"Next upgrade trigger: {next_trigger}",
|
||||||
|
"action": "Monitor growth and plan for infrastructure scaling",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check growth rate
|
||||||
|
if trends.get("trends"):
|
||||||
|
vendor_growth = trends["trends"].get("vendors", {})
|
||||||
|
if vendor_growth.get("monthly_projection", 0) > 0:
|
||||||
|
monthly_rate = vendor_growth.get("growth_rate_percent", 0)
|
||||||
|
if monthly_rate > 20:
|
||||||
|
recommendations.append({
|
||||||
|
"category": "growth",
|
||||||
|
"severity": "info",
|
||||||
|
"title": "High vendor growth rate",
|
||||||
|
"description": f"Vendor base growing at {monthly_rate:.1f}% over last 30 days",
|
||||||
|
"action": "Ensure infrastructure can scale to meet demand",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check storage
|
||||||
|
storage_percent = health.get("image_storage", {}).get("total_size_gb", 0)
|
||||||
|
if storage_percent > 800: # 80% of 1TB
|
||||||
|
recommendations.append({
|
||||||
|
"category": "storage",
|
||||||
|
"severity": "warning",
|
||||||
|
"title": "Storage usage high",
|
||||||
|
"description": f"Image storage at {storage_percent:.1f} GB",
|
||||||
|
"action": "Plan for storage expansion or implement cleanup policies",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by severity
|
||||||
|
severity_order = {"critical": 0, "warning": 1, "info": 2}
|
||||||
|
recommendations.sort(key=lambda r: severity_order.get(r["severity"], 3))
|
||||||
|
|
||||||
|
return recommendations
|
||||||
|
|
||||||
|
def get_days_until_threshold(
|
||||||
|
self, db: Session, metric: str, threshold: int
|
||||||
|
) -> int | None:
|
||||||
|
"""
|
||||||
|
Calculate days until a metric reaches a threshold based on current growth.
|
||||||
|
|
||||||
|
Returns None if insufficient data or no growth.
|
||||||
|
"""
|
||||||
|
trends = self.get_growth_trends(db, days=30)
|
||||||
|
|
||||||
|
if not trends.get("trends") or metric not in trends["trends"]:
|
||||||
|
return None
|
||||||
|
|
||||||
|
metric_data = trends["trends"][metric]
|
||||||
|
current = metric_data.get("current_value", 0)
|
||||||
|
daily_rate = metric_data.get("daily_growth_rate", 0)
|
||||||
|
|
||||||
|
if daily_rate <= 0 or current >= threshold:
|
||||||
|
return None
|
||||||
|
|
||||||
|
remaining = threshold - current
|
||||||
|
days = remaining / (current * daily_rate / 100) if current > 0 else None
|
||||||
|
|
||||||
|
return int(days) if days else None
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
capacity_forecast_service = CapacityForecastService()
|
||||||
@@ -166,6 +166,101 @@ class PlatformHealthService:
|
|||||||
"active_vendors": active_vendors,
|
"active_vendors": active_vendors,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_subscription_capacity(self, db: Session) -> dict:
|
||||||
|
"""
|
||||||
|
Calculate theoretical capacity based on all vendor subscriptions.
|
||||||
|
|
||||||
|
Returns aggregated limits and current usage for capacity planning.
|
||||||
|
"""
|
||||||
|
from models.database.subscription import VendorSubscription
|
||||||
|
from models.database.vendor import VendorUser
|
||||||
|
|
||||||
|
# Get all active subscriptions with their limits
|
||||||
|
subscriptions = (
|
||||||
|
db.query(VendorSubscription)
|
||||||
|
.filter(VendorSubscription.status.in_(["active", "trial"]))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Aggregate theoretical limits
|
||||||
|
total_products_limit = 0
|
||||||
|
total_orders_limit = 0
|
||||||
|
total_team_limit = 0
|
||||||
|
unlimited_products = 0
|
||||||
|
unlimited_orders = 0
|
||||||
|
unlimited_team = 0
|
||||||
|
|
||||||
|
tier_distribution = {}
|
||||||
|
|
||||||
|
for sub in subscriptions:
|
||||||
|
# Track tier distribution
|
||||||
|
tier = sub.tier or "unknown"
|
||||||
|
tier_distribution[tier] = tier_distribution.get(tier, 0) + 1
|
||||||
|
|
||||||
|
# Aggregate limits
|
||||||
|
if sub.products_limit is None:
|
||||||
|
unlimited_products += 1
|
||||||
|
else:
|
||||||
|
total_products_limit += sub.products_limit
|
||||||
|
|
||||||
|
if sub.orders_limit is None:
|
||||||
|
unlimited_orders += 1
|
||||||
|
else:
|
||||||
|
total_orders_limit += sub.orders_limit
|
||||||
|
|
||||||
|
if sub.team_members_limit is None:
|
||||||
|
unlimited_team += 1
|
||||||
|
else:
|
||||||
|
total_team_limit += sub.team_members_limit
|
||||||
|
|
||||||
|
# Get actual usage
|
||||||
|
actual_products = db.query(func.count(Product.id)).scalar() or 0
|
||||||
|
actual_team = (
|
||||||
|
db.query(func.count(VendorUser.id))
|
||||||
|
.filter(VendorUser.is_active == True) # noqa: E712
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Orders this period (aggregate across all subscriptions)
|
||||||
|
total_orders_used = sum(s.orders_this_period for s in subscriptions)
|
||||||
|
|
||||||
|
def calc_utilization(actual: int, limit: int, unlimited: int) -> dict:
|
||||||
|
if unlimited > 0:
|
||||||
|
# Some subscriptions have unlimited - can't calculate true %
|
||||||
|
return {
|
||||||
|
"actual": actual,
|
||||||
|
"theoretical_limit": limit,
|
||||||
|
"unlimited_count": unlimited,
|
||||||
|
"utilization_percent": None,
|
||||||
|
"has_unlimited": True,
|
||||||
|
}
|
||||||
|
elif limit > 0:
|
||||||
|
return {
|
||||||
|
"actual": actual,
|
||||||
|
"theoretical_limit": limit,
|
||||||
|
"unlimited_count": 0,
|
||||||
|
"utilization_percent": round((actual / limit) * 100, 1),
|
||||||
|
"headroom": limit - actual,
|
||||||
|
"has_unlimited": False,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"actual": actual,
|
||||||
|
"theoretical_limit": 0,
|
||||||
|
"unlimited_count": 0,
|
||||||
|
"utilization_percent": 0,
|
||||||
|
"has_unlimited": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_subscriptions": len(subscriptions),
|
||||||
|
"tier_distribution": tier_distribution,
|
||||||
|
"products": calc_utilization(actual_products, total_products_limit, unlimited_products),
|
||||||
|
"orders_monthly": calc_utilization(total_orders_used, total_orders_limit, unlimited_orders),
|
||||||
|
"team_members": calc_utilization(actual_team, total_team_limit, unlimited_team),
|
||||||
|
}
|
||||||
|
|
||||||
def get_full_health_report(self, db: Session) -> dict:
|
def get_full_health_report(self, db: Session) -> dict:
|
||||||
"""Get comprehensive platform health report."""
|
"""Get comprehensive platform health report."""
|
||||||
# System metrics
|
# System metrics
|
||||||
@@ -177,6 +272,9 @@ class PlatformHealthService:
|
|||||||
# Image storage metrics
|
# Image storage metrics
|
||||||
image_storage = self.get_image_storage_metrics()
|
image_storage = self.get_image_storage_metrics()
|
||||||
|
|
||||||
|
# Subscription capacity
|
||||||
|
subscription_capacity = self.get_subscription_capacity(db)
|
||||||
|
|
||||||
# Calculate thresholds
|
# Calculate thresholds
|
||||||
thresholds = self._calculate_thresholds(system, database, image_storage)
|
thresholds = self._calculate_thresholds(system, database, image_storage)
|
||||||
|
|
||||||
@@ -197,6 +295,7 @@ class PlatformHealthService:
|
|||||||
"system": system,
|
"system": system,
|
||||||
"database": database,
|
"database": database,
|
||||||
"image_storage": image_storage,
|
"image_storage": image_storage,
|
||||||
|
"subscription_capacity": subscription_capacity,
|
||||||
"thresholds": thresholds,
|
"thresholds": thresholds,
|
||||||
"recommendations": recommendations,
|
"recommendations": recommendations,
|
||||||
"infrastructure_tier": tier,
|
"infrastructure_tier": tier,
|
||||||
|
|||||||
@@ -251,6 +251,19 @@ class StripeService:
|
|||||||
logger.info(f"Reactivated Stripe subscription {subscription_id}")
|
logger.info(f"Reactivated Stripe subscription {subscription_id}")
|
||||||
return subscription
|
return subscription
|
||||||
|
|
||||||
|
def cancel_subscription_item(self, subscription_item_id: str) -> None:
|
||||||
|
"""
|
||||||
|
Cancel a subscription item (used for add-ons).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subscription_item_id: Stripe subscription item ID
|
||||||
|
"""
|
||||||
|
if not self.is_configured:
|
||||||
|
raise ValueError("Stripe is not configured")
|
||||||
|
|
||||||
|
stripe.SubscriptionItem.delete(subscription_item_id)
|
||||||
|
logger.info(f"Cancelled Stripe subscription item {subscription_item_id}")
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Checkout & Portal
|
# Checkout & Portal
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -263,6 +276,8 @@ class StripeService:
|
|||||||
success_url: str,
|
success_url: str,
|
||||||
cancel_url: str,
|
cancel_url: str,
|
||||||
trial_days: int | None = None,
|
trial_days: int | None = None,
|
||||||
|
quantity: int = 1,
|
||||||
|
metadata: dict | None = None,
|
||||||
) -> stripe.checkout.Session:
|
) -> stripe.checkout.Session:
|
||||||
"""
|
"""
|
||||||
Create a Stripe Checkout session for subscription signup.
|
Create a Stripe Checkout session for subscription signup.
|
||||||
@@ -274,6 +289,8 @@ class StripeService:
|
|||||||
success_url: URL to redirect on success
|
success_url: URL to redirect on success
|
||||||
cancel_url: URL to redirect on cancel
|
cancel_url: URL to redirect on cancel
|
||||||
trial_days: Optional trial period
|
trial_days: Optional trial period
|
||||||
|
quantity: Number of items (default 1)
|
||||||
|
metadata: Additional metadata to store
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Stripe Checkout Session object
|
Stripe Checkout Session object
|
||||||
@@ -311,16 +328,21 @@ class StripeService:
|
|||||||
subscription.stripe_customer_id = customer_id
|
subscription.stripe_customer_id = customer_id
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
|
# Build metadata
|
||||||
|
session_metadata = {
|
||||||
|
"vendor_id": str(vendor.id),
|
||||||
|
"vendor_code": vendor.vendor_code,
|
||||||
|
}
|
||||||
|
if metadata:
|
||||||
|
session_metadata.update(metadata)
|
||||||
|
|
||||||
session_data = {
|
session_data = {
|
||||||
"customer": customer_id,
|
"customer": customer_id,
|
||||||
"line_items": [{"price": price_id, "quantity": 1}],
|
"line_items": [{"price": price_id, "quantity": quantity}],
|
||||||
"mode": "subscription",
|
"mode": "subscription",
|
||||||
"success_url": success_url,
|
"success_url": success_url,
|
||||||
"cancel_url": cancel_url,
|
"cancel_url": cancel_url,
|
||||||
"metadata": {
|
"metadata": session_metadata,
|
||||||
"vendor_id": str(vendor.id),
|
|
||||||
"vendor_code": vendor.vendor_code,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if trial_days:
|
if trial_days:
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ from models.schema.subscription import (
|
|||||||
SubscriptionUsage,
|
SubscriptionUsage,
|
||||||
TierInfo,
|
TierInfo,
|
||||||
TierLimits,
|
TierLimits,
|
||||||
|
UsageSummary,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -79,8 +80,35 @@ class SubscriptionService:
|
|||||||
# Tier Information
|
# Tier Information
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
def get_tier_info(self, tier_code: str) -> TierInfo:
|
def get_tier_info(self, tier_code: str, db: Session | None = None) -> TierInfo:
|
||||||
"""Get full tier information."""
|
"""
|
||||||
|
Get full tier information.
|
||||||
|
|
||||||
|
Queries database if db session provided, otherwise falls back to TIER_LIMITS.
|
||||||
|
"""
|
||||||
|
# Try database first if session provided
|
||||||
|
if db is not None:
|
||||||
|
db_tier = self.get_tier_by_code(db, tier_code)
|
||||||
|
if db_tier:
|
||||||
|
return TierInfo(
|
||||||
|
code=db_tier.code,
|
||||||
|
name=db_tier.name,
|
||||||
|
price_monthly_cents=db_tier.price_monthly_cents,
|
||||||
|
price_annual_cents=db_tier.price_annual_cents,
|
||||||
|
limits=TierLimits(
|
||||||
|
orders_per_month=db_tier.orders_per_month,
|
||||||
|
products_limit=db_tier.products_limit,
|
||||||
|
team_members=db_tier.team_members,
|
||||||
|
order_history_months=db_tier.order_history_months,
|
||||||
|
),
|
||||||
|
features=db_tier.features or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fallback to hardcoded TIER_LIMITS
|
||||||
|
return self._get_tier_from_legacy(tier_code)
|
||||||
|
|
||||||
|
def _get_tier_from_legacy(self, tier_code: str) -> TierInfo:
|
||||||
|
"""Get tier info from hardcoded TIER_LIMITS (fallback)."""
|
||||||
try:
|
try:
|
||||||
tier = TierCode(tier_code)
|
tier = TierCode(tier_code)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -101,10 +129,43 @@ class SubscriptionService:
|
|||||||
features=limits.get("features", []),
|
features=limits.get("features", []),
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_all_tiers(self) -> list[TierInfo]:
|
def get_all_tiers(self, db: Session | None = None) -> list[TierInfo]:
|
||||||
"""Get information for all tiers."""
|
"""
|
||||||
|
Get information for all tiers.
|
||||||
|
|
||||||
|
Queries database if db session provided, otherwise falls back to TIER_LIMITS.
|
||||||
|
"""
|
||||||
|
if db is not None:
|
||||||
|
db_tiers = (
|
||||||
|
db.query(SubscriptionTier)
|
||||||
|
.filter(
|
||||||
|
SubscriptionTier.is_active == True, # noqa: E712
|
||||||
|
SubscriptionTier.is_public == True, # noqa: E712
|
||||||
|
)
|
||||||
|
.order_by(SubscriptionTier.display_order)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if db_tiers:
|
||||||
|
return [
|
||||||
|
TierInfo(
|
||||||
|
code=t.code,
|
||||||
|
name=t.name,
|
||||||
|
price_monthly_cents=t.price_monthly_cents,
|
||||||
|
price_annual_cents=t.price_annual_cents,
|
||||||
|
limits=TierLimits(
|
||||||
|
orders_per_month=t.orders_per_month,
|
||||||
|
products_limit=t.products_limit,
|
||||||
|
team_members=t.team_members,
|
||||||
|
order_history_months=t.order_history_months,
|
||||||
|
),
|
||||||
|
features=t.features or [],
|
||||||
|
)
|
||||||
|
for t in db_tiers
|
||||||
|
]
|
||||||
|
|
||||||
|
# Fallback to hardcoded
|
||||||
return [
|
return [
|
||||||
self.get_tier_info(tier.value)
|
self._get_tier_from_legacy(tier.value)
|
||||||
for tier in TierCode
|
for tier in TierCode
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -363,6 +424,47 @@ class SubscriptionService:
|
|||||||
team_members_percent_used=calc_percent(team_count, team_limit),
|
team_members_percent_used=calc_percent(team_count, team_limit),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_usage_summary(self, db: Session, vendor_id: int) -> UsageSummary:
|
||||||
|
"""Get usage summary for billing page display."""
|
||||||
|
subscription = self.get_or_create_subscription(db, vendor_id)
|
||||||
|
|
||||||
|
# Get actual counts
|
||||||
|
products_count = (
|
||||||
|
db.query(func.count(Product.id))
|
||||||
|
.filter(Product.vendor_id == vendor_id)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
team_count = (
|
||||||
|
db.query(func.count(VendorUser.id))
|
||||||
|
.filter(VendorUser.vendor_id == vendor_id, VendorUser.is_active == True)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get limits
|
||||||
|
orders_limit = subscription.orders_limit
|
||||||
|
products_limit = subscription.products_limit
|
||||||
|
team_limit = subscription.team_members_limit
|
||||||
|
|
||||||
|
def calc_remaining(current: int, limit: int | None) -> int | None:
|
||||||
|
if limit is None:
|
||||||
|
return None
|
||||||
|
return max(0, limit - current)
|
||||||
|
|
||||||
|
return UsageSummary(
|
||||||
|
orders_this_period=subscription.orders_this_period,
|
||||||
|
orders_limit=orders_limit,
|
||||||
|
orders_remaining=calc_remaining(subscription.orders_this_period, orders_limit),
|
||||||
|
products_count=products_count,
|
||||||
|
products_limit=products_limit,
|
||||||
|
products_remaining=calc_remaining(products_count, products_limit),
|
||||||
|
team_count=team_count,
|
||||||
|
team_limit=team_limit,
|
||||||
|
team_remaining=calc_remaining(team_count, team_limit),
|
||||||
|
)
|
||||||
|
|
||||||
def increment_order_count(self, db: Session, vendor_id: int) -> None:
|
def increment_order_count(self, db: Session, vendor_id: int) -> None:
|
||||||
"""
|
"""
|
||||||
Increment the order counter for the current period.
|
Increment the order counter for the current period.
|
||||||
|
|||||||
318
app/tasks/subscription_tasks.py
Normal file
318
app/tasks/subscription_tasks.py
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
# app/tasks/subscription_tasks.py
|
||||||
|
"""
|
||||||
|
Background tasks for subscription management.
|
||||||
|
|
||||||
|
Provides scheduled tasks for:
|
||||||
|
- Resetting period counters at billing period end
|
||||||
|
- Expiring trials without payment methods
|
||||||
|
- Syncing subscription status with Stripe
|
||||||
|
- Capturing daily capacity snapshots
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.services.stripe_service import stripe_service
|
||||||
|
from models.database.subscription import SubscriptionStatus, VendorSubscription
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def reset_period_counters():
|
||||||
|
"""
|
||||||
|
Reset order counters for subscriptions whose billing period has ended.
|
||||||
|
|
||||||
|
Should run daily. Resets orders_this_period to 0 and updates period dates.
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
reset_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Find subscriptions where period has ended
|
||||||
|
expired_periods = (
|
||||||
|
db.query(VendorSubscription)
|
||||||
|
.filter(
|
||||||
|
VendorSubscription.period_end <= now,
|
||||||
|
VendorSubscription.status.in_(["active", "trial"]),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
for subscription in expired_periods:
|
||||||
|
old_period_end = subscription.period_end
|
||||||
|
|
||||||
|
# Reset counters
|
||||||
|
subscription.orders_this_period = 0
|
||||||
|
subscription.orders_limit_reached_at = None
|
||||||
|
|
||||||
|
# Set new period dates
|
||||||
|
if subscription.is_annual:
|
||||||
|
subscription.period_start = now
|
||||||
|
subscription.period_end = now + timedelta(days=365)
|
||||||
|
else:
|
||||||
|
subscription.period_start = now
|
||||||
|
subscription.period_end = now + timedelta(days=30)
|
||||||
|
|
||||||
|
subscription.updated_at = now
|
||||||
|
reset_count += 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Reset period counters for vendor {subscription.vendor_id}: "
|
||||||
|
f"old_period_end={old_period_end}, new_period_end={subscription.period_end}"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Reset period counters for {reset_count} subscriptions")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error resetting period counters: {e}")
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
return {"reset_count": reset_count}
|
||||||
|
|
||||||
|
|
||||||
|
async def check_trial_expirations():
|
||||||
|
"""
|
||||||
|
Check for expired trials and update their status.
|
||||||
|
|
||||||
|
Trials without a payment method are marked as expired.
|
||||||
|
Trials with a payment method transition to active.
|
||||||
|
|
||||||
|
Should run daily.
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
expired_count = 0
|
||||||
|
activated_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Find expired trials
|
||||||
|
expired_trials = (
|
||||||
|
db.query(VendorSubscription)
|
||||||
|
.filter(
|
||||||
|
VendorSubscription.status == SubscriptionStatus.TRIAL.value,
|
||||||
|
VendorSubscription.trial_ends_at <= now,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
for subscription in expired_trials:
|
||||||
|
if subscription.stripe_payment_method_id:
|
||||||
|
# Has payment method - activate
|
||||||
|
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||||
|
activated_count += 1
|
||||||
|
logger.info(
|
||||||
|
f"Activated subscription for vendor {subscription.vendor_id} "
|
||||||
|
f"(trial ended with payment method)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No payment method - expire
|
||||||
|
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||||
|
expired_count += 1
|
||||||
|
logger.info(
|
||||||
|
f"Expired trial for vendor {subscription.vendor_id} "
|
||||||
|
f"(no payment method)"
|
||||||
|
)
|
||||||
|
|
||||||
|
subscription.updated_at = now
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info(
|
||||||
|
f"Trial expiration check: {expired_count} expired, {activated_count} activated"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking trial expirations: {e}")
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
return {"expired_count": expired_count, "activated_count": activated_count}
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_stripe_status():
|
||||||
|
"""
|
||||||
|
Sync subscription status with Stripe.
|
||||||
|
|
||||||
|
Fetches current status from Stripe and updates local records.
|
||||||
|
Handles cases where Stripe status differs from local status.
|
||||||
|
|
||||||
|
Should run hourly.
|
||||||
|
"""
|
||||||
|
if not stripe_service.is_configured:
|
||||||
|
logger.warning("Stripe not configured, skipping sync")
|
||||||
|
return {"synced": 0, "skipped": True}
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
synced_count = 0
|
||||||
|
error_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Find subscriptions with Stripe IDs
|
||||||
|
subscriptions = (
|
||||||
|
db.query(VendorSubscription)
|
||||||
|
.filter(VendorSubscription.stripe_subscription_id.isnot(None))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
for subscription in subscriptions:
|
||||||
|
try:
|
||||||
|
# Fetch from Stripe
|
||||||
|
stripe_sub = stripe_service.get_subscription(
|
||||||
|
subscription.stripe_subscription_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not stripe_sub:
|
||||||
|
logger.warning(
|
||||||
|
f"Stripe subscription {subscription.stripe_subscription_id} "
|
||||||
|
f"not found for vendor {subscription.vendor_id}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Map Stripe status to local status
|
||||||
|
status_map = {
|
||||||
|
"active": SubscriptionStatus.ACTIVE.value,
|
||||||
|
"trialing": SubscriptionStatus.TRIAL.value,
|
||||||
|
"past_due": SubscriptionStatus.PAST_DUE.value,
|
||||||
|
"canceled": SubscriptionStatus.CANCELLED.value,
|
||||||
|
"unpaid": SubscriptionStatus.PAST_DUE.value,
|
||||||
|
"incomplete": SubscriptionStatus.TRIAL.value,
|
||||||
|
"incomplete_expired": SubscriptionStatus.EXPIRED.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
new_status = status_map.get(stripe_sub.status)
|
||||||
|
if new_status and new_status != subscription.status:
|
||||||
|
old_status = subscription.status
|
||||||
|
subscription.status = new_status
|
||||||
|
subscription.updated_at = datetime.now(UTC)
|
||||||
|
logger.info(
|
||||||
|
f"Updated vendor {subscription.vendor_id} status: "
|
||||||
|
f"{old_status} -> {new_status} (from Stripe)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update period dates from Stripe
|
||||||
|
if stripe_sub.current_period_start:
|
||||||
|
subscription.period_start = datetime.fromtimestamp(
|
||||||
|
stripe_sub.current_period_start, tz=UTC
|
||||||
|
)
|
||||||
|
if stripe_sub.current_period_end:
|
||||||
|
subscription.period_end = datetime.fromtimestamp(
|
||||||
|
stripe_sub.current_period_end, tz=UTC
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update payment method
|
||||||
|
if stripe_sub.default_payment_method:
|
||||||
|
subscription.stripe_payment_method_id = (
|
||||||
|
stripe_sub.default_payment_method
|
||||||
|
if isinstance(stripe_sub.default_payment_method, str)
|
||||||
|
else stripe_sub.default_payment_method.id
|
||||||
|
)
|
||||||
|
|
||||||
|
synced_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error syncing subscription {subscription.stripe_subscription_id}: {e}"
|
||||||
|
)
|
||||||
|
error_count += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Stripe sync complete: {synced_count} synced, {error_count} errors")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in Stripe sync task: {e}")
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
return {"synced_count": synced_count, "error_count": error_count}
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_stale_subscriptions():
|
||||||
|
"""
|
||||||
|
Clean up subscriptions in inconsistent states.
|
||||||
|
|
||||||
|
Handles edge cases like:
|
||||||
|
- Subscriptions stuck in processing
|
||||||
|
- Old cancelled subscriptions past their period end
|
||||||
|
|
||||||
|
Should run weekly.
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
cleaned_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Find cancelled subscriptions past their period end
|
||||||
|
stale_cancelled = (
|
||||||
|
db.query(VendorSubscription)
|
||||||
|
.filter(
|
||||||
|
VendorSubscription.status == SubscriptionStatus.CANCELLED.value,
|
||||||
|
VendorSubscription.period_end < now - timedelta(days=30),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
for subscription in stale_cancelled:
|
||||||
|
# Mark as expired (fully terminated)
|
||||||
|
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||||
|
subscription.updated_at = now
|
||||||
|
cleaned_count += 1
|
||||||
|
logger.info(
|
||||||
|
f"Marked stale cancelled subscription as expired: "
|
||||||
|
f"vendor {subscription.vendor_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Cleaned up {cleaned_count} stale subscriptions")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error cleaning up stale subscriptions: {e}")
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
return {"cleaned_count": cleaned_count}
|
||||||
|
|
||||||
|
|
||||||
|
async def capture_capacity_snapshot():
|
||||||
|
"""
|
||||||
|
Capture a daily snapshot of platform capacity metrics.
|
||||||
|
|
||||||
|
Used for growth trending and capacity forecasting.
|
||||||
|
Should run daily (e.g., at midnight).
|
||||||
|
"""
|
||||||
|
from app.services.capacity_forecast_service import capacity_forecast_service
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
snapshot = capacity_forecast_service.capture_daily_snapshot(db)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Captured capacity snapshot: {snapshot.total_vendors} vendors, "
|
||||||
|
f"{snapshot.total_products} products"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"snapshot_id": snapshot.id,
|
||||||
|
"snapshot_date": snapshot.snapshot_date.isoformat(),
|
||||||
|
"total_vendors": snapshot.total_vendors,
|
||||||
|
"total_products": snapshot.total_products,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error capturing capacity snapshot: {e}")
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -106,6 +106,146 @@
|
|||||||
</div>
|
</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">
|
||||||
|
<!-- Orders Usage -->
|
||||||
|
<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">Orders This Period</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-baseline gap-1">
|
||||||
|
<span class="text-xl font-bold text-gray-700 dark:text-gray-200" x-text="subscription?.orders_this_period || 0"></span>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
/ <span x-text="subscription?.orders_limit || '∞'"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 h-1.5 bg-gray-200 rounded-full dark:bg-gray-600" x-show="subscription?.orders_limit">
|
||||||
|
<div class="h-1.5 rounded-full transition-all"
|
||||||
|
:class="getUsageBarColor(subscription?.orders_this_period, subscription?.orders_limit)"
|
||||||
|
:style="`width: ${Math.min(100, (subscription?.orders_this_period / subscription?.orders_limit) * 100)}%`">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Products Usage -->
|
||||||
|
<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">Products</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-baseline gap-1">
|
||||||
|
<span class="text-xl font-bold text-gray-700 dark:text-gray-200" x-text="subscription?.products_count || 0"></span>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
/ <span x-text="subscription?.products_limit || '∞'"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 h-1.5 bg-gray-200 rounded-full dark:bg-gray-600" x-show="subscription?.products_limit">
|
||||||
|
<div class="h-1.5 rounded-full transition-all"
|
||||||
|
:class="getUsageBarColor(subscription?.products_count, subscription?.products_limit)"
|
||||||
|
:style="`width: ${Math.min(100, (subscription?.products_count / subscription?.products_limit) * 100)}%`">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Team Members Usage -->
|
||||||
|
<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">Team Members</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-baseline gap-1">
|
||||||
|
<span class="text-xl font-bold text-gray-700 dark:text-gray-200" x-text="subscription?.team_count || 0"></span>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
/ <span x-text="subscription?.team_members_limit || '∞'"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 h-1.5 bg-gray-200 rounded-full dark:bg-gray-600" x-show="subscription?.team_members_limit">
|
||||||
|
<div class="h-1.5 rounded-full transition-all"
|
||||||
|
:class="getUsageBarColor(subscription?.team_count, subscription?.team_members_limit)"
|
||||||
|
:style="`width: ${Math.min(100, (subscription?.team_count / subscription?.team_members_limit) * 100)}%`">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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 vendor 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 -->
|
<!-- Main Info Cards -->
|
||||||
<div class="grid gap-6 mb-8 md:grid-cols-2">
|
<div class="grid gap-6 mb-8 md:grid-cols-2">
|
||||||
<!-- Basic Information -->
|
<!-- Basic Information -->
|
||||||
|
|||||||
@@ -86,7 +86,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
x-model="vendor.vendor_code"
|
:value="vendor?.vendor_code || ''"
|
||||||
disabled
|
disabled
|
||||||
class="block w-full mt-1 text-sm bg-gray-100 border-gray-300 rounded-md dark:bg-gray-700 dark:text-gray-400 dark:border-gray-600 cursor-not-allowed"
|
class="block w-full mt-1 text-sm bg-gray-100 border-gray-300 rounded-md dark:bg-gray-700 dark:text-gray-400 dark:border-gray-600 cursor-not-allowed"
|
||||||
>
|
>
|
||||||
@@ -171,7 +171,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
x-model="vendor.owner_email"
|
:value="vendor?.owner_email || ''"
|
||||||
disabled
|
disabled
|
||||||
class="block w-full mt-1 text-sm bg-gray-100 border-gray-300 rounded-md dark:bg-gray-700 dark:text-gray-400 dark:border-gray-600 cursor-not-allowed"
|
class="block w-full mt-1 text-sm bg-gray-100 border-gray-300 rounded-md dark:bg-gray-700 dark:text-gray-400 dark:border-gray-600 cursor-not-allowed"
|
||||||
>
|
>
|
||||||
|
|||||||
64
app/templates/vendor/billing.html
vendored
64
app/templates/vendor/billing.html
vendored
@@ -37,6 +37,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</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 -->
|
<!-- Loading State -->
|
||||||
<template x-if="loading">
|
<template x-if="loading">
|
||||||
<div class="flex justify-center items-center py-12">
|
<div class="flex justify-center items-center py-12">
|
||||||
@@ -314,18 +326,46 @@
|
|||||||
x-transition:leave-end="opacity-0"
|
x-transition:leave-end="opacity-0"
|
||||||
class="fixed inset-0 z-30 flex items-center justify-center overflow-auto bg-black bg-opacity-50"
|
class="fixed inset-0 z-30 flex items-center justify-center overflow-auto bg-black bg-opacity-50"
|
||||||
@click.self="showAddonsModal = false">
|
@click.self="showAddonsModal = false">
|
||||||
<div class="w-full max-w-2xl mx-4 bg-white dark:bg-gray-800 rounded-lg shadow-xl">
|
<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">
|
<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>
|
<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">
|
<button @click="showAddonsModal = false" class="text-gray-400 hover:text-gray-600">
|
||||||
<span x-html="$icon('x-mark', 'w-6 h-6')"></span>
|
<span x-html="$icon('x-mark', 'w-6 h-6')"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<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">
|
<template x-if="addons.length === 0">
|
||||||
<p class="text-gray-500 text-center py-8">No add-ons available</p>
|
<p class="text-gray-500 text-center py-8">No add-ons available</p>
|
||||||
</template>
|
</template>
|
||||||
<div class="space-y-4">
|
<div class="space-y-3">
|
||||||
<template x-for="addon in addons" :key="addon.id">
|
<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 class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
<div>
|
<div>
|
||||||
@@ -336,8 +376,22 @@
|
|||||||
<span x-text="`/${addon.billing_period}`"></span>
|
<span x-text="`/${addon.billing_period}`"></span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="px-4 py-2 text-sm font-medium text-purple-600 bg-purple-100 rounded-lg hover:bg-purple-200">
|
<button @click="purchaseAddon(addon)"
|
||||||
Add
|
: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">
|
||||||
|
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" 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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Processing...
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template x-if="purchasingAddon !== addon.code">
|
||||||
|
<span x-text="isAddonPurchased(addon.code) ? 'Active' : 'Add'"></span>
|
||||||
|
</template>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -652,3 +652,61 @@ class VendorSubscription(Base, TimestampMixin):
|
|||||||
"""Reset counters for new billing period."""
|
"""Reset counters for new billing period."""
|
||||||
self.orders_this_period = 0
|
self.orders_this_period = 0
|
||||||
self.orders_limit_reached_at = None
|
self.orders_limit_reached_at = None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Capacity Planning
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class CapacitySnapshot(Base, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Daily snapshot of platform capacity metrics.
|
||||||
|
|
||||||
|
Used for growth trending and capacity forecasting.
|
||||||
|
Captured daily by background job.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "capacity_snapshots"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
snapshot_date = Column(DateTime(timezone=True), nullable=False, unique=True, index=True)
|
||||||
|
|
||||||
|
# Vendor metrics
|
||||||
|
total_vendors = Column(Integer, default=0, nullable=False)
|
||||||
|
active_vendors = Column(Integer, default=0, nullable=False)
|
||||||
|
trial_vendors = Column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
# Subscription metrics
|
||||||
|
total_subscriptions = Column(Integer, default=0, nullable=False)
|
||||||
|
active_subscriptions = Column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
# Resource metrics
|
||||||
|
total_products = Column(Integer, default=0, nullable=False)
|
||||||
|
total_orders_month = Column(Integer, default=0, nullable=False)
|
||||||
|
total_team_members = Column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
# Storage metrics
|
||||||
|
storage_used_gb = Column(Numeric(10, 2), default=0, nullable=False)
|
||||||
|
db_size_mb = Column(Numeric(10, 2), default=0, nullable=False)
|
||||||
|
|
||||||
|
# Capacity metrics (theoretical limits from subscriptions)
|
||||||
|
theoretical_products_limit = Column(Integer, nullable=True)
|
||||||
|
theoretical_orders_limit = Column(Integer, nullable=True)
|
||||||
|
theoretical_team_limit = Column(Integer, nullable=True)
|
||||||
|
|
||||||
|
# Tier distribution (JSON: {"essential": 10, "professional": 5, ...})
|
||||||
|
tier_distribution = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
# Performance metrics
|
||||||
|
avg_response_ms = Column(Integer, nullable=True)
|
||||||
|
peak_cpu_percent = Column(Numeric(5, 2), nullable=True)
|
||||||
|
peak_memory_percent = Column(Numeric(5, 2), nullable=True)
|
||||||
|
|
||||||
|
# Indexes
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_capacity_snapshots_date", "snapshot_date"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<CapacitySnapshot(date={self.snapshot_date}, vendors={self.total_vendors})>"
|
||||||
|
|||||||
@@ -146,6 +146,10 @@ class VendorSubscriptionWithVendor(VendorSubscriptionResponse):
|
|||||||
vendor_name: str
|
vendor_name: str
|
||||||
vendor_code: str
|
vendor_code: str
|
||||||
|
|
||||||
|
# Usage counts (for admin display)
|
||||||
|
products_count: int | None = None
|
||||||
|
team_count: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class VendorSubscriptionListResponse(BaseModel):
|
class VendorSubscriptionListResponse(BaseModel):
|
||||||
"""Response for listing vendor subscriptions."""
|
"""Response for listing vendor subscriptions."""
|
||||||
@@ -157,6 +161,15 @@ class VendorSubscriptionListResponse(BaseModel):
|
|||||||
pages: int
|
pages: int
|
||||||
|
|
||||||
|
|
||||||
|
class VendorSubscriptionCreate(BaseModel):
|
||||||
|
"""Schema for admin creating a vendor subscription."""
|
||||||
|
|
||||||
|
tier: str = "essential"
|
||||||
|
status: str = "trial"
|
||||||
|
trial_days: int = 14
|
||||||
|
is_annual: bool = False
|
||||||
|
|
||||||
|
|
||||||
class VendorSubscriptionUpdate(BaseModel):
|
class VendorSubscriptionUpdate(BaseModel):
|
||||||
"""Schema for admin updating a vendor subscription."""
|
"""Schema for admin updating a vendor subscription."""
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,22 @@ class SubscriptionUsage(BaseModel):
|
|||||||
team_members_percent_used: float | None
|
team_members_percent_used: float | None
|
||||||
|
|
||||||
|
|
||||||
|
class UsageSummary(BaseModel):
|
||||||
|
"""Usage summary for billing page display."""
|
||||||
|
|
||||||
|
orders_this_period: int
|
||||||
|
orders_limit: int | None
|
||||||
|
orders_remaining: int | None
|
||||||
|
|
||||||
|
products_count: int
|
||||||
|
products_limit: int | None
|
||||||
|
products_remaining: int | None
|
||||||
|
|
||||||
|
team_count: int
|
||||||
|
team_limit: int | None
|
||||||
|
team_remaining: int | None
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionStatusResponse(BaseModel):
|
class SubscriptionStatusResponse(BaseModel):
|
||||||
"""Subscription status with usage and limits."""
|
"""Subscription status with usage and limits."""
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ function adminVendorDetail() {
|
|||||||
// Vendor detail page specific state
|
// Vendor detail page specific state
|
||||||
currentPage: 'vendor-detail',
|
currentPage: 'vendor-detail',
|
||||||
vendor: null,
|
vendor: null,
|
||||||
|
subscription: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
vendorCode: null,
|
vendorCode: null,
|
||||||
|
showSubscriptionModal: false,
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
async init() {
|
async init() {
|
||||||
@@ -35,6 +37,10 @@ function adminVendorDetail() {
|
|||||||
this.vendorCode = match[1];
|
this.vendorCode = match[1];
|
||||||
detailLog.info('Viewing vendor:', this.vendorCode);
|
detailLog.info('Viewing vendor:', this.vendorCode);
|
||||||
await this.loadVendor();
|
await this.loadVendor();
|
||||||
|
// Load subscription after vendor is loaded
|
||||||
|
if (this.vendor?.id) {
|
||||||
|
await this.loadSubscription();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
detailLog.error('No vendor code in URL');
|
detailLog.error('No vendor code in URL');
|
||||||
this.error = 'Invalid vendor URL';
|
this.error = 'Invalid vendor URL';
|
||||||
@@ -91,6 +97,82 @@ function adminVendorDetail() {
|
|||||||
return formatted;
|
return formatted;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Load subscription data for this vendor
|
||||||
|
async loadSubscription() {
|
||||||
|
if (!this.vendor?.id) {
|
||||||
|
detailLog.warn('Cannot load subscription: no vendor ID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
detailLog.info('Loading subscription for vendor:', this.vendor.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `/admin/subscriptions/${this.vendor.id}`;
|
||||||
|
window.LogConfig.logApiCall('GET', url, null, 'request');
|
||||||
|
|
||||||
|
const response = await apiClient.get(url);
|
||||||
|
window.LogConfig.logApiCall('GET', url, response, 'response');
|
||||||
|
|
||||||
|
this.subscription = response;
|
||||||
|
detailLog.info('Subscription loaded:', {
|
||||||
|
tier: this.subscription?.tier,
|
||||||
|
status: this.subscription?.status,
|
||||||
|
orders_this_period: this.subscription?.orders_this_period
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// 404 means no subscription exists - that's OK
|
||||||
|
if (error.status === 404) {
|
||||||
|
detailLog.info('No subscription found for vendor');
|
||||||
|
this.subscription = null;
|
||||||
|
} 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 vendor
|
||||||
|
async createSubscription() {
|
||||||
|
if (!this.vendor?.id) {
|
||||||
|
Utils.showToast('No vendor loaded', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
detailLog.info('Creating subscription for vendor:', this.vendor.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a trial subscription with default tier
|
||||||
|
const url = `/admin/subscriptions/${this.vendor.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('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 vendor
|
// Delete vendor
|
||||||
async deleteVendor() {
|
async deleteVendor() {
|
||||||
detailLog.info('Delete vendor requested:', this.vendorCode);
|
detailLog.info('Delete vendor requested:', this.vendorCode);
|
||||||
|
|||||||
67
static/vendor/js/billing.js
vendored
67
static/vendor/js/billing.js
vendored
@@ -17,7 +17,9 @@ function billingData() {
|
|||||||
showCancelModal: false,
|
showCancelModal: false,
|
||||||
showSuccessMessage: false,
|
showSuccessMessage: false,
|
||||||
showCancelMessage: false,
|
showCancelMessage: false,
|
||||||
|
showAddonSuccessMessage: false,
|
||||||
cancelReason: '',
|
cancelReason: '',
|
||||||
|
purchasingAddon: null,
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
async init() {
|
async init() {
|
||||||
@@ -25,13 +27,16 @@ function billingData() {
|
|||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
if (params.get('success') === 'true') {
|
if (params.get('success') === 'true') {
|
||||||
this.showSuccessMessage = true;
|
this.showSuccessMessage = true;
|
||||||
// Clean URL
|
|
||||||
window.history.replaceState({}, document.title, window.location.pathname);
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
}
|
}
|
||||||
if (params.get('cancelled') === 'true') {
|
if (params.get('cancelled') === 'true') {
|
||||||
this.showCancelMessage = true;
|
this.showCancelMessage = true;
|
||||||
window.history.replaceState({}, document.title, window.location.pathname);
|
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();
|
await this.loadData();
|
||||||
},
|
},
|
||||||
@@ -40,16 +45,18 @@ function billingData() {
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
try {
|
||||||
// Load all data in parallel
|
// Load all data in parallel
|
||||||
const [subscriptionRes, tiersRes, addonsRes, invoicesRes] = await Promise.all([
|
const [subscriptionRes, tiersRes, addonsRes, myAddonsRes, invoicesRes] = await Promise.all([
|
||||||
this.apiGet('/billing/subscription'),
|
this.apiGet('/billing/subscription'),
|
||||||
this.apiGet('/billing/tiers'),
|
this.apiGet('/billing/tiers'),
|
||||||
this.apiGet('/billing/addons'),
|
this.apiGet('/billing/addons'),
|
||||||
|
this.apiGet('/billing/my-addons'),
|
||||||
this.apiGet('/billing/invoices?limit=5'),
|
this.apiGet('/billing/invoices?limit=5'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
this.subscription = subscriptionRes;
|
this.subscription = subscriptionRes;
|
||||||
this.tiers = tiersRes.tiers || [];
|
this.tiers = tiersRes.tiers || [];
|
||||||
this.addons = addonsRes || [];
|
this.addons = addonsRes || [];
|
||||||
|
this.myAddons = myAddonsRes || [];
|
||||||
this.invoices = invoicesRes.invoices || [];
|
this.invoices = invoicesRes.invoices || [];
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -119,6 +126,45 @@ function billingData() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async purchaseAddon(addon) {
|
||||||
|
this.purchasingAddon = addon.code;
|
||||||
|
try {
|
||||||
|
const response = await this.apiPost('/billing/addons/purchase', {
|
||||||
|
addon_code: addon.code,
|
||||||
|
quantity: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.checkout_url) {
|
||||||
|
window.location.href = response.checkout_url;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error purchasing addon:', error);
|
||||||
|
this.showNotification('Failed to purchase add-on', 'error');
|
||||||
|
} finally {
|
||||||
|
this.purchasingAddon = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancelAddon(addon) {
|
||||||
|
if (!confirm(`Are you sure you want to cancel ${addon.addon_name}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.apiDelete(`/billing/addons/${addon.id}`);
|
||||||
|
this.showNotification('Add-on cancelled successfully', 'success');
|
||||||
|
await this.loadData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error cancelling addon:', error);
|
||||||
|
this.showNotification('Failed to cancel add-on', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Check if addon is already purchased
|
||||||
|
isAddonPurchased(addonCode) {
|
||||||
|
return this.myAddons.some(a => a.addon_code === addonCode && a.status === 'active');
|
||||||
|
},
|
||||||
|
|
||||||
// API helpers
|
// API helpers
|
||||||
async apiGet(endpoint) {
|
async apiGet(endpoint) {
|
||||||
const response = await fetch(`/api/v1/vendor${endpoint}`, {
|
const response = await fetch(`/api/v1/vendor${endpoint}`, {
|
||||||
@@ -153,6 +199,23 @@ function billingData() {
|
|||||||
return response.json();
|
return response.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async apiDelete(endpoint) {
|
||||||
|
const response = await fetch(`/api/v1/vendor${endpoint}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(error.detail || `API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
// Formatters
|
// Formatters
|
||||||
formatDate(dateString) {
|
formatDate(dateString) {
|
||||||
if (!dateString) return '-';
|
if (!dateString) return '-';
|
||||||
|
|||||||
Reference in New Issue
Block a user