feat: add subscription and billing system with Stripe integration
- 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>
This commit is contained in:
@@ -51,9 +51,16 @@ from .order_item_exception import OrderItemException
|
||||
from .product import Product
|
||||
from .product_translation import ProductTranslation
|
||||
from .subscription import (
|
||||
AddOnCategory,
|
||||
AddOnProduct,
|
||||
BillingHistory,
|
||||
BillingPeriod,
|
||||
StripeWebhookEvent,
|
||||
SubscriptionStatus,
|
||||
SubscriptionTier,
|
||||
TierCode,
|
||||
TIER_LIMITS,
|
||||
VendorAddOn,
|
||||
VendorSubscription,
|
||||
)
|
||||
from .test_run import TestCollection, TestResult, TestRun
|
||||
@@ -121,11 +128,18 @@ __all__ = [
|
||||
"LetzshopFulfillmentQueue",
|
||||
"LetzshopSyncLog",
|
||||
"LetzshopHistoricalImportJob",
|
||||
# Subscription
|
||||
# Subscription & Billing
|
||||
"VendorSubscription",
|
||||
"SubscriptionStatus",
|
||||
"SubscriptionTier",
|
||||
"TierCode",
|
||||
"TIER_LIMITS",
|
||||
"AddOnProduct",
|
||||
"AddOnCategory",
|
||||
"BillingPeriod",
|
||||
"VendorAddOn",
|
||||
"BillingHistory",
|
||||
"StripeWebhookEvent",
|
||||
# Messaging
|
||||
"Conversation",
|
||||
"ConversationParticipant",
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
Subscription database models for tier-based access control.
|
||||
|
||||
Provides models for:
|
||||
- SubscriptionTier: Tier definitions with limits and features
|
||||
- SubscriptionTier: Database-driven tier definitions with Stripe integration
|
||||
- VendorSubscription: Per-vendor subscription tracking
|
||||
- AddOnProduct: Purchasable add-ons (domains, SSL, email packages)
|
||||
- VendorAddOn: Add-ons purchased by each vendor
|
||||
- StripeWebhookEvent: Idempotency tracking for webhook processing
|
||||
- BillingHistory: Invoice and payment history
|
||||
|
||||
Tier Structure:
|
||||
- Essential (€49/mo): 100 orders/mo, 200 products, 1 user, LU invoicing
|
||||
@@ -53,6 +57,274 @@ class SubscriptionStatus(str, enum.Enum):
|
||||
EXPIRED = "expired" # No longer active
|
||||
|
||||
|
||||
class AddOnCategory(str, enum.Enum):
|
||||
"""Add-on product categories."""
|
||||
|
||||
DOMAIN = "domain"
|
||||
SSL = "ssl"
|
||||
EMAIL = "email"
|
||||
STORAGE = "storage"
|
||||
|
||||
|
||||
class BillingPeriod(str, enum.Enum):
|
||||
"""Billing period for add-ons."""
|
||||
|
||||
MONTHLY = "monthly"
|
||||
ANNUAL = "annual"
|
||||
ONE_TIME = "one_time"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SubscriptionTier - Database-driven tier definitions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class SubscriptionTier(Base, TimestampMixin):
|
||||
"""
|
||||
Database-driven tier definitions with Stripe integration.
|
||||
|
||||
Replaces the hardcoded TIER_LIMITS dict for dynamic tier management.
|
||||
"""
|
||||
|
||||
__tablename__ = "subscription_tiers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(30), unique=True, nullable=False, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Pricing (in cents for precision)
|
||||
price_monthly_cents = Column(Integer, nullable=False)
|
||||
price_annual_cents = Column(Integer, nullable=True) # Null for enterprise/custom
|
||||
|
||||
# Limits (null = unlimited)
|
||||
orders_per_month = Column(Integer, nullable=True)
|
||||
products_limit = Column(Integer, nullable=True)
|
||||
team_members = Column(Integer, nullable=True)
|
||||
order_history_months = Column(Integer, nullable=True)
|
||||
|
||||
# Features (JSON array of feature codes)
|
||||
features = Column(JSON, default=list)
|
||||
|
||||
# Stripe Product/Price IDs
|
||||
stripe_product_id = Column(String(100), nullable=True)
|
||||
stripe_price_monthly_id = Column(String(100), nullable=True)
|
||||
stripe_price_annual_id = Column(String(100), nullable=True)
|
||||
|
||||
# Display and visibility
|
||||
display_order = Column(Integer, default=0)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
is_public = Column(Boolean, default=True, nullable=False) # False for enterprise
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SubscriptionTier(code='{self.code}', name='{self.name}')>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert tier to dictionary (compatible with TIER_LIMITS format)."""
|
||||
return {
|
||||
"name": self.name,
|
||||
"price_monthly_cents": self.price_monthly_cents,
|
||||
"price_annual_cents": self.price_annual_cents,
|
||||
"orders_per_month": self.orders_per_month,
|
||||
"products_limit": self.products_limit,
|
||||
"team_members": self.team_members,
|
||||
"order_history_months": self.order_history_months,
|
||||
"features": self.features or [],
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# AddOnProduct - Purchasable add-ons
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class AddOnProduct(Base, TimestampMixin):
|
||||
"""
|
||||
Purchasable add-on products (domains, SSL, email packages).
|
||||
|
||||
These are separate from subscription tiers and can be added to any tier.
|
||||
"""
|
||||
|
||||
__tablename__ = "addon_products"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(50), unique=True, nullable=False, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
category = Column(String(50), nullable=False, index=True)
|
||||
|
||||
# Pricing
|
||||
price_cents = Column(Integer, nullable=False)
|
||||
billing_period = Column(
|
||||
String(20), default=BillingPeriod.MONTHLY.value, nullable=False
|
||||
)
|
||||
|
||||
# For tiered add-ons (e.g., email_5, email_10)
|
||||
quantity_unit = Column(String(50), nullable=True) # emails, GB, etc.
|
||||
quantity_value = Column(Integer, nullable=True) # 5, 10, 50, etc.
|
||||
|
||||
# Stripe
|
||||
stripe_product_id = Column(String(100), nullable=True)
|
||||
stripe_price_id = Column(String(100), nullable=True)
|
||||
|
||||
# Display
|
||||
display_order = Column(Integer, default=0)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AddOnProduct(code='{self.code}', name='{self.name}')>"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# VendorAddOn - Add-ons purchased by vendor
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class VendorAddOn(Base, TimestampMixin):
|
||||
"""
|
||||
Add-ons purchased by a vendor.
|
||||
|
||||
Tracks active add-on subscriptions and their billing status.
|
||||
"""
|
||||
|
||||
__tablename__ = "vendor_addons"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
|
||||
addon_product_id = Column(
|
||||
Integer, ForeignKey("addon_products.id"), nullable=False, index=True
|
||||
)
|
||||
|
||||
# Status
|
||||
status = Column(String(20), default="active", nullable=False, index=True)
|
||||
|
||||
# For domains: store the actual domain name
|
||||
domain_name = Column(String(255), nullable=True, index=True)
|
||||
|
||||
# Quantity (for tiered add-ons like email packages)
|
||||
quantity = Column(Integer, default=1, nullable=False)
|
||||
|
||||
# Stripe billing
|
||||
stripe_subscription_item_id = Column(String(100), nullable=True)
|
||||
|
||||
# Period tracking
|
||||
period_start = Column(DateTime(timezone=True), nullable=True)
|
||||
period_end = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Cancellation
|
||||
cancelled_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
vendor = relationship("Vendor", back_populates="addons")
|
||||
addon_product = relationship("AddOnProduct")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_vendor_addon_status", "vendor_id", "status"),
|
||||
Index("idx_vendor_addon_product", "vendor_id", "addon_product_id"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<VendorAddOn(vendor_id={self.vendor_id}, addon={self.addon_product_id}, status='{self.status}')>"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# StripeWebhookEvent - Webhook idempotency tracking
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class StripeWebhookEvent(Base, TimestampMixin):
|
||||
"""
|
||||
Log of processed Stripe webhook events for idempotency.
|
||||
|
||||
Prevents duplicate processing of the same event.
|
||||
"""
|
||||
|
||||
__tablename__ = "stripe_webhook_events"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
event_id = Column(String(100), unique=True, nullable=False, index=True)
|
||||
event_type = Column(String(100), nullable=False, index=True)
|
||||
|
||||
# Processing status
|
||||
status = Column(String(20), default="pending", nullable=False, index=True)
|
||||
processed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
error_message = Column(Text, nullable=True)
|
||||
|
||||
# Raw event data (encrypted for security)
|
||||
payload_encrypted = Column(Text, nullable=True)
|
||||
|
||||
# Related entities (for quick lookup)
|
||||
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=True, index=True)
|
||||
subscription_id = Column(
|
||||
Integer, ForeignKey("vendor_subscriptions.id"), nullable=True, index=True
|
||||
)
|
||||
|
||||
__table_args__ = (Index("idx_webhook_event_type_status", "event_type", "status"),)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<StripeWebhookEvent(event_id='{self.event_id}', type='{self.event_type}', status='{self.status}')>"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# BillingHistory - Invoice and payment history
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class BillingHistory(Base, TimestampMixin):
|
||||
"""
|
||||
Invoice and payment history for vendors.
|
||||
|
||||
Stores Stripe invoice data for display and reporting.
|
||||
"""
|
||||
|
||||
__tablename__ = "billing_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
|
||||
|
||||
# Stripe references
|
||||
stripe_invoice_id = Column(String(100), unique=True, nullable=True, index=True)
|
||||
stripe_payment_intent_id = Column(String(100), nullable=True)
|
||||
|
||||
# Invoice details
|
||||
invoice_number = Column(String(50), nullable=True)
|
||||
invoice_date = Column(DateTime(timezone=True), nullable=False)
|
||||
due_date = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Amounts (in cents for precision)
|
||||
subtotal_cents = Column(Integer, nullable=False)
|
||||
tax_cents = Column(Integer, default=0, nullable=False)
|
||||
total_cents = Column(Integer, nullable=False)
|
||||
amount_paid_cents = Column(Integer, default=0, nullable=False)
|
||||
currency = Column(String(3), default="EUR", nullable=False)
|
||||
|
||||
# Status
|
||||
status = Column(String(20), nullable=False, index=True)
|
||||
|
||||
# PDF URLs
|
||||
invoice_pdf_url = Column(String(500), nullable=True)
|
||||
hosted_invoice_url = Column(String(500), nullable=True)
|
||||
|
||||
# Description and line items
|
||||
description = Column(Text, nullable=True)
|
||||
line_items = Column(JSON, nullable=True)
|
||||
|
||||
# Relationships
|
||||
vendor = relationship("Vendor", back_populates="billing_history")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_billing_vendor_date", "vendor_id", "invoice_date"),
|
||||
Index("idx_billing_status", "vendor_id", "status"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<BillingHistory(vendor_id={self.vendor_id}, invoice='{self.invoice_number}', status='{self.status}')>"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Legacy TIER_LIMITS (kept for backward compatibility during migration)
|
||||
# ============================================================================
|
||||
|
||||
# Tier limit definitions (hardcoded for now, could be moved to DB)
|
||||
TIER_LIMITS = {
|
||||
TierCode.ESSENTIAL: {
|
||||
@@ -186,9 +458,20 @@ class VendorSubscription(Base, TimestampMixin):
|
||||
custom_products_limit = Column(Integer, nullable=True)
|
||||
custom_team_limit = Column(Integer, nullable=True)
|
||||
|
||||
# Payment info (for future Stripe integration)
|
||||
# Payment info (Stripe integration)
|
||||
stripe_customer_id = Column(String(100), nullable=True, index=True)
|
||||
stripe_subscription_id = Column(String(100), nullable=True, index=True)
|
||||
stripe_price_id = Column(String(100), nullable=True) # Current price being billed
|
||||
stripe_payment_method_id = Column(String(100), nullable=True) # Default payment method
|
||||
|
||||
# Proration and upgrade/downgrade tracking
|
||||
proration_behavior = Column(String(50), default="create_prorations")
|
||||
scheduled_tier_change = Column(String(30), nullable=True) # Pending tier change
|
||||
scheduled_change_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Payment failure tracking
|
||||
payment_retry_count = Column(Integer, default=0, nullable=False)
|
||||
last_payment_error = Column(Text, nullable=True)
|
||||
|
||||
# Cancellation
|
||||
cancelled_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
@@ -166,6 +166,21 @@ class Vendor(Base, TimestampMixin):
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Add-ons purchased by vendor (one-to-many)
|
||||
addons = relationship(
|
||||
"VendorAddOn",
|
||||
back_populates="vendor",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Billing/invoice history (one-to-many)
|
||||
billing_history = relationship(
|
||||
"BillingHistory",
|
||||
back_populates="vendor",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="BillingHistory.invoice_date.desc()",
|
||||
)
|
||||
|
||||
domains = relationship(
|
||||
"VendorDomain",
|
||||
back_populates="vendor",
|
||||
|
||||
Reference in New Issue
Block a user