Replace all ~1,086 occurrences of Wizamart/wizamart/WIZAMART/WizaMart with Orion/orion/ORION across 184 files. This includes database identifiers, email addresses, domain references, R2 bucket names, DNS prefixes, encryption salt, Celery app name, config defaults, Docker configs, CI configs, documentation, seed data, and templates. Renames homepage-wizamart.html template to homepage-orion.html. Fixes duplicate file_pattern key in api.yaml architecture rule. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
165 lines
4.5 KiB
Python
165 lines
4.5 KiB
Python
# 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"<MerchantSubscription("
|
|
f"merchant_id={self.merchant_id}, "
|
|
f"platform_id={self.platform_id}, "
|
|
f"status='{self.status}'"
|
|
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"]
|