# 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"} 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 billing_service = BillingService()