progress
This commit is contained in:
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user