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

@@ -1,348 +0,0 @@
# app/api/v1/vendor/invoices.py
"""
Vendor invoice management 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.
Endpoints:
- GET /invoices - List vendor invoices
- GET /invoices/{invoice_id} - Get invoice details
- POST /invoices - Create invoice from order
- PUT /invoices/{invoice_id}/status - Update invoice status
- GET /invoices/{invoice_id}/pdf - Download invoice PDF
- POST /invoices/{invoice_id}/pdf - Generate/regenerate invoice PDF
- GET /invoices/settings - Get invoice settings
- POST /invoices/settings - Create invoice settings
- PUT /invoices/settings - Update invoice settings
- GET /invoices/stats - Get invoice statistics
Feature Requirements:
- invoice_lu: Basic Luxembourg invoicing (Essential tier)
- invoice_eu_vat: EU VAT support (Professional tier)
- invoice_bulk: Bulk invoicing (Business tier)
- accounting_export: Export to accounting software (Business tier)
"""
import logging
from pathlib import Path
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.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
from models.schema.auth import UserContext
from app.modules.orders.schemas import (
InvoiceCreate,
InvoiceListPaginatedResponse,
InvoiceListResponse,
InvoicePDFGeneratedResponse,
InvoiceResponse,
InvoiceStatsResponse,
InvoiceStatusUpdate,
VendorInvoiceSettingsCreate,
VendorInvoiceSettingsResponse,
VendorInvoiceSettingsUpdate,
)
router = APIRouter(prefix="/invoices")
logger = logging.getLogger(__name__)
# ============================================================================
# Invoice Settings
# ============================================================================
@router.get("/settings", response_model=VendorInvoiceSettingsResponse | None)
def get_invoice_settings(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
_: None = Depends(RequireFeature(FeatureCode.INVOICE_LU)),
):
"""
Get vendor invoice settings.
Returns null if settings not yet configured.
Requires: invoice_lu feature (Essential tier)
"""
settings = invoice_service.get_settings(db, current_user.token_vendor_id)
if settings:
return VendorInvoiceSettingsResponse.model_validate(settings)
return None
@router.post("/settings", response_model=VendorInvoiceSettingsResponse, status_code=201)
def create_invoice_settings(
data: VendorInvoiceSettingsCreate,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Create vendor invoice settings.
Required before creating invoices. Sets company details,
VAT number, invoice numbering preferences, and payment info.
"""
settings = invoice_service.create_settings(
db=db,
vendor_id=current_user.token_vendor_id,
data=data,
)
return VendorInvoiceSettingsResponse.model_validate(settings)
@router.put("/settings", response_model=VendorInvoiceSettingsResponse)
def update_invoice_settings(
data: VendorInvoiceSettingsUpdate,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Update vendor invoice settings.
"""
settings = invoice_service.update_settings(
db=db,
vendor_id=current_user.token_vendor_id,
data=data,
)
return VendorInvoiceSettingsResponse.model_validate(settings)
# ============================================================================
# Invoice Statistics
# ============================================================================
@router.get("/stats", response_model=InvoiceStatsResponse)
def get_invoice_stats(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get invoice statistics for the vendor.
Returns:
- total_invoices: Total number of invoices
- total_revenue: Total revenue from issued/paid invoices (EUR)
- draft_count: Number of draft invoices
- paid_count: Number of paid invoices
"""
stats = invoice_service.get_invoice_stats(db, current_user.token_vendor_id)
return InvoiceStatsResponse(
total_invoices=stats.get("total_invoices", 0),
total_revenue_cents=stats.get("total_revenue_cents", 0),
draft_count=stats.get("draft_count", 0),
issued_count=stats.get("issued_count", 0),
paid_count=stats.get("paid_count", 0),
cancelled_count=stats.get("cancelled_count", 0),
)
# ============================================================================
# Invoice CRUD
# ============================================================================
@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"),
status: str | None = Query(None, description="Filter by status"),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
List vendor invoices with pagination.
Supports filtering by status: draft, issued, paid, cancelled
"""
invoices, total = invoice_service.list_invoices(
db=db,
vendor_id=current_user.token_vendor_id,
status=status,
page=page,
per_page=per_page,
)
# Build response with buyer name from snapshot
items = []
for inv in invoices:
buyer_name = inv.buyer_details.get("name") if inv.buyer_details else None
items.append(
InvoiceListResponse(
id=inv.id,
invoice_number=inv.invoice_number,
invoice_date=inv.invoice_date,
status=inv.status,
currency=inv.currency,
total_cents=inv.total_cents,
order_id=inv.order_id,
buyer_name=buyer_name,
)
)
pages = (total + per_page - 1) // per_page if total > 0 else 1
return InvoiceListPaginatedResponse(
items=items,
total=total,
page=page,
per_page=per_page,
pages=pages,
)
@router.get("/{invoice_id}", response_model=InvoiceResponse)
def get_invoice(
invoice_id: int,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get invoice details by ID.
"""
invoice = invoice_service.get_invoice_or_raise(
db=db,
vendor_id=current_user.token_vendor_id,
invoice_id=invoice_id,
)
return InvoiceResponse.model_validate(invoice)
@router.post("", response_model=InvoiceResponse, status_code=201)
def create_invoice(
data: InvoiceCreate,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Create an invoice from an order.
Automatically:
- Captures seller/buyer details snapshots
- Calculates VAT based on buyer country and OSS settings
- Generates invoice number
"""
invoice = invoice_service.create_invoice_from_order(
db=db,
vendor_id=current_user.token_vendor_id,
order_id=data.order_id,
notes=data.notes,
)
return InvoiceResponse.model_validate(invoice)
@router.put("/{invoice_id}/status", response_model=InvoiceResponse)
def update_invoice_status(
invoice_id: int,
data: InvoiceStatusUpdate,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Update invoice status.
Valid statuses:
- draft: Invoice created but not yet sent
- issued: Invoice sent to customer
- paid: Payment received
- cancelled: Invoice cancelled
Note: Cancelled invoices cannot be modified.
"""
invoice = invoice_service.update_status(
db=db,
vendor_id=current_user.token_vendor_id,
invoice_id=invoice_id,
new_status=data.status,
)
return InvoiceResponse.model_validate(invoice)
# ============================================================================
# PDF Generation
# ============================================================================
@router.post("/{invoice_id}/pdf", response_model=InvoicePDFGeneratedResponse)
def generate_invoice_pdf(
invoice_id: int,
regenerate: bool = Query(False, description="Force regenerate if exists"),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Generate PDF for an invoice.
Set regenerate=true to force regeneration if PDF already exists.
Returns the path to the generated PDF.
"""
pdf_path = invoice_service.generate_pdf(
db=db,
vendor_id=current_user.token_vendor_id,
invoice_id=invoice_id,
force_regenerate=regenerate,
)
return InvoicePDFGeneratedResponse(pdf_path=pdf_path)
@router.get("/{invoice_id}/pdf")
def download_invoice_pdf(
invoice_id: int,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Download invoice PDF.
Returns the PDF file for download.
Generates PDF if not already generated.
"""
# Check if PDF exists, generate if not
pdf_path = invoice_service.get_pdf_path(
db=db,
vendor_id=current_user.token_vendor_id,
invoice_id=invoice_id,
)
if not pdf_path:
# Generate PDF
pdf_path = invoice_service.generate_pdf(
db=db,
vendor_id=current_user.token_vendor_id,
invoice_id=invoice_id,
)
# Verify file exists
if not Path(pdf_path).exists():
raise InvoicePDFNotFoundException(invoice_id)
# Get invoice for filename
invoice = invoice_service.get_invoice_or_raise(
db=db,
vendor_id=current_user.token_vendor_id,
invoice_id=invoice_id,
)
filename = f"invoice-{invoice.invoice_number}.pdf"
return FileResponse(
path=pdf_path,
media_type="application/pdf",
filename=filename,
headers={
"Content-Disposition": f'attachment; filename="{filename}"'
},
)

View File

@@ -1,199 +0,0 @@
# app/api/v1/vendor/payments.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.
"""
import logging
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.vendor_service import vendor_service
from models.schema.auth import UserContext
from app.modules.payments.schemas import (
PaymentBalanceResponse,
PaymentConfigResponse,
PaymentConfigUpdate,
PaymentConfigUpdateResponse,
PaymentMethodsResponse,
PaymentRefundRequest as RefundRequest,
PaymentRefundResponse as RefundResponse,
StripeConnectRequest,
StripeConnectResponse,
StripeDisconnectResponse,
TransactionsResponse,
)
router = APIRouter(prefix="/payments")
logger = logging.getLogger(__name__)
@router.get("/config", response_model=PaymentConfigResponse)
def get_payment_configuration(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get vendor payment configuration.
TODO: Implement in Slice 5
- Get payment gateway settings (Stripe, PayPal, etc.)
- Get accepted payment methods
- Get currency settings
- Return masked/secure information only
"""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return PaymentConfigResponse(
payment_gateway=None,
accepted_methods=[],
currency="EUR",
stripe_connected=False,
message="Payment configuration coming in Slice 5",
)
@router.put("/config", response_model=PaymentConfigUpdateResponse)
def update_payment_configuration(
payment_config: PaymentConfigUpdate,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Update vendor payment configuration.
TODO: Implement in Slice 5
- Update payment gateway settings
- Connect/disconnect Stripe account
- Update accepted payment methods
- Validate configuration before saving
"""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return PaymentConfigUpdateResponse(
message="Payment configuration update coming in Slice 5"
)
@router.post("/stripe/connect", response_model=StripeConnectResponse)
def connect_stripe_account(
stripe_data: StripeConnectRequest,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Connect Stripe account for payment processing.
TODO: Implement in Slice 5
- Handle Stripe OAuth flow
- Store Stripe account ID securely
- Verify Stripe account is active
- Enable payment processing
"""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return StripeConnectResponse(message="Stripe connection coming in Slice 5")
@router.delete("/stripe/disconnect", response_model=StripeDisconnectResponse)
def disconnect_stripe_account(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Disconnect Stripe account.
TODO: Implement in Slice 5
- Remove Stripe account connection
- Disable payment processing
- Warn about pending payments
"""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return StripeDisconnectResponse(message="Stripe disconnection coming in Slice 5")
@router.get("/methods", response_model=PaymentMethodsResponse)
def get_payment_methods(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get accepted payment methods for vendor.
TODO: Implement in Slice 5
- Return list of enabled payment methods
- Include: credit card, PayPal, bank transfer, etc.
"""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return PaymentMethodsResponse(
methods=[],
message="Payment methods coming in Slice 5",
)
@router.get("/transactions", response_model=TransactionsResponse)
def get_payment_transactions(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get payment transaction history.
TODO: Implement in Slice 5
- Get all payment transactions for vendor
- Filter by date range, status, etc.
- Include payment details
- Support pagination
"""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return TransactionsResponse(
transactions=[],
total=0,
message="Payment transactions coming in Slice 5",
)
@router.get("/balance", response_model=PaymentBalanceResponse)
def get_payment_balance(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get vendor payment balance and payout information.
TODO: Implement in Slice 5
- Get available balance
- Get pending balance
- Get next payout date
- Get payout history
"""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return PaymentBalanceResponse(
available_balance=0.0,
pending_balance=0.0,
currency="EUR",
next_payout_date=None,
message="Payment balance coming in Slice 5",
)
@router.post("/refund/{payment_id}", response_model=RefundResponse)
def refund_payment(
payment_id: int,
refund_data: RefundRequest,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Process payment refund.
TODO: Implement in Slice 5
- Verify payment belongs to vendor
- Process refund through payment gateway
- Update order status
- Send refund notification to customer
"""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return RefundResponse(message="Payment refund coming in Slice 5")