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:
2025-12-25 20:29:44 +01:00
parent b98c7c553b
commit 9d8d5e7138
27 changed files with 4558 additions and 23 deletions

View File

@@ -0,0 +1,419 @@
"""add_subscription_billing_tables
Revision ID: 2953ed10d22c
Revises: e1bfb453fbe9
Create Date: 2025-12-25 18:29:34.167773
"""
from datetime import datetime
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import sqlite
# revision identifiers, used by Alembic.
revision: str = '2953ed10d22c'
down_revision: Union[str, None] = 'e1bfb453fbe9'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# =========================================================================
# Create new subscription and billing tables
# =========================================================================
# subscription_tiers - Database-driven tier definitions
op.create_table('subscription_tiers',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=30), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('price_monthly_cents', sa.Integer(), nullable=False),
sa.Column('price_annual_cents', sa.Integer(), nullable=True),
sa.Column('orders_per_month', sa.Integer(), nullable=True),
sa.Column('products_limit', sa.Integer(), nullable=True),
sa.Column('team_members', sa.Integer(), nullable=True),
sa.Column('order_history_months', sa.Integer(), nullable=True),
sa.Column('features', sqlite.JSON(), nullable=True),
sa.Column('stripe_product_id', sa.String(length=100), nullable=True),
sa.Column('stripe_price_monthly_id', sa.String(length=100), nullable=True),
sa.Column('stripe_price_annual_id', sa.String(length=100), nullable=True),
sa.Column('display_order', sa.Integer(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('is_public', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_subscription_tiers_code'), 'subscription_tiers', ['code'], unique=True)
op.create_index(op.f('ix_subscription_tiers_id'), 'subscription_tiers', ['id'], unique=False)
# addon_products - Purchasable add-ons (domains, SSL, email)
op.create_table('addon_products',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=50), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('category', sa.String(length=50), nullable=False),
sa.Column('price_cents', sa.Integer(), nullable=False),
sa.Column('billing_period', sa.String(length=20), nullable=False),
sa.Column('quantity_unit', sa.String(length=50), nullable=True),
sa.Column('quantity_value', sa.Integer(), nullable=True),
sa.Column('stripe_product_id', sa.String(length=100), nullable=True),
sa.Column('stripe_price_id', sa.String(length=100), nullable=True),
sa.Column('display_order', sa.Integer(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_addon_products_category'), 'addon_products', ['category'], unique=False)
op.create_index(op.f('ix_addon_products_code'), 'addon_products', ['code'], unique=True)
op.create_index(op.f('ix_addon_products_id'), 'addon_products', ['id'], unique=False)
# billing_history - Invoice and payment history
op.create_table('billing_history',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('vendor_id', sa.Integer(), nullable=False),
sa.Column('stripe_invoice_id', sa.String(length=100), nullable=True),
sa.Column('stripe_payment_intent_id', sa.String(length=100), nullable=True),
sa.Column('invoice_number', sa.String(length=50), nullable=True),
sa.Column('invoice_date', sa.DateTime(timezone=True), nullable=False),
sa.Column('due_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('subtotal_cents', sa.Integer(), nullable=False),
sa.Column('tax_cents', sa.Integer(), nullable=False),
sa.Column('total_cents', sa.Integer(), nullable=False),
sa.Column('amount_paid_cents', sa.Integer(), nullable=False),
sa.Column('currency', sa.String(length=3), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('invoice_pdf_url', sa.String(length=500), nullable=True),
sa.Column('hosted_invoice_url', sa.String(length=500), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('line_items', sqlite.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_billing_status', 'billing_history', ['vendor_id', 'status'], unique=False)
op.create_index('idx_billing_vendor_date', 'billing_history', ['vendor_id', 'invoice_date'], unique=False)
op.create_index(op.f('ix_billing_history_id'), 'billing_history', ['id'], unique=False)
op.create_index(op.f('ix_billing_history_status'), 'billing_history', ['status'], unique=False)
op.create_index(op.f('ix_billing_history_stripe_invoice_id'), 'billing_history', ['stripe_invoice_id'], unique=True)
op.create_index(op.f('ix_billing_history_vendor_id'), 'billing_history', ['vendor_id'], unique=False)
# vendor_addons - Add-ons purchased by vendor
op.create_table('vendor_addons',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('vendor_id', sa.Integer(), nullable=False),
sa.Column('addon_product_id', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('domain_name', sa.String(length=255), nullable=True),
sa.Column('quantity', sa.Integer(), nullable=False),
sa.Column('stripe_subscription_item_id', sa.String(length=100), nullable=True),
sa.Column('period_start', sa.DateTime(timezone=True), nullable=True),
sa.Column('period_end', sa.DateTime(timezone=True), nullable=True),
sa.Column('cancelled_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['addon_product_id'], ['addon_products.id'], ),
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_vendor_addon_product', 'vendor_addons', ['vendor_id', 'addon_product_id'], unique=False)
op.create_index('idx_vendor_addon_status', 'vendor_addons', ['vendor_id', 'status'], unique=False)
op.create_index(op.f('ix_vendor_addons_addon_product_id'), 'vendor_addons', ['addon_product_id'], unique=False)
op.create_index(op.f('ix_vendor_addons_domain_name'), 'vendor_addons', ['domain_name'], unique=False)
op.create_index(op.f('ix_vendor_addons_id'), 'vendor_addons', ['id'], unique=False)
op.create_index(op.f('ix_vendor_addons_status'), 'vendor_addons', ['status'], unique=False)
op.create_index(op.f('ix_vendor_addons_vendor_id'), 'vendor_addons', ['vendor_id'], unique=False)
# stripe_webhook_events - Webhook idempotency tracking
op.create_table('stripe_webhook_events',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('event_id', sa.String(length=100), nullable=False),
sa.Column('event_type', sa.String(length=100), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('processed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('payload_encrypted', sa.Text(), nullable=True),
sa.Column('vendor_id', sa.Integer(), nullable=True),
sa.Column('subscription_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['subscription_id'], ['vendor_subscriptions.id'], ),
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_webhook_event_type_status', 'stripe_webhook_events', ['event_type', 'status'], unique=False)
op.create_index(op.f('ix_stripe_webhook_events_event_id'), 'stripe_webhook_events', ['event_id'], unique=True)
op.create_index(op.f('ix_stripe_webhook_events_event_type'), 'stripe_webhook_events', ['event_type'], unique=False)
op.create_index(op.f('ix_stripe_webhook_events_id'), 'stripe_webhook_events', ['id'], unique=False)
op.create_index(op.f('ix_stripe_webhook_events_status'), 'stripe_webhook_events', ['status'], unique=False)
op.create_index(op.f('ix_stripe_webhook_events_subscription_id'), 'stripe_webhook_events', ['subscription_id'], unique=False)
op.create_index(op.f('ix_stripe_webhook_events_vendor_id'), 'stripe_webhook_events', ['vendor_id'], unique=False)
# =========================================================================
# Add new columns to vendor_subscriptions
# =========================================================================
op.add_column('vendor_subscriptions', sa.Column('stripe_price_id', sa.String(length=100), nullable=True))
op.add_column('vendor_subscriptions', sa.Column('stripe_payment_method_id', sa.String(length=100), nullable=True))
op.add_column('vendor_subscriptions', sa.Column('proration_behavior', sa.String(length=50), nullable=True))
op.add_column('vendor_subscriptions', sa.Column('scheduled_tier_change', sa.String(length=30), nullable=True))
op.add_column('vendor_subscriptions', sa.Column('scheduled_change_at', sa.DateTime(timezone=True), nullable=True))
op.add_column('vendor_subscriptions', sa.Column('payment_retry_count', sa.Integer(), server_default='0', nullable=False))
op.add_column('vendor_subscriptions', sa.Column('last_payment_error', sa.Text(), nullable=True))
# =========================================================================
# Seed subscription tiers
# =========================================================================
now = datetime.utcnow()
subscription_tiers = sa.table(
'subscription_tiers',
sa.column('code', sa.String),
sa.column('name', sa.String),
sa.column('description', sa.Text),
sa.column('price_monthly_cents', sa.Integer),
sa.column('price_annual_cents', sa.Integer),
sa.column('orders_per_month', sa.Integer),
sa.column('products_limit', sa.Integer),
sa.column('team_members', sa.Integer),
sa.column('order_history_months', sa.Integer),
sa.column('features', sqlite.JSON),
sa.column('display_order', sa.Integer),
sa.column('is_active', sa.Boolean),
sa.column('is_public', sa.Boolean),
sa.column('created_at', sa.DateTime),
sa.column('updated_at', sa.DateTime),
)
op.bulk_insert(subscription_tiers, [
{
'code': 'essential',
'name': 'Essential',
'description': 'Perfect for solo vendors getting started with Letzshop',
'price_monthly_cents': 4900,
'price_annual_cents': 49000,
'orders_per_month': 100,
'products_limit': 200,
'team_members': 1,
'order_history_months': 6,
'features': ['letzshop_sync', 'inventory_basic', 'invoice_lu', 'customer_view'],
'display_order': 1,
'is_active': True,
'is_public': True,
'created_at': now,
'updated_at': now,
},
{
'code': 'professional',
'name': 'Professional',
'description': 'For active multi-channel vendors shipping EU-wide',
'price_monthly_cents': 9900,
'price_annual_cents': 99000,
'orders_per_month': 500,
'products_limit': None,
'team_members': 3,
'order_history_months': 24,
'features': [
'letzshop_sync', 'inventory_locations', 'inventory_purchase_orders',
'invoice_lu', 'invoice_eu_vat', 'customer_view', 'customer_export'
],
'display_order': 2,
'is_active': True,
'is_public': True,
'created_at': now,
'updated_at': now,
},
{
'code': 'business',
'name': 'Business',
'description': 'For high-volume vendors with teams and data-driven operations',
'price_monthly_cents': 19900,
'price_annual_cents': 199000,
'orders_per_month': 2000,
'products_limit': None,
'team_members': 10,
'order_history_months': None,
'features': [
'letzshop_sync', 'inventory_locations', 'inventory_purchase_orders',
'invoice_lu', 'invoice_eu_vat', 'invoice_bulk', 'customer_view',
'customer_export', 'analytics_dashboard', 'accounting_export',
'api_access', 'automation_rules', 'team_roles'
],
'display_order': 3,
'is_active': True,
'is_public': True,
'created_at': now,
'updated_at': now,
},
{
'code': 'enterprise',
'name': 'Enterprise',
'description': 'Custom solutions for large operations and agencies',
'price_monthly_cents': 39900,
'price_annual_cents': None,
'orders_per_month': None,
'products_limit': None,
'team_members': None,
'order_history_months': None,
'features': [
'letzshop_sync', 'inventory_locations', 'inventory_purchase_orders',
'invoice_lu', 'invoice_eu_vat', 'invoice_bulk', 'customer_view',
'customer_export', 'analytics_dashboard', 'accounting_export',
'api_access', 'automation_rules', 'team_roles', 'white_label',
'multi_vendor', 'custom_integrations', 'sla_guarantee', 'dedicated_support'
],
'display_order': 4,
'is_active': True,
'is_public': False,
'created_at': now,
'updated_at': now,
},
])
# =========================================================================
# Seed add-on products
# =========================================================================
addon_products = sa.table(
'addon_products',
sa.column('code', sa.String),
sa.column('name', sa.String),
sa.column('description', sa.Text),
sa.column('category', sa.String),
sa.column('price_cents', sa.Integer),
sa.column('billing_period', sa.String),
sa.column('quantity_unit', sa.String),
sa.column('quantity_value', sa.Integer),
sa.column('display_order', sa.Integer),
sa.column('is_active', sa.Boolean),
sa.column('created_at', sa.DateTime),
sa.column('updated_at', sa.DateTime),
)
op.bulk_insert(addon_products, [
{
'code': 'domain',
'name': 'Custom Domain',
'description': 'Connect your own domain with SSL certificate included',
'category': 'domain',
'price_cents': 1500,
'billing_period': 'annual',
'quantity_unit': None,
'quantity_value': None,
'display_order': 1,
'is_active': True,
'created_at': now,
'updated_at': now,
},
{
'code': 'email_5',
'name': '5 Email Addresses',
'description': 'Professional email addresses on your domain',
'category': 'email',
'price_cents': 500,
'billing_period': 'monthly',
'quantity_unit': 'emails',
'quantity_value': 5,
'display_order': 2,
'is_active': True,
'created_at': now,
'updated_at': now,
},
{
'code': 'email_10',
'name': '10 Email Addresses',
'description': 'Professional email addresses on your domain',
'category': 'email',
'price_cents': 900,
'billing_period': 'monthly',
'quantity_unit': 'emails',
'quantity_value': 10,
'display_order': 3,
'is_active': True,
'created_at': now,
'updated_at': now,
},
{
'code': 'email_25',
'name': '25 Email Addresses',
'description': 'Professional email addresses on your domain',
'category': 'email',
'price_cents': 1900,
'billing_period': 'monthly',
'quantity_unit': 'emails',
'quantity_value': 25,
'display_order': 4,
'is_active': True,
'created_at': now,
'updated_at': now,
},
{
'code': 'storage_10gb',
'name': 'Additional Storage (10GB)',
'description': 'Extra storage for product images and files',
'category': 'storage',
'price_cents': 500,
'billing_period': 'monthly',
'quantity_unit': 'GB',
'quantity_value': 10,
'display_order': 5,
'is_active': True,
'created_at': now,
'updated_at': now,
},
])
def downgrade() -> None:
# Remove new columns from vendor_subscriptions
op.drop_column('vendor_subscriptions', 'last_payment_error')
op.drop_column('vendor_subscriptions', 'payment_retry_count')
op.drop_column('vendor_subscriptions', 'scheduled_change_at')
op.drop_column('vendor_subscriptions', 'scheduled_tier_change')
op.drop_column('vendor_subscriptions', 'proration_behavior')
op.drop_column('vendor_subscriptions', 'stripe_payment_method_id')
op.drop_column('vendor_subscriptions', 'stripe_price_id')
# Drop stripe_webhook_events
op.drop_index(op.f('ix_stripe_webhook_events_vendor_id'), table_name='stripe_webhook_events')
op.drop_index(op.f('ix_stripe_webhook_events_subscription_id'), table_name='stripe_webhook_events')
op.drop_index(op.f('ix_stripe_webhook_events_status'), table_name='stripe_webhook_events')
op.drop_index(op.f('ix_stripe_webhook_events_id'), table_name='stripe_webhook_events')
op.drop_index(op.f('ix_stripe_webhook_events_event_type'), table_name='stripe_webhook_events')
op.drop_index(op.f('ix_stripe_webhook_events_event_id'), table_name='stripe_webhook_events')
op.drop_index('idx_webhook_event_type_status', table_name='stripe_webhook_events')
op.drop_table('stripe_webhook_events')
# Drop vendor_addons
op.drop_index(op.f('ix_vendor_addons_vendor_id'), table_name='vendor_addons')
op.drop_index(op.f('ix_vendor_addons_status'), table_name='vendor_addons')
op.drop_index(op.f('ix_vendor_addons_id'), table_name='vendor_addons')
op.drop_index(op.f('ix_vendor_addons_domain_name'), table_name='vendor_addons')
op.drop_index(op.f('ix_vendor_addons_addon_product_id'), table_name='vendor_addons')
op.drop_index('idx_vendor_addon_status', table_name='vendor_addons')
op.drop_index('idx_vendor_addon_product', table_name='vendor_addons')
op.drop_table('vendor_addons')
# Drop billing_history
op.drop_index(op.f('ix_billing_history_vendor_id'), table_name='billing_history')
op.drop_index(op.f('ix_billing_history_stripe_invoice_id'), table_name='billing_history')
op.drop_index(op.f('ix_billing_history_status'), table_name='billing_history')
op.drop_index(op.f('ix_billing_history_id'), table_name='billing_history')
op.drop_index('idx_billing_vendor_date', table_name='billing_history')
op.drop_index('idx_billing_status', table_name='billing_history')
op.drop_table('billing_history')
# Drop addon_products
op.drop_index(op.f('ix_addon_products_id'), table_name='addon_products')
op.drop_index(op.f('ix_addon_products_code'), table_name='addon_products')
op.drop_index(op.f('ix_addon_products_category'), table_name='addon_products')
op.drop_table('addon_products')
# Drop subscription_tiers
op.drop_index(op.f('ix_subscription_tiers_id'), table_name='subscription_tiers')
op.drop_index(op.f('ix_subscription_tiers_code'), table_name='subscription_tiers')
op.drop_table('subscription_tiers')

View File

@@ -11,7 +11,7 @@ This module provides:
from fastapi import APIRouter
from app.api.v1 import admin, shop, vendor
from app.api.v1.shared import language
from app.api.v1.shared import language, webhooks
api_router = APIRouter()
@@ -42,3 +42,4 @@ api_router.include_router(shop.router, prefix="/v1/shop", tags=["shop"])
# ============================================================================
api_router.include_router(language.router, prefix="/v1", tags=["language"])
api_router.include_router(webhooks.router, prefix="/v1", tags=["webhooks"])

View File

@@ -101,6 +101,8 @@ class CopyToVendorResponse(BaseModel):
copied: int
skipped: int
failed: int
auto_matched: int = 0
limit_reached: bool = False
details: list[dict] | None = None

View File

@@ -1 +1,63 @@
# External webhooks (Stripe, etc.
# app/api/v1/shared/webhooks.py
"""
External webhook endpoints.
Handles webhooks from:
- Stripe (payments and subscriptions)
"""
import logging
from fastapi import APIRouter, Header, Request
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.exceptions import InvalidWebhookSignatureException, WebhookMissingSignatureException
from app.services.stripe_service import stripe_service
from app.services.stripe_webhook_handler import stripe_webhook_handler
router = APIRouter(prefix="/webhooks")
logger = logging.getLogger(__name__)
@router.post("/stripe") # public - Stripe webhooks use signature verification
async def stripe_webhook(
request: Request,
stripe_signature: str = Header(None, alias="Stripe-Signature"),
):
"""
Handle Stripe webhook events.
Stripe sends events for:
- Subscription lifecycle (created, updated, deleted)
- Invoice and payment events
- Checkout session completion
The endpoint verifies the webhook signature and processes events idempotently.
"""
if not stripe_signature:
logger.warning("Stripe webhook received without signature")
raise WebhookMissingSignatureException()
# Get raw body for signature verification
payload = await request.body()
try:
# Verify and construct event
event = stripe_service.construct_event(payload, stripe_signature)
except ValueError as e:
logger.warning(f"Invalid Stripe webhook: {e}")
raise InvalidWebhookSignatureException(str(e))
# Process the event
db = next(get_db())
try:
result = stripe_webhook_handler.handle_event(db, event)
return {"received": True, **result}
except Exception as e:
logger.error(f"Error processing Stripe webhook: {e}")
# Return 200 to prevent Stripe retries for processing errors
# The event is marked as failed and can be retried manually
return {"received": True, "error": str(e)}
finally:
db.close()

View File

@@ -16,6 +16,7 @@ from fastapi import APIRouter
from . import (
analytics,
auth,
billing,
content_pages,
customers,
dashboard,
@@ -73,6 +74,7 @@ router.include_router(media.router, tags=["vendor-media"])
router.include_router(notifications.router, tags=["vendor-notifications"])
router.include_router(messages.router, tags=["vendor-messages"])
router.include_router(analytics.router, tags=["vendor-analytics"])
router.include_router(billing.router, tags=["vendor-billing"])
# Content pages management
router.include_router(

405
app/api/v1/vendor/billing.py vendored Normal file
View File

@@ -0,0 +1,405 @@
# app/api/v1/vendor/billing.py
"""
Vendor billing and subscription management endpoints.
Provides:
- Subscription status and usage
- Tier listing and comparison
- Stripe checkout and portal access
- Invoice history
- Add-on management
"""
import logging
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.config import settings
from app.core.database import get_db
from app.services.billing_service import billing_service
from app.services.subscription_service import subscription_service
from models.database.user import User
router = APIRouter(prefix="/billing")
logger = logging.getLogger(__name__)
# ============================================================================
# Schemas
# ============================================================================
class SubscriptionStatusResponse(BaseModel):
"""Current subscription status and usage."""
tier_code: str
tier_name: str
status: str
is_trial: bool
trial_ends_at: str | None = None
period_start: str | None = None
period_end: str | None = None
cancelled_at: str | None = None
cancellation_reason: str | None = None
# Usage
orders_this_period: int
orders_limit: int | None
orders_remaining: int | None
products_count: int
products_limit: int | None
products_remaining: int | None
team_count: int
team_limit: int | None
team_remaining: int | None
# Payment
has_payment_method: bool
last_payment_error: str | None = None
class Config:
from_attributes = True
class TierResponse(BaseModel):
"""Subscription tier information."""
code: str
name: str
description: str | None = None
price_monthly_cents: int
price_annual_cents: int | None = None
orders_per_month: int | None = None
products_limit: int | None = None
team_members: int | None = None
features: list[str] = []
is_current: bool = False
can_upgrade: bool = False
can_downgrade: bool = False
class TierListResponse(BaseModel):
"""List of available tiers."""
tiers: list[TierResponse]
current_tier: str
class CheckoutRequest(BaseModel):
"""Request to create a checkout session."""
tier_code: str
is_annual: bool = False
class CheckoutResponse(BaseModel):
"""Checkout session response."""
checkout_url: str
session_id: str
class PortalResponse(BaseModel):
"""Customer portal session response."""
portal_url: str
class InvoiceResponse(BaseModel):
"""Invoice information."""
id: int
invoice_number: str | None = None
invoice_date: str
due_date: str | None = None
total_cents: int
amount_paid_cents: int
currency: str
status: str
pdf_url: str | None = None
hosted_url: str | None = None
class InvoiceListResponse(BaseModel):
"""List of invoices."""
invoices: list[InvoiceResponse]
total: int
class AddOnResponse(BaseModel):
"""Add-on product information."""
id: int
code: str
name: str
description: str | None = None
category: str
price_cents: int
billing_period: str
quantity_unit: str | None = None
quantity_value: int | None = None
class VendorAddOnResponse(BaseModel):
"""Vendor's purchased add-on."""
id: int
addon_code: str
addon_name: str
status: str
domain_name: str | None = None
quantity: int
period_start: str | None = None
period_end: str | None = None
class AddOnPurchaseRequest(BaseModel):
"""Request to purchase an add-on."""
addon_code: str
domain_name: str | None = None # For domain add-ons
quantity: int = 1
class CancelRequest(BaseModel):
"""Request to cancel subscription."""
reason: str | None = None
immediately: bool = False
class CancelResponse(BaseModel):
"""Cancellation response."""
message: str
effective_date: str
# ============================================================================
# Endpoints
# ============================================================================
@router.get("/subscription", response_model=SubscriptionStatusResponse)
def get_subscription_status(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get current subscription status and usage metrics."""
vendor_id = current_user.token_vendor_id
usage = subscription_service.get_usage_summary(db, vendor_id)
subscription, tier = billing_service.get_subscription_with_tier(db, vendor_id)
return SubscriptionStatusResponse(
tier_code=subscription.tier,
tier_name=tier.name if tier else subscription.tier.title(),
status=subscription.status.value,
is_trial=subscription.is_in_trial(),
trial_ends_at=subscription.trial_ends_at.isoformat()
if subscription.trial_ends_at
else None,
period_start=subscription.period_start.isoformat()
if subscription.period_start
else None,
period_end=subscription.period_end.isoformat()
if subscription.period_end
else None,
cancelled_at=subscription.cancelled_at.isoformat()
if subscription.cancelled_at
else None,
cancellation_reason=subscription.cancellation_reason,
orders_this_period=usage.orders_this_period,
orders_limit=usage.orders_limit,
orders_remaining=usage.orders_remaining,
products_count=usage.products_count,
products_limit=usage.products_limit,
products_remaining=usage.products_remaining,
team_count=usage.team_count,
team_limit=usage.team_limit,
team_remaining=usage.team_remaining,
has_payment_method=bool(subscription.stripe_payment_method_id),
last_payment_error=subscription.last_payment_error,
)
@router.get("/tiers", response_model=TierListResponse)
def get_available_tiers(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get available subscription tiers for upgrade/downgrade."""
vendor_id = current_user.token_vendor_id
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
current_tier = subscription.tier
tier_list, _ = billing_service.get_available_tiers(db, current_tier)
tier_responses = [TierResponse(**tier_data) for tier_data in tier_list]
return TierListResponse(tiers=tier_responses, current_tier=current_tier)
@router.post("/checkout", response_model=CheckoutResponse)
def create_checkout_session(
request: CheckoutRequest,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Create a Stripe checkout session for subscription."""
vendor_id = current_user.token_vendor_id
vendor = billing_service.get_vendor(db, vendor_id)
# Build URLs
base_url = f"https://{settings.platform_domain}"
success_url = f"{base_url}/vendor/{vendor.vendor_code}/billing?success=true"
cancel_url = f"{base_url}/vendor/{vendor.vendor_code}/billing?cancelled=true"
result = billing_service.create_checkout_session(
db=db,
vendor_id=vendor_id,
tier_code=request.tier_code,
is_annual=request.is_annual,
success_url=success_url,
cancel_url=cancel_url,
)
db.commit()
return CheckoutResponse(checkout_url=result["checkout_url"], session_id=result["session_id"])
@router.post("/portal", response_model=PortalResponse)
def create_portal_session(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Create a Stripe customer portal session."""
vendor_id = current_user.token_vendor_id
vendor = billing_service.get_vendor(db, vendor_id)
return_url = f"https://{settings.platform_domain}/vendor/{vendor.vendor_code}/billing"
result = billing_service.create_portal_session(db, vendor_id, return_url)
return PortalResponse(portal_url=result["portal_url"])
@router.get("/invoices", response_model=InvoiceListResponse)
def get_invoices(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get invoice history."""
vendor_id = current_user.token_vendor_id
invoices, total = billing_service.get_invoices(db, vendor_id, skip=skip, limit=limit)
invoice_responses = [
InvoiceResponse(
id=inv.id,
invoice_number=inv.invoice_number,
invoice_date=inv.invoice_date.isoformat(),
due_date=inv.due_date.isoformat() if inv.due_date else None,
total_cents=inv.total_cents,
amount_paid_cents=inv.amount_paid_cents,
currency=inv.currency,
status=inv.status,
pdf_url=inv.invoice_pdf_url,
hosted_url=inv.hosted_invoice_url,
)
for inv in invoices
]
return InvoiceListResponse(invoices=invoice_responses, total=total)
@router.get("/addons", response_model=list[AddOnResponse])
def get_available_addons(
category: str | None = Query(None, description="Filter by category"),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get available add-on products."""
addons = billing_service.get_available_addons(db, category=category)
return [
AddOnResponse(
id=addon.id,
code=addon.code,
name=addon.name,
description=addon.description,
category=addon.category,
price_cents=addon.price_cents,
billing_period=addon.billing_period,
quantity_unit=addon.quantity_unit,
quantity_value=addon.quantity_value,
)
for addon in addons
]
@router.get("/my-addons", response_model=list[VendorAddOnResponse])
def get_vendor_addons(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get vendor's purchased add-ons."""
vendor_id = current_user.token_vendor_id
vendor_addons = billing_service.get_vendor_addons(db, vendor_id)
return [
VendorAddOnResponse(
id=va.id,
addon_code=va.addon_product.code,
addon_name=va.addon_product.name,
status=va.status,
domain_name=va.domain_name,
quantity=va.quantity,
period_start=va.period_start.isoformat() if va.period_start else None,
period_end=va.period_end.isoformat() if va.period_end else None,
)
for va in vendor_addons
]
@router.post("/cancel", response_model=CancelResponse)
def cancel_subscription(
request: CancelRequest,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Cancel subscription."""
vendor_id = current_user.token_vendor_id
result = billing_service.cancel_subscription(
db=db,
vendor_id=vendor_id,
reason=request.reason,
immediately=request.immediately,
)
db.commit()
return CancelResponse(
message=result["message"],
effective_date=result["effective_date"],
)
@router.post("/reactivate")
def reactivate_subscription(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Reactivate a cancelled subscription."""
vendor_id = current_user.token_vendor_id
result = billing_service.reactivate_subscription(db, vendor_id)
db.commit()
return result

View File

@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.product_service import product_service
from app.services.subscription_service import subscription_service
from models.database.user import User
from models.schema.product import (
ProductCreate,
@@ -89,6 +90,9 @@ def add_product_to_catalog(
This publishes a MarketplaceProduct to the vendor's public catalog.
"""
# Check product limit before creating
subscription_service.check_product_limit(db, current_user.token_vendor_id)
product = product_service.create_product(
db=db, vendor_id=current_user.token_vendor_id, product_data=product_data
)
@@ -157,6 +161,9 @@ def publish_from_marketplace(
Shortcut endpoint for publishing directly from marketplace import.
"""
# Check product limit before creating
subscription_service.check_product_limit(db, current_user.token_vendor_id)
product_data = ProductCreate(
marketplace_product_id=marketplace_product_id, is_active=True
)

View File

@@ -117,6 +117,14 @@ class Settings(BaseSettings):
max_team_members_per_vendor: int = 50
invitation_expiry_days: int = 7
# =============================================================================
# STRIPE BILLING
# =============================================================================
stripe_secret_key: str = ""
stripe_publishable_key: str = ""
stripe_webhook_secret: str = ""
stripe_trial_days: int = 14
# =============================================================================
# DEMO/SEED DATA CONFIGURATION
# =============================================================================

View File

@@ -46,6 +46,18 @@ from .base import (
WizamartException,
)
# Billing exceptions
from .billing import (
InvalidWebhookSignatureException,
NoActiveSubscriptionException,
PaymentSystemNotConfiguredException,
StripePriceNotConfiguredException,
SubscriptionAlreadyCancelledException,
SubscriptionNotCancelledException,
TierNotFoundException,
WebhookMissingSignatureException,
)
# Cart exceptions
from .cart import (
CartItemNotFoundException,
@@ -416,4 +428,13 @@ __all__ = [
"InvalidConversationTypeException",
"InvalidRecipientTypeException",
"AttachmentNotFoundException",
# Billing exceptions
"PaymentSystemNotConfiguredException",
"TierNotFoundException",
"StripePriceNotConfiguredException",
"NoActiveSubscriptionException",
"SubscriptionNotCancelledException",
"SubscriptionAlreadyCancelledException",
"InvalidWebhookSignatureException",
"WebhookMissingSignatureException",
]

95
app/exceptions/billing.py Normal file
View File

@@ -0,0 +1,95 @@
# app/exceptions/billing.py
"""
Billing and subscription related exceptions.
This module provides exceptions for:
- Payment system configuration issues
- Subscription management errors
- Tier-related errors
"""
from typing import Any
from .base import BusinessLogicException, ResourceNotFoundException, ServiceUnavailableException
class PaymentSystemNotConfiguredException(ServiceUnavailableException):
"""Raised when the payment system (Stripe) is not configured."""
def __init__(self):
super().__init__(message="Payment system not configured")
class TierNotFoundException(ResourceNotFoundException):
"""Raised when a subscription tier is not found."""
def __init__(self, tier_code: str):
super().__init__(
resource_type="SubscriptionTier",
identifier=tier_code,
message=f"Subscription tier '{tier_code}' not found",
error_code="TIER_NOT_FOUND",
)
self.tier_code = tier_code
class StripePriceNotConfiguredException(BusinessLogicException):
"""Raised when Stripe price is not configured for a tier."""
def __init__(self, tier_code: str):
super().__init__(
message=f"Stripe price not configured for tier '{tier_code}'",
error_code="STRIPE_PRICE_NOT_CONFIGURED",
details={"tier_code": tier_code},
)
self.tier_code = tier_code
class NoActiveSubscriptionException(BusinessLogicException):
"""Raised when no active subscription exists for an operation that requires one."""
def __init__(self, message: str = "No active subscription found"):
super().__init__(
message=message,
error_code="NO_ACTIVE_SUBSCRIPTION",
)
class SubscriptionNotCancelledException(BusinessLogicException):
"""Raised when trying to reactivate a subscription that is not cancelled."""
def __init__(self):
super().__init__(
message="Subscription is not cancelled",
error_code="SUBSCRIPTION_NOT_CANCELLED",
)
class SubscriptionAlreadyCancelledException(BusinessLogicException):
"""Raised when trying to cancel an already cancelled subscription."""
def __init__(self):
super().__init__(
message="Subscription is already cancelled",
error_code="SUBSCRIPTION_ALREADY_CANCELLED",
)
class InvalidWebhookSignatureException(BusinessLogicException):
"""Raised when Stripe webhook signature verification fails."""
def __init__(self, message: str = "Invalid webhook signature"):
super().__init__(
message=message,
error_code="INVALID_WEBHOOK_SIGNATURE",
)
class WebhookMissingSignatureException(BusinessLogicException):
"""Raised when Stripe webhook is missing the signature header."""
def __init__(self):
super().__init__(
message="Missing Stripe-Signature header",
error_code="WEBHOOK_MISSING_SIGNATURE",
)

View File

@@ -454,6 +454,28 @@ async def vendor_settings_page(
)
@router.get(
"/{vendor_code}/billing", response_class=HTMLResponse, include_in_schema=False
)
async def vendor_billing_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
):
"""
Render billing and subscription management page.
JavaScript loads subscription status, tiers, and invoices via API.
"""
return templates.TemplateResponse(
"vendor/billing.html",
{
"request": request,
"user": current_user,
"vendor_code": vendor_code,
},
)
# ============================================================================
# DYNAMIC CONTENT PAGES (CMS)
# ============================================================================

View File

@@ -0,0 +1,370 @@
# 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()

View File

@@ -865,12 +865,42 @@ class MarketplaceProductService:
if not marketplace_products:
raise MarketplaceProductNotFoundException("No marketplace products found")
# Check product limit from subscription
from app.services.subscription_service import subscription_service
from sqlalchemy import func
current_products = (
db.query(func.count(Product.id))
.filter(Product.vendor_id == vendor_id)
.scalar()
or 0
)
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
products_limit = subscription.products_limit
remaining_capacity = (
products_limit - current_products if products_limit is not None else None
)
copied = 0
skipped = 0
failed = 0
limit_reached = False
details = []
for mp in marketplace_products:
# Check if we've hit the product limit
if remaining_capacity is not None and copied >= remaining_capacity:
limit_reached = True
details.append(
{
"id": mp.id,
"status": "skipped",
"reason": "Product limit reached",
}
)
skipped += 1
continue
try:
existing = (
db.query(Product)
@@ -994,6 +1024,7 @@ class MarketplaceProductService:
"skipped": skipped,
"failed": failed,
"auto_matched": auto_matched,
"limit_reached": limit_reached,
"details": details if len(details) <= 100 else None,
}

View File

@@ -0,0 +1,459 @@
# 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()

View File

@@ -0,0 +1,411 @@
# app/services/stripe_webhook_handler.py
"""
Stripe webhook event handler.
Processes webhook events from Stripe:
- Subscription lifecycle events
- Invoice and payment events
- Checkout session completion
"""
import logging
from datetime import datetime, timezone
import stripe
from sqlalchemy.orm import Session
from models.database.subscription import (
BillingHistory,
StripeWebhookEvent,
SubscriptionStatus,
SubscriptionTier,
VendorSubscription,
)
logger = logging.getLogger(__name__)
class StripeWebhookHandler:
"""Handler for Stripe webhook events."""
def __init__(self):
self.handlers = {
"checkout.session.completed": self._handle_checkout_completed,
"customer.subscription.created": self._handle_subscription_created,
"customer.subscription.updated": self._handle_subscription_updated,
"customer.subscription.deleted": self._handle_subscription_deleted,
"invoice.paid": self._handle_invoice_paid,
"invoice.payment_failed": self._handle_payment_failed,
"invoice.finalized": self._handle_invoice_finalized,
}
def handle_event(self, db: Session, event: stripe.Event) -> dict:
"""
Process a Stripe webhook event.
Args:
db: Database session
event: Stripe Event object
Returns:
Dict with processing result
"""
event_id = event.id
event_type = event.type
# Check for duplicate processing (idempotency)
existing = (
db.query(StripeWebhookEvent)
.filter(StripeWebhookEvent.event_id == event_id)
.first()
)
if existing:
if existing.status == "processed":
logger.info(f"Skipping duplicate event {event_id}")
return {"status": "skipped", "reason": "duplicate"}
elif existing.status == "failed":
logger.info(f"Retrying previously failed event {event_id}")
else:
# Record the event
webhook_event = StripeWebhookEvent(
event_id=event_id,
event_type=event_type,
status="pending",
)
db.add(webhook_event)
db.flush()
existing = webhook_event
# Process the event
handler = self.handlers.get(event_type)
if not handler:
logger.debug(f"No handler for event type {event_type}")
existing.status = "processed"
existing.processed_at = datetime.now(timezone.utc)
db.commit() # noqa: SVC-006 - Webhook handler controls its own transaction
return {"status": "ignored", "reason": f"no handler for {event_type}"}
try:
result = handler(db, event)
existing.status = "processed"
existing.processed_at = datetime.now(timezone.utc)
db.commit() # noqa: SVC-006 - Webhook handler controls its own transaction
logger.info(f"Successfully processed event {event_id} ({event_type})")
return {"status": "processed", "result": result}
except Exception as e:
logger.error(f"Error processing event {event_id}: {e}")
existing.status = "failed"
existing.error_message = str(e)
db.commit() # noqa: SVC-006 - Webhook handler controls its own transaction
raise
# =========================================================================
# Event Handlers
# =========================================================================
def _handle_checkout_completed(
self, db: Session, event: stripe.Event
) -> dict:
"""Handle checkout.session.completed event."""
session = event.data.object
vendor_id = session.metadata.get("vendor_id")
if not vendor_id:
logger.warning(f"Checkout session {session.id} missing vendor_id")
return {"action": "skipped", "reason": "no vendor_id"}
vendor_id = int(vendor_id)
subscription = (
db.query(VendorSubscription)
.filter(VendorSubscription.vendor_id == vendor_id)
.first()
)
if not subscription:
logger.warning(f"No subscription found for vendor {vendor_id}")
return {"action": "skipped", "reason": "no subscription"}
# Update subscription with Stripe IDs
subscription.stripe_customer_id = session.customer
subscription.stripe_subscription_id = session.subscription
subscription.status = SubscriptionStatus.ACTIVE
# Get subscription details to set period dates
if session.subscription:
stripe_sub = stripe.Subscription.retrieve(session.subscription)
subscription.period_start = datetime.fromtimestamp(
stripe_sub.current_period_start, tz=timezone.utc
)
subscription.period_end = datetime.fromtimestamp(
stripe_sub.current_period_end, tz=timezone.utc
)
if stripe_sub.trial_end:
subscription.trial_ends_at = datetime.fromtimestamp(
stripe_sub.trial_end, tz=timezone.utc
)
logger.info(f"Checkout completed for vendor {vendor_id}")
return {"action": "activated", "vendor_id": vendor_id}
def _handle_subscription_created(
self, db: Session, event: stripe.Event
) -> dict:
"""Handle customer.subscription.created event."""
stripe_sub = event.data.object
customer_id = stripe_sub.customer
# Find subscription by customer ID
subscription = (
db.query(VendorSubscription)
.filter(VendorSubscription.stripe_customer_id == customer_id)
.first()
)
if not subscription:
logger.warning(f"No subscription found for customer {customer_id}")
return {"action": "skipped", "reason": "no subscription"}
# Update subscription
subscription.stripe_subscription_id = stripe_sub.id
subscription.status = self._map_stripe_status(stripe_sub.status)
subscription.period_start = datetime.fromtimestamp(
stripe_sub.current_period_start, tz=timezone.utc
)
subscription.period_end = datetime.fromtimestamp(
stripe_sub.current_period_end, tz=timezone.utc
)
logger.info(f"Subscription created for vendor {subscription.vendor_id}")
return {"action": "created", "vendor_id": subscription.vendor_id}
def _handle_subscription_updated(
self, db: Session, event: stripe.Event
) -> dict:
"""Handle customer.subscription.updated event."""
stripe_sub = event.data.object
subscription = (
db.query(VendorSubscription)
.filter(VendorSubscription.stripe_subscription_id == stripe_sub.id)
.first()
)
if not subscription:
logger.warning(f"No subscription found for {stripe_sub.id}")
return {"action": "skipped", "reason": "no subscription"}
# Update status and period
subscription.status = self._map_stripe_status(stripe_sub.status)
subscription.period_start = datetime.fromtimestamp(
stripe_sub.current_period_start, tz=timezone.utc
)
subscription.period_end = datetime.fromtimestamp(
stripe_sub.current_period_end, tz=timezone.utc
)
# Handle cancellation
if stripe_sub.cancel_at_period_end:
subscription.cancelled_at = datetime.now(timezone.utc)
subscription.cancellation_reason = stripe_sub.metadata.get(
"cancellation_reason", "user_request"
)
elif subscription.cancelled_at and not stripe_sub.cancel_at_period_end:
# Subscription reactivated
subscription.cancelled_at = None
subscription.cancellation_reason = None
# Check for tier change via price
if stripe_sub.items.data:
new_price_id = stripe_sub.items.data[0].price.id
if subscription.stripe_price_id != new_price_id:
# Price changed, look up new tier
tier = (
db.query(SubscriptionTier)
.filter(SubscriptionTier.stripe_price_monthly_id == new_price_id)
.first()
)
if tier:
subscription.tier = tier.code
logger.info(
f"Tier changed to {tier.code} for vendor {subscription.vendor_id}"
)
subscription.stripe_price_id = new_price_id
logger.info(f"Subscription updated for vendor {subscription.vendor_id}")
return {"action": "updated", "vendor_id": subscription.vendor_id}
def _handle_subscription_deleted(
self, db: Session, event: stripe.Event
) -> dict:
"""Handle customer.subscription.deleted event."""
stripe_sub = event.data.object
subscription = (
db.query(VendorSubscription)
.filter(VendorSubscription.stripe_subscription_id == stripe_sub.id)
.first()
)
if not subscription:
logger.warning(f"No subscription found for {stripe_sub.id}")
return {"action": "skipped", "reason": "no subscription"}
subscription.status = SubscriptionStatus.CANCELLED
subscription.cancelled_at = datetime.now(timezone.utc)
logger.info(f"Subscription deleted for vendor {subscription.vendor_id}")
return {"action": "cancelled", "vendor_id": subscription.vendor_id}
def _handle_invoice_paid(self, db: Session, event: stripe.Event) -> dict:
"""Handle invoice.paid event."""
invoice = event.data.object
customer_id = invoice.customer
subscription = (
db.query(VendorSubscription)
.filter(VendorSubscription.stripe_customer_id == customer_id)
.first()
)
if not subscription:
logger.warning(f"No subscription found for customer {customer_id}")
return {"action": "skipped", "reason": "no subscription"}
# Record billing history
billing_record = BillingHistory(
vendor_id=subscription.vendor_id,
stripe_invoice_id=invoice.id,
stripe_payment_intent_id=invoice.payment_intent,
invoice_number=invoice.number,
invoice_date=datetime.fromtimestamp(invoice.created, tz=timezone.utc),
subtotal_cents=invoice.subtotal,
tax_cents=invoice.tax or 0,
total_cents=invoice.total,
amount_paid_cents=invoice.amount_paid,
currency=invoice.currency.upper(),
status="paid",
invoice_pdf_url=invoice.invoice_pdf,
hosted_invoice_url=invoice.hosted_invoice_url,
)
db.add(billing_record)
# Reset payment retry count on successful payment
subscription.payment_retry_count = 0
subscription.last_payment_error = None
# Reset period counters if this is a new billing cycle
if subscription.status == SubscriptionStatus.ACTIVE:
subscription.orders_this_period = 0
subscription.orders_limit_reached_at = None
logger.info(f"Invoice paid for vendor {subscription.vendor_id}")
return {
"action": "recorded",
"vendor_id": subscription.vendor_id,
"invoice_id": invoice.id,
}
def _handle_payment_failed(self, db: Session, event: stripe.Event) -> dict:
"""Handle invoice.payment_failed event."""
invoice = event.data.object
customer_id = invoice.customer
subscription = (
db.query(VendorSubscription)
.filter(VendorSubscription.stripe_customer_id == customer_id)
.first()
)
if not subscription:
logger.warning(f"No subscription found for customer {customer_id}")
return {"action": "skipped", "reason": "no subscription"}
# Update subscription status
subscription.status = SubscriptionStatus.PAST_DUE
subscription.payment_retry_count = (subscription.payment_retry_count or 0) + 1
# Store error message
if invoice.last_payment_error:
subscription.last_payment_error = invoice.last_payment_error.get("message")
logger.warning(
f"Payment failed for vendor {subscription.vendor_id} "
f"(retry #{subscription.payment_retry_count})"
)
return {
"action": "marked_past_due",
"vendor_id": subscription.vendor_id,
"retry_count": subscription.payment_retry_count,
}
def _handle_invoice_finalized(
self, db: Session, event: stripe.Event
) -> dict:
"""Handle invoice.finalized event."""
invoice = event.data.object
customer_id = invoice.customer
subscription = (
db.query(VendorSubscription)
.filter(VendorSubscription.stripe_customer_id == customer_id)
.first()
)
if not subscription:
return {"action": "skipped", "reason": "no subscription"}
# Check if we already have this invoice
existing = (
db.query(BillingHistory)
.filter(BillingHistory.stripe_invoice_id == invoice.id)
.first()
)
if existing:
return {"action": "skipped", "reason": "already recorded"}
# Record as pending invoice
billing_record = BillingHistory(
vendor_id=subscription.vendor_id,
stripe_invoice_id=invoice.id,
invoice_number=invoice.number,
invoice_date=datetime.fromtimestamp(invoice.created, tz=timezone.utc),
due_date=datetime.fromtimestamp(invoice.due_date, tz=timezone.utc)
if invoice.due_date
else None,
subtotal_cents=invoice.subtotal,
tax_cents=invoice.tax or 0,
total_cents=invoice.total,
amount_paid_cents=0,
currency=invoice.currency.upper(),
status="open",
invoice_pdf_url=invoice.invoice_pdf,
hosted_invoice_url=invoice.hosted_invoice_url,
)
db.add(billing_record)
return {"action": "recorded_pending", "vendor_id": subscription.vendor_id}
# =========================================================================
# Helpers
# =========================================================================
def _map_stripe_status(self, stripe_status: str) -> SubscriptionStatus:
"""Map Stripe subscription status to internal status."""
status_map = {
"active": SubscriptionStatus.ACTIVE,
"trialing": SubscriptionStatus.TRIAL,
"past_due": SubscriptionStatus.PAST_DUE,
"canceled": SubscriptionStatus.CANCELLED,
"unpaid": SubscriptionStatus.PAST_DUE,
"incomplete": SubscriptionStatus.TRIAL, # Treat as trial until complete
"incomplete_expired": SubscriptionStatus.EXPIRED,
}
return status_map.get(stripe_status, SubscriptionStatus.EXPIRED)
# Create handler instance
stripe_webhook_handler = StripeWebhookHandler()

View File

@@ -20,11 +20,11 @@ from app.core.permissions import get_preset_permissions
from app.exceptions import (
CannotRemoveOwnerException,
InvalidInvitationTokenException,
MaxTeamMembersReachedException,
TeamInvitationAlreadyAcceptedException,
TeamMemberAlreadyExistsException,
UserNotFoundException,
)
from app.services.subscription_service import TierLimitExceededException
from middleware.auth import AuthManager
from models.database.user import User
from models.database.vendor import Role, Vendor, VendorUser, VendorUserType
@@ -37,7 +37,6 @@ class VendorTeamService:
def __init__(self):
self.auth_manager = AuthManager()
self.max_team_members = 50 # Configure as needed
def invite_team_member(
self,
@@ -68,21 +67,10 @@ class VendorTeamService:
Dict with invitation details
"""
try:
# Check team size limit
current_team_size = (
db.query(VendorUser)
.filter(
VendorUser.vendor_id == vendor.id,
VendorUser.is_active == True,
)
.count()
)
# Check team size limit from subscription
from app.services.subscription_service import subscription_service
if current_team_size >= self.max_team_members:
raise MaxTeamMembersReachedException(
self.max_team_members,
vendor.vendor_code,
)
subscription_service.check_team_limit(db, vendor.id)
# Check if user already exists
user = db.query(User).filter(User.email == email).first()
@@ -187,7 +175,7 @@ class VendorTeamService:
"existing_user": user.is_active,
}
except (TeamMemberAlreadyExistsException, MaxTeamMembersReachedException):
except (TeamMemberAlreadyExistsException, TierLimitExceededException):
raise
except Exception as e:
logger.error(f"Error inviting team member: {str(e)}")

397
app/templates/vendor/billing.html vendored Normal file
View File

@@ -0,0 +1,397 @@
{# app/templates/vendor/billing.html #}
{% extends "vendor/base.html" %}
{% block title %}Billing & Subscription{% endblock %}
{% block alpine_data %}billingData(){% endblock %}
{% block content %}
<div class="flex items-center justify-between my-6">
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Billing & Subscription
</h2>
</div>
<!-- Success/Cancel Messages -->
<template x-if="showSuccessMessage">
<div class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg flex items-center justify-between">
<div class="flex items-center">
<span x-html="$icon('check-circle', 'w-5 h-5 mr-2')"></span>
<span>Your subscription has been updated successfully!</span>
</div>
<button @click="showSuccessMessage = false" class="text-green-700 hover:text-green-900">
<span x-html="$icon('x-mark', 'w-5 h-5')"></span>
</button>
</div>
</template>
<template x-if="showCancelMessage">
<div class="mb-6 p-4 bg-yellow-100 border border-yellow-400 text-yellow-700 rounded-lg flex items-center justify-between">
<div class="flex items-center">
<span x-html="$icon('exclamation-triangle', 'w-5 h-5 mr-2')"></span>
<span>Checkout was cancelled. No changes were made to your subscription.</span>
</div>
<button @click="showCancelMessage = false" class="text-yellow-700 hover:text-yellow-900">
<span x-html="$icon('x-mark', 'w-5 h-5')"></span>
</button>
</div>
</template>
<!-- Loading State -->
<template x-if="loading">
<div class="flex justify-center items-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600"></div>
</div>
</template>
<template x-if="!loading">
<div class="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
<!-- Current Plan Card -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Current Plan</h3>
<span :class="{
'bg-green-100 text-green-800': subscription?.status === 'active',
'bg-yellow-100 text-yellow-800': subscription?.status === 'trial',
'bg-red-100 text-red-800': subscription?.status === 'past_due' || subscription?.status === 'cancelled',
'bg-gray-100 text-gray-800': !['active', 'trial', 'past_due', 'cancelled'].includes(subscription?.status)
}" class="px-2 py-1 text-xs font-semibold rounded-full">
<span x-text="subscription?.status?.replace('_', ' ')?.toUpperCase() || 'INACTIVE'"></span>
</span>
</div>
<div class="mb-4">
<div class="text-3xl font-bold text-purple-600 dark:text-purple-400" x-text="subscription?.tier_name || 'No Plan'"></div>
<template x-if="subscription?.is_trial">
<p class="text-sm text-yellow-600 dark:text-yellow-400 mt-1">
Trial ends <span x-text="formatDate(subscription?.trial_ends_at)"></span>
</p>
</template>
<template x-if="subscription?.cancelled_at">
<p class="text-sm text-red-600 dark:text-red-400 mt-1">
Cancels on <span x-text="formatDate(subscription?.period_end)"></span>
</p>
</template>
</div>
<div class="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<template x-if="subscription?.period_end && !subscription?.cancelled_at">
<p>
Next billing: <span class="font-medium text-gray-800 dark:text-gray-200" x-text="formatDate(subscription?.period_end)"></span>
</p>
</template>
</div>
<div class="mt-6 space-y-2">
<template x-if="subscription?.stripe_customer_id">
<button @click="openPortal()"
class="w-full px-4 py-2 text-sm font-medium text-purple-600 bg-purple-100 rounded-lg hover:bg-purple-200 dark:bg-purple-900 dark:text-purple-300">
Manage Payment Method
</button>
</template>
<template x-if="subscription?.cancelled_at">
<button @click="reactivate()"
class="w-full px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700">
Reactivate Subscription
</button>
</template>
<template x-if="!subscription?.cancelled_at && subscription?.status === 'active'">
<button @click="showCancelModal = true"
class="w-full px-4 py-2 text-sm font-medium text-red-600 bg-red-100 rounded-lg hover:bg-red-200 dark:bg-red-900 dark:text-red-300">
Cancel Subscription
</button>
</template>
</div>
</div>
<!-- Usage Summary Card -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">Usage This Period</h3>
<!-- Orders Usage -->
<div class="mb-4">
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-600 dark:text-gray-400">Orders</span>
<span class="font-medium text-gray-800 dark:text-gray-200">
<span x-text="subscription?.orders_this_period || 0"></span>
<span x-text="subscription?.orders_limit ? ` / ${subscription.orders_limit}` : ' (Unlimited)'"></span>
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2 dark:bg-gray-700">
<div class="bg-purple-600 h-2 rounded-full transition-all duration-300"
:style="`width: ${subscription?.orders_limit ? Math.min(100, (subscription.orders_this_period / subscription.orders_limit) * 100) : 0}%`"></div>
</div>
</div>
<!-- Products Usage -->
<div class="mb-4">
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-600 dark:text-gray-400">Products</span>
<span class="font-medium text-gray-800 dark:text-gray-200">
<span x-text="subscription?.products_count || 0"></span>
<span x-text="subscription?.products_limit ? ` / ${subscription.products_limit}` : ' (Unlimited)'"></span>
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2 dark:bg-gray-700">
<div class="bg-blue-600 h-2 rounded-full transition-all duration-300"
:style="`width: ${subscription?.products_limit ? Math.min(100, (subscription.products_count / subscription.products_limit) * 100) : 0}%`"></div>
</div>
</div>
<!-- Team Usage -->
<div class="mb-4">
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-600 dark:text-gray-400">Team Members</span>
<span class="font-medium text-gray-800 dark:text-gray-200">
<span x-text="subscription?.team_count || 0"></span>
<span x-text="subscription?.team_limit ? ` / ${subscription.team_limit}` : ' (Unlimited)'"></span>
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2 dark:bg-gray-700">
<div class="bg-green-600 h-2 rounded-full transition-all duration-300"
:style="`width: ${subscription?.team_limit ? Math.min(100, (subscription.team_count / subscription.team_limit) * 100) : 0}%`"></div>
</div>
</div>
<template x-if="subscription?.last_payment_error">
<div class="mt-4 p-3 bg-red-100 dark:bg-red-900 rounded-lg">
<p class="text-sm text-red-700 dark:text-red-300">
<span x-html="$icon('exclamation-circle', 'w-4 h-4 inline mr-1')"></span>
Payment issue: <span x-text="subscription.last_payment_error"></span>
</p>
</div>
</template>
</div>
<!-- Quick Actions Card -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">Quick Actions</h3>
<div class="space-y-3">
<button @click="showTiersModal = true"
class="w-full flex items-center justify-between px-4 py-3 text-left bg-gray-50 dark:bg-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors">
<div class="flex items-center">
<span x-html="$icon('arrow-trending-up', 'w-5 h-5 text-purple-600 mr-3')"></span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Change Plan</span>
</div>
<span x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
</button>
<button @click="showAddonsModal = true"
class="w-full flex items-center justify-between px-4 py-3 text-left bg-gray-50 dark:bg-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors">
<div class="flex items-center">
<span x-html="$icon('puzzle-piece', 'w-5 h-5 text-blue-600 mr-3')"></span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Add-ons</span>
</div>
<span x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
</button>
<a :href="`/vendor/${vendorCode}/invoices`"
class="w-full flex items-center justify-between px-4 py-3 text-left bg-gray-50 dark:bg-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors">
<div class="flex items-center">
<span x-html="$icon('document-text', 'w-5 h-5 text-green-600 mr-3')"></span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">View Invoices</span>
</div>
<span x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
</a>
</div>
</div>
</div>
<!-- Invoice History Section -->
<div class="mt-8 bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">Recent Invoices</h3>
<template x-if="invoices.length === 0">
<p class="text-gray-500 dark:text-gray-400 text-center py-8">No invoices yet</p>
</template>
<template x-if="invoices.length > 0">
<div class="overflow-x-auto">
<table class="w-full whitespace-nowrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Invoice</th>
<th class="px-4 py-3">Date</th>
<th class="px-4 py-3">Amount</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="invoice in invoices.slice(0, 5)" :key="invoice.id">
<tr class="text-gray-700 dark:text-gray-400">
<td class="px-4 py-3 text-sm font-medium" x-text="invoice.invoice_number || `#${invoice.id}`"></td>
<td class="px-4 py-3 text-sm" x-text="formatDate(invoice.invoice_date)"></td>
<td class="px-4 py-3 text-sm font-medium" x-text="formatCurrency(invoice.total_cents, invoice.currency)"></td>
<td class="px-4 py-3 text-sm">
<span :class="{
'bg-green-100 text-green-800': invoice.status === 'paid',
'bg-yellow-100 text-yellow-800': invoice.status === 'open',
'bg-red-100 text-red-800': invoice.status === 'uncollectible'
}" class="px-2 py-1 text-xs font-semibold rounded-full" x-text="invoice.status.toUpperCase()"></span>
</td>
<td class="px-4 py-3 text-sm">
<template x-if="invoice.pdf_url">
<a :href="invoice.pdf_url" target="_blank" class="text-purple-600 hover:text-purple-800">
<span x-html="$icon('arrow-down-tray', 'w-5 h-5')"></span>
</a>
</template>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</div>
</template>
<!-- Tiers Modal -->
<div x-show="showTiersModal"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-30 flex items-center justify-center overflow-auto bg-black bg-opacity-50"
@click.self="showTiersModal = false">
<div class="w-full max-w-4xl mx-4 bg-white dark:bg-gray-800 rounded-lg shadow-xl">
<div class="flex items-center justify-between px-6 py-4 border-b dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Choose Your Plan</h3>
<button @click="showTiersModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x-mark', 'w-6 h-6')"></span>
</button>
</div>
<div class="p-6">
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<template x-for="tier in tiers" :key="tier.code">
<div :class="{'ring-2 ring-purple-600': tier.is_current}"
class="relative p-6 bg-gray-50 dark:bg-gray-700 rounded-lg">
<template x-if="tier.is_current">
<span class="absolute top-0 right-0 px-2 py-1 text-xs font-semibold text-white bg-purple-600 rounded-bl-lg rounded-tr-lg">Current</span>
</template>
<h4 class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="tier.name"></h4>
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
<span x-text="formatCurrency(tier.price_monthly_cents, 'EUR')"></span>
<span class="text-sm font-normal text-gray-500">/mo</span>
</p>
<ul class="mt-4 space-y-2 text-sm text-gray-600 dark:text-gray-400">
<li class="flex items-center">
<span x-html="$icon('check', 'w-4 h-4 text-green-500 mr-2')"></span>
<span x-text="tier.orders_per_month ? `${tier.orders_per_month} orders/mo` : 'Unlimited orders'"></span>
</li>
<li class="flex items-center">
<span x-html="$icon('check', 'w-4 h-4 text-green-500 mr-2')"></span>
<span x-text="tier.products_limit ? `${tier.products_limit} products` : 'Unlimited products'"></span>
</li>
<li class="flex items-center">
<span x-html="$icon('check', 'w-4 h-4 text-green-500 mr-2')"></span>
<span x-text="tier.team_members ? `${tier.team_members} team members` : 'Unlimited team'"></span>
</li>
</ul>
<button @click="selectTier(tier)"
:disabled="tier.is_current"
:class="tier.is_current ? 'bg-gray-300 cursor-not-allowed' : 'bg-purple-600 hover:bg-purple-700'"
class="w-full mt-4 px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors">
<span x-text="tier.is_current ? 'Current Plan' : (tier.can_upgrade ? 'Upgrade' : 'Downgrade')"></span>
</button>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- Add-ons Modal -->
<div x-show="showAddonsModal"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-30 flex items-center justify-center overflow-auto bg-black bg-opacity-50"
@click.self="showAddonsModal = false">
<div class="w-full max-w-2xl mx-4 bg-white dark:bg-gray-800 rounded-lg shadow-xl">
<div class="flex items-center justify-between px-6 py-4 border-b dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Add-ons</h3>
<button @click="showAddonsModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x-mark', 'w-6 h-6')"></span>
</button>
</div>
<div class="p-6">
<template x-if="addons.length === 0">
<p class="text-gray-500 text-center py-8">No add-ons available</p>
</template>
<div class="space-y-4">
<template x-for="addon in addons" :key="addon.id">
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div>
<h4 class="font-medium text-gray-700 dark:text-gray-200" x-text="addon.name"></h4>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="addon.description"></p>
<p class="text-sm font-medium text-purple-600 mt-1">
<span x-text="formatCurrency(addon.price_cents, 'EUR')"></span>
<span x-text="`/${addon.billing_period}`"></span>
</p>
</div>
<button class="px-4 py-2 text-sm font-medium text-purple-600 bg-purple-100 rounded-lg hover:bg-purple-200">
Add
</button>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- Cancel Subscription Modal -->
<div x-show="showCancelModal"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-30 flex items-center justify-center overflow-auto bg-black bg-opacity-50"
@click.self="showCancelModal = false">
<div class="w-full max-w-md mx-4 bg-white dark:bg-gray-800 rounded-lg shadow-xl">
<div class="flex items-center justify-between px-6 py-4 border-b dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Cancel Subscription</h3>
<button @click="showCancelModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x-mark', 'w-6 h-6')"></span>
</button>
</div>
<div class="p-6">
<p class="text-gray-600 dark:text-gray-400 mb-4">
Are you sure you want to cancel your subscription? You'll continue to have access until the end of your current billing period.
</p>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Reason for cancelling (optional)
</label>
<textarea x-model="cancelReason"
rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="Tell us why you're leaving..."></textarea>
</div>
<div class="flex justify-end space-x-3">
<button @click="showCancelModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300">
Keep Subscription
</button>
<button @click="cancelSubscription()"
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700">
Cancel Subscription
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/vendor/js/billing.js"></script>
{% endblock %}

View File

@@ -184,6 +184,17 @@ Follows same pattern as admin sidebar
<span class="ml-4">Settings</span>
</a>
</li>
<li class="relative px-6 py-3">
<span x-show="currentPage === 'billing'"
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'billing' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/vendor/${vendorCode}/billing`">
<span x-html="$icon('credit-card', 'w-5 h-5')"></span>
<span class="ml-4">Billing</span>
</a>
</li>
</ul>
<!-- Quick Actions -->

View File

@@ -0,0 +1,270 @@
# Subscription & Billing System
The platform provides a comprehensive subscription and billing system for managing vendor subscriptions, usage limits, and payments through Stripe.
## Overview
The billing system enables:
- **Subscription Tiers**: Database-driven tier definitions with configurable limits
- **Usage Tracking**: Orders, products, and team member limits per tier
- **Stripe Integration**: Checkout sessions, customer portal, and webhook handling
- **Self-Service Billing**: Vendor-facing billing page for subscription management
- **Add-ons**: Optional purchasable items (domains, SSL, email packages)
## Architecture
### Database Models
All subscription models are defined in `models/database/subscription.py`:
| Model | Purpose |
|-------|---------|
| `SubscriptionTier` | Tier definitions with limits and Stripe price IDs |
| `VendorSubscription` | Per-vendor subscription status and usage |
| `AddOnProduct` | Purchasable add-ons (domains, SSL, email) |
| `VendorAddOn` | Add-ons purchased by each vendor |
| `StripeWebhookEvent` | Idempotency tracking for webhooks |
| `BillingHistory` | Invoice and payment history |
### Services
| Service | Location | Purpose |
|---------|----------|---------|
| `BillingService` | `app/services/billing_service.py` | Subscription operations, checkout, portal |
| `SubscriptionService` | `app/services/subscription_service.py` | Limit checks, usage tracking |
| `StripeService` | `app/services/stripe_service.py` | Core Stripe API operations |
| `StripeWebhookHandler` | `app/services/stripe_webhook_handler.py` | Webhook event processing |
### API Endpoints
All billing endpoints are under `/api/v1/vendor/billing`:
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/billing/subscription` | GET | Current subscription status & usage |
| `/billing/tiers` | GET | Available tiers for upgrade |
| `/billing/checkout` | POST | Create Stripe checkout session |
| `/billing/portal` | POST | Create Stripe customer portal session |
| `/billing/invoices` | GET | Invoice history |
| `/billing/addons` | GET | Available add-on products |
| `/billing/my-addons` | GET | Vendor's purchased add-ons |
| `/billing/cancel` | POST | Cancel subscription |
| `/billing/reactivate` | POST | Reactivate cancelled subscription |
## Subscription Tiers
### Default Tiers
| Tier | Price | Products | Orders/mo | Team |
|------|-------|----------|-----------|------|
| Essential | €49/mo | 200 | 100 | 1 |
| Professional | €99/mo | Unlimited | 500 | 3 |
| Business | €199/mo | Unlimited | 2000 | 10 |
| Enterprise | Custom | Unlimited | Unlimited | Unlimited |
### Tier Features
Each tier includes specific features stored in the `features` JSON column:
```python
tier.features = [
"basic_support", # Essential
"priority_support", # Professional+
"analytics", # Business+
"api_access", # Business+
"white_label", # Enterprise
"custom_integrations", # Enterprise
]
```
## Limit Enforcement
Limits are enforced at the service layer:
### Orders
```python
# app/services/order_service.py
subscription_service.check_order_limit(db, vendor_id)
```
### Products
```python
# app/api/v1/vendor/products.py
subscription_service.check_product_limit(db, vendor_id)
```
### Team Members
```python
# app/services/vendor_team_service.py
subscription_service.can_add_team_member(db, vendor_id)
```
## Stripe Integration
### Configuration
Required environment variables:
```bash
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_TRIAL_DAYS=14 # Optional, default trial period
```
### Webhook Events
The system handles these Stripe events:
| Event | Handler |
|-------|---------|
| `checkout.session.completed` | Activates subscription, links customer |
| `customer.subscription.updated` | Updates tier, status, period |
| `customer.subscription.deleted` | Marks subscription cancelled |
| `invoice.paid` | Records payment, resets counters |
| `invoice.payment_failed` | Marks past due, increments retry count |
### Webhook Endpoint
Webhooks are received at `/api/v1/webhooks/stripe`:
```python
# Uses signature verification for security
event = stripe_service.construct_event(payload, stripe_signature)
```
## Vendor Billing Page
The vendor billing page is at `/vendor/{vendor_code}/billing`:
### Page Sections
1. **Current Plan**: Tier name, status, next billing date
2. **Usage Meters**: Products, orders, team members with limits
3. **Change Plan**: Upgrade/downgrade options
4. **Payment Method**: Link to Stripe portal
5. **Invoice History**: Recent invoices with PDF links
### JavaScript Component
The billing page uses Alpine.js (`static/vendor/js/billing.js`):
```javascript
function billingData() {
return {
subscription: null,
tiers: [],
invoices: [],
async init() {
await this.loadData();
},
async selectTier(tier) {
const response = await this.apiPost('/billing/checkout', {
tier_code: tier.code,
is_annual: false
});
window.location.href = response.checkout_url;
},
async openPortal() {
const response = await this.apiPost('/billing/portal', {});
window.location.href = response.portal_url;
}
};
}
```
## Add-ons
### Available Add-ons
| Code | Name | Category | Price |
|------|------|----------|-------|
| `domain` | Custom Domain | domain | €15/year |
| `ssl_premium` | Premium SSL | ssl | €49/year |
| `email_5` | 5 Email Addresses | email | €5/month |
| `email_10` | 10 Email Addresses | email | €9/month |
| `email_25` | 25 Email Addresses | email | €19/month |
### Purchase Flow
1. Vendor selects add-on on billing page
2. For domains: enter domain name, validate availability
3. Create Stripe checkout session with add-on price
4. On webhook success: create `VendorAddOn` record
## Exception Handling
Custom exceptions for billing operations (`app/exceptions/billing.py`):
| Exception | HTTP Status | Description |
|-----------|-------------|-------------|
| `PaymentSystemNotConfiguredException` | 503 | Stripe not configured |
| `TierNotFoundException` | 404 | Invalid tier code |
| `StripePriceNotConfiguredException` | 400 | No Stripe price for tier |
| `NoActiveSubscriptionException` | 400 | Operation requires subscription |
| `SubscriptionNotCancelledException` | 400 | Cannot reactivate active subscription |
## Testing
Unit tests for the billing system:
```bash
# Run billing service tests
pytest tests/unit/services/test_billing_service.py -v
# Run webhook handler tests
pytest tests/unit/services/test_stripe_webhook_handler.py -v
```
### Test Coverage
- `BillingService`: Subscription queries, checkout, portal, cancellation
- `StripeWebhookHandler`: Event idempotency, checkout completion, invoice handling
## Migration
### Creating Tiers
Tiers are seeded via migration:
```python
# alembic/versions/xxx_add_subscription_billing_tables.py
def seed_subscription_tiers(op):
op.bulk_insert(subscription_tiers_table, [
{
"code": "essential",
"name": "Essential",
"price_monthly_cents": 4900,
"orders_per_month": 100,
"products_limit": 200,
"team_members": 1,
},
# ... more tiers
])
```
### Setting Up Stripe
1. Create products and prices in Stripe Dashboard
2. Update `SubscriptionTier` records with Stripe IDs:
```python
tier.stripe_product_id = "prod_xxx"
tier.stripe_price_monthly_id = "price_xxx"
tier.stripe_price_annual_id = "price_yyy"
```
3. Configure webhook endpoint in Stripe Dashboard:
- URL: `https://yourdomain.com/api/v1/webhooks/stripe`
- Events: `checkout.session.completed`, `customer.subscription.*`, `invoice.*`
## Security Considerations
- Webhook signatures verified before processing
- Idempotency keys prevent duplicate event processing
- Customer portal links are session-based and expire
- Stripe API key stored securely in environment variables

View File

@@ -204,6 +204,7 @@ nav:
- Implementation Guide: features/cms-implementation-guide.md
- Platform Homepage: features/platform-homepage.md
- Vendor Landing Pages: features/vendor-landing-pages.md
- Subscription & Billing: features/subscription-billing.md
# ============================================
# USER GUIDES

View File

@@ -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",

View File

@@ -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)

View File

@@ -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",

View File

@@ -33,3 +33,6 @@ weasyprint==62.3
# Environment and configuration
python-dotenv==1.0.1
# Payment processing
stripe>=7.0.0

187
static/vendor/js/billing.js vendored Normal file
View File

@@ -0,0 +1,187 @@
// static/vendor/js/billing.js
// Vendor billing and subscription management
function billingData() {
return {
// State
loading: true,
subscription: null,
tiers: [],
addons: [],
myAddons: [],
invoices: [],
// UI state
showTiersModal: false,
showAddonsModal: false,
showCancelModal: false,
showSuccessMessage: false,
showCancelMessage: false,
cancelReason: '',
// Initialize
async init() {
// Check URL params for success/cancel
const params = new URLSearchParams(window.location.search);
if (params.get('success') === 'true') {
this.showSuccessMessage = true;
// Clean URL
window.history.replaceState({}, document.title, window.location.pathname);
}
if (params.get('cancelled') === 'true') {
this.showCancelMessage = true;
window.history.replaceState({}, document.title, window.location.pathname);
}
await this.loadData();
},
async loadData() {
this.loading = true;
try {
// Load all data in parallel
const [subscriptionRes, tiersRes, addonsRes, invoicesRes] = await Promise.all([
this.apiGet('/billing/subscription'),
this.apiGet('/billing/tiers'),
this.apiGet('/billing/addons'),
this.apiGet('/billing/invoices?limit=5'),
]);
this.subscription = subscriptionRes;
this.tiers = tiersRes.tiers || [];
this.addons = addonsRes || [];
this.invoices = invoicesRes.invoices || [];
} catch (error) {
console.error('Error loading billing data:', error);
this.showNotification('Failed to load billing data', 'error');
} finally {
this.loading = false;
}
},
async selectTier(tier) {
if (tier.is_current) return;
try {
const response = await this.apiPost('/billing/checkout', {
tier_code: tier.code,
is_annual: false
});
if (response.checkout_url) {
window.location.href = response.checkout_url;
}
} catch (error) {
console.error('Error creating checkout:', error);
this.showNotification('Failed to create checkout session', 'error');
}
},
async openPortal() {
try {
const response = await this.apiPost('/billing/portal', {});
if (response.portal_url) {
window.location.href = response.portal_url;
}
} catch (error) {
console.error('Error opening portal:', error);
this.showNotification('Failed to open payment portal', 'error');
}
},
async cancelSubscription() {
try {
await this.apiPost('/billing/cancel', {
reason: this.cancelReason,
immediately: false
});
this.showCancelModal = false;
this.showNotification('Subscription cancelled. You have access until the end of your billing period.', 'success');
await this.loadData();
} catch (error) {
console.error('Error cancelling subscription:', error);
this.showNotification('Failed to cancel subscription', 'error');
}
},
async reactivate() {
try {
await this.apiPost('/billing/reactivate', {});
this.showNotification('Subscription reactivated!', 'success');
await this.loadData();
} catch (error) {
console.error('Error reactivating subscription:', error);
this.showNotification('Failed to reactivate subscription', 'error');
}
},
// API helpers
async apiGet(endpoint) {
const response = await fetch(`/api/v1/vendor${endpoint}`, {
headers: {
'Content-Type': 'application/json',
},
credentials: 'include'
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
},
async apiPost(endpoint, data) {
const response = await fetch(`/api/v1/vendor${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || `API error: ${response.status}`);
}
return response.json();
},
// Formatters
formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
},
formatCurrency(cents, currency = 'EUR') {
if (cents === null || cents === undefined) return '-';
const amount = cents / 100;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency
}).format(amount);
},
showNotification(message, type = 'info') {
// Use Alpine's $dispatch if available, or fallback to alert
if (window.Alpine) {
window.dispatchEvent(new CustomEvent('show-notification', {
detail: { message, type }
}));
} else {
alert(message);
}
}
};
}

View File

@@ -0,0 +1,658 @@
# tests/unit/services/test_billing_service.py
"""Unit tests for BillingService."""
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from app.exceptions import VendorNotFoundException
from app.services.billing_service import (
BillingService,
NoActiveSubscriptionError,
PaymentSystemNotConfiguredError,
StripePriceNotConfiguredError,
SubscriptionNotCancelledError,
TierNotFoundError,
)
from models.database.subscription import (
AddOnProduct,
BillingHistory,
SubscriptionStatus,
SubscriptionTier,
VendorAddOn,
VendorSubscription,
)
@pytest.mark.unit
@pytest.mark.billing
class TestBillingServiceSubscription:
"""Test suite for BillingService subscription operations."""
def setup_method(self):
"""Initialize service instance before each test."""
self.service = BillingService()
def test_get_subscription_with_tier_creates_if_not_exists(
self, db, test_vendor, test_subscription_tier
):
"""Test get_subscription_with_tier creates subscription if needed."""
subscription, tier = self.service.get_subscription_with_tier(db, test_vendor.id)
assert subscription is not None
assert subscription.vendor_id == test_vendor.id
assert tier is not None
assert tier.code == subscription.tier
def test_get_subscription_with_tier_returns_existing(
self, db, test_vendor, test_subscription
):
"""Test get_subscription_with_tier returns existing subscription."""
# Note: test_subscription fixture already creates the tier
subscription, tier = self.service.get_subscription_with_tier(db, test_vendor.id)
assert subscription.id == test_subscription.id
assert tier.code == test_subscription.tier
@pytest.mark.unit
@pytest.mark.billing
class TestBillingServiceTiers:
"""Test suite for BillingService tier operations."""
def setup_method(self):
"""Initialize service instance before each test."""
self.service = BillingService()
def test_get_available_tiers(self, db, test_subscription_tiers):
"""Test getting available tiers."""
tier_list, tier_order = self.service.get_available_tiers(db, "essential")
assert len(tier_list) > 0
assert "essential" in tier_order
assert "professional" in tier_order
# Check tier has expected fields
essential_tier = next(t for t in tier_list if t["code"] == "essential")
assert essential_tier["is_current"] is True
assert essential_tier["can_upgrade"] is False
assert essential_tier["can_downgrade"] is False
professional_tier = next(t for t in tier_list if t["code"] == "professional")
assert professional_tier["can_upgrade"] is True
assert professional_tier["can_downgrade"] is False
def test_get_tier_by_code_success(self, db, test_subscription_tier):
"""Test getting tier by code."""
tier = self.service.get_tier_by_code(db, "essential")
assert tier.code == "essential"
assert tier.is_active is True
def test_get_tier_by_code_not_found(self, db):
"""Test getting non-existent tier raises error."""
with pytest.raises(TierNotFoundError) as exc_info:
self.service.get_tier_by_code(db, "nonexistent")
assert exc_info.value.tier_code == "nonexistent"
@pytest.mark.unit
@pytest.mark.billing
class TestBillingServiceCheckout:
"""Test suite for BillingService checkout operations."""
def setup_method(self):
"""Initialize service instance before each test."""
self.service = BillingService()
@patch("app.services.billing_service.stripe_service")
def test_create_checkout_session_stripe_not_configured(
self, mock_stripe, db, test_vendor, test_subscription_tier
):
"""Test checkout fails when Stripe not configured."""
mock_stripe.is_configured = False
with pytest.raises(PaymentSystemNotConfiguredError):
self.service.create_checkout_session(
db=db,
vendor_id=test_vendor.id,
tier_code="essential",
is_annual=False,
success_url="https://example.com/success",
cancel_url="https://example.com/cancel",
)
@patch("app.services.billing_service.stripe_service")
def test_create_checkout_session_success(
self, mock_stripe, db, test_vendor, test_subscription_tier_with_stripe
):
"""Test successful checkout session creation."""
mock_stripe.is_configured = True
mock_session = MagicMock()
mock_session.url = "https://checkout.stripe.com/test"
mock_session.id = "cs_test_123"
mock_stripe.create_checkout_session.return_value = mock_session
result = self.service.create_checkout_session(
db=db,
vendor_id=test_vendor.id,
tier_code="essential",
is_annual=False,
success_url="https://example.com/success",
cancel_url="https://example.com/cancel",
)
assert result["checkout_url"] == "https://checkout.stripe.com/test"
assert result["session_id"] == "cs_test_123"
@patch("app.services.billing_service.stripe_service")
def test_create_checkout_session_tier_not_found(
self, mock_stripe, db, test_vendor
):
"""Test checkout fails with invalid tier."""
mock_stripe.is_configured = True
with pytest.raises(TierNotFoundError):
self.service.create_checkout_session(
db=db,
vendor_id=test_vendor.id,
tier_code="nonexistent",
is_annual=False,
success_url="https://example.com/success",
cancel_url="https://example.com/cancel",
)
@patch("app.services.billing_service.stripe_service")
def test_create_checkout_session_no_price(
self, mock_stripe, db, test_vendor, test_subscription_tier
):
"""Test checkout fails when tier has no Stripe price."""
mock_stripe.is_configured = True
with pytest.raises(StripePriceNotConfiguredError):
self.service.create_checkout_session(
db=db,
vendor_id=test_vendor.id,
tier_code="essential",
is_annual=False,
success_url="https://example.com/success",
cancel_url="https://example.com/cancel",
)
@pytest.mark.unit
@pytest.mark.billing
class TestBillingServicePortal:
"""Test suite for BillingService portal operations."""
def setup_method(self):
"""Initialize service instance before each test."""
self.service = BillingService()
@patch("app.services.billing_service.stripe_service")
def test_create_portal_session_stripe_not_configured(self, mock_stripe, db, test_vendor):
"""Test portal fails when Stripe not configured."""
mock_stripe.is_configured = False
with pytest.raises(PaymentSystemNotConfiguredError):
self.service.create_portal_session(
db=db,
vendor_id=test_vendor.id,
return_url="https://example.com/billing",
)
@patch("app.services.billing_service.stripe_service")
def test_create_portal_session_no_subscription(self, mock_stripe, db, test_vendor):
"""Test portal fails when no subscription exists."""
mock_stripe.is_configured = True
with pytest.raises(NoActiveSubscriptionError):
self.service.create_portal_session(
db=db,
vendor_id=test_vendor.id,
return_url="https://example.com/billing",
)
@patch("app.services.billing_service.stripe_service")
def test_create_portal_session_success(
self, mock_stripe, db, test_vendor, test_active_subscription
):
"""Test successful portal session creation."""
mock_stripe.is_configured = True
mock_session = MagicMock()
mock_session.url = "https://billing.stripe.com/portal"
mock_stripe.create_portal_session.return_value = mock_session
result = self.service.create_portal_session(
db=db,
vendor_id=test_vendor.id,
return_url="https://example.com/billing",
)
assert result["portal_url"] == "https://billing.stripe.com/portal"
@pytest.mark.unit
@pytest.mark.billing
class TestBillingServiceInvoices:
"""Test suite for BillingService invoice operations."""
def setup_method(self):
"""Initialize service instance before each test."""
self.service = BillingService()
def test_get_invoices_empty(self, db, test_vendor):
"""Test getting invoices when none exist."""
invoices, total = self.service.get_invoices(db, test_vendor.id)
assert invoices == []
assert total == 0
def test_get_invoices_with_data(self, db, test_vendor, test_billing_history):
"""Test getting invoices returns data."""
invoices, total = self.service.get_invoices(db, test_vendor.id)
assert len(invoices) == 1
assert total == 1
assert invoices[0].invoice_number == "INV-001"
def test_get_invoices_pagination(self, db, test_vendor, test_multiple_invoices):
"""Test invoice pagination."""
# Get first page
page1, total = self.service.get_invoices(db, test_vendor.id, skip=0, limit=2)
assert len(page1) == 2
assert total == 5
# Get second page
page2, _ = self.service.get_invoices(db, test_vendor.id, skip=2, limit=2)
assert len(page2) == 2
@pytest.mark.unit
@pytest.mark.billing
class TestBillingServiceAddons:
"""Test suite for BillingService addon operations."""
def setup_method(self):
"""Initialize service instance before each test."""
self.service = BillingService()
def test_get_available_addons_empty(self, db):
"""Test getting addons when none exist."""
addons = self.service.get_available_addons(db)
assert addons == []
def test_get_available_addons_with_data(self, db, test_addon_products):
"""Test getting all available addons."""
addons = self.service.get_available_addons(db)
assert len(addons) == 3
assert all(addon.is_active for addon in addons)
def test_get_available_addons_by_category(self, db, test_addon_products):
"""Test filtering addons by category."""
domain_addons = self.service.get_available_addons(db, category="domain")
assert len(domain_addons) == 1
assert domain_addons[0].category == "domain"
def test_get_vendor_addons_empty(self, db, test_vendor):
"""Test getting vendor addons when none purchased."""
addons = self.service.get_vendor_addons(db, test_vendor.id)
assert addons == []
@pytest.mark.unit
@pytest.mark.billing
class TestBillingServiceCancellation:
"""Test suite for BillingService cancellation operations."""
def setup_method(self):
"""Initialize service instance before each test."""
self.service = BillingService()
@patch("app.services.billing_service.stripe_service")
def test_cancel_subscription_no_subscription(
self, mock_stripe, db, test_vendor
):
"""Test cancel fails when no subscription."""
mock_stripe.is_configured = True
with pytest.raises(NoActiveSubscriptionError):
self.service.cancel_subscription(
db=db,
vendor_id=test_vendor.id,
reason="Test reason",
immediately=False,
)
@patch("app.services.billing_service.stripe_service")
def test_cancel_subscription_success(
self, mock_stripe, db, test_vendor, test_active_subscription
):
"""Test successful subscription cancellation."""
mock_stripe.is_configured = True
result = self.service.cancel_subscription(
db=db,
vendor_id=test_vendor.id,
reason="Too expensive",
immediately=False,
)
assert result["message"] == "Subscription cancelled successfully"
assert test_active_subscription.cancelled_at is not None
assert test_active_subscription.cancellation_reason == "Too expensive"
@patch("app.services.billing_service.stripe_service")
def test_reactivate_subscription_not_cancelled(
self, mock_stripe, db, test_vendor, test_active_subscription
):
"""Test reactivate fails when subscription not cancelled."""
mock_stripe.is_configured = True
with pytest.raises(SubscriptionNotCancelledError):
self.service.reactivate_subscription(db, test_vendor.id)
@patch("app.services.billing_service.stripe_service")
def test_reactivate_subscription_success(
self, mock_stripe, db, test_vendor, test_cancelled_subscription
):
"""Test successful subscription reactivation."""
mock_stripe.is_configured = True
result = self.service.reactivate_subscription(db, test_vendor.id)
assert result["message"] == "Subscription reactivated successfully"
assert test_cancelled_subscription.cancelled_at is None
assert test_cancelled_subscription.cancellation_reason is None
@pytest.mark.unit
@pytest.mark.billing
class TestBillingServiceVendor:
"""Test suite for BillingService vendor operations."""
def setup_method(self):
"""Initialize service instance before each test."""
self.service = BillingService()
def test_get_vendor_success(self, db, test_vendor):
"""Test getting vendor by ID."""
vendor = self.service.get_vendor(db, test_vendor.id)
assert vendor.id == test_vendor.id
def test_get_vendor_not_found(self, db):
"""Test getting non-existent vendor raises error."""
with pytest.raises(VendorNotFoundException):
self.service.get_vendor(db, 99999)
# ==================== Fixtures ====================
@pytest.fixture
def test_subscription_tier(db):
"""Create a basic subscription tier."""
tier = SubscriptionTier(
code="essential",
name="Essential",
description="Essential plan",
price_monthly_cents=4900,
price_annual_cents=49000,
orders_per_month=100,
products_limit=200,
team_members=1,
features=["basic_support"],
display_order=1,
is_active=True,
is_public=True,
)
db.add(tier)
db.commit()
db.refresh(tier)
return tier
@pytest.fixture
def test_subscription_tier_with_stripe(db):
"""Create a subscription tier with Stripe configuration."""
tier = SubscriptionTier(
code="essential",
name="Essential",
description="Essential plan",
price_monthly_cents=4900,
price_annual_cents=49000,
orders_per_month=100,
products_limit=200,
team_members=1,
features=["basic_support"],
display_order=1,
is_active=True,
is_public=True,
stripe_product_id="prod_test123",
stripe_price_monthly_id="price_test123",
stripe_price_annual_id="price_test456",
)
db.add(tier)
db.commit()
db.refresh(tier)
return tier
@pytest.fixture
def test_subscription_tiers(db):
"""Create multiple subscription tiers."""
tiers = [
SubscriptionTier(
code="essential",
name="Essential",
price_monthly_cents=4900,
display_order=1,
is_active=True,
is_public=True,
),
SubscriptionTier(
code="professional",
name="Professional",
price_monthly_cents=9900,
display_order=2,
is_active=True,
is_public=True,
),
SubscriptionTier(
code="business",
name="Business",
price_monthly_cents=19900,
display_order=3,
is_active=True,
is_public=True,
),
]
db.add_all(tiers)
db.commit()
for tier in tiers:
db.refresh(tier)
return tiers
@pytest.fixture
def test_subscription(db, test_vendor):
"""Create a basic subscription for testing."""
# Create tier first
tier = SubscriptionTier(
code="essential",
name="Essential",
price_monthly_cents=4900,
display_order=1,
is_active=True,
is_public=True,
)
db.add(tier)
db.commit()
subscription = VendorSubscription(
vendor_id=test_vendor.id,
tier="essential",
status=SubscriptionStatus.ACTIVE,
period_start=datetime.now(timezone.utc),
period_end=datetime.now(timezone.utc),
)
db.add(subscription)
db.commit()
db.refresh(subscription)
return subscription
@pytest.fixture
def test_active_subscription(db, test_vendor):
"""Create an active subscription with Stripe IDs."""
# Create tier first if not exists
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first()
if not tier:
tier = SubscriptionTier(
code="essential",
name="Essential",
price_monthly_cents=4900,
display_order=1,
is_active=True,
is_public=True,
)
db.add(tier)
db.commit()
subscription = VendorSubscription(
vendor_id=test_vendor.id,
tier="essential",
status=SubscriptionStatus.ACTIVE,
stripe_customer_id="cus_test123",
stripe_subscription_id="sub_test123",
period_start=datetime.now(timezone.utc),
period_end=datetime.now(timezone.utc),
)
db.add(subscription)
db.commit()
db.refresh(subscription)
return subscription
@pytest.fixture
def test_cancelled_subscription(db, test_vendor):
"""Create a cancelled subscription."""
# Create tier first if not exists
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first()
if not tier:
tier = SubscriptionTier(
code="essential",
name="Essential",
price_monthly_cents=4900,
display_order=1,
is_active=True,
is_public=True,
)
db.add(tier)
db.commit()
subscription = VendorSubscription(
vendor_id=test_vendor.id,
tier="essential",
status=SubscriptionStatus.ACTIVE,
stripe_customer_id="cus_test123",
stripe_subscription_id="sub_test123",
period_start=datetime.now(timezone.utc),
period_end=datetime.now(timezone.utc),
cancelled_at=datetime.now(timezone.utc),
cancellation_reason="Too expensive",
)
db.add(subscription)
db.commit()
db.refresh(subscription)
return subscription
@pytest.fixture
def test_billing_history(db, test_vendor):
"""Create a billing history record."""
record = BillingHistory(
vendor_id=test_vendor.id,
stripe_invoice_id="in_test123",
invoice_number="INV-001",
invoice_date=datetime.now(timezone.utc),
subtotal_cents=4900,
tax_cents=0,
total_cents=4900,
amount_paid_cents=4900,
currency="EUR",
status="paid",
)
db.add(record)
db.commit()
db.refresh(record)
return record
@pytest.fixture
def test_multiple_invoices(db, test_vendor):
"""Create multiple billing history records."""
records = []
for i in range(5):
record = BillingHistory(
vendor_id=test_vendor.id,
stripe_invoice_id=f"in_test{i}",
invoice_number=f"INV-{i:03d}",
invoice_date=datetime.now(timezone.utc),
subtotal_cents=4900,
tax_cents=0,
total_cents=4900,
amount_paid_cents=4900,
currency="EUR",
status="paid",
)
records.append(record)
db.add_all(records)
db.commit()
return records
@pytest.fixture
def test_addon_products(db):
"""Create test addon products."""
addons = [
AddOnProduct(
code="domain",
name="Custom Domain",
category="domain",
price_cents=1500,
billing_period="annual",
display_order=1,
is_active=True,
),
AddOnProduct(
code="email_5",
name="5 Email Addresses",
category="email",
price_cents=500,
billing_period="monthly",
quantity_value=5,
display_order=2,
is_active=True,
),
AddOnProduct(
code="email_10",
name="10 Email Addresses",
category="email",
price_cents=900,
billing_period="monthly",
quantity_value=10,
display_order=3,
is_active=True,
),
]
db.add_all(addons)
db.commit()
for addon in addons:
db.refresh(addon)
return addons

View File

@@ -0,0 +1,393 @@
# tests/unit/services/test_stripe_webhook_handler.py
"""Unit tests for StripeWebhookHandler."""
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from app.services.stripe_webhook_handler import StripeWebhookHandler
from models.database.subscription import (
BillingHistory,
StripeWebhookEvent,
SubscriptionStatus,
SubscriptionTier,
VendorSubscription,
)
@pytest.mark.unit
@pytest.mark.billing
class TestStripeWebhookHandlerIdempotency:
"""Test suite for webhook handler idempotency."""
def setup_method(self):
"""Initialize handler instance before each test."""
self.handler = StripeWebhookHandler()
def test_handle_event_creates_webhook_event_record(self, db, mock_stripe_event):
"""Test that handling an event creates a webhook event record."""
self.handler.handle_event(db, mock_stripe_event)
record = (
db.query(StripeWebhookEvent)
.filter(StripeWebhookEvent.event_id == mock_stripe_event.id)
.first()
)
assert record is not None
assert record.event_type == mock_stripe_event.type
assert record.status == "processed"
def test_handle_event_skips_duplicate(self, db, mock_stripe_event):
"""Test that duplicate events are skipped."""
# Process first time
result1 = self.handler.handle_event(db, mock_stripe_event)
assert result1["status"] != "skipped"
# Process second time
result2 = self.handler.handle_event(db, mock_stripe_event)
assert result2["status"] == "skipped"
assert result2["reason"] == "duplicate"
@pytest.mark.unit
@pytest.mark.billing
class TestStripeWebhookHandlerCheckout:
"""Test suite for checkout.session.completed event handling."""
def setup_method(self):
"""Initialize handler instance before each test."""
self.handler = StripeWebhookHandler()
@patch("app.services.stripe_webhook_handler.stripe.Subscription.retrieve")
def test_handle_checkout_completed_success(
self, mock_stripe_retrieve, db, test_vendor, test_subscription, mock_checkout_event
):
"""Test successful checkout completion."""
# Mock Stripe subscription retrieve
mock_stripe_sub = MagicMock()
mock_stripe_sub.current_period_start = int(datetime.now(timezone.utc).timestamp())
mock_stripe_sub.current_period_end = int(datetime.now(timezone.utc).timestamp())
mock_stripe_sub.trial_end = None
mock_stripe_retrieve.return_value = mock_stripe_sub
mock_checkout_event.data.object.metadata = {"vendor_id": str(test_vendor.id)}
result = self.handler.handle_event(db, mock_checkout_event)
assert result["status"] == "processed"
db.refresh(test_subscription)
assert test_subscription.stripe_customer_id == "cus_test123"
assert test_subscription.status == SubscriptionStatus.ACTIVE
def test_handle_checkout_completed_no_vendor_id(self, db, mock_checkout_event):
"""Test checkout with missing vendor_id is skipped."""
mock_checkout_event.data.object.metadata = {}
result = self.handler.handle_event(db, mock_checkout_event)
assert result["status"] == "processed"
assert result["result"]["action"] == "skipped"
assert result["result"]["reason"] == "no vendor_id"
@pytest.mark.unit
@pytest.mark.billing
class TestStripeWebhookHandlerSubscription:
"""Test suite for subscription event handling."""
def setup_method(self):
"""Initialize handler instance before each test."""
self.handler = StripeWebhookHandler()
def test_handle_subscription_updated_status_change(
self, db, test_vendor, test_active_subscription, mock_subscription_updated_event
):
"""Test subscription update changes status."""
result = self.handler.handle_event(db, mock_subscription_updated_event)
assert result["status"] == "processed"
def test_handle_subscription_deleted(
self, db, test_vendor, test_active_subscription, mock_subscription_deleted_event
):
"""Test subscription deletion."""
result = self.handler.handle_event(db, mock_subscription_deleted_event)
assert result["status"] == "processed"
db.refresh(test_active_subscription)
assert test_active_subscription.status == SubscriptionStatus.CANCELLED
@pytest.mark.unit
@pytest.mark.billing
class TestStripeWebhookHandlerInvoice:
"""Test suite for invoice event handling."""
def setup_method(self):
"""Initialize handler instance before each test."""
self.handler = StripeWebhookHandler()
def test_handle_invoice_paid_creates_billing_record(
self, db, test_vendor, test_active_subscription, mock_invoice_paid_event
):
"""Test invoice.paid creates billing history record."""
result = self.handler.handle_event(db, mock_invoice_paid_event)
assert result["status"] == "processed"
# Check billing record created
record = (
db.query(BillingHistory)
.filter(BillingHistory.vendor_id == test_vendor.id)
.first()
)
assert record is not None
assert record.status == "paid"
assert record.total_cents == 4900
def test_handle_invoice_paid_resets_counters(
self, db, test_vendor, test_active_subscription, mock_invoice_paid_event
):
"""Test invoice.paid resets order counters."""
test_active_subscription.orders_this_period = 50
db.commit()
self.handler.handle_event(db, mock_invoice_paid_event)
db.refresh(test_active_subscription)
assert test_active_subscription.orders_this_period == 0
def test_handle_payment_failed_marks_past_due(
self, db, test_vendor, test_active_subscription, mock_payment_failed_event
):
"""Test payment failure marks subscription as past due."""
result = self.handler.handle_event(db, mock_payment_failed_event)
assert result["status"] == "processed"
db.refresh(test_active_subscription)
assert test_active_subscription.status == SubscriptionStatus.PAST_DUE
assert test_active_subscription.payment_retry_count == 1
@pytest.mark.unit
@pytest.mark.billing
class TestStripeWebhookHandlerUnknownEvents:
"""Test suite for unknown event handling."""
def setup_method(self):
"""Initialize handler instance before each test."""
self.handler = StripeWebhookHandler()
def test_handle_unknown_event_type(self, db):
"""Test unknown event types are ignored."""
mock_event = MagicMock()
mock_event.id = "evt_unknown123"
mock_event.type = "customer.unknown_event"
mock_event.data.object = {}
result = self.handler.handle_event(db, mock_event)
assert result["status"] == "ignored"
assert "no handler" in result["reason"]
@pytest.mark.unit
@pytest.mark.billing
class TestStripeWebhookHandlerStatusMapping:
"""Test suite for status mapping helper."""
def setup_method(self):
"""Initialize handler instance before each test."""
self.handler = StripeWebhookHandler()
def test_map_active_status(self):
"""Test mapping active status."""
result = self.handler._map_stripe_status("active")
assert result == SubscriptionStatus.ACTIVE
def test_map_trialing_status(self):
"""Test mapping trialing status."""
result = self.handler._map_stripe_status("trialing")
assert result == SubscriptionStatus.TRIAL
def test_map_past_due_status(self):
"""Test mapping past_due status."""
result = self.handler._map_stripe_status("past_due")
assert result == SubscriptionStatus.PAST_DUE
def test_map_canceled_status(self):
"""Test mapping canceled status."""
result = self.handler._map_stripe_status("canceled")
assert result == SubscriptionStatus.CANCELLED
def test_map_unknown_status(self):
"""Test mapping unknown status defaults to expired."""
result = self.handler._map_stripe_status("unknown_status")
assert result == SubscriptionStatus.EXPIRED
# ==================== Fixtures ====================
@pytest.fixture
def test_subscription_tier(db):
"""Create a basic subscription tier."""
tier = SubscriptionTier(
code="essential",
name="Essential",
price_monthly_cents=4900,
display_order=1,
is_active=True,
is_public=True,
)
db.add(tier)
db.commit()
db.refresh(tier)
return tier
@pytest.fixture
def test_subscription(db, test_vendor):
"""Create a basic subscription for testing."""
# Create tier first if not exists
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first()
if not tier:
tier = SubscriptionTier(
code="essential",
name="Essential",
price_monthly_cents=4900,
display_order=1,
is_active=True,
is_public=True,
)
db.add(tier)
db.commit()
subscription = VendorSubscription(
vendor_id=test_vendor.id,
tier="essential",
status=SubscriptionStatus.TRIAL,
period_start=datetime.now(timezone.utc),
period_end=datetime.now(timezone.utc),
)
db.add(subscription)
db.commit()
db.refresh(subscription)
return subscription
@pytest.fixture
def test_active_subscription(db, test_vendor):
"""Create an active subscription with Stripe IDs."""
# Create tier first if not exists
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first()
if not tier:
tier = SubscriptionTier(
code="essential",
name="Essential",
price_monthly_cents=4900,
display_order=1,
is_active=True,
is_public=True,
)
db.add(tier)
db.commit()
subscription = VendorSubscription(
vendor_id=test_vendor.id,
tier="essential",
status=SubscriptionStatus.ACTIVE,
stripe_customer_id="cus_test123",
stripe_subscription_id="sub_test123",
period_start=datetime.now(timezone.utc),
period_end=datetime.now(timezone.utc),
)
db.add(subscription)
db.commit()
db.refresh(subscription)
return subscription
@pytest.fixture
def mock_stripe_event():
"""Create a mock Stripe event."""
event = MagicMock()
event.id = "evt_test123"
event.type = "customer.created"
event.data.object = {"id": "cus_test123"}
return event
@pytest.fixture
def mock_checkout_event():
"""Create a mock checkout.session.completed event."""
event = MagicMock()
event.id = "evt_checkout123"
event.type = "checkout.session.completed"
event.data.object.id = "cs_test123"
event.data.object.customer = "cus_test123"
event.data.object.subscription = "sub_test123"
event.data.object.metadata = {}
return event
@pytest.fixture
def mock_subscription_updated_event():
"""Create a mock customer.subscription.updated event."""
event = MagicMock()
event.id = "evt_subupdated123"
event.type = "customer.subscription.updated"
event.data.object.id = "sub_test123"
event.data.object.customer = "cus_test123"
event.data.object.status = "active"
event.data.object.current_period_start = int(datetime.now(timezone.utc).timestamp())
event.data.object.current_period_end = int(datetime.now(timezone.utc).timestamp())
event.data.object.cancel_at_period_end = False
event.data.object.items.data = []
event.data.object.metadata = {}
return event
@pytest.fixture
def mock_subscription_deleted_event():
"""Create a mock customer.subscription.deleted event."""
event = MagicMock()
event.id = "evt_subdeleted123"
event.type = "customer.subscription.deleted"
event.data.object.id = "sub_test123"
event.data.object.customer = "cus_test123"
return event
@pytest.fixture
def mock_invoice_paid_event():
"""Create a mock invoice.paid event."""
event = MagicMock()
event.id = "evt_invoicepaid123"
event.type = "invoice.paid"
event.data.object.id = "in_test123"
event.data.object.customer = "cus_test123"
event.data.object.payment_intent = "pi_test123"
event.data.object.number = "INV-001"
event.data.object.created = int(datetime.now(timezone.utc).timestamp())
event.data.object.subtotal = 4900
event.data.object.tax = 0
event.data.object.total = 4900
event.data.object.amount_paid = 4900
event.data.object.currency = "eur"
event.data.object.invoice_pdf = "https://stripe.com/invoice.pdf"
event.data.object.hosted_invoice_url = "https://invoice.stripe.com"
return event
@pytest.fixture
def mock_payment_failed_event():
"""Create a mock invoice.payment_failed event."""
event = MagicMock()
event.id = "evt_paymentfailed123"
event.type = "invoice.payment_failed"
event.data.object.id = "in_test123"
event.data.object.customer = "cus_test123"
event.data.object.last_payment_error = {"message": "Card declined"}
return event