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>
370 lines
12 KiB
Python
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)}")
|