This commit is contained in:
HotSwapp
2025-08-16 10:05:42 -05:00
parent 0347284556
commit ae4484381f
15 changed files with 3966 additions and 77 deletions

View File

@@ -14,7 +14,7 @@ from enum import Enum
from fastapi import APIRouter, Depends, HTTPException, status, Query, BackgroundTasks
from fastapi import Path as PathParam
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, HTMLResponse
from fastapi import WebSocket, WebSocketDisconnect
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session, joinedload
@@ -29,7 +29,11 @@ from app.auth.security import get_current_user, verify_token
from app.utils.responses import BulkOperationResponse, ErrorDetail
from app.utils.logging import StructuredLogger
from app.services.cache import cache_get_json, cache_set_json
from app.models.billing import BillingBatch, BillingBatchFile
from app.models.billing import (
BillingBatch, BillingBatchFile, BillingStatement, StatementTemplate,
BillingStatementItem, StatementStatus
)
from app.services.billing import BillingStatementService, StatementGenerationError
router = APIRouter()
@@ -1605,3 +1609,417 @@ async def download_latest_statement(
media_type="text/html",
filename=latest_path.name,
)
# =====================================================================
# NEW BILLING STATEMENT MANAGEMENT ENDPOINTS
# =====================================================================
from pydantic import BaseModel as PydanticBaseModel, Field as PydanticField
from typing import Union
class StatementTemplateResponse(PydanticBaseModel):
"""Response model for statement templates"""
id: int
name: str
description: Optional[str] = None
is_default: bool
is_active: bool
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
created_by: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class StatementTemplateCreate(PydanticBaseModel):
"""Create statement template request"""
name: str = PydanticField(..., min_length=1, max_length=100)
description: Optional[str] = None
header_template: Optional[str] = None
footer_template: Optional[str] = None
css_styles: Optional[str] = None
is_default: bool = False
class StatementTemplateUpdate(PydanticBaseModel):
"""Update statement template request"""
name: Optional[str] = PydanticField(None, min_length=1, max_length=100)
description: Optional[str] = None
header_template: Optional[str] = None
footer_template: Optional[str] = None
css_styles: Optional[str] = None
is_default: Optional[bool] = None
is_active: Optional[bool] = None
class BillingStatementResponse(PydanticBaseModel):
"""Response model for billing statements"""
id: int
statement_number: str
file_no: str
customer_id: Optional[str] = None
period_start: date
period_end: date
statement_date: date
due_date: Optional[date] = None
previous_balance: float
current_charges: float
payments_credits: float
total_due: float
trust_balance: float
trust_applied: float
status: StatementStatus
billed_transaction_count: int
approved_by: Optional[str] = None
approved_at: Optional[datetime] = None
sent_by: Optional[str] = None
sent_at: Optional[datetime] = None
created_at: Optional[datetime] = None
created_by: Optional[str] = None
custom_footer: Optional[str] = None
internal_notes: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class BillingStatementCreate(PydanticBaseModel):
"""Create billing statement request"""
file_no: str
period_start: date
period_end: date
template_id: Optional[int] = None
custom_footer: Optional[str] = None
class PaginatedStatementsResponse(PydanticBaseModel):
"""Paginated statements response"""
items: List[BillingStatementResponse]
total: int
class PaginatedTemplatesResponse(PydanticBaseModel):
"""Paginated templates response"""
items: List[StatementTemplateResponse]
total: int
# Statement Templates endpoints
@router.get("/statement-templates", response_model=Union[List[StatementTemplateResponse], PaginatedTemplatesResponse])
async def list_statement_templates(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
include_total: bool = Query(False, description="When true, returns {items, total} instead of a plain list"),
active_only: bool = Query(False, description="Filter to active templates only"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List statement templates"""
query = db.query(StatementTemplate)
if active_only:
query = query.filter(StatementTemplate.is_active == True)
query = query.order_by(StatementTemplate.is_default.desc(), StatementTemplate.name)
if include_total:
total = query.count()
templates = query.offset(skip).limit(limit).all()
return {"items": templates, "total": total}
templates = query.offset(skip).limit(limit).all()
return templates
@router.post("/statement-templates", response_model=StatementTemplateResponse)
async def create_statement_template(
template_data: StatementTemplateCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create a new statement template"""
# Check if template name already exists
existing = db.query(StatementTemplate).filter(StatementTemplate.name == template_data.name).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Template name already exists"
)
# If this is set as default, unset other defaults
if template_data.is_default:
db.query(StatementTemplate).filter(StatementTemplate.is_default == True).update({
StatementTemplate.is_default: False
})
template = StatementTemplate(
**template_data.model_dump(),
created_by=current_user.username,
is_active=True
)
db.add(template)
db.commit()
db.refresh(template)
return template
@router.get("/statement-templates/{template_id}", response_model=StatementTemplateResponse)
async def get_statement_template(
template_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get a specific statement template"""
template = db.query(StatementTemplate).filter(StatementTemplate.id == template_id).first()
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Template not found"
)
return template
@router.put("/statement-templates/{template_id}", response_model=StatementTemplateResponse)
async def update_statement_template(
template_id: int,
template_data: StatementTemplateUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update a statement template"""
template = db.query(StatementTemplate).filter(StatementTemplate.id == template_id).first()
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Template not found"
)
# Check if new name conflicts with existing template
if template_data.name and template_data.name != template.name:
existing = db.query(StatementTemplate).filter(
StatementTemplate.name == template_data.name,
StatementTemplate.id != template_id
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Template name already exists"
)
# If setting as default, unset other defaults
if template_data.is_default:
db.query(StatementTemplate).filter(
StatementTemplate.is_default == True,
StatementTemplate.id != template_id
).update({StatementTemplate.is_default: False})
# Update fields
for field, value in template_data.model_dump(exclude_unset=True).items():
setattr(template, field, value)
db.commit()
db.refresh(template)
return template
@router.delete("/statement-templates/{template_id}")
async def delete_statement_template(
template_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete a statement template"""
template = db.query(StatementTemplate).filter(StatementTemplate.id == template_id).first()
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Template not found"
)
# Check if template is being used by statements
statement_count = db.query(BillingStatement).filter(BillingStatement.template_id == template_id).count()
if statement_count > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot delete template: {statement_count} statements are using this template"
)
db.delete(template)
db.commit()
return {"message": "Template deleted successfully"}
# Billing Statements endpoints
@router.get("/billing-statements", response_model=Union[List[BillingStatementResponse], PaginatedStatementsResponse])
async def list_billing_statements(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
include_total: bool = Query(False, description="When true, returns {items, total} instead of a plain list"),
file_no: Optional[str] = Query(None, description="Filter by file number"),
status: Optional[StatementStatus] = Query(None, description="Filter by statement status"),
start_date: Optional[date] = Query(None, description="Filter statements from this date"),
end_date: Optional[date] = Query(None, description="Filter statements to this date"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List billing statements with filtering"""
query = db.query(BillingStatement).options(
joinedload(BillingStatement.file),
joinedload(BillingStatement.customer),
joinedload(BillingStatement.template)
)
if file_no:
query = query.filter(BillingStatement.file_no == file_no)
if status:
query = query.filter(BillingStatement.status == status)
if start_date:
query = query.filter(BillingStatement.statement_date >= start_date)
if end_date:
query = query.filter(BillingStatement.statement_date <= end_date)
query = query.order_by(BillingStatement.statement_date.desc())
if include_total:
total = query.count()
statements = query.offset(skip).limit(limit).all()
return {"items": statements, "total": total}
statements = query.offset(skip).limit(limit).all()
return statements
@router.post("/billing-statements", response_model=BillingStatementResponse)
async def create_billing_statement(
statement_data: BillingStatementCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create a new billing statement"""
try:
service = BillingStatementService(db)
statement = service.create_statement(
file_no=statement_data.file_no,
period_start=statement_data.period_start,
period_end=statement_data.period_end,
template_id=statement_data.template_id,
custom_footer=statement_data.custom_footer,
created_by=current_user.username
)
return statement
except StatementGenerationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.get("/billing-statements/{statement_id}", response_model=BillingStatementResponse)
async def get_billing_statement(
statement_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get a specific billing statement"""
statement = db.query(BillingStatement).options(
joinedload(BillingStatement.file),
joinedload(BillingStatement.customer),
joinedload(BillingStatement.template),
joinedload(BillingStatement.statement_items)
).filter(BillingStatement.id == statement_id).first()
if not statement:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Statement not found"
)
return statement
@router.post("/billing-statements/{statement_id}/generate-html")
async def generate_statement_html(
statement_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Generate HTML content for a statement"""
try:
service = BillingStatementService(db)
html_content = service.generate_statement_html(statement_id)
return {"html_content": html_content}
except StatementGenerationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/billing-statements/{statement_id}/approve", response_model=BillingStatementResponse)
async def approve_billing_statement(
statement_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Approve a statement and mark transactions as billed"""
try:
service = BillingStatementService(db)
statement = service.approve_statement(statement_id, current_user.username)
return statement
except StatementGenerationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/billing-statements/{statement_id}/send", response_model=BillingStatementResponse)
async def mark_statement_sent(
statement_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Mark statement as sent to client"""
try:
service = BillingStatementService(db)
statement = service.mark_statement_sent(statement_id, current_user.username)
return statement
except StatementGenerationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.get("/billing-statements/{statement_id}/preview")
async def preview_billing_statement(
statement_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get HTML preview of billing statement"""
try:
service = BillingStatementService(db)
html_content = service.generate_statement_html(statement_id)
return HTMLResponse(content=html_content)
except StatementGenerationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.delete("/billing-statements/{statement_id}")
async def delete_billing_statement(
statement_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete a billing statement (only if in draft status)"""
statement = db.query(BillingStatement).filter(BillingStatement.id == statement_id).first()
if not statement:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Statement not found"
)
if statement.status != StatementStatus.DRAFT:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Only draft statements can be deleted"
)
db.delete(statement)
db.commit()
return {"message": "Statement deleted successfully"}