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:
@@ -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"])
|
||||
|
||||
348
app/modules/orders/routes/api/vendor_invoices.py
Normal file
348
app/modules/orders/routes/api/vendor_invoices.py
Normal file
@@ -0,0 +1,348 @@
|
||||
# app/modules/orders/routes/api/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, require_module_access
|
||||
from app.core.database import get_db
|
||||
from app.core.feature_gate import RequireFeature
|
||||
from app.exceptions.invoice import (
|
||||
InvoicePDFNotFoundException,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
vendor_invoices_router = APIRouter(
|
||||
prefix="/invoices",
|
||||
dependencies=[Depends(require_module_access("orders"))],
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Invoice Settings
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@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),
|
||||
_: 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
|
||||
|
||||
|
||||
@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),
|
||||
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)
|
||||
|
||||
|
||||
@vendor_invoices_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
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@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),
|
||||
):
|
||||
"""
|
||||
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
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@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"),
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@vendor_invoices_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)
|
||||
|
||||
|
||||
@vendor_invoices_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)
|
||||
|
||||
|
||||
@vendor_invoices_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
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@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"),
|
||||
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)
|
||||
|
||||
|
||||
@vendor_invoices_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}"'
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user