# app/services/billing_service.py """ Billing service for subscription and payment operations. Provides: - Subscription status and usage queries - Tier management - Invoice history - Add-on management """ import logging from datetime import datetime from sqlalchemy.orm import Session from app.services.stripe_service import stripe_service from app.services.subscription_service import subscription_service from models.database.subscription import ( AddOnProduct, BillingHistory, SubscriptionTier, VendorAddOn, VendorSubscription, ) from models.database.vendor import Vendor logger = logging.getLogger(__name__) class BillingServiceError(Exception): """Base exception for billing service errors.""" pass class PaymentSystemNotConfiguredError(BillingServiceError): """Raised when Stripe is not configured.""" def __init__(self): super().__init__("Payment system not configured") class TierNotFoundError(BillingServiceError): """Raised when a tier is not found.""" def __init__(self, tier_code: str): self.tier_code = tier_code super().__init__(f"Tier '{tier_code}' not found") class StripePriceNotConfiguredError(BillingServiceError): """Raised when Stripe price is not configured for a tier.""" def __init__(self, tier_code: str): self.tier_code = tier_code super().__init__(f"Stripe price not configured for tier '{tier_code}'") class NoActiveSubscriptionError(BillingServiceError): """Raised when no active subscription exists.""" def __init__(self): super().__init__("No active subscription found") class SubscriptionNotCancelledError(BillingServiceError): """Raised when trying to reactivate a non-cancelled subscription.""" def __init__(self): super().__init__("Subscription is not cancelled") class BillingService: """Service for billing operations.""" def get_subscription_with_tier( self, db: Session, vendor_id: int ) -> tuple[VendorSubscription, SubscriptionTier | None]: """ Get subscription and its tier info. Returns: Tuple of (subscription, tier) where tier may be None """ subscription = subscription_service.get_or_create_subscription(db, vendor_id) tier = ( db.query(SubscriptionTier) .filter(SubscriptionTier.code == subscription.tier) .first() ) return subscription, tier def get_available_tiers( self, db: Session, current_tier: str ) -> tuple[list[dict], dict[str, int]]: """ Get all available tiers with upgrade/downgrade flags. Returns: Tuple of (tier_list, tier_order_map) """ tiers = ( db.query(SubscriptionTier) .filter( SubscriptionTier.is_active == True, # noqa: E712 SubscriptionTier.is_public == True, # noqa: E712 ) .order_by(SubscriptionTier.display_order) .all() ) tier_order = {t.code: t.display_order for t in tiers} current_order = tier_order.get(current_tier, 0) tier_list = [] for tier in tiers: tier_list.append({ "code": tier.code, "name": tier.name, "description": tier.description, "price_monthly_cents": tier.price_monthly_cents, "price_annual_cents": tier.price_annual_cents, "orders_per_month": tier.orders_per_month, "products_limit": tier.products_limit, "team_members": tier.team_members, "features": tier.features or [], "is_current": tier.code == current_tier, "can_upgrade": tier.display_order > current_order, "can_downgrade": tier.display_order < current_order, }) return tier_list, tier_order def get_tier_by_code(self, db: Session, tier_code: str) -> SubscriptionTier: """ Get a tier by its code. Raises: TierNotFoundError: If tier doesn't exist """ tier = ( db.query(SubscriptionTier) .filter( SubscriptionTier.code == tier_code, SubscriptionTier.is_active == True, # noqa: E712 ) .first() ) if not tier: raise TierNotFoundError(tier_code) return tier def get_vendor(self, db: Session, vendor_id: int) -> Vendor: """ Get vendor by ID. Raises: VendorNotFoundException from app.exceptions """ from app.exceptions import VendorNotFoundException vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() if not vendor: raise VendorNotFoundException(str(vendor_id), identifier_type="id") return vendor def create_checkout_session( self, db: Session, vendor_id: int, tier_code: str, is_annual: bool, success_url: str, cancel_url: str, ) -> dict: """ Create a Stripe checkout session. Returns: Dict with checkout_url and session_id Raises: PaymentSystemNotConfiguredError: If Stripe not configured TierNotFoundError: If tier doesn't exist StripePriceNotConfiguredError: If price not configured """ if not stripe_service.is_configured: raise PaymentSystemNotConfiguredError() vendor = self.get_vendor(db, vendor_id) tier = self.get_tier_by_code(db, 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(tier_code) # Check if this is a new subscription (for trial) existing_sub = subscription_service.get_subscription(db, vendor_id) trial_days = None if not existing_sub or not existing_sub.stripe_subscription_id: from app.core.config import settings trial_days = settings.stripe_trial_days session = stripe_service.create_checkout_session( db=db, vendor=vendor, price_id=price_id, success_url=success_url, cancel_url=cancel_url, trial_days=trial_days, ) # Update subscription with tier info subscription = subscription_service.get_or_create_subscription(db, vendor_id) subscription.tier = tier_code subscription.is_annual = is_annual return { "checkout_url": session.url, "session_id": session.id, } def create_portal_session(self, db: Session, vendor_id: int, return_url: str) -> dict: """ Create a Stripe customer portal session. Returns: Dict with portal_url Raises: PaymentSystemNotConfiguredError: If Stripe not configured NoActiveSubscriptionError: If no subscription with customer ID """ if not stripe_service.is_configured: raise PaymentSystemNotConfiguredError() subscription = subscription_service.get_subscription(db, vendor_id) if not subscription or not subscription.stripe_customer_id: raise NoActiveSubscriptionError() session = stripe_service.create_portal_session( customer_id=subscription.stripe_customer_id, return_url=return_url, ) return {"portal_url": session.url} def get_invoices( self, db: Session, vendor_id: int, skip: int = 0, limit: int = 20 ) -> tuple[list[BillingHistory], int]: """ Get invoice history for a vendor. Returns: Tuple of (invoices, total_count) """ query = db.query(BillingHistory).filter(BillingHistory.vendor_id == vendor_id) total = query.count() invoices = ( query.order_by(BillingHistory.invoice_date.desc()) .offset(skip) .limit(limit) .all() ) return invoices, total def get_available_addons( self, db: Session, category: str | None = None ) -> list[AddOnProduct]: """Get available add-on products.""" query = db.query(AddOnProduct).filter(AddOnProduct.is_active == True) # noqa: E712 if category: query = query.filter(AddOnProduct.category == category) return query.order_by(AddOnProduct.display_order).all() def get_vendor_addons(self, db: Session, vendor_id: int) -> list[VendorAddOn]: """Get vendor's purchased add-ons.""" return ( db.query(VendorAddOn) .filter(VendorAddOn.vendor_id == vendor_id) .all() ) def cancel_subscription( self, db: Session, vendor_id: int, reason: str | None, immediately: bool ) -> dict: """ Cancel a subscription. Returns: Dict with message and effective_date Raises: NoActiveSubscriptionError: If no subscription to cancel """ subscription = subscription_service.get_subscription(db, vendor_id) if not subscription or not subscription.stripe_subscription_id: raise NoActiveSubscriptionError() if stripe_service.is_configured: stripe_service.cancel_subscription( subscription_id=subscription.stripe_subscription_id, immediately=immediately, cancellation_reason=reason, ) subscription.cancelled_at = datetime.utcnow() subscription.cancellation_reason = reason effective_date = ( datetime.utcnow().isoformat() if immediately else subscription.period_end.isoformat() if subscription.period_end else datetime.utcnow().isoformat() ) return { "message": "Subscription cancelled successfully", "effective_date": effective_date, } def reactivate_subscription(self, db: Session, vendor_id: int) -> dict: """ Reactivate a cancelled subscription. Returns: Dict with success message Raises: NoActiveSubscriptionError: If no subscription SubscriptionNotCancelledError: If not cancelled """ subscription = subscription_service.get_subscription(db, vendor_id) if not subscription or not subscription.stripe_subscription_id: raise NoActiveSubscriptionError() if not subscription.cancelled_at: raise SubscriptionNotCancelledError() if stripe_service.is_configured: stripe_service.reactivate_subscription(subscription.stripe_subscription_id) subscription.cancelled_at = None subscription.cancellation_reason = None return {"message": "Subscription reactivated successfully"} # Create service instance billing_service = BillingService()