Files
orion/app/api/v1/vendor/invoices.py
Samir Boulahtit 6232bb47f6 feat: add invoicing system and subscription tier enforcement
Phase 1 OMS implementation:

Invoicing:
- Add Invoice and VendorInvoiceSettings database models
- Full EU VAT support (27 countries, OSS, B2B reverse charge)
- Invoice PDF generation with WeasyPrint + Jinja2 templates
- Vendor invoice API endpoints for settings, creation, PDF download

Subscription Tiers:
- Add VendorSubscription model with 4 tiers (Essential/Professional/Business/Enterprise)
- Tier limit enforcement for orders, products, team members
- Feature gating based on subscription tier
- Automatic trial subscription creation for new vendors
- Integrate limit checks into order creation (direct and Letzshop sync)

Marketing:
- Update pricing documentation with 4-tier structure
- Revise back-office positioning strategy
- Update homepage with Veeqo-inspired Letzshop-focused messaging

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

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

370 lines
12 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, HTTPException, 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.services.invoice_service import (
InvoiceNotFoundException,
InvoiceSettingsNotFoundException,
invoice_service,
)
from models.database.user import User
from models.schema.invoice import (
InvoiceCreate,
InvoiceListPaginatedResponse,
InvoiceListResponse,
InvoiceResponse,
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.
"""
try:
settings = invoice_service.create_settings(
db=db,
vendor_id=current_user.token_vendor_id,
data=data,
)
return VendorInvoiceSettingsResponse.model_validate(settings)
except Exception as e:
logger.error(f"Failed to create invoice settings: {e}")
raise HTTPException(status_code=400, detail=str(e))
@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.
"""
try:
settings = invoice_service.update_settings(
db=db,
vendor_id=current_user.token_vendor_id,
data=data,
)
return VendorInvoiceSettingsResponse.model_validate(settings)
except InvoiceSettingsNotFoundException:
raise HTTPException(
status_code=404,
detail="Invoice settings not found. Create settings first.",
)
except Exception as e:
logger.error(f"Failed to update invoice settings: {e}")
raise HTTPException(status_code=400, detail=str(e))
# ============================================================================
# Invoice Statistics
# ============================================================================
@router.get("/stats")
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
"""
return invoice_service.get_invoice_stats(db, current_user.token_vendor_id)
# ============================================================================
# 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.
"""
try:
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)
except InvoiceNotFoundException:
raise HTTPException(status_code=404, detail="Invoice not found")
@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
"""
try:
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)
except InvoiceSettingsNotFoundException:
raise HTTPException(
status_code=400,
detail="Invoice settings not configured. Set up invoice settings first.",
)
except Exception as e:
logger.error(f"Failed to create invoice: {e}")
raise HTTPException(status_code=400, detail=str(e))
@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.
"""
try:
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)
except InvoiceNotFoundException:
raise HTTPException(status_code=404, detail="Invoice not found")
except Exception as e:
logger.error(f"Failed to update invoice status: {e}")
raise HTTPException(status_code=400, detail=str(e))
# ============================================================================
# PDF Generation
# ============================================================================
@router.post("/{invoice_id}/pdf")
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.
"""
try:
pdf_path = invoice_service.generate_pdf(
db=db,
vendor_id=current_user.token_vendor_id,
invoice_id=invoice_id,
force_regenerate=regenerate,
)
return {"pdf_path": pdf_path, "message": "PDF generated successfully"}
except InvoiceNotFoundException:
raise HTTPException(status_code=404, detail="Invoice not found")
except Exception as e:
logger.error(f"Failed to generate PDF: {e}")
raise HTTPException(status_code=500, detail=f"Failed to generate PDF: {str(e)}")
@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.
"""
try:
# 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 HTTPException(status_code=404, detail="PDF file not found")
# 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}"'
},
)
except InvoiceNotFoundException:
raise HTTPException(status_code=404, detail="Invoice not found")
except Exception as e:
logger.error(f"Failed to download PDF: {e}")
raise HTTPException(status_code=500, detail=f"Failed to download PDF: {str(e)}")