# models/database/subscription.py """ Subscription database models for tier-based access control. Provides models for: - 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 - Professional (€99/mo): 500 orders/mo, unlimited products, 3 users, EU VAT - Business (€199/mo): 2000 orders/mo, unlimited products, 10 users, analytics, API - Enterprise (€399+/mo): Unlimited, white-label, custom integrations """ import enum from datetime import UTC, datetime from sqlalchemy import ( Boolean, Column, DateTime, ForeignKey, Index, Integer, Numeric, String, Text, ) from sqlalchemy.dialects.sqlite import JSON from sqlalchemy.orm import relationship from app.core.database import Base from models.database.base import TimestampMixin class TierCode(str, enum.Enum): """Subscription tier codes.""" ESSENTIAL = "essential" PROFESSIONAL = "professional" BUSINESS = "business" ENTERPRISE = "enterprise" class SubscriptionStatus(str, enum.Enum): """Subscription status.""" TRIAL = "trial" # Free trial period ACTIVE = "active" # Paid and active PAST_DUE = "past_due" # Payment failed, grace period CANCELLED = "cancelled" # Cancelled, access until period end 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. Can be: - Global tier (platform_id=NULL): Available to all platforms - Platform-specific tier (platform_id set): Only for that platform """ __tablename__ = "subscription_tiers" id = Column(Integer, primary_key=True, index=True) # Platform association (NULL = global tier available to all platforms) platform_id = Column( Integer, ForeignKey("platforms.id", ondelete="CASCADE"), nullable=True, index=True, comment="Platform this tier belongs to (NULL = global tier)", ) code = Column(String(30), 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) # CMS Limits (null = unlimited) cms_pages_limit = Column( Integer, nullable=True, comment="Total CMS pages limit (NULL = unlimited)", ) cms_custom_pages_limit = Column( Integer, nullable=True, comment="Custom pages limit, excluding overrides (NULL = unlimited)", ) # 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 # Relationship to Platform platform = relationship( "Platform", back_populates="subscription_tiers", foreign_keys=[platform_id], ) # Unique constraint: tier code must be unique per platform (or globally if NULL) __table_args__ = ( Index("idx_tier_platform_active", "platform_id", "is_active"), ) def __repr__(self): platform_info = f", platform_id={self.platform_id}" if self.platform_id else "" return f"" 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, "cms_pages_limit": self.cms_pages_limit, "cms_custom_pages_limit": self.cms_custom_pages_limit, "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"" # ============================================================================ # 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"" # ============================================================================ # 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"" # ============================================================================ # 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"" # ============================================================================ # Legacy TIER_LIMITS (kept for backward compatibility during migration) # ============================================================================ # Tier limit definitions (hardcoded for now, could be moved to DB) TIER_LIMITS = { TierCode.ESSENTIAL: { "name": "Essential", "price_monthly_cents": 4900, # €49 "price_annual_cents": 49000, # €490 (2 months free) "orders_per_month": 100, "products_limit": 200, "team_members": 1, "order_history_months": 6, "features": [ "letzshop_sync", "inventory_basic", "invoice_lu", "customer_view", ], }, TierCode.PROFESSIONAL: { "name": "Professional", "price_monthly_cents": 9900, # €99 "price_annual_cents": 99000, # €990 "orders_per_month": 500, "products_limit": None, # Unlimited "team_members": 3, "order_history_months": 24, "features": [ "letzshop_sync", "inventory_locations", "inventory_purchase_orders", "invoice_lu", "invoice_eu_vat", "customer_view", "customer_export", ], }, TierCode.BUSINESS: { "name": "Business", "price_monthly_cents": 19900, # €199 "price_annual_cents": 199000, # €1990 "orders_per_month": 2000, "products_limit": None, # Unlimited "team_members": 10, "order_history_months": None, # Unlimited "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", ], }, TierCode.ENTERPRISE: { "name": "Enterprise", "price_monthly_cents": 39900, # €399 starting "price_annual_cents": None, # Custom "orders_per_month": None, # Unlimited "products_limit": None, # Unlimited "team_members": None, # Unlimited "order_history_months": None, # Unlimited "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", ], }, } class VendorSubscription(Base, TimestampMixin): """ Per-vendor subscription tracking. Tracks the vendor's subscription tier, billing period, and usage counters for limit enforcement. """ __tablename__ = "vendor_subscriptions" id = Column(Integer, primary_key=True, index=True) vendor_id = Column( Integer, ForeignKey("vendors.id"), unique=True, nullable=False, index=True ) # Tier - tier_id is the FK, tier (code) kept for backwards compatibility tier_id = Column( Integer, ForeignKey("subscription_tiers.id"), nullable=True, index=True ) tier = Column( String(20), default=TierCode.ESSENTIAL.value, nullable=False, index=True ) # Status status = Column( String(20), default=SubscriptionStatus.TRIAL.value, nullable=False, index=True ) # Billing period period_start = Column(DateTime(timezone=True), nullable=False) period_end = Column(DateTime(timezone=True), nullable=False) is_annual = Column(Boolean, default=False, nullable=False) # Trial info trial_ends_at = Column(DateTime(timezone=True), nullable=True) # Card collection tracking (for trials that require card upfront) card_collected_at = Column(DateTime(timezone=True), nullable=True) # Usage counters (reset each billing period) orders_this_period = Column(Integer, default=0, nullable=False) orders_limit_reached_at = Column(DateTime(timezone=True), nullable=True) # Overrides (for custom enterprise deals) custom_orders_limit = Column(Integer, nullable=True) # Override tier limit custom_products_limit = Column(Integer, nullable=True) custom_team_limit = Column(Integer, nullable=True) # 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) cancellation_reason = Column(Text, nullable=True) # Relationships vendor = relationship("Vendor", back_populates="subscription") tier_obj = relationship("SubscriptionTier", backref="subscriptions") __table_args__ = ( Index("idx_subscription_vendor_status", "vendor_id", "status"), Index("idx_subscription_period", "period_start", "period_end"), ) def __repr__(self): return f"" # ========================================================================= # Tier Limit Properties # ========================================================================= @property def tier_limits(self) -> dict: """Get the limit definitions for current tier. Uses database tier (tier_obj) if available, otherwise falls back to hardcoded TIER_LIMITS for backwards compatibility. """ # Use database tier if relationship is loaded if self.tier_obj is not None: return { "orders_per_month": self.tier_obj.orders_per_month, "products_limit": self.tier_obj.products_limit, "team_members": self.tier_obj.team_members, "features": self.tier_obj.features or [], } # Fall back to hardcoded limits return TIER_LIMITS.get(TierCode(self.tier), TIER_LIMITS[TierCode.ESSENTIAL]) @property def orders_limit(self) -> int | None: """Get effective orders limit (custom or tier default).""" if self.custom_orders_limit is not None: return self.custom_orders_limit return self.tier_limits.get("orders_per_month") @property def products_limit(self) -> int | None: """Get effective products limit (custom or tier default).""" if self.custom_products_limit is not None: return self.custom_products_limit return self.tier_limits.get("products_limit") @property def team_members_limit(self) -> int | None: """Get effective team members limit (custom or tier default).""" if self.custom_team_limit is not None: return self.custom_team_limit return self.tier_limits.get("team_members") @property def features(self) -> list[str]: """Get list of enabled features for current tier.""" return self.tier_limits.get("features", []) # ========================================================================= # Status Checks # ========================================================================= @property def is_active(self) -> bool: """Check if subscription allows access.""" return self.status in [ SubscriptionStatus.TRIAL.value, SubscriptionStatus.ACTIVE.value, SubscriptionStatus.PAST_DUE.value, # Grace period SubscriptionStatus.CANCELLED.value, # Until period end ] @property def is_trial(self) -> bool: """Check if currently in trial.""" return self.status == SubscriptionStatus.TRIAL.value @property def trial_days_remaining(self) -> int | None: """Get remaining trial days.""" if not self.is_trial or not self.trial_ends_at: return None remaining = (self.trial_ends_at - datetime.now(UTC)).days return max(0, remaining) # ========================================================================= # Limit Checks # ========================================================================= def can_create_order(self) -> tuple[bool, str | None]: """ Check if vendor can create/import another order. Returns: (can_create, error_message) """ if not self.is_active: return False, "Subscription is not active" limit = self.orders_limit if limit is None: # Unlimited return True, None if self.orders_this_period >= limit: return False, f"Monthly order limit reached ({limit} orders). Upgrade to continue." return True, None def can_add_product(self, current_count: int) -> tuple[bool, str | None]: """ Check if vendor can add another product. Args: current_count: Current number of products Returns: (can_add, error_message) """ if not self.is_active: return False, "Subscription is not active" limit = self.products_limit if limit is None: # Unlimited return True, None if current_count >= limit: return False, f"Product limit reached ({limit} products). Upgrade to add more." return True, None def can_add_team_member(self, current_count: int) -> tuple[bool, str | None]: """ Check if vendor can add another team member. Args: current_count: Current number of team members Returns: (can_add, error_message) """ if not self.is_active: return False, "Subscription is not active" limit = self.team_members_limit if limit is None: # Unlimited return True, None if current_count >= limit: return False, f"Team member limit reached ({limit} members). Upgrade to add more." return True, None def has_feature(self, feature: str) -> bool: """Check if a feature is enabled for current tier.""" return feature in self.features # ========================================================================= # Usage Tracking # ========================================================================= def increment_order_count(self) -> None: """Increment the order counter for this period.""" self.orders_this_period += 1 # Track when limit was first reached limit = self.orders_limit if limit and self.orders_this_period >= limit and not self.orders_limit_reached_at: self.orders_limit_reached_at = datetime.now(UTC) def reset_period_counters(self) -> None: """Reset counters for new billing period.""" self.orders_this_period = 0 self.orders_limit_reached_at = None # ============================================================================ # Capacity Planning # ============================================================================ class CapacitySnapshot(Base, TimestampMixin): """ Daily snapshot of platform capacity metrics. Used for growth trending and capacity forecasting. Captured daily by background job. """ __tablename__ = "capacity_snapshots" id = Column(Integer, primary_key=True, index=True) snapshot_date = Column(DateTime(timezone=True), nullable=False, unique=True, index=True) # Vendor metrics total_vendors = Column(Integer, default=0, nullable=False) active_vendors = Column(Integer, default=0, nullable=False) trial_vendors = Column(Integer, default=0, nullable=False) # Subscription metrics total_subscriptions = Column(Integer, default=0, nullable=False) active_subscriptions = Column(Integer, default=0, nullable=False) # Resource metrics total_products = Column(Integer, default=0, nullable=False) total_orders_month = Column(Integer, default=0, nullable=False) total_team_members = Column(Integer, default=0, nullable=False) # Storage metrics storage_used_gb = Column(Numeric(10, 2), default=0, nullable=False) db_size_mb = Column(Numeric(10, 2), default=0, nullable=False) # Capacity metrics (theoretical limits from subscriptions) theoretical_products_limit = Column(Integer, nullable=True) theoretical_orders_limit = Column(Integer, nullable=True) theoretical_team_limit = Column(Integer, nullable=True) # Tier distribution (JSON: {"essential": 10, "professional": 5, ...}) tier_distribution = Column(JSON, nullable=True) # Performance metrics avg_response_ms = Column(Integer, nullable=True) peak_cpu_percent = Column(Numeric(5, 2), nullable=True) peak_memory_percent = Column(Numeric(5, 2), nullable=True) # Indexes __table_args__ = ( Index("ix_capacity_snapshots_date", "snapshot_date"), ) def __repr__(self) -> str: return f""