# app/modules/billing/models/merchant_subscription.py """ Merchant-level subscription model. Replaces StoreSubscription with merchant-level billing: - One subscription per merchant per platform - Merchant is the billing entity (not the store) - Stores inherit features/limits from their merchant's subscription """ from datetime import UTC, datetime from sqlalchemy import ( Boolean, Column, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, ) from sqlalchemy.orm import relationship from app.core.database import Base from app.modules.billing.models.subscription import SubscriptionStatus from models.database.base import TimestampMixin class MerchantSubscription(Base, TimestampMixin): """ Per-merchant, per-platform subscription tracking. The merchant (legal entity) subscribes and pays, not the store. A merchant can own multiple stores and subscribe per-platform. Example: Merchant "Boucherie Luxembourg" subscribes to: - Orion OMS (Professional tier) - Loyalty+ (Essential tier) Their stores inherit features from the merchant's subscription. """ __tablename__ = "merchant_subscriptions" id = Column(Integer, primary_key=True, index=True) # Who pays merchant_id = Column( Integer, ForeignKey("merchants.id", ondelete="CASCADE"), nullable=False, index=True, ) # Which platform platform_id = Column( Integer, ForeignKey("platforms.id", ondelete="CASCADE"), nullable=False, index=True, ) # Which tier tier_id = Column( Integer, ForeignKey("subscription_tiers.id", ondelete="SET NULL"), nullable=True, index=True, ) # Status status = Column( String(20), default=SubscriptionStatus.TRIAL.value, nullable=False, index=True, ) # Billing period is_annual = Column(Boolean, default=False, nullable=False) period_start = Column(DateTime(timezone=True), nullable=False) period_end = Column(DateTime(timezone=True), nullable=False) # Trial info trial_ends_at = Column(DateTime(timezone=True), nullable=True) # Stripe integration (per merchant) stripe_customer_id = Column(String(100), nullable=True, index=True) stripe_subscription_id = Column(String(100), nullable=True, index=True) stripe_payment_method_id = Column(String(100), 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 merchant = relationship( "Merchant", backref="subscriptions", foreign_keys=[merchant_id], ) platform = relationship( "Platform", foreign_keys=[platform_id], ) tier = relationship( "SubscriptionTier", foreign_keys=[tier_id], ) __table_args__ = ( UniqueConstraint( "merchant_id", "platform_id", name="uq_merchant_platform_subscription", ), Index("idx_merchant_sub_status", "merchant_id", "status"), Index("idx_merchant_sub_platform", "platform_id", "status"), ) def __repr__(self): return ( f"" ) # ========================================================================= # 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, SubscriptionStatus.CANCELLED.value, ] @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) __all__ = ["MerchantSubscription"]