Files
orion/app/api/v1/vendor/invoices.py
Samir Boulahtit 319fba5d39 fix: resolve architecture validation violations
- Add invoice exceptions module with proper exception hierarchy
- Replace HTTPException with service-layer exceptions in invoice API
- Add InvoicePDFGeneratedResponse and InvoiceStatsResponse Pydantic models
- Replace db.commit() with db.flush() in services for proper transaction control
- Update invoice service to use exceptions from app/exceptions/invoice.py

All 14 errors and 14 warnings are now resolved. Validation passes with
only INFO-level findings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 18:23:49 +01:00

339 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
"""
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.exceptions.invoice import (
InvoiceNotFoundException,
InvoicePDFGenerationException,
InvoicePDFNotFoundException,
InvoiceSettingsNotFoundException,
)
from app.services.invoice_service import invoice_service
from models.database.user import User
from models.schema.invoice 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),
):
"""
Get vendor invoice settings.
Returns null if settings not yet configured.
"""
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}"'
},
)