feat: complete billing module self-containment
Migrate billing module routes to self-contained structure: - routes/api/admin.py - Admin API endpoints - routes/api/vendor.py - Vendor API endpoints - routes/pages/ - Page routes (placeholder) - models/subscription.py - Subscription model (moved) - schemas/subscription.py - Pydantic schemas (moved) - locales/ - Translations (en, de, fr, lu) Removed legacy route files: - app/modules/billing/routes/admin.py - app/modules/billing/routes/vendor.py Updated __init__.py files to use new structure. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,9 +24,21 @@ Usage:
|
||||
from app.modules.billing.exceptions import TierLimitExceededException
|
||||
"""
|
||||
|
||||
from app.modules.billing.definition import billing_module, get_billing_module_with_routers
|
||||
# Lazy imports to avoid circular dependencies
|
||||
# Routers and module definition are imported on-demand
|
||||
|
||||
__all__ = [
|
||||
"billing_module",
|
||||
"get_billing_module_with_routers",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy import to avoid circular dependencies."""
|
||||
if name == "billing_module":
|
||||
from app.modules.billing.definition import billing_module
|
||||
return billing_module
|
||||
elif name == "get_billing_module_with_routers":
|
||||
from app.modules.billing.definition import get_billing_module_with_routers
|
||||
return get_billing_module_with_routers
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
@@ -12,14 +12,14 @@ from models.database.admin_menu_config import FrontendType
|
||||
|
||||
def _get_admin_router():
|
||||
"""Lazy import of admin router to avoid circular imports."""
|
||||
from app.modules.billing.routes.admin import admin_router
|
||||
from app.modules.billing.routes.api.admin import admin_router
|
||||
|
||||
return admin_router
|
||||
|
||||
|
||||
def _get_vendor_router():
|
||||
"""Lazy import of vendor router to avoid circular imports."""
|
||||
from app.modules.billing.routes.vendor import vendor_router
|
||||
from app.modules.billing.routes.api.vendor import vendor_router
|
||||
|
||||
return vendor_router
|
||||
|
||||
|
||||
99
app/modules/billing/locales/de.json
Normal file
99
app/modules/billing/locales/de.json
Normal file
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"title": "Abrechnung & Abonnements",
|
||||
"description": "Abonnementstufen, Rechnungshistorie und Zahlungen verwalten",
|
||||
"subscription": {
|
||||
"title": "Abonnement",
|
||||
"current_tier": "Aktuelle Stufe",
|
||||
"status": "Status",
|
||||
"statuses": {
|
||||
"trial": "Testphase",
|
||||
"active": "Aktiv",
|
||||
"past_due": "Überfällig",
|
||||
"cancelled": "Gekündigt",
|
||||
"expired": "Abgelaufen"
|
||||
},
|
||||
"trial_ends": "Testphase endet",
|
||||
"period_ends": "Periode endet",
|
||||
"cancelled_at": "Gekündigt am",
|
||||
"cancellation_reason": "Kündigungsgrund"
|
||||
},
|
||||
"tiers": {
|
||||
"title": "Abonnementstufen",
|
||||
"subtitle": "Preisstufen und Funktionen verwalten",
|
||||
"essential": "Essential",
|
||||
"professional": "Professional",
|
||||
"business": "Business",
|
||||
"enterprise": "Enterprise",
|
||||
"create": "Stufe erstellen",
|
||||
"edit": "Stufe bearbeiten",
|
||||
"features": "Funktionen",
|
||||
"limits": "Limits",
|
||||
"pricing": "Preisgestaltung",
|
||||
"monthly": "Monatlich",
|
||||
"annual": "Jährlich",
|
||||
"per_month": "/Monat",
|
||||
"per_year": "/Jahr",
|
||||
"unlimited": "Unbegrenzt",
|
||||
"orders_per_month": "Bestellungen/Monat",
|
||||
"products_limit": "Produkte",
|
||||
"team_members": "Teammitglieder"
|
||||
},
|
||||
"usage": {
|
||||
"title": "Nutzung",
|
||||
"orders": "Bestellungen",
|
||||
"products": "Produkte",
|
||||
"team": "Teammitglieder",
|
||||
"used": "verwendet",
|
||||
"remaining": "verbleibend",
|
||||
"of": "von"
|
||||
},
|
||||
"invoices": {
|
||||
"title": "Rechnungen",
|
||||
"subtitle": "Rechnungshistorie und Rechnungen",
|
||||
"invoice_number": "Rechnung Nr.",
|
||||
"date": "Datum",
|
||||
"due_date": "Fälligkeitsdatum",
|
||||
"amount": "Betrag",
|
||||
"status": "Status",
|
||||
"download": "PDF herunterladen",
|
||||
"view_online": "Online ansehen",
|
||||
"statuses": {
|
||||
"paid": "Bezahlt",
|
||||
"open": "Offen",
|
||||
"void": "Storniert",
|
||||
"uncollectible": "Uneinbringlich",
|
||||
"draft": "Entwurf"
|
||||
}
|
||||
},
|
||||
"payment": {
|
||||
"title": "Zahlung",
|
||||
"method": "Zahlungsmethode",
|
||||
"add_card": "Karte hinzufügen",
|
||||
"update_card": "Karte aktualisieren",
|
||||
"no_method": "Keine Zahlungsmethode hinterlegt",
|
||||
"card_ending": "Karte endet auf",
|
||||
"expires": "Gültig bis"
|
||||
},
|
||||
"upgrade": {
|
||||
"title": "Upgrade",
|
||||
"compare": "Pläne vergleichen",
|
||||
"select": "Plan auswählen",
|
||||
"current": "Aktueller Plan",
|
||||
"recommended": "Empfohlen"
|
||||
},
|
||||
"messages": {
|
||||
"subscription_updated": "Abonnement erfolgreich aktualisiert",
|
||||
"tier_created": "Stufe erfolgreich erstellt",
|
||||
"tier_updated": "Stufe erfolgreich aktualisiert",
|
||||
"tier_deactivated": "Stufe deaktiviert",
|
||||
"payment_method_updated": "Zahlungsmethode aktualisiert",
|
||||
"subscription_cancelled": "Abonnement gekündigt",
|
||||
"error_loading": "Fehler beim Laden der Abrechnungsinformationen",
|
||||
"error_updating": "Fehler beim Aktualisieren des Abonnements"
|
||||
},
|
||||
"limits": {
|
||||
"orders_exceeded": "Monatliches Bestelllimit erreicht. Upgrade für mehr.",
|
||||
"products_exceeded": "Produktlimit erreicht. Upgrade für mehr.",
|
||||
"team_exceeded": "Teammitgliederlimit erreicht. Upgrade für mehr."
|
||||
}
|
||||
}
|
||||
99
app/modules/billing/locales/en.json
Normal file
99
app/modules/billing/locales/en.json
Normal file
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"title": "Billing & Subscriptions",
|
||||
"description": "Manage subscription tiers, billing history, and payments",
|
||||
"subscription": {
|
||||
"title": "Subscription",
|
||||
"current_tier": "Current Tier",
|
||||
"status": "Status",
|
||||
"statuses": {
|
||||
"trial": "Trial",
|
||||
"active": "Active",
|
||||
"past_due": "Past Due",
|
||||
"cancelled": "Cancelled",
|
||||
"expired": "Expired"
|
||||
},
|
||||
"trial_ends": "Trial ends",
|
||||
"period_ends": "Period ends",
|
||||
"cancelled_at": "Cancelled on",
|
||||
"cancellation_reason": "Cancellation reason"
|
||||
},
|
||||
"tiers": {
|
||||
"title": "Subscription Tiers",
|
||||
"subtitle": "Manage pricing tiers and features",
|
||||
"essential": "Essential",
|
||||
"professional": "Professional",
|
||||
"business": "Business",
|
||||
"enterprise": "Enterprise",
|
||||
"create": "Create Tier",
|
||||
"edit": "Edit Tier",
|
||||
"features": "Features",
|
||||
"limits": "Limits",
|
||||
"pricing": "Pricing",
|
||||
"monthly": "Monthly",
|
||||
"annual": "Annual",
|
||||
"per_month": "/month",
|
||||
"per_year": "/year",
|
||||
"unlimited": "Unlimited",
|
||||
"orders_per_month": "Orders/month",
|
||||
"products_limit": "Products",
|
||||
"team_members": "Team members"
|
||||
},
|
||||
"usage": {
|
||||
"title": "Usage",
|
||||
"orders": "Orders",
|
||||
"products": "Products",
|
||||
"team": "Team Members",
|
||||
"used": "used",
|
||||
"remaining": "remaining",
|
||||
"of": "of"
|
||||
},
|
||||
"invoices": {
|
||||
"title": "Invoices",
|
||||
"subtitle": "Billing history and invoices",
|
||||
"invoice_number": "Invoice #",
|
||||
"date": "Date",
|
||||
"due_date": "Due Date",
|
||||
"amount": "Amount",
|
||||
"status": "Status",
|
||||
"download": "Download PDF",
|
||||
"view_online": "View Online",
|
||||
"statuses": {
|
||||
"paid": "Paid",
|
||||
"open": "Open",
|
||||
"void": "Void",
|
||||
"uncollectible": "Uncollectible",
|
||||
"draft": "Draft"
|
||||
}
|
||||
},
|
||||
"payment": {
|
||||
"title": "Payment",
|
||||
"method": "Payment Method",
|
||||
"add_card": "Add Card",
|
||||
"update_card": "Update Card",
|
||||
"no_method": "No payment method on file",
|
||||
"card_ending": "Card ending in",
|
||||
"expires": "Expires"
|
||||
},
|
||||
"upgrade": {
|
||||
"title": "Upgrade Plan",
|
||||
"compare": "Compare Plans",
|
||||
"select": "Select Plan",
|
||||
"current": "Current Plan",
|
||||
"recommended": "Recommended"
|
||||
},
|
||||
"messages": {
|
||||
"subscription_updated": "Subscription updated successfully",
|
||||
"tier_created": "Tier created successfully",
|
||||
"tier_updated": "Tier updated successfully",
|
||||
"tier_deactivated": "Tier deactivated",
|
||||
"payment_method_updated": "Payment method updated",
|
||||
"subscription_cancelled": "Subscription cancelled",
|
||||
"error_loading": "Error loading billing information",
|
||||
"error_updating": "Error updating subscription"
|
||||
},
|
||||
"limits": {
|
||||
"orders_exceeded": "Monthly order limit reached. Upgrade to continue.",
|
||||
"products_exceeded": "Product limit reached. Upgrade to add more.",
|
||||
"team_exceeded": "Team member limit reached. Upgrade to add more."
|
||||
}
|
||||
}
|
||||
99
app/modules/billing/locales/fr.json
Normal file
99
app/modules/billing/locales/fr.json
Normal file
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"title": "Facturation & Abonnements",
|
||||
"description": "Gérer les niveaux d'abonnement, l'historique de facturation et les paiements",
|
||||
"subscription": {
|
||||
"title": "Abonnement",
|
||||
"current_tier": "Niveau actuel",
|
||||
"status": "Statut",
|
||||
"statuses": {
|
||||
"trial": "Essai",
|
||||
"active": "Actif",
|
||||
"past_due": "En retard",
|
||||
"cancelled": "Annulé",
|
||||
"expired": "Expiré"
|
||||
},
|
||||
"trial_ends": "Fin de l'essai",
|
||||
"period_ends": "Fin de période",
|
||||
"cancelled_at": "Annulé le",
|
||||
"cancellation_reason": "Raison de l'annulation"
|
||||
},
|
||||
"tiers": {
|
||||
"title": "Niveaux d'abonnement",
|
||||
"subtitle": "Gérer les tarifs et fonctionnalités",
|
||||
"essential": "Essentiel",
|
||||
"professional": "Professionnel",
|
||||
"business": "Business",
|
||||
"enterprise": "Enterprise",
|
||||
"create": "Créer un niveau",
|
||||
"edit": "Modifier le niveau",
|
||||
"features": "Fonctionnalités",
|
||||
"limits": "Limites",
|
||||
"pricing": "Tarification",
|
||||
"monthly": "Mensuel",
|
||||
"annual": "Annuel",
|
||||
"per_month": "/mois",
|
||||
"per_year": "/an",
|
||||
"unlimited": "Illimité",
|
||||
"orders_per_month": "Commandes/mois",
|
||||
"products_limit": "Produits",
|
||||
"team_members": "Membres d'équipe"
|
||||
},
|
||||
"usage": {
|
||||
"title": "Utilisation",
|
||||
"orders": "Commandes",
|
||||
"products": "Produits",
|
||||
"team": "Membres d'équipe",
|
||||
"used": "utilisé",
|
||||
"remaining": "restant",
|
||||
"of": "sur"
|
||||
},
|
||||
"invoices": {
|
||||
"title": "Factures",
|
||||
"subtitle": "Historique de facturation et factures",
|
||||
"invoice_number": "Facture N°",
|
||||
"date": "Date",
|
||||
"due_date": "Date d'échéance",
|
||||
"amount": "Montant",
|
||||
"status": "Statut",
|
||||
"download": "Télécharger PDF",
|
||||
"view_online": "Voir en ligne",
|
||||
"statuses": {
|
||||
"paid": "Payée",
|
||||
"open": "Ouverte",
|
||||
"void": "Annulée",
|
||||
"uncollectible": "Irrécouvrable",
|
||||
"draft": "Brouillon"
|
||||
}
|
||||
},
|
||||
"payment": {
|
||||
"title": "Paiement",
|
||||
"method": "Moyen de paiement",
|
||||
"add_card": "Ajouter une carte",
|
||||
"update_card": "Modifier la carte",
|
||||
"no_method": "Aucun moyen de paiement enregistré",
|
||||
"card_ending": "Carte se terminant par",
|
||||
"expires": "Expire"
|
||||
},
|
||||
"upgrade": {
|
||||
"title": "Mettre à niveau",
|
||||
"compare": "Comparer les plans",
|
||||
"select": "Sélectionner le plan",
|
||||
"current": "Plan actuel",
|
||||
"recommended": "Recommandé"
|
||||
},
|
||||
"messages": {
|
||||
"subscription_updated": "Abonnement mis à jour avec succès",
|
||||
"tier_created": "Niveau créé avec succès",
|
||||
"tier_updated": "Niveau mis à jour avec succès",
|
||||
"tier_deactivated": "Niveau désactivé",
|
||||
"payment_method_updated": "Moyen de paiement mis à jour",
|
||||
"subscription_cancelled": "Abonnement annulé",
|
||||
"error_loading": "Erreur lors du chargement des informations de facturation",
|
||||
"error_updating": "Erreur lors de la mise à jour de l'abonnement"
|
||||
},
|
||||
"limits": {
|
||||
"orders_exceeded": "Limite mensuelle de commandes atteinte. Passez à un niveau supérieur.",
|
||||
"products_exceeded": "Limite de produits atteinte. Passez à un niveau supérieur.",
|
||||
"team_exceeded": "Limite de membres d'équipe atteinte. Passez à un niveau supérieur."
|
||||
}
|
||||
}
|
||||
99
app/modules/billing/locales/lb.json
Normal file
99
app/modules/billing/locales/lb.json
Normal file
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"title": "Rechnung & Abonnementer",
|
||||
"description": "Abonnementstufe, Rechnungshistorik an Zuelungen verwalten",
|
||||
"subscription": {
|
||||
"title": "Abonnement",
|
||||
"current_tier": "Aktuell Stuf",
|
||||
"status": "Status",
|
||||
"statuses": {
|
||||
"trial": "Testphas",
|
||||
"active": "Aktiv",
|
||||
"past_due": "Iwwerfälleg",
|
||||
"cancelled": "Gekënnegt",
|
||||
"expired": "Ofgelaf"
|
||||
},
|
||||
"trial_ends": "Testphas endet",
|
||||
"period_ends": "Period endet",
|
||||
"cancelled_at": "Gekënnegt den",
|
||||
"cancellation_reason": "Kënnegungsgrond"
|
||||
},
|
||||
"tiers": {
|
||||
"title": "Abonnementstufen",
|
||||
"subtitle": "Präisstufen a Funktiounen verwalten",
|
||||
"essential": "Essential",
|
||||
"professional": "Professional",
|
||||
"business": "Business",
|
||||
"enterprise": "Enterprise",
|
||||
"create": "Stuf erstellen",
|
||||
"edit": "Stuf beaarbechten",
|
||||
"features": "Funktiounen",
|
||||
"limits": "Limiten",
|
||||
"pricing": "Präisgestaltung",
|
||||
"monthly": "Monatlech",
|
||||
"annual": "Jäerlech",
|
||||
"per_month": "/Mount",
|
||||
"per_year": "/Joer",
|
||||
"unlimited": "Onbegrenzt",
|
||||
"orders_per_month": "Bestellungen/Mount",
|
||||
"products_limit": "Produkter",
|
||||
"team_members": "Teammemberen"
|
||||
},
|
||||
"usage": {
|
||||
"title": "Notzung",
|
||||
"orders": "Bestellungen",
|
||||
"products": "Produkter",
|
||||
"team": "Teammemberen",
|
||||
"used": "benotzt",
|
||||
"remaining": "iwwreg",
|
||||
"of": "vun"
|
||||
},
|
||||
"invoices": {
|
||||
"title": "Rechnungen",
|
||||
"subtitle": "Rechnungshistorik a Rechnungen",
|
||||
"invoice_number": "Rechnung Nr.",
|
||||
"date": "Datum",
|
||||
"due_date": "Fällegkeetsdatum",
|
||||
"amount": "Betrag",
|
||||
"status": "Status",
|
||||
"download": "PDF eroflueden",
|
||||
"view_online": "Online kucken",
|
||||
"statuses": {
|
||||
"paid": "Bezuelt",
|
||||
"open": "Oppen",
|
||||
"void": "Stornéiert",
|
||||
"uncollectible": "Onabtreidbar",
|
||||
"draft": "Entworf"
|
||||
}
|
||||
},
|
||||
"payment": {
|
||||
"title": "Zuelung",
|
||||
"method": "Zuelungsmethod",
|
||||
"add_card": "Kaart dobäisetzen",
|
||||
"update_card": "Kaart aktualiséieren",
|
||||
"no_method": "Keng Zuelungsmethod hannerlued",
|
||||
"card_ending": "Kaart endet op",
|
||||
"expires": "Gëlteg bis"
|
||||
},
|
||||
"upgrade": {
|
||||
"title": "Upgrade",
|
||||
"compare": "Pläng vergläichen",
|
||||
"select": "Plang auswielen",
|
||||
"current": "Aktuellen Plang",
|
||||
"recommended": "Recommandéiert"
|
||||
},
|
||||
"messages": {
|
||||
"subscription_updated": "Abonnement erfollegräich aktualiséiert",
|
||||
"tier_created": "Stuf erfollegräich erstallt",
|
||||
"tier_updated": "Stuf erfollegräich aktualiséiert",
|
||||
"tier_deactivated": "Stuf deaktivéiert",
|
||||
"payment_method_updated": "Zuelungsmethod aktualiséiert",
|
||||
"subscription_cancelled": "Abonnement gekënnegt",
|
||||
"error_loading": "Feeler beim Lueden vun de Rechnungsinformatiounen",
|
||||
"error_updating": "Feeler beim Aktualiséieren vum Abonnement"
|
||||
},
|
||||
"limits": {
|
||||
"orders_exceeded": "Monatlech Bestellungslimit erreecht. Upgrade fir méi.",
|
||||
"products_exceeded": "Produktlimit erreecht. Upgrade fir méi.",
|
||||
"team_exceeded": "Teammemberlimit erreecht. Upgrade fir méi."
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
# app/modules/billing/models/__init__.py
|
||||
"""
|
||||
Billing module models.
|
||||
Billing module database models.
|
||||
|
||||
Re-exports subscription models from the central models location.
|
||||
Models remain in models/database/ for now to avoid breaking existing imports
|
||||
across the codebase. This provides a module-local import path.
|
||||
This is the canonical location for billing models. Module models are automatically
|
||||
discovered and registered with SQLAlchemy's Base.metadata at startup.
|
||||
|
||||
Usage:
|
||||
from app.modules.billing.models import (
|
||||
@@ -15,7 +14,7 @@ Usage:
|
||||
)
|
||||
"""
|
||||
|
||||
from models.database.subscription import (
|
||||
from app.modules.billing.models.subscription import (
|
||||
# Enums
|
||||
TierCode,
|
||||
SubscriptionStatus,
|
||||
|
||||
756
app/modules/billing/models/subscription.py
Normal file
756
app/modules/billing/models/subscription.py
Normal file
@@ -0,0 +1,756 @@
|
||||
# app/modules/billing/models/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"<SubscriptionTier(code='{self.code}', name='{self.name}'{platform_info})>"
|
||||
|
||||
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"<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: {
|
||||
"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"<VendorSubscription(vendor_id={self.vendor_id}, tier='{self.tier}', status='{self.status}')>"
|
||||
|
||||
# =========================================================================
|
||||
# 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"<CapacitySnapshot(date={self.snapshot_date}, vendors={self.total_vendors})>"
|
||||
@@ -2,27 +2,13 @@
|
||||
"""
|
||||
Billing module route registration.
|
||||
|
||||
This module provides functions to register billing routes
|
||||
with module-based access control.
|
||||
This module provides billing routes with module-based access control.
|
||||
|
||||
NOTE: Routers are NOT auto-imported to avoid circular dependencies.
|
||||
Import directly from admin.py or vendor.py as needed:
|
||||
from app.modules.billing.routes.admin import admin_router
|
||||
from app.modules.billing.routes.vendor import vendor_router
|
||||
Structure:
|
||||
- routes/api/ - REST API endpoints
|
||||
- routes/pages/ - HTML page rendering (templates)
|
||||
"""
|
||||
|
||||
# Routers are imported on-demand to avoid circular dependencies
|
||||
# Do NOT add auto-imports here
|
||||
from app.modules.billing.routes.api import admin_router, vendor_router
|
||||
|
||||
__all__ = ["admin_router", "vendor_router"]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy import routers to avoid circular dependencies."""
|
||||
if name == "admin_router":
|
||||
from app.modules.billing.routes.admin import admin_router
|
||||
return admin_router
|
||||
elif name == "vendor_router":
|
||||
from app.modules.billing.routes.vendor import vendor_router
|
||||
return vendor_router
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
13
app/modules/billing/routes/api/__init__.py
Normal file
13
app/modules/billing/routes/api/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
# app/modules/billing/routes/api/__init__.py
|
||||
"""
|
||||
Billing module API routes.
|
||||
|
||||
Provides REST API endpoints for subscription and billing management:
|
||||
- Admin API: Subscription tier management, vendor subscriptions, billing history
|
||||
- Vendor API: Subscription status, tier comparison, invoices
|
||||
"""
|
||||
|
||||
from app.modules.billing.routes.api.admin import admin_router
|
||||
from app.modules.billing.routes.api.vendor import vendor_router
|
||||
|
||||
__all__ = ["admin_router", "vendor_router"]
|
||||
18
app/modules/billing/routes/pages/__init__.py
Normal file
18
app/modules/billing/routes/pages/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# app/modules/billing/routes/pages/__init__.py
|
||||
"""
|
||||
Billing module page routes (HTML rendering).
|
||||
|
||||
Provides Jinja2 template rendering for billing management:
|
||||
- Admin pages: Subscription tiers, subscriptions list, billing history
|
||||
- Vendor pages: Billing dashboard, invoices
|
||||
|
||||
Note: These routes are placeholders. The actual page rendering
|
||||
is currently handled by routes in app/api/v1/ and can be migrated here.
|
||||
"""
|
||||
|
||||
__all__ = []
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy import routers to avoid circular dependencies."""
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -1,9 +1,8 @@
|
||||
# app/modules/billing/schemas/__init__.py
|
||||
"""
|
||||
Billing module Pydantic schemas.
|
||||
Billing module Pydantic schemas for API request/response validation.
|
||||
|
||||
Re-exports subscription schemas from the central schemas location.
|
||||
Provides a module-local import path while maintaining backwards compatibility.
|
||||
This is the canonical location for billing schemas.
|
||||
|
||||
Usage:
|
||||
from app.modules.billing.schemas import (
|
||||
@@ -13,7 +12,7 @@ Usage:
|
||||
)
|
||||
"""
|
||||
|
||||
from models.schema.subscription import (
|
||||
from app.modules.billing.schemas.subscription import (
|
||||
# Tier schemas
|
||||
TierFeatures,
|
||||
TierLimits,
|
||||
|
||||
209
app/modules/billing/schemas/subscription.py
Normal file
209
app/modules/billing/schemas/subscription.py
Normal file
@@ -0,0 +1,209 @@
|
||||
# app/modules/billing/schemas/subscription.py
|
||||
"""
|
||||
Pydantic schemas for subscription operations.
|
||||
|
||||
Supports subscription management and tier limit checks.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Tier Information Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TierFeatures(BaseModel):
|
||||
"""Features included in a tier."""
|
||||
|
||||
letzshop_sync: bool = True
|
||||
inventory_basic: bool = True
|
||||
inventory_locations: bool = False
|
||||
inventory_purchase_orders: bool = False
|
||||
invoice_lu: bool = True
|
||||
invoice_eu_vat: bool = False
|
||||
invoice_bulk: bool = False
|
||||
customer_view: bool = True
|
||||
customer_export: bool = False
|
||||
analytics_dashboard: bool = False
|
||||
accounting_export: bool = False
|
||||
api_access: bool = False
|
||||
automation_rules: bool = False
|
||||
team_roles: bool = False
|
||||
white_label: bool = False
|
||||
multi_vendor: bool = False
|
||||
custom_integrations: bool = False
|
||||
sla_guarantee: bool = False
|
||||
dedicated_support: bool = False
|
||||
|
||||
|
||||
class TierLimits(BaseModel):
|
||||
"""Limits for a subscription tier."""
|
||||
|
||||
orders_per_month: int | None = Field(None, description="None = unlimited")
|
||||
products_limit: int | None = Field(None, description="None = unlimited")
|
||||
team_members: int | None = Field(None, description="None = unlimited")
|
||||
order_history_months: int | None = Field(None, description="None = unlimited")
|
||||
|
||||
|
||||
class TierInfo(BaseModel):
|
||||
"""Full tier information."""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
price_monthly_cents: int
|
||||
price_annual_cents: int | None
|
||||
limits: TierLimits
|
||||
features: list[str]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Subscription Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class SubscriptionCreate(BaseModel):
|
||||
"""Schema for creating a subscription (admin/internal use)."""
|
||||
|
||||
tier: str = Field(default="essential", pattern="^(essential|professional|business|enterprise)$")
|
||||
is_annual: bool = False
|
||||
trial_days: int = Field(default=14, ge=0, le=30)
|
||||
|
||||
|
||||
class SubscriptionUpdate(BaseModel):
|
||||
"""Schema for updating a subscription."""
|
||||
|
||||
tier: str | None = Field(None, pattern="^(essential|professional|business|enterprise)$")
|
||||
status: str | None = Field(None, pattern="^(trial|active|past_due|cancelled|expired)$")
|
||||
is_annual: bool | None = None
|
||||
custom_orders_limit: int | None = None
|
||||
custom_products_limit: int | None = None
|
||||
custom_team_limit: int | None = None
|
||||
|
||||
|
||||
class SubscriptionResponse(BaseModel):
|
||||
"""Schema for subscription response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
vendor_id: int
|
||||
tier: str
|
||||
status: str
|
||||
|
||||
period_start: datetime
|
||||
period_end: datetime
|
||||
is_annual: bool
|
||||
|
||||
trial_ends_at: datetime | None
|
||||
orders_this_period: int
|
||||
orders_limit_reached_at: datetime | None
|
||||
|
||||
# Effective limits (with custom overrides applied)
|
||||
orders_limit: int | None
|
||||
products_limit: int | None
|
||||
team_members_limit: int | None
|
||||
|
||||
# Computed properties
|
||||
is_active: bool
|
||||
is_trial: bool
|
||||
trial_days_remaining: int | None
|
||||
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class SubscriptionUsage(BaseModel):
|
||||
"""Current subscription usage statistics."""
|
||||
|
||||
orders_used: int
|
||||
orders_limit: int | None
|
||||
orders_remaining: int | None
|
||||
orders_percent_used: float | None
|
||||
|
||||
products_used: int
|
||||
products_limit: int | None
|
||||
products_remaining: int | None
|
||||
products_percent_used: float | None
|
||||
|
||||
team_members_used: int
|
||||
team_members_limit: int | None
|
||||
team_members_remaining: int | None
|
||||
team_members_percent_used: float | None
|
||||
|
||||
|
||||
class UsageSummary(BaseModel):
|
||||
"""Usage summary for billing page display."""
|
||||
|
||||
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
|
||||
|
||||
|
||||
class SubscriptionStatusResponse(BaseModel):
|
||||
"""Subscription status with usage and limits."""
|
||||
|
||||
subscription: SubscriptionResponse
|
||||
usage: SubscriptionUsage
|
||||
tier_info: TierInfo
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Limit Check Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class LimitCheckResult(BaseModel):
|
||||
"""Result of a limit check."""
|
||||
|
||||
allowed: bool
|
||||
limit: int | None
|
||||
current: int
|
||||
remaining: int | None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class CanCreateOrderResponse(BaseModel):
|
||||
"""Response for order creation check."""
|
||||
|
||||
allowed: bool
|
||||
orders_this_period: int
|
||||
orders_limit: int | None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class CanAddProductResponse(BaseModel):
|
||||
"""Response for product addition check."""
|
||||
|
||||
allowed: bool
|
||||
products_count: int
|
||||
products_limit: int | None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class CanAddTeamMemberResponse(BaseModel):
|
||||
"""Response for team member addition check."""
|
||||
|
||||
allowed: bool
|
||||
team_count: int
|
||||
team_limit: int | None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class FeatureCheckResponse(BaseModel):
|
||||
"""Response for feature check."""
|
||||
|
||||
feature: str
|
||||
enabled: bool
|
||||
tier_required: str | None = None
|
||||
message: str | None = None
|
||||
Reference in New Issue
Block a user