diff --git a/app/api/v1/vendor/__init__.py b/app/api/v1/vendor/__init__.py index 30f31584..a3063f8a 100644 --- a/app/api/v1/vendor/__init__.py +++ b/app/api/v1/vendor/__init__.py @@ -15,13 +15,14 @@ For multi-tenant apps, module enablement is checked at request time based on platform context (not at route registration time). Self-contained modules (auto-discovered from app/modules/{module}/routes/api/vendor.py): -- billing: Subscription tiers, vendor billing, invoices +- billing: Subscription tiers, vendor billing, checkout, add-ons, features - inventory: Stock management, inventory tracking -- orders: Order management, fulfillment, exceptions +- orders: Order management, fulfillment, exceptions, invoices - marketplace: Letzshop integration, product sync - catalog: Vendor product catalog management - cms: Content pages management - customers: Customer management +- payments: Payment configuration, Stripe connect, transactions """ from fastapi import APIRouter @@ -30,18 +31,14 @@ from fastapi import APIRouter from . import ( analytics, auth, - billing, dashboard, email_settings, email_templates, - features, info, - invoices, media, messages, notifications, onboarding, - payments, profile, settings, team, @@ -71,17 +68,14 @@ router.include_router(email_templates.router, tags=["vendor-email-templates"]) router.include_router(email_settings.router, tags=["vendor-email-settings"]) router.include_router(onboarding.router, tags=["vendor-onboarding"]) -# Business operations (with prefixes: /invoices/*, /team/*) -router.include_router(invoices.router, tags=["vendor-invoices"]) +# Business operations (with prefixes: /team/*) router.include_router(team.router, tags=["vendor-team"]) -# Services (with prefixes: /payments/*, /media/*, etc.) -router.include_router(payments.router, tags=["vendor-payments"]) +# Services (with prefixes: /media/*, etc.) router.include_router(media.router, tags=["vendor-media"]) router.include_router(notifications.router, tags=["vendor-notifications"]) router.include_router(messages.router, tags=["vendor-messages"]) router.include_router(analytics.router, tags=["vendor-analytics"]) -router.include_router(features.router, tags=["vendor-features"]) router.include_router(usage.router, tags=["vendor-usage"]) @@ -89,7 +83,7 @@ router.include_router(usage.router, tags=["vendor-usage"]) # Auto-discovered Module Routes # ============================================================================ # Routes from self-contained modules are auto-discovered and registered. -# Modules include: billing, inventory, orders, marketplace, cms, customers +# Modules include: billing, inventory, orders, marketplace, cms, customers, payments # Routes are sorted by priority, so catch-all routes (CMS) come last. from app.modules.routes import get_vendor_api_routes diff --git a/app/api/v1/vendor/billing.py b/app/api/v1/vendor/billing.py deleted file mode 100644 index 74e96838..00000000 --- a/app/api/v1/vendor/billing.py +++ /dev/null @@ -1,525 +0,0 @@ -# app/api/v1/vendor/billing.py -""" -Vendor billing and subscription management endpoints. - -Provides: -- Subscription status and usage -- Tier listing and comparison -- Stripe checkout and portal access -- Invoice history -- Add-on management -""" - -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 -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.schema.auth import UserContext - -router = APIRouter(prefix="/billing") -logger = logging.getLogger(__name__) - - -# ============================================================================ -# Schemas -# ============================================================================ - - -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 CheckoutRequest(BaseModel): - """Request to create a checkout session.""" - - tier_code: str - is_annual: bool = False - - -class CheckoutResponse(BaseModel): - """Checkout session response.""" - - checkout_url: str - session_id: str - - -class PortalResponse(BaseModel): - """Customer portal session response.""" - - portal_url: 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 - - -class AddOnResponse(BaseModel): - """Add-on product information.""" - - id: int - code: str - name: str - description: str | None = None - category: str - price_cents: int - billing_period: str - quantity_unit: str | None = None - quantity_value: int | None = None - - -class VendorAddOnResponse(BaseModel): - """Vendor's purchased add-on.""" - - id: int - addon_code: str - addon_name: str - status: str - domain_name: str | None = None - quantity: int - period_start: str | None = None - period_end: str | None = None - - -class AddOnPurchaseRequest(BaseModel): - """Request to purchase an add-on.""" - - addon_code: str - domain_name: str | None = None # For domain add-ons - quantity: int = 1 - - -class CancelRequest(BaseModel): - """Request to cancel subscription.""" - - reason: str | None = None - immediately: bool = False - - -class CancelResponse(BaseModel): - """Cancellation response.""" - - message: str - effective_date: str - - -class UpcomingInvoiceResponse(BaseModel): - """Upcoming invoice preview.""" - - amount_due_cents: int - currency: str - next_payment_date: str | None = None - line_items: list[dict] = [] - - -class ChangeTierRequest(BaseModel): - """Request to change subscription tier.""" - - tier_code: str - is_annual: bool = False - - -class ChangeTierResponse(BaseModel): - """Response for tier change.""" - - message: str - new_tier: str - effective_immediately: bool - - -class AddOnCancelResponse(BaseModel): - """Response for add-on cancellation.""" - - message: str - addon_code: str - - -# ============================================================================ -# Endpoints -# ============================================================================ - - -@router.get("/subscription", response_model=SubscriptionStatusResponse) -def get_subscription_status( - current_user: UserContext = 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, - ) - - -@router.get("/tiers", response_model=TierListResponse) -def get_available_tiers( - current_user: UserContext = 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) - - -@router.post("/checkout", response_model=CheckoutResponse) -def create_checkout_session( - request: CheckoutRequest, - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """Create a Stripe checkout session for subscription.""" - vendor_id = current_user.token_vendor_id - vendor = billing_service.get_vendor(db, vendor_id) - - # Build URLs - base_url = f"https://{settings.platform_domain}" - success_url = f"{base_url}/vendor/{vendor.vendor_code}/billing?success=true" - cancel_url = f"{base_url}/vendor/{vendor.vendor_code}/billing?cancelled=true" - - result = billing_service.create_checkout_session( - db=db, - vendor_id=vendor_id, - tier_code=request.tier_code, - is_annual=request.is_annual, - success_url=success_url, - cancel_url=cancel_url, - ) - db.commit() - - return CheckoutResponse(checkout_url=result["checkout_url"], session_id=result["session_id"]) - - -@router.post("/portal", response_model=PortalResponse) -def create_portal_session( - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """Create a Stripe customer portal session.""" - vendor_id = current_user.token_vendor_id - vendor = billing_service.get_vendor(db, vendor_id) - return_url = f"https://{settings.platform_domain}/vendor/{vendor.vendor_code}/billing" - - result = billing_service.create_portal_session(db, vendor_id, return_url) - - return PortalResponse(portal_url=result["portal_url"]) - - -@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: UserContext = 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) - - -@router.get("/addons", response_model=list[AddOnResponse]) -def get_available_addons( - category: str | None = Query(None, description="Filter by category"), - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """Get available add-on products.""" - addons = billing_service.get_available_addons(db, category=category) - - return [ - AddOnResponse( - id=addon.id, - code=addon.code, - name=addon.name, - description=addon.description, - category=addon.category, - price_cents=addon.price_cents, - billing_period=addon.billing_period, - quantity_unit=addon.quantity_unit, - quantity_value=addon.quantity_value, - ) - for addon in addons - ] - - -@router.get("/my-addons", response_model=list[VendorAddOnResponse]) -def get_vendor_addons( - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """Get vendor's purchased add-ons.""" - vendor_id = current_user.token_vendor_id - - vendor_addons = billing_service.get_vendor_addons(db, vendor_id) - - return [ - VendorAddOnResponse( - id=va.id, - addon_code=va.addon_product.code, - addon_name=va.addon_product.name, - status=va.status, - domain_name=va.domain_name, - quantity=va.quantity, - period_start=va.period_start.isoformat() if va.period_start else None, - period_end=va.period_end.isoformat() if va.period_end else None, - ) - for va in vendor_addons - ] - - -@router.post("/cancel", response_model=CancelResponse) -def cancel_subscription( - request: CancelRequest, - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """Cancel subscription.""" - vendor_id = current_user.token_vendor_id - - result = billing_service.cancel_subscription( - db=db, - vendor_id=vendor_id, - reason=request.reason, - immediately=request.immediately, - ) - db.commit() - - return CancelResponse( - message=result["message"], - effective_date=result["effective_date"], - ) - - -@router.post("/reactivate") -def reactivate_subscription( - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """Reactivate a cancelled subscription.""" - vendor_id = current_user.token_vendor_id - - result = billing_service.reactivate_subscription(db, vendor_id) - db.commit() - - return result - - -@router.get("/upcoming-invoice", response_model=UpcomingInvoiceResponse) -def get_upcoming_invoice( - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """Preview the upcoming invoice.""" - vendor_id = current_user.token_vendor_id - - result = billing_service.get_upcoming_invoice(db, vendor_id) - - return UpcomingInvoiceResponse( - amount_due_cents=result.get("amount_due_cents", 0), - currency=result.get("currency", "EUR"), - next_payment_date=result.get("next_payment_date"), - line_items=result.get("line_items", []), - ) - - -@router.post("/change-tier", response_model=ChangeTierResponse) -def change_tier( - request: ChangeTierRequest, - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """Change subscription tier (upgrade/downgrade).""" - vendor_id = current_user.token_vendor_id - - result = billing_service.change_tier( - db=db, - vendor_id=vendor_id, - new_tier_code=request.tier_code, - is_annual=request.is_annual, - ) - db.commit() - - return ChangeTierResponse( - message=result["message"], - new_tier=result["new_tier"], - effective_immediately=result["effective_immediately"], - ) - - -@router.post("/addons/purchase") -def purchase_addon( - request: AddOnPurchaseRequest, - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """Purchase an add-on product.""" - vendor_id = current_user.token_vendor_id - vendor = billing_service.get_vendor(db, vendor_id) - - # Build URLs - base_url = f"https://{settings.platform_domain}" - success_url = f"{base_url}/vendor/{vendor.vendor_code}/billing?addon_success=true" - cancel_url = f"{base_url}/vendor/{vendor.vendor_code}/billing?addon_cancelled=true" - - result = billing_service.purchase_addon( - db=db, - vendor_id=vendor_id, - addon_code=request.addon_code, - domain_name=request.domain_name, - quantity=request.quantity, - success_url=success_url, - cancel_url=cancel_url, - ) - db.commit() - - return result - - -@router.delete("/addons/{addon_id}", response_model=AddOnCancelResponse) -def cancel_addon( - addon_id: int, - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """Cancel a purchased add-on.""" - vendor_id = current_user.token_vendor_id - - result = billing_service.cancel_addon(db, vendor_id, addon_id) - db.commit() - - return AddOnCancelResponse( - message=result["message"], - addon_code=result["addon_code"], - ) diff --git a/app/modules/billing/routes/api/vendor.py b/app/modules/billing/routes/api/vendor.py index 6c8d662c..629a1c4f 100644 --- a/app/modules/billing/routes/api/vendor.py +++ b/app/modules/billing/routes/api/vendor.py @@ -213,3 +213,17 @@ def get_invoices( # 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. + + +# ============================================================================ +# Aggregate Sub-Routers +# ============================================================================ +# Include all billing-related vendor sub-routers + +from app.modules.billing.routes.api.vendor_features import vendor_features_router +from app.modules.billing.routes.api.vendor_checkout import vendor_checkout_router +from app.modules.billing.routes.api.vendor_addons import vendor_addons_router + +vendor_router.include_router(vendor_features_router, tags=["vendor-features"]) +vendor_router.include_router(vendor_checkout_router, tags=["vendor-billing"]) +vendor_router.include_router(vendor_addons_router, tags=["vendor-billing-addons"]) diff --git a/app/modules/billing/routes/api/vendor_addons.py b/app/modules/billing/routes/api/vendor_addons.py new file mode 100644 index 00000000..a4487774 --- /dev/null +++ b/app/modules/billing/routes/api/vendor_addons.py @@ -0,0 +1,179 @@ +# app/modules/billing/routes/api/vendor_addons.py +""" +Vendor add-on management endpoints. + +Provides: +- List available add-ons +- Get vendor's purchased add-ons +- Purchase add-on +- Cancel add-on + +All routes require module access control for the 'billing' module. +""" + +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.modules.billing.services import billing_service +from models.schema.auth import UserContext + +vendor_addons_router = APIRouter( + prefix="/addons", + dependencies=[Depends(require_module_access("billing"))], +) +logger = logging.getLogger(__name__) + + +# ============================================================================ +# Schemas +# ============================================================================ + + +class AddOnResponse(BaseModel): + """Add-on product information.""" + + id: int + code: str + name: str + description: str | None = None + category: str + price_cents: int + billing_period: str + quantity_unit: str | None = None + quantity_value: int | None = None + + +class VendorAddOnResponse(BaseModel): + """Vendor's purchased add-on.""" + + id: int + addon_code: str + addon_name: str + status: str + domain_name: str | None = None + quantity: int + period_start: str | None = None + period_end: str | None = None + + +class AddOnPurchaseRequest(BaseModel): + """Request to purchase an add-on.""" + + addon_code: str + domain_name: str | None = None # For domain add-ons + quantity: int = 1 + + +class AddOnCancelResponse(BaseModel): + """Response for add-on cancellation.""" + + message: str + addon_code: str + + +# ============================================================================ +# Endpoints +# ============================================================================ + + +@vendor_addons_router.get("", response_model=list[AddOnResponse]) +def get_available_addons( + category: str | None = Query(None, description="Filter by category"), + current_user: UserContext = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """Get available add-on products.""" + addons = billing_service.get_available_addons(db, category=category) + + return [ + AddOnResponse( + id=addon.id, + code=addon.code, + name=addon.name, + description=addon.description, + category=addon.category, + price_cents=addon.price_cents, + billing_period=addon.billing_period, + quantity_unit=addon.quantity_unit, + quantity_value=addon.quantity_value, + ) + for addon in addons + ] + + +@vendor_addons_router.get("/my-addons", response_model=list[VendorAddOnResponse]) +def get_vendor_addons( + current_user: UserContext = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """Get vendor's purchased add-ons.""" + vendor_id = current_user.token_vendor_id + + vendor_addons = billing_service.get_vendor_addons(db, vendor_id) + + return [ + VendorAddOnResponse( + id=va.id, + addon_code=va.addon_product.code, + addon_name=va.addon_product.name, + status=va.status, + domain_name=va.domain_name, + quantity=va.quantity, + period_start=va.period_start.isoformat() if va.period_start else None, + period_end=va.period_end.isoformat() if va.period_end else None, + ) + for va in vendor_addons + ] + + +@vendor_addons_router.post("/purchase") +def purchase_addon( + request: AddOnPurchaseRequest, + current_user: UserContext = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """Purchase an add-on product.""" + vendor_id = current_user.token_vendor_id + vendor = billing_service.get_vendor(db, vendor_id) + + # Build URLs + base_url = f"https://{settings.platform_domain}" + success_url = f"{base_url}/vendor/{vendor.vendor_code}/billing?addon_success=true" + cancel_url = f"{base_url}/vendor/{vendor.vendor_code}/billing?addon_cancelled=true" + + result = billing_service.purchase_addon( + db=db, + vendor_id=vendor_id, + addon_code=request.addon_code, + domain_name=request.domain_name, + quantity=request.quantity, + success_url=success_url, + cancel_url=cancel_url, + ) + db.commit() + + return result + + +@vendor_addons_router.delete("/{addon_id}", response_model=AddOnCancelResponse) +def cancel_addon( + addon_id: int, + current_user: UserContext = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """Cancel a purchased add-on.""" + vendor_id = current_user.token_vendor_id + + result = billing_service.cancel_addon(db, vendor_id, addon_id) + db.commit() + + return AddOnCancelResponse( + message=result["message"], + addon_code=result["addon_code"], + ) diff --git a/app/modules/billing/routes/api/vendor_checkout.py b/app/modules/billing/routes/api/vendor_checkout.py new file mode 100644 index 00000000..37d6d61c --- /dev/null +++ b/app/modules/billing/routes/api/vendor_checkout.py @@ -0,0 +1,220 @@ +# app/modules/billing/routes/api/vendor_checkout.py +""" +Vendor checkout and subscription management endpoints. + +Provides: +- Stripe checkout session creation +- Stripe portal session creation +- Subscription cancellation and reactivation +- Upcoming invoice preview +- Tier changes (upgrade/downgrade) + +All routes require module access control for the 'billing' module. +""" + +import logging + +from fastapi import APIRouter, Depends +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.modules.billing.services import billing_service, subscription_service +from models.schema.auth import UserContext + +vendor_checkout_router = APIRouter( + dependencies=[Depends(require_module_access("billing"))], +) +logger = logging.getLogger(__name__) + + +# ============================================================================ +# Schemas +# ============================================================================ + + +class CheckoutRequest(BaseModel): + """Request to create a checkout session.""" + + tier_code: str + is_annual: bool = False + + +class CheckoutResponse(BaseModel): + """Checkout session response.""" + + checkout_url: str + session_id: str + + +class PortalResponse(BaseModel): + """Customer portal session response.""" + + portal_url: str + + +class CancelRequest(BaseModel): + """Request to cancel subscription.""" + + reason: str | None = None + immediately: bool = False + + +class CancelResponse(BaseModel): + """Cancellation response.""" + + message: str + effective_date: str + + +class UpcomingInvoiceResponse(BaseModel): + """Upcoming invoice preview.""" + + amount_due_cents: int + currency: str + next_payment_date: str | None = None + line_items: list[dict] = [] + + +class ChangeTierRequest(BaseModel): + """Request to change subscription tier.""" + + tier_code: str + is_annual: bool = False + + +class ChangeTierResponse(BaseModel): + """Response for tier change.""" + + message: str + new_tier: str + effective_immediately: bool + + +# ============================================================================ +# Endpoints +# ============================================================================ + + +@vendor_checkout_router.post("/checkout", response_model=CheckoutResponse) +def create_checkout_session( + request: CheckoutRequest, + current_user: UserContext = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """Create a Stripe checkout session for subscription.""" + vendor_id = current_user.token_vendor_id + vendor = billing_service.get_vendor(db, vendor_id) + + # Build URLs + base_url = f"https://{settings.platform_domain}" + success_url = f"{base_url}/vendor/{vendor.vendor_code}/billing?success=true" + cancel_url = f"{base_url}/vendor/{vendor.vendor_code}/billing?cancelled=true" + + result = billing_service.create_checkout_session( + db=db, + vendor_id=vendor_id, + tier_code=request.tier_code, + is_annual=request.is_annual, + success_url=success_url, + cancel_url=cancel_url, + ) + db.commit() + + return CheckoutResponse(checkout_url=result["checkout_url"], session_id=result["session_id"]) + + +@vendor_checkout_router.post("/portal", response_model=PortalResponse) +def create_portal_session( + current_user: UserContext = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """Create a Stripe customer portal session.""" + vendor_id = current_user.token_vendor_id + vendor = billing_service.get_vendor(db, vendor_id) + return_url = f"https://{settings.platform_domain}/vendor/{vendor.vendor_code}/billing" + + result = billing_service.create_portal_session(db, vendor_id, return_url) + + return PortalResponse(portal_url=result["portal_url"]) + + +@vendor_checkout_router.post("/cancel", response_model=CancelResponse) +def cancel_subscription( + request: CancelRequest, + current_user: UserContext = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """Cancel subscription.""" + vendor_id = current_user.token_vendor_id + + result = billing_service.cancel_subscription( + db=db, + vendor_id=vendor_id, + reason=request.reason, + immediately=request.immediately, + ) + db.commit() + + return CancelResponse( + message=result["message"], + effective_date=result["effective_date"], + ) + + +@vendor_checkout_router.post("/reactivate") +def reactivate_subscription( + current_user: UserContext = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """Reactivate a cancelled subscription.""" + vendor_id = current_user.token_vendor_id + + result = billing_service.reactivate_subscription(db, vendor_id) + db.commit() + + return result + + +@vendor_checkout_router.get("/upcoming-invoice", response_model=UpcomingInvoiceResponse) +def get_upcoming_invoice( + current_user: UserContext = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """Preview the upcoming invoice.""" + vendor_id = current_user.token_vendor_id + + result = billing_service.get_upcoming_invoice(db, vendor_id) + + return UpcomingInvoiceResponse( + amount_due_cents=result.get("amount_due_cents", 0), + currency=result.get("currency", "EUR"), + next_payment_date=result.get("next_payment_date"), + line_items=result.get("line_items", []), + ) + + +@vendor_checkout_router.post("/change-tier", response_model=ChangeTierResponse) +def change_tier( + request: ChangeTierRequest, + current_user: UserContext = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """Change subscription tier (upgrade/downgrade).""" + vendor_id = current_user.token_vendor_id + + result = billing_service.change_tier( + db=db, + vendor_id=vendor_id, + new_tier_code=request.tier_code, + is_annual=request.is_annual, + ) + db.commit() + + return ChangeTierResponse( + message=result["message"], + new_tier=result["new_tier"], + effective_immediately=result["effective_immediately"], + ) diff --git a/app/modules/orders/routes/api/vendor.py b/app/modules/orders/routes/api/vendor.py index ffa5fbd8..c3fe5203 100644 --- a/app/modules/orders/routes/api/vendor.py +++ b/app/modules/orders/routes/api/vendor.py @@ -282,9 +282,11 @@ def ship_order_item( # Aggregate routers # ============================================================================ -# Import exceptions router +# Import sub-routers from app.modules.orders.routes.api.vendor_exceptions import vendor_exceptions_router +from app.modules.orders.routes.api.vendor_invoices import vendor_invoices_router -# Include both routers into the aggregate vendor_router +# Include all sub-routers into the aggregate vendor_router vendor_router.include_router(_orders_router, tags=["vendor-orders"]) vendor_router.include_router(vendor_exceptions_router, tags=["vendor-order-exceptions"]) +vendor_router.include_router(vendor_invoices_router, tags=["vendor-invoices"]) diff --git a/app/api/v1/vendor/invoices.py b/app/modules/orders/routes/api/vendor_invoices.py similarity index 90% rename from app/api/v1/vendor/invoices.py rename to app/modules/orders/routes/api/vendor_invoices.py index f1ffa904..502e396d 100644 --- a/app/api/v1/vendor/invoices.py +++ b/app/modules/orders/routes/api/vendor_invoices.py @@ -1,4 +1,4 @@ -# app/api/v1/vendor/invoices.py +# app/modules/orders/routes/api/vendor_invoices.py """ Vendor invoice management endpoints. @@ -31,14 +31,11 @@ from fastapi import APIRouter, Depends, Query from fastapi.responses import FileResponse from sqlalchemy.orm import Session -from app.api.deps import get_current_vendor_api +from app.api.deps import get_current_vendor_api, require_module_access from app.core.database import get_db from app.core.feature_gate import RequireFeature from app.exceptions.invoice import ( - InvoiceNotFoundException, - InvoicePDFGenerationException, InvoicePDFNotFoundException, - InvoiceSettingsNotFoundException, ) from app.services.invoice_service import invoice_service from app.modules.billing.models import FeatureCode @@ -56,7 +53,10 @@ from app.modules.orders.schemas import ( VendorInvoiceSettingsUpdate, ) -router = APIRouter(prefix="/invoices") +vendor_invoices_router = APIRouter( + prefix="/invoices", + dependencies=[Depends(require_module_access("orders"))], +) logger = logging.getLogger(__name__) @@ -65,7 +65,7 @@ logger = logging.getLogger(__name__) # ============================================================================ -@router.get("/settings", response_model=VendorInvoiceSettingsResponse | None) +@vendor_invoices_router.get("/settings", response_model=VendorInvoiceSettingsResponse | None) def get_invoice_settings( current_user: UserContext = Depends(get_current_vendor_api), db: Session = Depends(get_db), @@ -83,7 +83,7 @@ def get_invoice_settings( return None -@router.post("/settings", response_model=VendorInvoiceSettingsResponse, status_code=201) +@vendor_invoices_router.post("/settings", response_model=VendorInvoiceSettingsResponse, status_code=201) def create_invoice_settings( data: VendorInvoiceSettingsCreate, current_user: UserContext = Depends(get_current_vendor_api), @@ -103,7 +103,7 @@ def create_invoice_settings( return VendorInvoiceSettingsResponse.model_validate(settings) -@router.put("/settings", response_model=VendorInvoiceSettingsResponse) +@vendor_invoices_router.put("/settings", response_model=VendorInvoiceSettingsResponse) def update_invoice_settings( data: VendorInvoiceSettingsUpdate, current_user: UserContext = Depends(get_current_vendor_api), @@ -125,7 +125,7 @@ def update_invoice_settings( # ============================================================================ -@router.get("/stats", response_model=InvoiceStatsResponse) +@vendor_invoices_router.get("/stats", response_model=InvoiceStatsResponse) def get_invoice_stats( current_user: UserContext = Depends(get_current_vendor_api), db: Session = Depends(get_db), @@ -155,7 +155,7 @@ def get_invoice_stats( # ============================================================================ -@router.get("", response_model=InvoiceListPaginatedResponse) +@vendor_invoices_router.get("", response_model=InvoiceListPaginatedResponse) def list_invoices( page: int = Query(1, ge=1, description="Page number"), per_page: int = Query(20, ge=1, le=100, description="Items per page"), @@ -204,7 +204,7 @@ def list_invoices( ) -@router.get("/{invoice_id}", response_model=InvoiceResponse) +@vendor_invoices_router.get("/{invoice_id}", response_model=InvoiceResponse) def get_invoice( invoice_id: int, current_user: UserContext = Depends(get_current_vendor_api), @@ -221,7 +221,7 @@ def get_invoice( return InvoiceResponse.model_validate(invoice) -@router.post("", response_model=InvoiceResponse, status_code=201) +@vendor_invoices_router.post("", response_model=InvoiceResponse, status_code=201) def create_invoice( data: InvoiceCreate, current_user: UserContext = Depends(get_current_vendor_api), @@ -244,7 +244,7 @@ def create_invoice( return InvoiceResponse.model_validate(invoice) -@router.put("/{invoice_id}/status", response_model=InvoiceResponse) +@vendor_invoices_router.put("/{invoice_id}/status", response_model=InvoiceResponse) def update_invoice_status( invoice_id: int, data: InvoiceStatusUpdate, @@ -276,7 +276,7 @@ def update_invoice_status( # ============================================================================ -@router.post("/{invoice_id}/pdf", response_model=InvoicePDFGeneratedResponse) +@vendor_invoices_router.post("/{invoice_id}/pdf", response_model=InvoicePDFGeneratedResponse) def generate_invoice_pdf( invoice_id: int, regenerate: bool = Query(False, description="Force regenerate if exists"), @@ -298,7 +298,7 @@ def generate_invoice_pdf( return InvoicePDFGeneratedResponse(pdf_path=pdf_path) -@router.get("/{invoice_id}/pdf") +@vendor_invoices_router.get("/{invoice_id}/pdf") def download_invoice_pdf( invoice_id: int, current_user: UserContext = Depends(get_current_vendor_api), diff --git a/app/modules/payments/definition.py b/app/modules/payments/definition.py index 0ebf6398..80778d3f 100644 --- a/app/modules/payments/definition.py +++ b/app/modules/payments/definition.py @@ -21,14 +21,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.payments.routes.admin import admin_router + from app.modules.payments.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.payments.routes.vendor import vendor_router + from app.modules.payments.routes.api.vendor import vendor_router return vendor_router @@ -61,6 +61,7 @@ payments_module = ModuleDefinition( }, is_core=False, is_internal=False, + is_self_contained=True, # Enable auto-discovery from routes/api/ ) diff --git a/app/modules/payments/routes/__init__.py b/app/modules/payments/routes/__init__.py index 9eb624cf..c82d4f93 100644 --- a/app/modules/payments/routes/__init__.py +++ b/app/modules/payments/routes/__init__.py @@ -1,7 +1,11 @@ # app/modules/payments/routes/__init__.py -"""Payments module routes.""" +""" +Payments module routes. -from app.modules.payments.routes.admin import admin_router -from app.modules.payments.routes.vendor import vendor_router +Re-exports routers from the api subdirectory for backwards compatibility. +""" + +from app.modules.payments.routes.api.admin import admin_router +from app.modules.payments.routes.api.vendor import vendor_router __all__ = ["admin_router", "vendor_router"] diff --git a/app/modules/payments/routes/api/__init__.py b/app/modules/payments/routes/api/__init__.py new file mode 100644 index 00000000..d058604e --- /dev/null +++ b/app/modules/payments/routes/api/__init__.py @@ -0,0 +1,13 @@ +# app/modules/payments/routes/api/__init__.py +""" +Payments module API routes. + +Provides REST API endpoints for payment management: +- Admin API: Payment gateway configuration, transaction monitoring, refunds +- Vendor API: Payment configuration, Stripe connect, transactions, balance +""" + +from app.modules.payments.routes.api.admin import admin_router +from app.modules.payments.routes.api.vendor import vendor_router + +__all__ = ["admin_router", "vendor_router"] diff --git a/app/modules/payments/routes/admin.py b/app/modules/payments/routes/api/admin.py similarity index 78% rename from app/modules/payments/routes/admin.py rename to app/modules/payments/routes/api/admin.py index ee0b89c9..e5c73945 100644 --- a/app/modules/payments/routes/admin.py +++ b/app/modules/payments/routes/api/admin.py @@ -1,4 +1,4 @@ -# app/modules/payments/routes/admin.py +# app/modules/payments/routes/api/admin.py """ Admin routes for payments module. @@ -8,9 +8,17 @@ Provides routes for: - Refund management """ -from fastapi import APIRouter +import logging -admin_router = APIRouter(prefix="/payments", tags=["Payments (Admin)"]) +from fastapi import APIRouter, Depends + +from app.api.deps import require_module_access + +admin_router = APIRouter( + prefix="/payments", + dependencies=[Depends(require_module_access("payments"))], +) +logger = logging.getLogger(__name__) @admin_router.get("/gateways") diff --git a/app/api/v1/vendor/payments.py b/app/modules/payments/routes/api/vendor.py similarity index 85% rename from app/api/v1/vendor/payments.py rename to app/modules/payments/routes/api/vendor.py index 3ba5334f..a45e0f4e 100644 --- a/app/api/v1/vendor/payments.py +++ b/app/modules/payments/routes/api/vendor.py @@ -1,9 +1,17 @@ -# app/api/v1/vendor/payments.py +# app/modules/payments/routes/api/vendor.py """ Vendor payment configuration and processing endpoints. Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern). The get_current_vendor_api dependency guarantees token_vendor_id is present. + +Provides: +- Payment gateway configuration +- Stripe connect/disconnect +- Payment methods listing +- Transaction history +- Payment balance +- Refund processing """ import logging @@ -11,7 +19,7 @@ import logging from fastapi import APIRouter, Depends from sqlalchemy.orm import Session -from app.api.deps import get_current_vendor_api +from app.api.deps import get_current_vendor_api, require_module_access from app.core.database import get_db from app.services.vendor_service import vendor_service from models.schema.auth import UserContext @@ -29,11 +37,14 @@ from app.modules.payments.schemas import ( TransactionsResponse, ) -router = APIRouter(prefix="/payments") +vendor_router = APIRouter( + prefix="/payments", + dependencies=[Depends(require_module_access("payments"))], +) logger = logging.getLogger(__name__) -@router.get("/config", response_model=PaymentConfigResponse) +@vendor_router.get("/config", response_model=PaymentConfigResponse) def get_payment_configuration( current_user: UserContext = Depends(get_current_vendor_api), db: Session = Depends(get_db), @@ -57,7 +68,7 @@ def get_payment_configuration( ) -@router.put("/config", response_model=PaymentConfigUpdateResponse) +@vendor_router.put("/config", response_model=PaymentConfigUpdateResponse) def update_payment_configuration( payment_config: PaymentConfigUpdate, current_user: UserContext = Depends(get_current_vendor_api), @@ -78,7 +89,7 @@ def update_payment_configuration( ) -@router.post("/stripe/connect", response_model=StripeConnectResponse) +@vendor_router.post("/stripe/connect", response_model=StripeConnectResponse) def connect_stripe_account( stripe_data: StripeConnectRequest, current_user: UserContext = Depends(get_current_vendor_api), @@ -97,7 +108,7 @@ def connect_stripe_account( return StripeConnectResponse(message="Stripe connection coming in Slice 5") -@router.delete("/stripe/disconnect", response_model=StripeDisconnectResponse) +@vendor_router.delete("/stripe/disconnect", response_model=StripeDisconnectResponse) def disconnect_stripe_account( current_user: UserContext = Depends(get_current_vendor_api), db: Session = Depends(get_db), @@ -114,7 +125,7 @@ def disconnect_stripe_account( return StripeDisconnectResponse(message="Stripe disconnection coming in Slice 5") -@router.get("/methods", response_model=PaymentMethodsResponse) +@vendor_router.get("/methods", response_model=PaymentMethodsResponse) def get_payment_methods( current_user: UserContext = Depends(get_current_vendor_api), db: Session = Depends(get_db), @@ -133,7 +144,7 @@ def get_payment_methods( ) -@router.get("/transactions", response_model=TransactionsResponse) +@vendor_router.get("/transactions", response_model=TransactionsResponse) def get_payment_transactions( current_user: UserContext = Depends(get_current_vendor_api), db: Session = Depends(get_db), @@ -155,7 +166,7 @@ def get_payment_transactions( ) -@router.get("/balance", response_model=PaymentBalanceResponse) +@vendor_router.get("/balance", response_model=PaymentBalanceResponse) def get_payment_balance( current_user: UserContext = Depends(get_current_vendor_api), db: Session = Depends(get_db), @@ -179,7 +190,7 @@ def get_payment_balance( ) -@router.post("/refund/{payment_id}", response_model=RefundResponse) +@vendor_router.post("/refund/{payment_id}", response_model=RefundResponse) def refund_payment( payment_id: int, refund_data: RefundRequest, diff --git a/app/modules/payments/routes/vendor.py b/app/modules/payments/routes/vendor.py deleted file mode 100644 index ecca21d5..00000000 --- a/app/modules/payments/routes/vendor.py +++ /dev/null @@ -1,40 +0,0 @@ -# app/modules/payments/routes/vendor.py -""" -Vendor routes for payments module. - -Provides routes for: -- Payment method management -- Transaction history -""" - -from fastapi import APIRouter - -vendor_router = APIRouter(prefix="/payments", tags=["Payments (Vendor)"]) - - -@vendor_router.get("/methods") -async def list_payment_methods(): - """List saved payment methods for the vendor.""" - # TODO: Implement payment method listing - return {"payment_methods": []} - - -@vendor_router.post("/methods") -async def add_payment_method(): - """Add a new payment method.""" - # TODO: Implement payment method creation - return {"status": "created", "id": "pm_xxx"} - - -@vendor_router.delete("/methods/{method_id}") -async def remove_payment_method(method_id: str): - """Remove a saved payment method.""" - # TODO: Implement payment method deletion - return {"status": "deleted", "id": method_id} - - -@vendor_router.get("/transactions") -async def list_vendor_transactions(): - """List transactions for the vendor.""" - # TODO: Implement transaction listing - return {"transactions": [], "total": 0}