"""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')