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>
This commit is contained in:
201
app/api/v1/vendor/invoices.py
vendored
201
app/api/v1/vendor/invoices.py
vendored
@@ -21,23 +21,27 @@ Endpoints:
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_vendor_api
|
from app.api.deps import get_current_vendor_api
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.services.invoice_service import (
|
from app.exceptions.invoice import (
|
||||||
InvoiceNotFoundException,
|
InvoiceNotFoundException,
|
||||||
|
InvoicePDFGenerationException,
|
||||||
|
InvoicePDFNotFoundException,
|
||||||
InvoiceSettingsNotFoundException,
|
InvoiceSettingsNotFoundException,
|
||||||
invoice_service,
|
|
||||||
)
|
)
|
||||||
|
from app.services.invoice_service import invoice_service
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
from models.schema.invoice import (
|
from models.schema.invoice import (
|
||||||
InvoiceCreate,
|
InvoiceCreate,
|
||||||
InvoiceListPaginatedResponse,
|
InvoiceListPaginatedResponse,
|
||||||
InvoiceListResponse,
|
InvoiceListResponse,
|
||||||
|
InvoicePDFGeneratedResponse,
|
||||||
InvoiceResponse,
|
InvoiceResponse,
|
||||||
|
InvoiceStatsResponse,
|
||||||
InvoiceStatusUpdate,
|
InvoiceStatusUpdate,
|
||||||
VendorInvoiceSettingsCreate,
|
VendorInvoiceSettingsCreate,
|
||||||
VendorInvoiceSettingsResponse,
|
VendorInvoiceSettingsResponse,
|
||||||
@@ -81,16 +85,12 @@ def create_invoice_settings(
|
|||||||
Required before creating invoices. Sets company details,
|
Required before creating invoices. Sets company details,
|
||||||
VAT number, invoice numbering preferences, and payment info.
|
VAT number, invoice numbering preferences, and payment info.
|
||||||
"""
|
"""
|
||||||
try:
|
settings = invoice_service.create_settings(
|
||||||
settings = invoice_service.create_settings(
|
db=db,
|
||||||
db=db,
|
vendor_id=current_user.token_vendor_id,
|
||||||
vendor_id=current_user.token_vendor_id,
|
data=data,
|
||||||
data=data,
|
)
|
||||||
)
|
return VendorInvoiceSettingsResponse.model_validate(settings)
|
||||||
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)
|
@router.put("/settings", response_model=VendorInvoiceSettingsResponse)
|
||||||
@@ -102,21 +102,12 @@ def update_invoice_settings(
|
|||||||
"""
|
"""
|
||||||
Update vendor invoice settings.
|
Update vendor invoice settings.
|
||||||
"""
|
"""
|
||||||
try:
|
settings = invoice_service.update_settings(
|
||||||
settings = invoice_service.update_settings(
|
db=db,
|
||||||
db=db,
|
vendor_id=current_user.token_vendor_id,
|
||||||
vendor_id=current_user.token_vendor_id,
|
data=data,
|
||||||
data=data,
|
)
|
||||||
)
|
return VendorInvoiceSettingsResponse.model_validate(settings)
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -124,7 +115,7 @@ def update_invoice_settings(
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats", response_model=InvoiceStatsResponse)
|
||||||
def get_invoice_stats(
|
def get_invoice_stats(
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -138,7 +129,15 @@ def get_invoice_stats(
|
|||||||
- draft_count: Number of draft invoices
|
- draft_count: Number of draft invoices
|
||||||
- paid_count: Number of paid invoices
|
- paid_count: Number of paid invoices
|
||||||
"""
|
"""
|
||||||
return invoice_service.get_invoice_stats(db, current_user.token_vendor_id)
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -204,15 +203,12 @@ def get_invoice(
|
|||||||
"""
|
"""
|
||||||
Get invoice details by ID.
|
Get invoice details by ID.
|
||||||
"""
|
"""
|
||||||
try:
|
invoice = invoice_service.get_invoice_or_raise(
|
||||||
invoice = invoice_service.get_invoice_or_raise(
|
db=db,
|
||||||
db=db,
|
vendor_id=current_user.token_vendor_id,
|
||||||
vendor_id=current_user.token_vendor_id,
|
invoice_id=invoice_id,
|
||||||
invoice_id=invoice_id,
|
)
|
||||||
)
|
return InvoiceResponse.model_validate(invoice)
|
||||||
return InvoiceResponse.model_validate(invoice)
|
|
||||||
except InvoiceNotFoundException:
|
|
||||||
raise HTTPException(status_code=404, detail="Invoice not found")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=InvoiceResponse, status_code=201)
|
@router.post("", response_model=InvoiceResponse, status_code=201)
|
||||||
@@ -229,22 +225,13 @@ def create_invoice(
|
|||||||
- Calculates VAT based on buyer country and OSS settings
|
- Calculates VAT based on buyer country and OSS settings
|
||||||
- Generates invoice number
|
- Generates invoice number
|
||||||
"""
|
"""
|
||||||
try:
|
invoice = invoice_service.create_invoice_from_order(
|
||||||
invoice = invoice_service.create_invoice_from_order(
|
db=db,
|
||||||
db=db,
|
vendor_id=current_user.token_vendor_id,
|
||||||
vendor_id=current_user.token_vendor_id,
|
order_id=data.order_id,
|
||||||
order_id=data.order_id,
|
notes=data.notes,
|
||||||
notes=data.notes,
|
)
|
||||||
)
|
return InvoiceResponse.model_validate(invoice)
|
||||||
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)
|
@router.put("/{invoice_id}/status", response_model=InvoiceResponse)
|
||||||
@@ -265,19 +252,13 @@ def update_invoice_status(
|
|||||||
|
|
||||||
Note: Cancelled invoices cannot be modified.
|
Note: Cancelled invoices cannot be modified.
|
||||||
"""
|
"""
|
||||||
try:
|
invoice = invoice_service.update_status(
|
||||||
invoice = invoice_service.update_status(
|
db=db,
|
||||||
db=db,
|
vendor_id=current_user.token_vendor_id,
|
||||||
vendor_id=current_user.token_vendor_id,
|
invoice_id=invoice_id,
|
||||||
invoice_id=invoice_id,
|
new_status=data.status,
|
||||||
new_status=data.status,
|
)
|
||||||
)
|
return InvoiceResponse.model_validate(invoice)
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -285,7 +266,7 @@ def update_invoice_status(
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{invoice_id}/pdf")
|
@router.post("/{invoice_id}/pdf", response_model=InvoicePDFGeneratedResponse)
|
||||||
def generate_invoice_pdf(
|
def generate_invoice_pdf(
|
||||||
invoice_id: int,
|
invoice_id: int,
|
||||||
regenerate: bool = Query(False, description="Force regenerate if exists"),
|
regenerate: bool = Query(False, description="Force regenerate if exists"),
|
||||||
@@ -298,19 +279,13 @@ def generate_invoice_pdf(
|
|||||||
Set regenerate=true to force regeneration if PDF already exists.
|
Set regenerate=true to force regeneration if PDF already exists.
|
||||||
Returns the path to the generated PDF.
|
Returns the path to the generated PDF.
|
||||||
"""
|
"""
|
||||||
try:
|
pdf_path = invoice_service.generate_pdf(
|
||||||
pdf_path = invoice_service.generate_pdf(
|
db=db,
|
||||||
db=db,
|
vendor_id=current_user.token_vendor_id,
|
||||||
vendor_id=current_user.token_vendor_id,
|
invoice_id=invoice_id,
|
||||||
invoice_id=invoice_id,
|
force_regenerate=regenerate,
|
||||||
force_regenerate=regenerate,
|
)
|
||||||
)
|
return InvoicePDFGeneratedResponse(pdf_path=pdf_path)
|
||||||
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")
|
@router.get("/{invoice_id}/pdf")
|
||||||
@@ -325,45 +300,39 @@ def download_invoice_pdf(
|
|||||||
Returns the PDF file for download.
|
Returns the PDF file for download.
|
||||||
Generates PDF if not already generated.
|
Generates PDF if not already generated.
|
||||||
"""
|
"""
|
||||||
try:
|
# Check if PDF exists, generate if not
|
||||||
# Check if PDF exists, generate if not
|
pdf_path = invoice_service.get_pdf_path(
|
||||||
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,
|
db=db,
|
||||||
vendor_id=current_user.token_vendor_id,
|
vendor_id=current_user.token_vendor_id,
|
||||||
invoice_id=invoice_id,
|
invoice_id=invoice_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not pdf_path:
|
# Verify file exists
|
||||||
# Generate PDF
|
if not Path(pdf_path).exists():
|
||||||
pdf_path = invoice_service.generate_pdf(
|
raise InvoicePDFNotFoundException(invoice_id)
|
||||||
db=db,
|
|
||||||
vendor_id=current_user.token_vendor_id,
|
|
||||||
invoice_id=invoice_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify file exists
|
# Get invoice for filename
|
||||||
if not Path(pdf_path).exists():
|
invoice = invoice_service.get_invoice_or_raise(
|
||||||
raise HTTPException(status_code=404, detail="PDF file not found")
|
db=db,
|
||||||
|
vendor_id=current_user.token_vendor_id,
|
||||||
|
invoice_id=invoice_id,
|
||||||
|
)
|
||||||
|
|
||||||
# Get invoice for filename
|
filename = f"invoice-{invoice.invoice_number}.pdf"
|
||||||
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,
|
||||||
return FileResponse(
|
media_type="application/pdf",
|
||||||
path=pdf_path,
|
filename=filename,
|
||||||
media_type="application/pdf",
|
headers={
|
||||||
filename=filename,
|
"Content-Disposition": f'attachment; 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)}")
|
|
||||||
|
|||||||
@@ -90,6 +90,18 @@ from .customer import (
|
|||||||
InvalidCustomerCredentialsException,
|
InvalidCustomerCredentialsException,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Invoice exceptions
|
||||||
|
from .invoice import (
|
||||||
|
InvoiceNotFoundException,
|
||||||
|
InvoicePDFGenerationException,
|
||||||
|
InvoicePDFNotFoundException,
|
||||||
|
InvoiceSettingsAlreadyExistException,
|
||||||
|
InvoiceSettingsNotFoundException,
|
||||||
|
InvoiceValidationException,
|
||||||
|
InvalidInvoiceStatusTransitionException,
|
||||||
|
OrderNotFoundException as OrderNotFoundForInvoiceException,
|
||||||
|
)
|
||||||
|
|
||||||
# Inventory exceptions
|
# Inventory exceptions
|
||||||
from .inventory import (
|
from .inventory import (
|
||||||
InsufficientInventoryException,
|
InsufficientInventoryException,
|
||||||
@@ -272,6 +284,15 @@ __all__ = [
|
|||||||
"TeamValidationException",
|
"TeamValidationException",
|
||||||
"InvalidInvitationDataException",
|
"InvalidInvitationDataException",
|
||||||
"InvalidInvitationTokenException",
|
"InvalidInvitationTokenException",
|
||||||
|
# Invoice exceptions
|
||||||
|
"InvoiceNotFoundException",
|
||||||
|
"InvoiceSettingsNotFoundException",
|
||||||
|
"InvoiceSettingsAlreadyExistException",
|
||||||
|
"InvoiceValidationException",
|
||||||
|
"InvoicePDFGenerationException",
|
||||||
|
"InvoicePDFNotFoundException",
|
||||||
|
"InvalidInvoiceStatusTransitionException",
|
||||||
|
"OrderNotFoundForInvoiceException",
|
||||||
# Inventory exceptions
|
# Inventory exceptions
|
||||||
"InventoryNotFoundException",
|
"InventoryNotFoundException",
|
||||||
"InsufficientInventoryException",
|
"InsufficientInventoryException",
|
||||||
|
|||||||
118
app/exceptions/invoice.py
Normal file
118
app/exceptions/invoice.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# app/exceptions/invoice.py
|
||||||
|
"""
|
||||||
|
Invoice-related exceptions.
|
||||||
|
|
||||||
|
This module provides exception classes for invoice operations including:
|
||||||
|
- Invoice not found errors
|
||||||
|
- Invoice settings validation
|
||||||
|
- PDF generation errors
|
||||||
|
- Invoice status transitions
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .base import BusinessLogicException, ResourceNotFoundException, WizamartException
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceNotFoundException(ResourceNotFoundException):
|
||||||
|
"""Raised when an invoice is not found."""
|
||||||
|
|
||||||
|
def __init__(self, invoice_id: int | str):
|
||||||
|
super().__init__(
|
||||||
|
resource_type="Invoice",
|
||||||
|
identifier=str(invoice_id),
|
||||||
|
error_code="INVOICE_NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceSettingsNotFoundException(ResourceNotFoundException):
|
||||||
|
"""Raised when invoice settings are not found for a vendor."""
|
||||||
|
|
||||||
|
def __init__(self, vendor_id: int):
|
||||||
|
super().__init__(
|
||||||
|
resource_type="InvoiceSettings",
|
||||||
|
identifier=str(vendor_id),
|
||||||
|
message="Invoice settings not found. Create settings first.",
|
||||||
|
error_code="INVOICE_SETTINGS_NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceSettingsAlreadyExistException(WizamartException):
|
||||||
|
"""Raised when trying to create invoice settings that already exist."""
|
||||||
|
|
||||||
|
def __init__(self, vendor_id: int):
|
||||||
|
super().__init__(
|
||||||
|
message=f"Invoice settings already exist for vendor {vendor_id}",
|
||||||
|
error_code="INVOICE_SETTINGS_ALREADY_EXIST",
|
||||||
|
status_code=409,
|
||||||
|
details={"vendor_id": vendor_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceValidationException(BusinessLogicException):
|
||||||
|
"""Raised when invoice data validation fails."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, details: dict[str, Any] | None = None):
|
||||||
|
super().__init__(
|
||||||
|
message=message,
|
||||||
|
error_code="INVOICE_VALIDATION_ERROR",
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InvoicePDFGenerationException(WizamartException):
|
||||||
|
"""Raised when PDF generation fails."""
|
||||||
|
|
||||||
|
def __init__(self, invoice_id: int, reason: str):
|
||||||
|
super().__init__(
|
||||||
|
message=f"Failed to generate PDF for invoice {invoice_id}: {reason}",
|
||||||
|
error_code="INVOICE_PDF_GENERATION_FAILED",
|
||||||
|
status_code=500,
|
||||||
|
details={"invoice_id": invoice_id, "reason": reason},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InvoicePDFNotFoundException(ResourceNotFoundException):
|
||||||
|
"""Raised when invoice PDF file is not found."""
|
||||||
|
|
||||||
|
def __init__(self, invoice_id: int):
|
||||||
|
super().__init__(
|
||||||
|
resource_type="InvoicePDF",
|
||||||
|
identifier=str(invoice_id),
|
||||||
|
message="PDF file not found. Generate the PDF first.",
|
||||||
|
error_code="INVOICE_PDF_NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidInvoiceStatusTransitionException(BusinessLogicException):
|
||||||
|
"""Raised when an invalid invoice status transition is attempted."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
current_status: str,
|
||||||
|
new_status: str,
|
||||||
|
reason: str | None = None,
|
||||||
|
):
|
||||||
|
message = f"Cannot change invoice status from '{current_status}' to '{new_status}'"
|
||||||
|
if reason:
|
||||||
|
message += f": {reason}"
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
message=message,
|
||||||
|
error_code="INVALID_INVOICE_STATUS_TRANSITION",
|
||||||
|
details={
|
||||||
|
"current_status": current_status,
|
||||||
|
"new_status": new_status,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderNotFoundException(ResourceNotFoundException):
|
||||||
|
"""Raised when an order for invoice creation is not found."""
|
||||||
|
|
||||||
|
def __init__(self, order_id: int):
|
||||||
|
super().__init__(
|
||||||
|
resource_type="Order",
|
||||||
|
identifier=str(order_id),
|
||||||
|
error_code="ORDER_NOT_FOUND_FOR_INVOICE",
|
||||||
|
)
|
||||||
@@ -97,7 +97,7 @@ class InvoicePDFService:
|
|||||||
# Update invoice record with PDF path and timestamp
|
# Update invoice record with PDF path and timestamp
|
||||||
invoice.pdf_path = str(pdf_path)
|
invoice.pdf_path = str(pdf_path)
|
||||||
invoice.pdf_generated_at = datetime.now(UTC)
|
invoice.pdf_generated_at = datetime.now(UTC)
|
||||||
db.commit()
|
db.flush()
|
||||||
|
|
||||||
return str(pdf_path)
|
return str(pdf_path)
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ class InvoicePDFService:
|
|||||||
# Clear PDF fields
|
# Clear PDF fields
|
||||||
invoice.pdf_path = None
|
invoice.pdf_path = None
|
||||||
invoice.pdf_generated_at = None
|
invoice.pdf_generated_at = None
|
||||||
db.commit()
|
db.flush()
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,17 @@ from sqlalchemy import and_, func
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.exceptions import (
|
from app.exceptions import (
|
||||||
OrderNotFoundException,
|
|
||||||
ValidationException,
|
ValidationException,
|
||||||
)
|
)
|
||||||
|
from app.exceptions.invoice import (
|
||||||
|
InvoiceNotFoundException,
|
||||||
|
InvoicePDFGenerationException,
|
||||||
|
InvoicePDFNotFoundException,
|
||||||
|
InvoiceSettingsAlreadyExistException,
|
||||||
|
InvoiceSettingsNotFoundException,
|
||||||
|
InvoiceValidationException,
|
||||||
|
OrderNotFoundException,
|
||||||
|
)
|
||||||
from models.database.invoice import (
|
from models.database.invoice import (
|
||||||
Invoice,
|
Invoice,
|
||||||
InvoiceStatus,
|
InvoiceStatus,
|
||||||
@@ -89,18 +97,6 @@ LU_VAT_RATES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class InvoiceNotFoundException(Exception):
|
|
||||||
"""Raised when invoice not found."""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class InvoiceSettingsNotFoundException(Exception):
|
|
||||||
"""Raised when vendor invoice settings not found."""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class InvoiceService:
|
class InvoiceService:
|
||||||
"""Service for invoice operations."""
|
"""Service for invoice operations."""
|
||||||
|
|
||||||
@@ -205,9 +201,7 @@ class InvoiceService:
|
|||||||
"""Get vendor invoice settings or raise exception."""
|
"""Get vendor invoice settings or raise exception."""
|
||||||
settings = self.get_settings(db, vendor_id)
|
settings = self.get_settings(db, vendor_id)
|
||||||
if not settings:
|
if not settings:
|
||||||
raise InvoiceSettingsNotFoundException(
|
raise InvoiceSettingsNotFoundException(vendor_id)
|
||||||
f"Invoice settings not configured for vendor {vendor_id}"
|
|
||||||
)
|
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
def create_settings(
|
def create_settings(
|
||||||
@@ -229,7 +223,7 @@ class InvoiceService:
|
|||||||
**data.model_dump(),
|
**data.model_dump(),
|
||||||
)
|
)
|
||||||
db.add(settings)
|
db.add(settings)
|
||||||
db.commit()
|
db.flush()
|
||||||
db.refresh(settings)
|
db.refresh(settings)
|
||||||
|
|
||||||
logger.info(f"Created invoice settings for vendor {vendor_id}")
|
logger.info(f"Created invoice settings for vendor {vendor_id}")
|
||||||
@@ -249,7 +243,7 @@ class InvoiceService:
|
|||||||
setattr(settings, key, value)
|
setattr(settings, key, value)
|
||||||
|
|
||||||
settings.updated_at = datetime.now(UTC)
|
settings.updated_at = datetime.now(UTC)
|
||||||
db.commit()
|
db.flush()
|
||||||
db.refresh(settings)
|
db.refresh(settings)
|
||||||
|
|
||||||
logger.info(f"Updated invoice settings for vendor {vendor_id}")
|
logger.info(f"Updated invoice settings for vendor {vendor_id}")
|
||||||
@@ -278,7 +272,7 @@ class InvoiceService:
|
|||||||
is_vat_registered=bool(vendor.effective_tax_number),
|
is_vat_registered=bool(vendor.effective_tax_number),
|
||||||
)
|
)
|
||||||
db.add(settings)
|
db.add(settings)
|
||||||
db.commit()
|
db.flush()
|
||||||
db.refresh(settings)
|
db.refresh(settings)
|
||||||
|
|
||||||
logger.info(f"Created invoice settings from vendor data for vendor {vendor.id}")
|
logger.info(f"Created invoice settings from vendor data for vendor {vendor.id}")
|
||||||
@@ -435,7 +429,7 @@ class InvoiceService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
db.add(invoice)
|
db.add(invoice)
|
||||||
db.commit()
|
db.flush()
|
||||||
db.refresh(invoice)
|
db.refresh(invoice)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -465,7 +459,7 @@ class InvoiceService:
|
|||||||
"""Get invoice by ID or raise exception."""
|
"""Get invoice by ID or raise exception."""
|
||||||
invoice = self.get_invoice(db, vendor_id, invoice_id)
|
invoice = self.get_invoice(db, vendor_id, invoice_id)
|
||||||
if not invoice:
|
if not invoice:
|
||||||
raise InvoiceNotFoundException(f"Invoice {invoice_id} not found")
|
raise InvoiceNotFoundException(invoice_id)
|
||||||
return invoice
|
return invoice
|
||||||
|
|
||||||
def get_invoice_by_number(
|
def get_invoice_by_number(
|
||||||
@@ -539,7 +533,7 @@ class InvoiceService:
|
|||||||
|
|
||||||
invoice.status = new_status
|
invoice.status = new_status
|
||||||
invoice.updated_at = datetime.now(UTC)
|
invoice.updated_at = datetime.now(UTC)
|
||||||
db.commit()
|
db.flush()
|
||||||
db.refresh(invoice)
|
db.refresh(invoice)
|
||||||
|
|
||||||
logger.info(f"Updated invoice {invoice.invoice_number} status to {new_status}")
|
logger.info(f"Updated invoice {invoice.invoice_number} status to {new_status}")
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ class SubscriptionService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
db.add(subscription)
|
db.add(subscription)
|
||||||
db.commit()
|
db.flush()
|
||||||
db.refresh(subscription)
|
db.refresh(subscription)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -212,7 +212,7 @@ class SubscriptionService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
db.add(subscription)
|
db.add(subscription)
|
||||||
db.commit()
|
db.flush()
|
||||||
db.refresh(subscription)
|
db.refresh(subscription)
|
||||||
|
|
||||||
logger.info(f"Created subscription for vendor {vendor_id}: {data.tier}")
|
logger.info(f"Created subscription for vendor {vendor_id}: {data.tier}")
|
||||||
@@ -232,7 +232,7 @@ class SubscriptionService:
|
|||||||
setattr(subscription, key, value)
|
setattr(subscription, key, value)
|
||||||
|
|
||||||
subscription.updated_at = datetime.now(UTC)
|
subscription.updated_at = datetime.now(UTC)
|
||||||
db.commit()
|
db.flush()
|
||||||
db.refresh(subscription)
|
db.refresh(subscription)
|
||||||
|
|
||||||
logger.info(f"Updated subscription for vendor {vendor_id}")
|
logger.info(f"Updated subscription for vendor {vendor_id}")
|
||||||
@@ -255,7 +255,7 @@ class SubscriptionService:
|
|||||||
if subscription.status == SubscriptionStatus.TRIAL.value:
|
if subscription.status == SubscriptionStatus.TRIAL.value:
|
||||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||||
|
|
||||||
db.commit()
|
db.flush()
|
||||||
db.refresh(subscription)
|
db.refresh(subscription)
|
||||||
|
|
||||||
logger.info(f"Upgraded vendor {vendor_id} from {old_tier} to {new_tier}")
|
logger.info(f"Upgraded vendor {vendor_id} from {old_tier} to {new_tier}")
|
||||||
@@ -275,7 +275,7 @@ class SubscriptionService:
|
|||||||
subscription.cancellation_reason = reason
|
subscription.cancellation_reason = reason
|
||||||
subscription.updated_at = datetime.now(UTC)
|
subscription.updated_at = datetime.now(UTC)
|
||||||
|
|
||||||
db.commit()
|
db.flush()
|
||||||
db.refresh(subscription)
|
db.refresh(subscription)
|
||||||
|
|
||||||
logger.info(f"Cancelled subscription for vendor {vendor_id}")
|
logger.info(f"Cancelled subscription for vendor {vendor_id}")
|
||||||
@@ -342,13 +342,13 @@ class SubscriptionService:
|
|||||||
"""
|
"""
|
||||||
subscription = self.get_or_create_subscription(db, vendor_id)
|
subscription = self.get_or_create_subscription(db, vendor_id)
|
||||||
subscription.increment_order_count()
|
subscription.increment_order_count()
|
||||||
db.commit()
|
db.flush()
|
||||||
|
|
||||||
def reset_period_counters(self, db: Session, vendor_id: int) -> None:
|
def reset_period_counters(self, db: Session, vendor_id: int) -> None:
|
||||||
"""Reset counters for a new billing period."""
|
"""Reset counters for a new billing period."""
|
||||||
subscription = self.get_subscription_or_raise(db, vendor_id)
|
subscription = self.get_subscription_or_raise(db, vendor_id)
|
||||||
subscription.reset_period_counters()
|
subscription.reset_period_counters()
|
||||||
db.commit()
|
db.flush()
|
||||||
logger.info(f"Reset period counters for vendor {vendor_id}")
|
logger.info(f"Reset period counters for vendor {vendor_id}")
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -281,3 +281,30 @@ class InvoiceListPaginatedResponse(BaseModel):
|
|||||||
page: int
|
page: int
|
||||||
per_page: int
|
per_page: int
|
||||||
pages: int
|
pages: int
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# PDF Response
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class InvoicePDFGeneratedResponse(BaseModel):
|
||||||
|
"""Response for PDF generation."""
|
||||||
|
|
||||||
|
pdf_path: str
|
||||||
|
message: str = "PDF generated successfully"
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceStatsResponse(BaseModel):
|
||||||
|
"""Invoice statistics response."""
|
||||||
|
|
||||||
|
total_invoices: int
|
||||||
|
total_revenue_cents: int
|
||||||
|
draft_count: int
|
||||||
|
issued_count: int
|
||||||
|
paid_count: int
|
||||||
|
cancelled_count: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_revenue(self) -> float:
|
||||||
|
return self.total_revenue_cents / 100
|
||||||
|
|||||||
Reference in New Issue
Block a user