- Move Feature model from models/database/ to app/modules/billing/models/ (tightly coupled to SubscriptionTier for tier-based access control) - Move ProductMedia from models/database/media.py to app/modules/catalog/models/ (product-specific media associations belong with catalog) - Keep MediaFile as CORE in models/database/media.py (cross-cutting file storage) - Convert legacy feature.py to re-export for backwards compatibility - Update all imports to use canonical module locations Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
349 lines
10 KiB
Python
349 lines
10 KiB
Python
# 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.database.user import User
|
|
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: User = 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: User = 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: User = 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: User = 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: User = 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: User = 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: User = 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: User = 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: User = 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: User = 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}"'
|
|
},
|
|
)
|