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:
@@ -40,6 +40,7 @@ from models.schema.subscription import (
|
||||
SubscriptionUsage,
|
||||
TierInfo,
|
||||
TierLimits,
|
||||
UsageSummary,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -79,8 +80,35 @@ class SubscriptionService:
|
||||
# Tier Information
|
||||
# =========================================================================
|
||||
|
||||
def get_tier_info(self, tier_code: str) -> TierInfo:
|
||||
"""Get full tier information."""
|
||||
def get_tier_info(self, tier_code: str, db: Session | None = None) -> TierInfo:
|
||||
"""
|
||||
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:
|
||||
tier = TierCode(tier_code)
|
||||
except ValueError:
|
||||
@@ -101,10 +129,43 @@ class SubscriptionService:
|
||||
features=limits.get("features", []),
|
||||
)
|
||||
|
||||
def get_all_tiers(self) -> list[TierInfo]:
|
||||
"""Get information for all tiers."""
|
||||
def get_all_tiers(self, db: Session | None = None) -> list[TierInfo]:
|
||||
"""
|
||||
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 [
|
||||
self.get_tier_info(tier.value)
|
||||
self._get_tier_from_legacy(tier.value)
|
||||
for tier in TierCode
|
||||
]
|
||||
|
||||
@@ -363,6 +424,47 @@ class SubscriptionService:
|
||||
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:
|
||||
"""
|
||||
Increment the order counter for the current period.
|
||||
|
||||
Reference in New Issue
Block a user