- Add database models for subscription tiers, vendor subscriptions, add-ons, billing history, and webhook events - Implement BillingService for subscription operations - Implement StripeService for Stripe API operations - Implement StripeWebhookHandler for webhook event processing - Add vendor billing API endpoints for subscription management - Create vendor billing page with Alpine.js frontend - Add limit enforcement for products and team members - Add billing exceptions for proper error handling - Create comprehensive unit tests (40 tests passing) - Add subscription billing documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
460 lines
14 KiB
Python
460 lines
14 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
|
|
|
|
# =========================================================================
|
|
# 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,
|
|
) -> 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
|
|
|
|
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()
|
|
|
|
session_data = {
|
|
"customer": customer_id,
|
|
"line_items": [{"price": price_id, "quantity": 1}],
|
|
"mode": "subscription",
|
|
"success_url": success_url,
|
|
"cancel_url": cancel_url,
|
|
"metadata": {
|
|
"vendor_id": str(vendor.id),
|
|
"vendor_code": vendor.vendor_code,
|
|
},
|
|
}
|
|
|
|
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()
|