diff --git a/app/modules/billing/__init__.py b/app/modules/billing/__init__.py index 47f37d32..e67c49c0 100644 --- a/app/modules/billing/__init__.py +++ b/app/modules/billing/__init__.py @@ -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}") diff --git a/app/modules/billing/definition.py b/app/modules/billing/definition.py index 9b1440a5..b162edbd 100644 --- a/app/modules/billing/definition.py +++ b/app/modules/billing/definition.py @@ -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 diff --git a/app/modules/billing/locales/de.json b/app/modules/billing/locales/de.json new file mode 100644 index 00000000..77992c4d --- /dev/null +++ b/app/modules/billing/locales/de.json @@ -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." + } +} diff --git a/app/modules/billing/locales/en.json b/app/modules/billing/locales/en.json new file mode 100644 index 00000000..b37e8513 --- /dev/null +++ b/app/modules/billing/locales/en.json @@ -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." + } +} diff --git a/app/modules/billing/locales/fr.json b/app/modules/billing/locales/fr.json new file mode 100644 index 00000000..36d79b7a --- /dev/null +++ b/app/modules/billing/locales/fr.json @@ -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." + } +} diff --git a/app/modules/billing/locales/lb.json b/app/modules/billing/locales/lb.json new file mode 100644 index 00000000..adac55ef --- /dev/null +++ b/app/modules/billing/locales/lb.json @@ -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." + } +} diff --git a/app/modules/billing/models/__init__.py b/app/modules/billing/models/__init__.py index f450bb1a..4851b35d 100644 --- a/app/modules/billing/models/__init__.py +++ b/app/modules/billing/models/__init__.py @@ -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, diff --git a/app/modules/billing/models/subscription.py b/app/modules/billing/models/subscription.py new file mode 100644 index 00000000..949956a4 --- /dev/null +++ b/app/modules/billing/models/subscription.py @@ -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"" + + 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"" diff --git a/app/modules/billing/routes/__init__.py b/app/modules/billing/routes/__init__.py index 048f994a..90572b6c 100644 --- a/app/modules/billing/routes/__init__.py +++ b/app/modules/billing/routes/__init__.py @@ -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}") diff --git a/app/modules/billing/routes/api/__init__.py b/app/modules/billing/routes/api/__init__.py new file mode 100644 index 00000000..1e4ed4a0 --- /dev/null +++ b/app/modules/billing/routes/api/__init__.py @@ -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"] diff --git a/app/modules/billing/routes/admin.py b/app/modules/billing/routes/api/admin.py similarity index 100% rename from app/modules/billing/routes/admin.py rename to app/modules/billing/routes/api/admin.py diff --git a/app/modules/billing/routes/vendor.py b/app/modules/billing/routes/api/vendor.py similarity index 100% rename from app/modules/billing/routes/vendor.py rename to app/modules/billing/routes/api/vendor.py diff --git a/app/modules/billing/routes/pages/__init__.py b/app/modules/billing/routes/pages/__init__.py new file mode 100644 index 00000000..56749c30 --- /dev/null +++ b/app/modules/billing/routes/pages/__init__.py @@ -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}") diff --git a/app/modules/billing/schemas/__init__.py b/app/modules/billing/schemas/__init__.py index 3537780f..04324717 100644 --- a/app/modules/billing/schemas/__init__.py +++ b/app/modules/billing/schemas/__init__.py @@ -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, diff --git a/app/modules/billing/schemas/subscription.py b/app/modules/billing/schemas/subscription.py new file mode 100644 index 00000000..76dacd4f --- /dev/null +++ b/app/modules/billing/schemas/subscription.py @@ -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