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>
482 lines
15 KiB
Python
482 lines
15 KiB
Python
# app/services/stripe_service.py
|
|
"""
|
|
Stripe payment integration service.
|
|
|
|
Provides:
|
|
- Customer management
|
|
- Subscription management
|
|
- Checkout session creation
|
|
- Customer portal access
|
|
- Webhook event construction
|
|
"""
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
|
|
import stripe
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.config import settings
|
|
from models.database.subscription import (
|
|
BillingHistory,
|
|
SubscriptionStatus,
|
|
SubscriptionTier,
|
|
VendorSubscription,
|
|
)
|
|
from models.database.vendor import Vendor
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class StripeService:
|
|
"""Service for Stripe payment operations."""
|
|
|
|
def __init__(self):
|
|
self._configured = False
|
|
self._configure()
|
|
|
|
def _configure(self):
|
|
"""Configure Stripe with API key."""
|
|
if settings.stripe_secret_key:
|
|
stripe.api_key = settings.stripe_secret_key
|
|
self._configured = True
|
|
else:
|
|
logger.warning("Stripe API key not configured")
|
|
|
|
@property
|
|
def is_configured(self) -> bool:
|
|
"""Check if Stripe is properly configured."""
|
|
return self._configured and bool(settings.stripe_secret_key)
|
|
|
|
# =========================================================================
|
|
# Customer Management
|
|
# =========================================================================
|
|
|
|
def create_customer(
|
|
self,
|
|
vendor: Vendor,
|
|
email: str,
|
|
name: str | None = None,
|
|
metadata: dict | None = None,
|
|
) -> str:
|
|
"""
|
|
Create a Stripe customer for a vendor.
|
|
|
|
Returns the Stripe customer ID.
|
|
"""
|
|
if not self.is_configured:
|
|
raise ValueError("Stripe is not configured")
|
|
|
|
customer_metadata = {
|
|
"vendor_id": str(vendor.id),
|
|
"vendor_code": vendor.vendor_code,
|
|
**(metadata or {}),
|
|
}
|
|
|
|
customer = stripe.Customer.create(
|
|
email=email,
|
|
name=name or vendor.name,
|
|
metadata=customer_metadata,
|
|
)
|
|
|
|
logger.info(
|
|
f"Created Stripe customer {customer.id} for vendor {vendor.vendor_code}"
|
|
)
|
|
return customer.id
|
|
|
|
def get_customer(self, customer_id: str) -> stripe.Customer:
|
|
"""Get a Stripe customer by ID."""
|
|
if not self.is_configured:
|
|
raise ValueError("Stripe is not configured")
|
|
|
|
return stripe.Customer.retrieve(customer_id)
|
|
|
|
def update_customer(
|
|
self,
|
|
customer_id: str,
|
|
email: str | None = None,
|
|
name: str | None = None,
|
|
metadata: dict | None = None,
|
|
) -> stripe.Customer:
|
|
"""Update a Stripe customer."""
|
|
if not self.is_configured:
|
|
raise ValueError("Stripe is not configured")
|
|
|
|
update_data = {}
|
|
if email:
|
|
update_data["email"] = email
|
|
if name:
|
|
update_data["name"] = name
|
|
if metadata:
|
|
update_data["metadata"] = metadata
|
|
|
|
return stripe.Customer.modify(customer_id, **update_data)
|
|
|
|
# =========================================================================
|
|
# Subscription Management
|
|
# =========================================================================
|
|
|
|
def create_subscription(
|
|
self,
|
|
customer_id: str,
|
|
price_id: str,
|
|
trial_days: int | None = None,
|
|
metadata: dict | None = None,
|
|
) -> stripe.Subscription:
|
|
"""
|
|
Create a new Stripe subscription.
|
|
|
|
Args:
|
|
customer_id: Stripe customer ID
|
|
price_id: Stripe price ID for the subscription
|
|
trial_days: Optional trial period in days
|
|
metadata: Optional metadata to attach
|
|
|
|
Returns:
|
|
Stripe Subscription object
|
|
"""
|
|
if not self.is_configured:
|
|
raise ValueError("Stripe is not configured")
|
|
|
|
subscription_data = {
|
|
"customer": customer_id,
|
|
"items": [{"price": price_id}],
|
|
"metadata": metadata or {},
|
|
"payment_behavior": "default_incomplete",
|
|
"expand": ["latest_invoice.payment_intent"],
|
|
}
|
|
|
|
if trial_days:
|
|
subscription_data["trial_period_days"] = trial_days
|
|
|
|
subscription = stripe.Subscription.create(**subscription_data)
|
|
logger.info(
|
|
f"Created Stripe subscription {subscription.id} for customer {customer_id}"
|
|
)
|
|
return subscription
|
|
|
|
def get_subscription(self, subscription_id: str) -> stripe.Subscription:
|
|
"""Get a Stripe subscription by ID."""
|
|
if not self.is_configured:
|
|
raise ValueError("Stripe is not configured")
|
|
|
|
return stripe.Subscription.retrieve(subscription_id)
|
|
|
|
def update_subscription(
|
|
self,
|
|
subscription_id: str,
|
|
new_price_id: str | None = None,
|
|
proration_behavior: str = "create_prorations",
|
|
metadata: dict | None = None,
|
|
) -> stripe.Subscription:
|
|
"""
|
|
Update a Stripe subscription (e.g., change tier).
|
|
|
|
Args:
|
|
subscription_id: Stripe subscription ID
|
|
new_price_id: New price ID for tier change
|
|
proration_behavior: How to handle prorations
|
|
metadata: Optional metadata to update
|
|
|
|
Returns:
|
|
Updated Stripe Subscription object
|
|
"""
|
|
if not self.is_configured:
|
|
raise ValueError("Stripe is not configured")
|
|
|
|
update_data = {"proration_behavior": proration_behavior}
|
|
|
|
if new_price_id:
|
|
# Get the subscription to find the item ID
|
|
subscription = stripe.Subscription.retrieve(subscription_id)
|
|
item_id = subscription["items"]["data"][0]["id"]
|
|
update_data["items"] = [{"id": item_id, "price": new_price_id}]
|
|
|
|
if metadata:
|
|
update_data["metadata"] = metadata
|
|
|
|
updated = stripe.Subscription.modify(subscription_id, **update_data)
|
|
logger.info(f"Updated Stripe subscription {subscription_id}")
|
|
return updated
|
|
|
|
def cancel_subscription(
|
|
self,
|
|
subscription_id: str,
|
|
immediately: bool = False,
|
|
cancellation_reason: str | None = None,
|
|
) -> stripe.Subscription:
|
|
"""
|
|
Cancel a Stripe subscription.
|
|
|
|
Args:
|
|
subscription_id: Stripe subscription ID
|
|
immediately: If True, cancel now. If False, cancel at period end.
|
|
cancellation_reason: Optional reason for cancellation
|
|
|
|
Returns:
|
|
Cancelled Stripe Subscription object
|
|
"""
|
|
if not self.is_configured:
|
|
raise ValueError("Stripe is not configured")
|
|
|
|
if immediately:
|
|
subscription = stripe.Subscription.cancel(subscription_id)
|
|
else:
|
|
subscription = stripe.Subscription.modify(
|
|
subscription_id,
|
|
cancel_at_period_end=True,
|
|
metadata={"cancellation_reason": cancellation_reason or "user_request"},
|
|
)
|
|
|
|
logger.info(
|
|
f"Cancelled Stripe subscription {subscription_id} "
|
|
f"(immediately={immediately})"
|
|
)
|
|
return subscription
|
|
|
|
def reactivate_subscription(self, subscription_id: str) -> stripe.Subscription:
|
|
"""
|
|
Reactivate a cancelled subscription (if not yet ended).
|
|
|
|
Returns:
|
|
Reactivated Stripe Subscription object
|
|
"""
|
|
if not self.is_configured:
|
|
raise ValueError("Stripe is not configured")
|
|
|
|
subscription = stripe.Subscription.modify(
|
|
subscription_id,
|
|
cancel_at_period_end=False,
|
|
)
|
|
logger.info(f"Reactivated Stripe subscription {subscription_id}")
|
|
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
|
|
# =========================================================================
|
|
|
|
def create_checkout_session(
|
|
self,
|
|
db: Session,
|
|
vendor: Vendor,
|
|
price_id: str,
|
|
success_url: str,
|
|
cancel_url: str,
|
|
trial_days: int | None = None,
|
|
quantity: int = 1,
|
|
metadata: dict | None = None,
|
|
) -> stripe.checkout.Session:
|
|
"""
|
|
Create a Stripe Checkout session for subscription signup.
|
|
|
|
Args:
|
|
db: Database session
|
|
vendor: Vendor to create checkout for
|
|
price_id: Stripe price ID
|
|
success_url: URL to redirect on success
|
|
cancel_url: URL to redirect on cancel
|
|
trial_days: Optional trial period
|
|
quantity: Number of items (default 1)
|
|
metadata: Additional metadata to store
|
|
|
|
Returns:
|
|
Stripe Checkout Session object
|
|
"""
|
|
if not self.is_configured:
|
|
raise ValueError("Stripe is not configured")
|
|
|
|
# Get or create Stripe customer
|
|
subscription = (
|
|
db.query(VendorSubscription)
|
|
.filter(VendorSubscription.vendor_id == vendor.id)
|
|
.first()
|
|
)
|
|
|
|
if subscription and subscription.stripe_customer_id:
|
|
customer_id = subscription.stripe_customer_id
|
|
else:
|
|
# Get vendor owner email
|
|
from models.database.vendor import VendorUser
|
|
|
|
owner = (
|
|
db.query(VendorUser)
|
|
.filter(
|
|
VendorUser.vendor_id == vendor.id,
|
|
VendorUser.is_owner == True,
|
|
)
|
|
.first()
|
|
)
|
|
email = owner.user.email if owner and owner.user else None
|
|
|
|
customer_id = self.create_customer(vendor, email or f"{vendor.vendor_code}@placeholder.com")
|
|
|
|
# Store the customer ID
|
|
if subscription:
|
|
subscription.stripe_customer_id = customer_id
|
|
db.flush()
|
|
|
|
# Build metadata
|
|
session_metadata = {
|
|
"vendor_id": str(vendor.id),
|
|
"vendor_code": vendor.vendor_code,
|
|
}
|
|
if metadata:
|
|
session_metadata.update(metadata)
|
|
|
|
session_data = {
|
|
"customer": customer_id,
|
|
"line_items": [{"price": price_id, "quantity": quantity}],
|
|
"mode": "subscription",
|
|
"success_url": success_url,
|
|
"cancel_url": cancel_url,
|
|
"metadata": session_metadata,
|
|
}
|
|
|
|
if trial_days:
|
|
session_data["subscription_data"] = {"trial_period_days": trial_days}
|
|
|
|
session = stripe.checkout.Session.create(**session_data)
|
|
logger.info(f"Created checkout session {session.id} for vendor {vendor.vendor_code}")
|
|
return session
|
|
|
|
def create_portal_session(
|
|
self,
|
|
customer_id: str,
|
|
return_url: str,
|
|
) -> stripe.billing_portal.Session:
|
|
"""
|
|
Create a Stripe Customer Portal session.
|
|
|
|
Allows customers to manage their subscription, payment methods, and invoices.
|
|
|
|
Args:
|
|
customer_id: Stripe customer ID
|
|
return_url: URL to return to after portal
|
|
|
|
Returns:
|
|
Stripe Portal Session object
|
|
"""
|
|
if not self.is_configured:
|
|
raise ValueError("Stripe is not configured")
|
|
|
|
session = stripe.billing_portal.Session.create(
|
|
customer=customer_id,
|
|
return_url=return_url,
|
|
)
|
|
logger.info(f"Created portal session for customer {customer_id}")
|
|
return session
|
|
|
|
# =========================================================================
|
|
# Invoice Management
|
|
# =========================================================================
|
|
|
|
def get_invoices(
|
|
self,
|
|
customer_id: str,
|
|
limit: int = 10,
|
|
) -> list[stripe.Invoice]:
|
|
"""Get invoices for a customer."""
|
|
if not self.is_configured:
|
|
raise ValueError("Stripe is not configured")
|
|
|
|
invoices = stripe.Invoice.list(customer=customer_id, limit=limit)
|
|
return list(invoices.data)
|
|
|
|
def get_upcoming_invoice(self, customer_id: str) -> stripe.Invoice | None:
|
|
"""Get the upcoming invoice for a customer."""
|
|
if not self.is_configured:
|
|
raise ValueError("Stripe is not configured")
|
|
|
|
try:
|
|
return stripe.Invoice.upcoming(customer=customer_id)
|
|
except stripe.error.InvalidRequestError:
|
|
# No upcoming invoice
|
|
return None
|
|
|
|
# =========================================================================
|
|
# Webhook Handling
|
|
# =========================================================================
|
|
|
|
def construct_event(
|
|
self,
|
|
payload: bytes,
|
|
sig_header: str,
|
|
) -> stripe.Event:
|
|
"""
|
|
Construct and verify a Stripe webhook event.
|
|
|
|
Args:
|
|
payload: Raw request body
|
|
sig_header: Stripe-Signature header
|
|
|
|
Returns:
|
|
Verified Stripe Event object
|
|
|
|
Raises:
|
|
ValueError: If signature verification fails
|
|
"""
|
|
if not settings.stripe_webhook_secret:
|
|
raise ValueError("Stripe webhook secret not configured")
|
|
|
|
try:
|
|
event = stripe.Webhook.construct_event(
|
|
payload,
|
|
sig_header,
|
|
settings.stripe_webhook_secret,
|
|
)
|
|
return event
|
|
except stripe.error.SignatureVerificationError as e:
|
|
logger.error(f"Webhook signature verification failed: {e}")
|
|
raise ValueError("Invalid webhook signature")
|
|
|
|
# =========================================================================
|
|
# Price/Product Management
|
|
# =========================================================================
|
|
|
|
def get_price(self, price_id: str) -> stripe.Price:
|
|
"""Get a Stripe price by ID."""
|
|
if not self.is_configured:
|
|
raise ValueError("Stripe is not configured")
|
|
|
|
return stripe.Price.retrieve(price_id)
|
|
|
|
def get_product(self, product_id: str) -> stripe.Product:
|
|
"""Get a Stripe product by ID."""
|
|
if not self.is_configured:
|
|
raise ValueError("Stripe is not configured")
|
|
|
|
return stripe.Product.retrieve(product_id)
|
|
|
|
def list_prices(
|
|
self,
|
|
product_id: str | None = None,
|
|
active: bool = True,
|
|
) -> list[stripe.Price]:
|
|
"""List Stripe prices, optionally filtered by product."""
|
|
if not self.is_configured:
|
|
raise ValueError("Stripe is not configured")
|
|
|
|
params = {"active": active}
|
|
if product_id:
|
|
params["product"] = product_id
|
|
|
|
prices = stripe.Price.list(**params)
|
|
return list(prices.data)
|
|
|
|
|
|
# Create service instance
|
|
stripe_service = StripeService()
|