refactor: migrate vendor billing, invoices, payments to module auto-discovery

Billing module:
- Create vendor_checkout.py (checkout, portal, cancel, reactivate, change-tier)
- Create vendor_addons.py (addon management routes)
- Update vendor.py to aggregate new routers

Orders module:
- Create vendor_invoices.py (invoice settings, CRUD, PDF generation)
- Update vendor.py to aggregate invoices router

Payments module:
- Restructure routes from routes/ to routes/api/
- Add require_module_access dependency
- Set is_self_contained=True for auto-discovery

Remove legacy files:
- app/api/v1/vendor/billing.py
- app/api/v1/vendor/invoices.py
- app/api/v1/vendor/payments.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 13:49:58 +01:00
parent 6f278131a3
commit e2cecff014
13 changed files with 495 additions and 614 deletions

View File

@@ -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

View File

@@ -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"],
)

View File

@@ -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"])

View File

@@ -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"],
)

View File

@@ -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"],
)

View File

@@ -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"])

View File

@@ -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),

View File

@@ -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/
)

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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")

View File

@@ -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,

View File

@@ -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}