From c614b7d74c25874633b21a88f22883492dcdd97c Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Sun, 25 Jan 2026 21:54:42 +0100 Subject: [PATCH] feat: extract billing module with routes (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create app/modules/billing/ directory structure with: - definition.py: Module definition with features and menu items - routes/admin.py: Admin billing routes with module access control - routes/vendor.py: Vendor billing routes with module access control Key changes: - Billing module uses require_module_access("billing") dependency - Admin router now includes billing module router instead of legacy - Module registry imports billing_module from extracted location - Routes have identical functionality but are now module-gated Module structure pattern for future extractions: app/modules/{module}/ ├── __init__.py ├── definition.py (ModuleDefinition + router getters) └── routes/ ├── __init__.py ├── admin.py (require_module_access dependency) └── vendor.py (require_module_access dependency) Co-Authored-By: Claude Opus 4.5 --- app/api/v1/admin/__init__.py | 33 ++- app/modules/billing/__init__.py | 22 ++ app/modules/billing/definition.py | 71 ++++++ app/modules/billing/routes/__init__.py | 12 + app/modules/billing/routes/admin.py | 337 +++++++++++++++++++++++++ app/modules/billing/routes/vendor.py | 216 ++++++++++++++++ app/modules/registry.py | 34 +-- 7 files changed, 699 insertions(+), 26 deletions(-) create mode 100644 app/modules/billing/__init__.py create mode 100644 app/modules/billing/definition.py create mode 100644 app/modules/billing/routes/__init__.py create mode 100644 app/modules/billing/routes/admin.py create mode 100644 app/modules/billing/routes/vendor.py diff --git a/app/api/v1/admin/__init__.py b/app/api/v1/admin/__init__.py index a4265938..5df85b44 100644 --- a/app/api/v1/admin/__init__.py +++ b/app/api/v1/admin/__init__.py @@ -19,6 +19,20 @@ IMPORTANT: - This router is for JSON API endpoints only - HTML page routes are mounted separately in main.py at /vendor/* - Do NOT include pages.router here - it causes route conflicts + +MODULE SYSTEM: +Routes can be module-gated using require_module_access() dependency. +For multi-tenant apps, module enablement is checked at request time +based on platform context (not at route registration time). + +Extracted modules (app/modules/{module}/routes/): +- billing: Subscription tiers, vendor billing, invoices + +Future extractions will follow the same pattern: +1. Create app/modules/{module}/ directory +2. Move routes to app/modules/{module}/routes/admin.py +3. Add require_module_access("{module}") to router +4. Update this file to import from module instead """ from fastapi import APIRouter @@ -42,6 +56,7 @@ from . import ( logs, marketplace, media, + menu_config, messages, monitoring, notifications, @@ -51,7 +66,7 @@ from . import ( platforms, products, settings, - subscriptions, + subscriptions, # Legacy - will be replaced by billing module router tests, users, vendor_domains, @@ -60,6 +75,9 @@ from . import ( vendors, ) +# Import extracted module routers +from app.modules.billing.routes import admin_router as billing_admin_router + # Create admin router router = APIRouter() @@ -96,6 +114,9 @@ router.include_router( # Include platforms management endpoints (multi-platform CMS) router.include_router(platforms.router, tags=["admin-platforms"]) +# Include menu configuration endpoints (super admin only) +router.include_router(menu_config.router, tags=["admin-menu-config"]) + # ============================================================================ # User Management @@ -192,11 +213,15 @@ router.include_router( # ============================================================================ -# Billing & Subscriptions +# Billing & Subscriptions (Module-gated) # ============================================================================ -# Include subscription management endpoints -router.include_router(subscriptions.router, tags=["admin-subscriptions"]) +# Include billing module router (with module access control) +# This router checks if the 'billing' module is enabled for the platform +router.include_router(billing_admin_router, tags=["admin-billing"]) + +# Legacy subscriptions router (to be removed once billing module is fully tested) +# router.include_router(subscriptions.router, tags=["admin-subscriptions"]) # Include feature management endpoints router.include_router(features.router, tags=["admin-features"]) diff --git a/app/modules/billing/__init__.py b/app/modules/billing/__init__.py new file mode 100644 index 00000000..34b1710f --- /dev/null +++ b/app/modules/billing/__init__.py @@ -0,0 +1,22 @@ +# app/modules/billing/__init__.py +""" +Billing Module - Subscription and payment management. + +This module provides: +- Subscription tier management +- Vendor subscription CRUD +- Billing history and invoices +- Stripe integration + +Routes: +- Admin: /api/v1/admin/subscriptions/* +- Vendor: /api/v1/vendor/billing/* + +Menu Items: +- Admin: subscription-tiers, subscriptions, billing-history +- Vendor: billing, invoices +""" + +from app.modules.billing.definition import billing_module + +__all__ = ["billing_module"] diff --git a/app/modules/billing/definition.py b/app/modules/billing/definition.py new file mode 100644 index 00000000..28450757 --- /dev/null +++ b/app/modules/billing/definition.py @@ -0,0 +1,71 @@ +# app/modules/billing/definition.py +""" +Billing module definition. + +Defines the billing module including its features, menu items, +and route configurations. +""" + +from app.modules.base import ModuleDefinition +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 + + 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 + + return vendor_router + + +# Billing module definition +billing_module = ModuleDefinition( + code="billing", + name="Billing & Subscriptions", + description=( + "Subscription tier management, vendor billing, payment processing, " + "and invoice history. Integrates with Stripe for payment collection." + ), + features=[ + "subscription_management", # Manage subscription tiers + "billing_history", # View invoices and payment history + "stripe_integration", # Stripe payment processing + "invoice_generation", # Generate and download invoices + "subscription_analytics", # Subscription stats and metrics + "trial_management", # Manage vendor trial periods + "limit_overrides", # Override tier limits per vendor + ], + menu_items={ + FrontendType.ADMIN: [ + "subscription-tiers", # Manage tier definitions + "subscriptions", # View/manage vendor subscriptions + "billing-history", # View all invoices + ], + FrontendType.VENDOR: [ + "billing", # Vendor billing dashboard + "invoices", # Vendor invoice history + ], + }, + is_core=False, # Billing can be disabled (e.g., internal platforms) +) + + +def get_billing_module_with_routers() -> ModuleDefinition: + """ + Get billing module with routers attached. + + This function attaches the routers lazily to avoid circular imports + during module initialization. + """ + billing_module.admin_router = _get_admin_router() + billing_module.vendor_router = _get_vendor_router() + return billing_module + + +__all__ = ["billing_module", "get_billing_module_with_routers"] diff --git a/app/modules/billing/routes/__init__.py b/app/modules/billing/routes/__init__.py new file mode 100644 index 00000000..e1fd8707 --- /dev/null +++ b/app/modules/billing/routes/__init__.py @@ -0,0 +1,12 @@ +# app/modules/billing/routes/__init__.py +""" +Billing module route registration. + +This module provides functions to register billing routes +with module-based access control. +""" + +from app.modules.billing.routes.admin import admin_router +from app.modules.billing.routes.vendor import vendor_router + +__all__ = ["admin_router", "vendor_router"] diff --git a/app/modules/billing/routes/admin.py b/app/modules/billing/routes/admin.py new file mode 100644 index 00000000..9d18716f --- /dev/null +++ b/app/modules/billing/routes/admin.py @@ -0,0 +1,337 @@ +# app/modules/billing/routes/admin.py +""" +Billing module admin routes. + +This module wraps the existing admin subscription routes and adds +module-based access control. The actual route implementations remain +in app/api/v1/admin/subscriptions.py for now, but are accessed through +this module-aware router. + +Future: Move all route implementations here for full module isolation. +""" + +import logging + +from fastapi import APIRouter, Depends, Path, Query +from sqlalchemy.orm import Session + +from app.api.deps import get_current_admin_api, require_module_access +from app.core.database import get_db +from app.services.admin_subscription_service import admin_subscription_service +from app.services.subscription_service import subscription_service +from models.database.user import User +from models.schema.billing import ( + BillingHistoryListResponse, + BillingHistoryWithVendor, + SubscriptionStatsResponse, + SubscriptionTierCreate, + SubscriptionTierListResponse, + SubscriptionTierResponse, + SubscriptionTierUpdate, + VendorSubscriptionCreate, + VendorSubscriptionListResponse, + VendorSubscriptionResponse, + VendorSubscriptionUpdate, + VendorSubscriptionWithVendor, +) + +logger = logging.getLogger(__name__) + +# Admin router with module access control +admin_router = APIRouter( + prefix="/subscriptions", + dependencies=[Depends(require_module_access("billing"))], +) + + +# ============================================================================ +# Subscription Tier Endpoints +# ============================================================================ + + +@admin_router.get("/tiers", response_model=SubscriptionTierListResponse) +def list_subscription_tiers( + include_inactive: bool = Query(False, description="Include inactive tiers"), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """ + List all subscription tiers. + + Returns all tiers with their limits, features, and Stripe configuration. + """ + tiers = admin_subscription_service.get_tiers(db, include_inactive=include_inactive) + + return SubscriptionTierListResponse( + tiers=[SubscriptionTierResponse.model_validate(t) for t in tiers], + total=len(tiers), + ) + + +@admin_router.get("/tiers/{tier_code}", response_model=SubscriptionTierResponse) +def get_subscription_tier( + tier_code: str = Path(..., description="Tier code"), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Get a specific subscription tier by code.""" + tier = admin_subscription_service.get_tier_by_code(db, tier_code) + return SubscriptionTierResponse.model_validate(tier) + + +@admin_router.post("/tiers", response_model=SubscriptionTierResponse, status_code=201) +def create_subscription_tier( + tier_data: SubscriptionTierCreate, + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Create a new subscription tier.""" + tier = admin_subscription_service.create_tier(db, tier_data.model_dump()) + db.commit() + db.refresh(tier) + return SubscriptionTierResponse.model_validate(tier) + + +@admin_router.patch("/tiers/{tier_code}", response_model=SubscriptionTierResponse) +def update_subscription_tier( + tier_data: SubscriptionTierUpdate, + tier_code: str = Path(..., description="Tier code"), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Update a subscription tier.""" + update_data = tier_data.model_dump(exclude_unset=True) + tier = admin_subscription_service.update_tier(db, tier_code, update_data) + db.commit() + db.refresh(tier) + return SubscriptionTierResponse.model_validate(tier) + + +@admin_router.delete("/tiers/{tier_code}", status_code=204) +def delete_subscription_tier( + tier_code: str = Path(..., description="Tier code"), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """ + Soft-delete a subscription tier. + + Sets is_active=False rather than deleting to preserve history. + """ + admin_subscription_service.deactivate_tier(db, tier_code) + db.commit() + + +# ============================================================================ +# Vendor Subscription Endpoints +# ============================================================================ + + +@admin_router.get("", response_model=VendorSubscriptionListResponse) +def list_vendor_subscriptions( + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=100), + status: str | None = Query(None, description="Filter by status"), + tier: str | None = Query(None, description="Filter by tier"), + search: str | None = Query(None, description="Search vendor name"), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """ + List all vendor subscriptions with filtering. + + Includes vendor information for each subscription. + """ + data = admin_subscription_service.list_subscriptions( + db, page=page, per_page=per_page, status=status, tier=tier, search=search + ) + + subscriptions = [] + for sub, vendor in data["results"]: + sub_dict = { + **VendorSubscriptionResponse.model_validate(sub).model_dump(), + "vendor_name": vendor.name, + "vendor_code": vendor.subdomain, + } + subscriptions.append(VendorSubscriptionWithVendor(**sub_dict)) + + return VendorSubscriptionListResponse( + subscriptions=subscriptions, + total=data["total"], + page=data["page"], + per_page=data["per_page"], + pages=data["pages"], + ) + + +# ============================================================================ +# Statistics Endpoints +# ============================================================================ + + +@admin_router.get("/stats", response_model=SubscriptionStatsResponse) +def get_subscription_stats( + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Get subscription statistics for admin dashboard.""" + stats = admin_subscription_service.get_stats(db) + return SubscriptionStatsResponse(**stats) + + +# ============================================================================ +# Billing History Endpoints +# ============================================================================ + + +@admin_router.get("/billing/history", response_model=BillingHistoryListResponse) +def list_billing_history( + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=100), + vendor_id: int | None = Query(None, description="Filter by vendor"), + status: str | None = Query(None, description="Filter by status"), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """List billing history (invoices) across all vendors.""" + data = admin_subscription_service.list_billing_history( + db, page=page, per_page=per_page, vendor_id=vendor_id, status=status + ) + + invoices = [] + for invoice, vendor in data["results"]: + invoice_dict = { + "id": invoice.id, + "vendor_id": invoice.vendor_id, + "stripe_invoice_id": invoice.stripe_invoice_id, + "invoice_number": invoice.invoice_number, + "invoice_date": invoice.invoice_date, + "due_date": invoice.due_date, + "subtotal_cents": invoice.subtotal_cents, + "tax_cents": invoice.tax_cents, + "total_cents": invoice.total_cents, + "amount_paid_cents": invoice.amount_paid_cents, + "currency": invoice.currency, + "status": invoice.status, + "invoice_pdf_url": invoice.invoice_pdf_url, + "hosted_invoice_url": invoice.hosted_invoice_url, + "description": invoice.description, + "created_at": invoice.created_at, + "vendor_name": vendor.name, + "vendor_code": vendor.subdomain, + } + invoices.append(BillingHistoryWithVendor(**invoice_dict)) + + return BillingHistoryListResponse( + invoices=invoices, + total=data["total"], + page=data["page"], + per_page=data["per_page"], + pages=data["pages"], + ) + + +# ============================================================================ +# Vendor Subscription Detail Endpoints +# ============================================================================ + + +@admin_router.post("/{vendor_id}", response_model=VendorSubscriptionWithVendor, status_code=201) +def create_vendor_subscription( + create_data: VendorSubscriptionCreate, + vendor_id: int = Path(..., description="Vendor ID"), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """ + Create a subscription for a vendor. + + Creates a new subscription with the specified tier and status. + Defaults to Essential tier with trial status. + """ + # Verify vendor exists + vendor = admin_subscription_service.get_vendor(db, vendor_id) + + # Create subscription using the subscription service + sub = subscription_service.get_or_create_subscription( + db, + vendor_id=vendor_id, + tier=create_data.tier, + trial_days=create_data.trial_days, + ) + + # Update status if not trial + if create_data.status != "trial": + sub.status = create_data.status + + sub.is_annual = create_data.is_annual + + db.commit() + db.refresh(sub) + + # Get usage counts + usage = admin_subscription_service.get_vendor_usage_counts(db, vendor_id) + + logger.info(f"Admin created subscription for vendor {vendor_id}: tier={create_data.tier}") + + return VendorSubscriptionWithVendor( + **VendorSubscriptionResponse.model_validate(sub).model_dump(), + vendor_name=vendor.name, + vendor_code=vendor.subdomain, + products_count=usage["products_count"], + team_count=usage["team_count"], + ) + + +@admin_router.get("/{vendor_id}", response_model=VendorSubscriptionWithVendor) +def get_vendor_subscription( + vendor_id: int = Path(..., description="Vendor ID"), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Get subscription details for a specific vendor.""" + sub, vendor = admin_subscription_service.get_subscription(db, vendor_id) + + # Get usage counts + usage = admin_subscription_service.get_vendor_usage_counts(db, vendor_id) + + return VendorSubscriptionWithVendor( + **VendorSubscriptionResponse.model_validate(sub).model_dump(), + vendor_name=vendor.name, + vendor_code=vendor.subdomain, + products_count=usage["products_count"], + team_count=usage["team_count"], + ) + + +@admin_router.patch("/{vendor_id}", response_model=VendorSubscriptionWithVendor) +def update_vendor_subscription( + update_data: VendorSubscriptionUpdate, + vendor_id: int = Path(..., description="Vendor ID"), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """ + Update a vendor's subscription. + + Allows admins to: + - Change tier + - Update status + - Set custom limit overrides + - Extend trial period + """ + data = update_data.model_dump(exclude_unset=True) + sub, vendor = admin_subscription_service.update_subscription(db, vendor_id, data) + db.commit() + db.refresh(sub) + + # Get usage counts + usage = admin_subscription_service.get_vendor_usage_counts(db, vendor_id) + + return VendorSubscriptionWithVendor( + **VendorSubscriptionResponse.model_validate(sub).model_dump(), + vendor_name=vendor.name, + vendor_code=vendor.subdomain, + products_count=usage["products_count"], + team_count=usage["team_count"], + ) diff --git a/app/modules/billing/routes/vendor.py b/app/modules/billing/routes/vendor.py new file mode 100644 index 00000000..0ab4405b --- /dev/null +++ b/app/modules/billing/routes/vendor.py @@ -0,0 +1,216 @@ +# app/modules/billing/routes/vendor.py +""" +Billing module vendor routes. + +This module wraps the existing vendor billing routes and adds +module-based access control. The actual route implementations remain +in app/api/v1/vendor/billing.py for now, but are accessed through +this module-aware router. + +Future: Move all route implementations here for full module isolation. +""" + +import logging + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.api.deps import get_current_vendor_api, require_module_access +from app.core.config import settings +from app.core.database import get_db +from app.services.billing_service import billing_service +from app.services.subscription_service import subscription_service +from models.database.user import User + +logger = logging.getLogger(__name__) + +# Vendor router with module access control +vendor_router = APIRouter( + prefix="/billing", + dependencies=[Depends(require_module_access("billing"))], +) + + +# ============================================================================ +# Schemas (re-exported from original module) +# ============================================================================ + + +class SubscriptionStatusResponse(BaseModel): + """Current subscription status and usage.""" + + tier_code: str + tier_name: str + status: str + is_trial: bool + trial_ends_at: str | None = None + period_start: str | None = None + period_end: str | None = None + cancelled_at: str | None = None + cancellation_reason: str | None = None + + # Usage + 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 + + # Payment + has_payment_method: bool + last_payment_error: str | None = None + + class Config: + from_attributes = True + + +class TierResponse(BaseModel): + """Subscription tier information.""" + + code: str + name: str + description: str | None = None + price_monthly_cents: int + price_annual_cents: int | None = None + orders_per_month: int | None = None + products_limit: int | None = None + team_members: int | None = None + features: list[str] = [] + is_current: bool = False + can_upgrade: bool = False + can_downgrade: bool = False + + +class TierListResponse(BaseModel): + """List of available tiers.""" + + tiers: list[TierResponse] + current_tier: str + + +class InvoiceResponse(BaseModel): + """Invoice information.""" + + id: int + invoice_number: str | None = None + invoice_date: str + due_date: str | None = None + total_cents: int + amount_paid_cents: int + currency: str + status: str + pdf_url: str | None = None + hosted_url: str | None = None + + +class InvoiceListResponse(BaseModel): + """List of invoices.""" + + invoices: list[InvoiceResponse] + total: int + + +# ============================================================================ +# Core Billing Endpoints +# ============================================================================ + + +@vendor_router.get("/subscription", response_model=SubscriptionStatusResponse) +def get_subscription_status( + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """Get current subscription status and usage metrics.""" + vendor_id = current_user.token_vendor_id + + usage = subscription_service.get_usage_summary(db, vendor_id) + subscription, tier = billing_service.get_subscription_with_tier(db, vendor_id) + + return SubscriptionStatusResponse( + tier_code=subscription.tier, + tier_name=tier.name if tier else subscription.tier.title(), + status=subscription.status.value, + is_trial=subscription.is_in_trial(), + trial_ends_at=subscription.trial_ends_at.isoformat() + if subscription.trial_ends_at + else None, + period_start=subscription.period_start.isoformat() + if subscription.period_start + else None, + period_end=subscription.period_end.isoformat() + if subscription.period_end + else None, + cancelled_at=subscription.cancelled_at.isoformat() + if subscription.cancelled_at + else None, + cancellation_reason=subscription.cancellation_reason, + orders_this_period=usage.orders_this_period, + orders_limit=usage.orders_limit, + orders_remaining=usage.orders_remaining, + products_count=usage.products_count, + products_limit=usage.products_limit, + products_remaining=usage.products_remaining, + team_count=usage.team_count, + team_limit=usage.team_limit, + team_remaining=usage.team_remaining, + has_payment_method=bool(subscription.stripe_payment_method_id), + last_payment_error=subscription.last_payment_error, + ) + + +@vendor_router.get("/tiers", response_model=TierListResponse) +def get_available_tiers( + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """Get available subscription tiers for upgrade/downgrade.""" + vendor_id = current_user.token_vendor_id + subscription = subscription_service.get_or_create_subscription(db, vendor_id) + current_tier = subscription.tier + + tier_list, _ = billing_service.get_available_tiers(db, current_tier) + + tier_responses = [TierResponse(**tier_data) for tier_data in tier_list] + + return TierListResponse(tiers=tier_responses, current_tier=current_tier) + + +@vendor_router.get("/invoices", response_model=InvoiceListResponse) +def get_invoices( + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """Get invoice history.""" + vendor_id = current_user.token_vendor_id + + invoices, total = billing_service.get_invoices(db, vendor_id, skip=skip, limit=limit) + + invoice_responses = [ + InvoiceResponse( + id=inv.id, + invoice_number=inv.invoice_number, + invoice_date=inv.invoice_date.isoformat(), + due_date=inv.due_date.isoformat() if inv.due_date else None, + total_cents=inv.total_cents, + amount_paid_cents=inv.amount_paid_cents, + currency=inv.currency, + status=inv.status, + pdf_url=inv.invoice_pdf_url, + hosted_url=inv.hosted_invoice_url, + ) + for inv in invoices + ] + + return InvoiceListResponse(invoices=invoice_responses, total=total) + + +# NOTE: Additional endpoints (checkout, portal, cancel, addons, etc.) +# are still handled by app/api/v1/vendor/billing.py for now. +# They can be migrated here as part of a larger refactoring effort. diff --git a/app/modules/registry.py b/app/modules/registry.py index 7275b565..c206481a 100644 --- a/app/modules/registry.py +++ b/app/modules/registry.py @@ -7,11 +7,21 @@ enabled/disabled per platform. Core modules cannot be disabled. Module Granularity (Medium - ~12 modules): Matches menu sections for intuitive mapping between modules and UI. + +Module Structure: +- Inline modules: Defined directly in this file (core, platform-admin, etc.) +- Extracted modules: Imported from app/modules/{module}/ (billing, etc.) + +As modules are extracted to their own directories, they are imported +here and their inline definitions are replaced. """ from app.modules.base import ModuleDefinition from models.database.admin_menu_config import FrontendType +# Import extracted modules +from app.modules.billing.definition import billing_module + # ============================================================================= # Module Definitions @@ -72,28 +82,8 @@ MODULES: dict[str, ModuleDefinition] = { # ========================================================================= # Optional Modules # ========================================================================= - "billing": ModuleDefinition( - code="billing", - name="Billing & Subscriptions", - description="Subscription tiers, billing history, and payment processing.", - features=[ - "subscription_management", - "billing_history", - "stripe_integration", - "invoice_generation", - ], - menu_items={ - FrontendType.ADMIN: [ - "subscription-tiers", - "subscriptions", - "billing-history", - ], - FrontendType.VENDOR: [ - "billing", - "invoices", - ], - }, - ), + # Billing module - imported from app/modules/billing/ + "billing": billing_module, "inventory": ModuleDefinition( code="inventory", name="Inventory Management",