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"}

515
app/api/file_management.py Normal file
View File

@@ -0,0 +1,515 @@
"""
Enhanced file management API endpoints
"""
from typing import List, Optional, Union, Dict, Any
from datetime import date, datetime
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from pydantic import BaseModel, Field, ConfigDict
from app.database.base import get_db
from app.models import (
File, FileStatus, FileType, Employee, User, FileStatusHistory,
FileTransferHistory, FileArchiveInfo
)
from app.services.file_management import FileManagementService, FileManagementError, FileStatusWorkflow
from app.auth.security import get_current_user
from app.utils.logging import app_logger
router = APIRouter()
logger = app_logger
# Pydantic schemas for requests/responses
class FileStatusChangeRequest(BaseModel):
"""Request to change file status"""
new_status: str = Field(..., description="New status code")
notes: Optional[str] = Field(None, description="Notes about the status change")
validate_transition: bool = Field(True, description="Whether to validate the status transition")
class FileClosureRequest(BaseModel):
"""Request to close a file"""
force_close: bool = Field(False, description="Force closure even if there are warnings")
final_payment_amount: Optional[float] = Field(None, gt=0, description="Final payment amount")
closing_notes: Optional[str] = Field(None, description="Notes about file closure")
class FileReopenRequest(BaseModel):
"""Request to reopen a closed file"""
new_status: str = Field("ACTIVE", description="Status to reopen file to")
notes: Optional[str] = Field(None, description="Notes about reopening")
class FileTransferRequest(BaseModel):
"""Request to transfer file to different attorney"""
new_attorney_id: str = Field(..., description="Employee ID of new attorney")
transfer_reason: Optional[str] = Field(None, description="Reason for transfer")
class FileArchiveRequest(BaseModel):
"""Request to archive a file"""
archive_location: Optional[str] = Field(None, description="Physical or digital archive location")
notes: Optional[str] = Field(None, description="Archive notes")
class BulkStatusUpdateRequest(BaseModel):
"""Request to update status for multiple files"""
file_numbers: List[str] = Field(..., max_length=100, description="List of file numbers")
new_status: str = Field(..., description="New status for all files")
notes: Optional[str] = Field(None, description="Notes for all status changes")
class FileStatusHistoryResponse(BaseModel):
"""Response for file status history"""
id: int
old_status: str
new_status: str
change_date: datetime
changed_by: str
notes: Optional[str] = None
system_generated: bool
class FileTransferHistoryResponse(BaseModel):
"""Response for file transfer history"""
id: int
old_attorney_id: str
new_attorney_id: str
transfer_date: datetime
authorized_by_name: str
reason: Optional[str] = None
old_hourly_rate: Optional[float] = None
new_hourly_rate: Optional[float] = None
model_config = ConfigDict(from_attributes=True)
class FileClosureSummaryResponse(BaseModel):
"""Response for file closure summary"""
file_no: str
closure_date: date
actions_taken: List[str]
warnings: List[str]
final_balance: float
trust_balance: float
class FileValidationResponse(BaseModel):
"""Response for file validation checks"""
file_no: str
current_status: str
valid_transitions: List[str]
can_close: bool
blocking_issues: List[str]
warnings: List[str]
class ClosureCandidateResponse(BaseModel):
"""Response for file closure candidates"""
file_no: str
client_name: str
attorney: str
opened_date: date
last_activity: Optional[date] = None
outstanding_balance: float
status: str
class BulkOperationResult(BaseModel):
"""Result of bulk operation"""
successful: List[str]
failed: List[Dict[str, str]]
total: int
# File status management endpoints
@router.post("/{file_no}/change-status")
async def change_file_status(
file_no: str,
request: FileStatusChangeRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Change file status with workflow validation"""
try:
service = FileManagementService(db)
file_obj = service.change_file_status(
file_no=file_no,
new_status=request.new_status,
user_id=current_user.id,
notes=request.notes,
validate_transition=request.validate_transition
)
return {
"message": f"File {file_no} status changed to {request.new_status}",
"file_no": file_obj.file_no,
"old_status": file_obj.status,
"new_status": request.new_status
}
except FileManagementError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.get("/{file_no}/valid-transitions")
async def get_valid_status_transitions(
file_no: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get valid status transitions for a file"""
file_obj = db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
workflow = FileStatusWorkflow()
valid_transitions = workflow.get_valid_transitions(file_obj.status)
return {
"file_no": file_no,
"current_status": file_obj.status,
"valid_transitions": valid_transitions
}
@router.get("/{file_no}/closure-validation", response_model=FileValidationResponse)
async def validate_file_closure(
file_no: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Validate if file is ready for closure"""
try:
service = FileManagementService(db)
file_obj = db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
validation_result = service._validate_file_closure(file_obj)
workflow = FileStatusWorkflow()
return FileValidationResponse(
file_no=file_no,
current_status=file_obj.status,
valid_transitions=workflow.get_valid_transitions(file_obj.status),
can_close=validation_result["can_close"],
blocking_issues=validation_result.get("blocking_issues", []),
warnings=validation_result.get("warnings", [])
)
except FileManagementError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/{file_no}/close", response_model=FileClosureSummaryResponse)
async def close_file(
file_no: str,
request: FileClosureRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Close a file with automated closure process"""
try:
service = FileManagementService(db)
closure_summary = service.close_file(
file_no=file_no,
user_id=current_user.id,
force_close=request.force_close,
final_payment_amount=request.final_payment_amount,
closing_notes=request.closing_notes
)
return FileClosureSummaryResponse(**closure_summary)
except FileManagementError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/{file_no}/reopen")
async def reopen_file(
file_no: str,
request: FileReopenRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Reopen a closed file"""
try:
service = FileManagementService(db)
file_obj = service.reopen_file(
file_no=file_no,
user_id=current_user.id,
new_status=request.new_status,
notes=request.notes
)
return {
"message": f"File {file_no} reopened with status {request.new_status}",
"file_no": file_obj.file_no,
"new_status": file_obj.status
}
except FileManagementError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/{file_no}/transfer")
async def transfer_file(
file_no: str,
request: FileTransferRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Transfer file to a different attorney"""
try:
service = FileManagementService(db)
file_obj = service.transfer_file(
file_no=file_no,
new_attorney_id=request.new_attorney_id,
user_id=current_user.id,
transfer_reason=request.transfer_reason
)
return {
"message": f"File {file_no} transferred to attorney {request.new_attorney_id}",
"file_no": file_obj.file_no,
"new_attorney": file_obj.empl_num,
"new_rate": file_obj.rate_per_hour
}
except FileManagementError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/{file_no}/archive")
async def archive_file(
file_no: str,
request: FileArchiveRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Archive a closed file"""
try:
service = FileManagementService(db)
file_obj = service.archive_file(
file_no=file_no,
user_id=current_user.id,
archive_location=request.archive_location,
notes=request.notes
)
return {
"message": f"File {file_no} has been archived",
"file_no": file_obj.file_no,
"status": file_obj.status,
"archive_location": request.archive_location
}
except FileManagementError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
# File history endpoints
@router.get("/{file_no}/status-history", response_model=List[FileStatusHistoryResponse])
async def get_file_status_history(
file_no: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get status change history for a file"""
# Verify file exists
file_obj = db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
service = FileManagementService(db)
history = service.get_file_status_history(file_no)
return [FileStatusHistoryResponse(**item) for item in history]
@router.get("/{file_no}/transfer-history", response_model=List[FileTransferHistoryResponse])
async def get_file_transfer_history(
file_no: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get transfer history for a file"""
# Verify file exists
file_obj = db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
transfers = db.query(FileTransferHistory).filter(
FileTransferHistory.file_no == file_no
).order_by(FileTransferHistory.transfer_date.desc()).all()
return transfers
# Bulk operations
@router.post("/bulk-status-update", response_model=BulkOperationResult)
async def bulk_status_update(
request: BulkStatusUpdateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update status for multiple files"""
try:
service = FileManagementService(db)
results = service.bulk_status_update(
file_numbers=request.file_numbers,
new_status=request.new_status,
user_id=current_user.id,
notes=request.notes
)
return BulkOperationResult(**results)
except FileManagementError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
# File queries and reports
@router.get("/by-status/{status}")
async def get_files_by_status(
status: str,
attorney_id: Optional[str] = Query(None, description="Filter by attorney ID"),
limit: int = Query(100, ge=1, le=500, description="Maximum number of files to return"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get files by status with optional attorney filter"""
service = FileManagementService(db)
files = service.get_files_by_status(status, attorney_id, limit)
return [
{
"file_no": f.file_no,
"client_name": f"{f.owner.first or ''} {f.owner.last}".strip() if f.owner else "Unknown",
"regarding": f.regarding,
"attorney": f.empl_num,
"opened_date": f.opened,
"closed_date": f.closed,
"status": f.status
}
for f in files
]
@router.get("/closure-candidates", response_model=List[ClosureCandidateResponse])
async def get_closure_candidates(
days_inactive: int = Query(90, ge=30, le=365, description="Days since last activity"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get files that are candidates for closure"""
service = FileManagementService(db)
candidates = service.get_closure_candidates(days_inactive)
return [ClosureCandidateResponse(**candidate) for candidate in candidates]
# Lookup endpoints
@router.get("/statuses")
async def get_file_statuses(
active_only: bool = Query(True, description="Return only active statuses"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get available file statuses"""
query = db.query(FileStatus)
if active_only:
query = query.filter(FileStatus.active == True)
statuses = query.order_by(FileStatus.sort_order, FileStatus.status_code).all()
return [
{
"status_code": s.status_code,
"description": s.description,
"active": s.active,
"send": s.send,
"footer_code": s.footer_code
}
for s in statuses
]
@router.get("/types")
async def get_file_types(
active_only: bool = Query(True, description="Return only active file types"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get available file types"""
query = db.query(FileType)
if active_only:
query = query.filter(FileType.active == True)
types = query.order_by(FileType.type_code).all()
return [
{
"type_code": t.type_code,
"description": t.description,
"default_rate": t.default_rate,
"active": t.active
}
for t in types
]
@router.get("/attorneys")
async def get_attorneys(
active_only: bool = Query(True, description="Return only active attorneys"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get available attorneys for file assignment"""
query = db.query(Employee)
if active_only:
query = query.filter(Employee.active == True)
attorneys = query.order_by(Employee.last_name, Employee.first_name).all()
return [
{
"empl_num": a.empl_num,
"name": f"{a.first_name or ''} {a.last_name}".strip(),
"title": a.title,
"rate_per_hour": a.rate_per_hour,
"active": a.active
}
for a in attorneys
]

577
app/api/timers.py Normal file
View File

@@ -0,0 +1,577 @@
"""
Timer and time tracking API endpoints
"""
from typing import List, Optional, Union
from datetime import datetime, date
from fastapi import APIRouter, Depends, HTTPException, status, Query
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from pydantic import BaseModel, Field, ConfigDict
from app.database.base import get_db
from app.models import Timer, TimeEntry, TimerTemplate, TimerStatus, TimerType, User
from app.services.timers import TimerService, TimerServiceError
from app.auth.security import get_current_user
from app.utils.logging import app_logger
router = APIRouter()
logger = app_logger
# Pydantic schemas for requests/responses
class TimerResponse(BaseModel):
"""Response model for timers"""
id: int
user_id: int
file_no: Optional[str] = None
customer_id: Optional[str] = None
title: str
description: Optional[str] = None
timer_type: TimerType
status: TimerStatus
total_seconds: int
hourly_rate: Optional[float] = None
is_billable: bool
task_category: Optional[str] = None
started_at: Optional[datetime] = None
last_started_at: Optional[datetime] = None
last_paused_at: Optional[datetime] = None
stopped_at: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
notes: Optional[str] = None
# Computed properties
total_hours: Optional[float] = None
is_active: Optional[bool] = None
current_session_seconds: Optional[int] = None
model_config = ConfigDict(from_attributes=True)
@classmethod
def from_timer(cls, timer: Timer) -> "TimerResponse":
"""Create response from Timer model with computed properties"""
return cls(
**timer.__dict__,
total_hours=timer.total_hours,
is_active=timer.is_active,
current_session_seconds=timer.get_current_session_seconds()
)
class TimerCreate(BaseModel):
"""Create timer request"""
title: str = Field(..., min_length=1, max_length=200)
description: Optional[str] = None
file_no: Optional[str] = None
customer_id: Optional[str] = None
timer_type: TimerType = TimerType.BILLABLE
hourly_rate: Optional[float] = Field(None, gt=0)
task_category: Optional[str] = None
template_id: Optional[int] = None
class TimerUpdate(BaseModel):
"""Update timer request"""
title: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = None
file_no: Optional[str] = None
customer_id: Optional[str] = None
timer_type: Optional[TimerType] = None
hourly_rate: Optional[float] = Field(None, gt=0)
task_category: Optional[str] = None
notes: Optional[str] = None
class TimeEntryResponse(BaseModel):
"""Response model for time entries"""
id: int
timer_id: Optional[int] = None
user_id: int
file_no: Optional[str] = None
customer_id: Optional[str] = None
title: str
description: Optional[str] = None
entry_type: TimerType
hours: float
entry_date: datetime
hourly_rate: Optional[float] = None
is_billable: bool
billed: bool
ledger_id: Optional[int] = None
task_category: Optional[str] = None
notes: Optional[str] = None
approved: bool
approved_by: Optional[str] = None
approved_at: Optional[datetime] = None
created_at: Optional[datetime] = None
created_by: Optional[str] = None
# Computed property
calculated_amount: Optional[float] = None
model_config = ConfigDict(from_attributes=True)
@classmethod
def from_time_entry(cls, entry: TimeEntry) -> "TimeEntryResponse":
"""Create response from TimeEntry model with computed properties"""
return cls(
**entry.__dict__,
calculated_amount=entry.calculated_amount
)
class TimeEntryCreate(BaseModel):
"""Create time entry request"""
title: str = Field(..., min_length=1, max_length=200)
description: Optional[str] = None
file_no: Optional[str] = None
customer_id: Optional[str] = None
hours: float = Field(..., gt=0, le=24)
entry_date: datetime
hourly_rate: Optional[float] = Field(None, gt=0)
entry_type: TimerType = TimerType.BILLABLE
task_category: Optional[str] = None
class TimeEntryFromTimerCreate(BaseModel):
"""Create time entry from timer request"""
title: Optional[str] = None
description: Optional[str] = None
hours_override: Optional[float] = Field(None, gt=0, le=24)
entry_date: Optional[datetime] = None
class TimerTemplateResponse(BaseModel):
"""Response model for timer templates"""
id: int
name: str
title_template: str
description_template: Optional[str] = None
timer_type: TimerType
task_category: Optional[str] = None
default_rate: Optional[float] = None
is_billable: bool
is_active: bool
usage_count: int
created_by: Optional[str] = None
created_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class TimerTemplateCreate(BaseModel):
"""Create timer template request"""
name: str = Field(..., min_length=1, max_length=100)
title_template: str = Field(..., min_length=1, max_length=200)
description_template: Optional[str] = None
timer_type: TimerType = TimerType.BILLABLE
task_category: Optional[str] = None
default_rate: Optional[float] = Field(None, gt=0)
is_billable: bool = True
class TimerStatistics(BaseModel):
"""Timer statistics response"""
period_days: int
total_hours: float
billable_hours: float
non_billable_hours: float
active_timers: int
time_entries_created: int
time_entries_billed: int
billable_percentage: float
class PaginatedTimersResponse(BaseModel):
"""Paginated timers response"""
items: List[TimerResponse]
total: int
class PaginatedTimeEntriesResponse(BaseModel):
"""Paginated time entries response"""
items: List[TimeEntryResponse]
total: int
# Timer endpoints
@router.get("/", response_model=Union[List[TimerResponse], PaginatedTimersResponse])
async def list_timers(
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"),
status: Optional[TimerStatus] = Query(None, description="Filter by timer status"),
file_no: Optional[str] = Query(None, description="Filter by file number"),
active_only: bool = Query(False, description="Show only active (running/paused) timers"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List timers for the current user"""
service = TimerService(db)
if active_only:
timers = service.get_active_timers(current_user.id)
timer_responses = [TimerResponse.from_timer(timer) for timer in timers]
if include_total:
return {"items": timer_responses, "total": len(timer_responses)}
return timer_responses
# Get all timers with filtering
timers = service.get_user_timers(
user_id=current_user.id,
status_filter=status,
file_no=file_no,
limit=limit + skip if not include_total else 1000 # Get more for pagination
)
# Apply pagination manually for now
if include_total:
total = len(timers)
paginated_timers = timers[skip:skip + limit]
timer_responses = [TimerResponse.from_timer(timer) for timer in paginated_timers]
return {"items": timer_responses, "total": total}
paginated_timers = timers[skip:skip + limit]
timer_responses = [TimerResponse.from_timer(timer) for timer in paginated_timers]
return timer_responses
@router.post("/", response_model=TimerResponse)
async def create_timer(
timer_data: TimerCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create a new timer"""
try:
service = TimerService(db)
timer = service.create_timer(
user_id=current_user.id,
**timer_data.model_dump()
)
return TimerResponse.from_timer(timer)
except TimerServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.get("/{timer_id}", response_model=TimerResponse)
async def get_timer(
timer_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get a specific timer"""
try:
service = TimerService(db)
timer = service._get_user_timer(timer_id, current_user.id)
return TimerResponse.from_timer(timer)
except TimerServiceError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
@router.put("/{timer_id}", response_model=TimerResponse)
async def update_timer(
timer_id: int,
timer_data: TimerUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update a timer"""
try:
service = TimerService(db)
timer = service._get_user_timer(timer_id, current_user.id)
# Update fields
for field, value in timer_data.model_dump(exclude_unset=True).items():
setattr(timer, field, value)
db.commit()
db.refresh(timer)
return TimerResponse.from_timer(timer)
except TimerServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.delete("/{timer_id}")
async def delete_timer(
timer_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete a timer"""
try:
service = TimerService(db)
service.delete_timer(timer_id, current_user.id)
return {"message": "Timer deleted successfully"}
except TimerServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
# Timer control endpoints
@router.post("/{timer_id}/start", response_model=TimerResponse)
async def start_timer(
timer_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Start a timer"""
try:
service = TimerService(db)
timer = service.start_timer(timer_id, current_user.id)
return TimerResponse.from_timer(timer)
except TimerServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/{timer_id}/pause", response_model=TimerResponse)
async def pause_timer(
timer_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Pause a timer"""
try:
service = TimerService(db)
timer = service.pause_timer(timer_id, current_user.id)
return TimerResponse.from_timer(timer)
except TimerServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/{timer_id}/resume", response_model=TimerResponse)
async def resume_timer(
timer_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Resume a paused timer"""
try:
service = TimerService(db)
timer = service.resume_timer(timer_id, current_user.id)
return TimerResponse.from_timer(timer)
except TimerServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/{timer_id}/stop", response_model=TimerResponse)
async def stop_timer(
timer_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Stop a timer"""
try:
service = TimerService(db)
timer = service.stop_timer(timer_id, current_user.id)
return TimerResponse.from_timer(timer)
except TimerServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
# Time entry endpoints
@router.get("/time-entries/", response_model=Union[List[TimeEntryResponse], PaginatedTimeEntriesResponse])
async def list_time_entries(
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"),
billed: Optional[bool] = Query(None, description="Filter by billing status"),
start_date: Optional[date] = Query(None, description="Filter entries from this date"),
end_date: Optional[date] = Query(None, description="Filter entries to this date"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List time entries for the current user"""
query = db.query(TimeEntry).filter(TimeEntry.user_id == current_user.id)
if file_no:
query = query.filter(TimeEntry.file_no == file_no)
if billed is not None:
query = query.filter(TimeEntry.billed == billed)
if start_date:
query = query.filter(TimeEntry.entry_date >= start_date)
if end_date:
query = query.filter(TimeEntry.entry_date <= end_date)
query = query.order_by(TimeEntry.entry_date.desc())
if include_total:
total = query.count()
entries = query.offset(skip).limit(limit).all()
entry_responses = [TimeEntryResponse.from_time_entry(entry) for entry in entries]
return {"items": entry_responses, "total": total}
entries = query.offset(skip).limit(limit).all()
entry_responses = [TimeEntryResponse.from_time_entry(entry) for entry in entries]
return entry_responses
@router.post("/time-entries/", response_model=TimeEntryResponse)
async def create_manual_time_entry(
entry_data: TimeEntryCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create a manual time entry"""
try:
service = TimerService(db)
entry = service.create_manual_time_entry(
user_id=current_user.id,
**entry_data.model_dump()
)
return TimeEntryResponse.from_time_entry(entry)
except TimerServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/{timer_id}/create-entry", response_model=TimeEntryResponse)
async def create_time_entry_from_timer(
timer_id: int,
entry_data: TimeEntryFromTimerCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create a time entry from a completed timer"""
try:
service = TimerService(db)
entry = service.create_time_entry_from_timer(
timer_id=timer_id,
user_id=current_user.id,
**entry_data.model_dump()
)
return TimeEntryResponse.from_time_entry(entry)
except TimerServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/time-entries/{entry_id}/convert-to-billing")
async def convert_time_entry_to_billing(
entry_id: int,
transaction_code: str = Query("TIME", description="Transaction code for billing entry"),
notes: Optional[str] = Query(None, description="Additional notes for billing entry"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Convert a time entry to a billable ledger transaction"""
try:
service = TimerService(db)
ledger_entry = service.convert_time_entry_to_ledger(
time_entry_id=entry_id,
user_id=current_user.id,
transaction_code=transaction_code,
notes=notes
)
return {
"message": "Time entry converted to billing successfully",
"ledger_id": ledger_entry.id,
"amount": ledger_entry.amount
}
except TimerServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
# Timer templates endpoints
@router.get("/templates/", response_model=List[TimerTemplateResponse])
async def list_timer_templates(
active_only: bool = Query(True, description="Show only active templates"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List timer templates"""
query = db.query(TimerTemplate)
if active_only:
query = query.filter(TimerTemplate.is_active == True)
templates = query.order_by(TimerTemplate.usage_count.desc(), TimerTemplate.name).all()
return templates
@router.post("/templates/", response_model=TimerTemplateResponse)
async def create_timer_template(
template_data: TimerTemplateCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create a new timer template"""
# Check if template name already exists
existing = db.query(TimerTemplate).filter(TimerTemplate.name == template_data.name).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Template name already exists"
)
template = TimerTemplate(
**template_data.model_dump(),
created_by=current_user.username
)
db.add(template)
db.commit()
db.refresh(template)
return template
# Statistics endpoint
@router.get("/statistics/", response_model=TimerStatistics)
async def get_timer_statistics(
days: int = Query(30, ge=1, le=365, description="Number of days to analyze"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get timer statistics for the current user"""
service = TimerService(db)
stats = service.get_timer_statistics(current_user.id, days)
return TimerStatistics(**stats)
# Active timers quick access
@router.get("/active/", response_model=List[TimerResponse])
async def get_active_timers(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get all active timers for the current user"""
service = TimerService(db)
timers = service.get_active_timers(current_user.id)
return [TimerResponse.from_timer(timer) for timer in timers]

View File

@@ -94,6 +94,8 @@ from app.api.mortality import router as mortality_router
from app.api.pensions import router as pensions_router
from app.api.templates import router as templates_router
from app.api.qdros import router as qdros_router
from app.api.timers import router as timers_router
from app.api.file_management import router as file_management_router
logger.info("Including API routers")
app.include_router(auth_router, prefix="/api/auth", tags=["authentication"])
@@ -112,6 +114,8 @@ app.include_router(mortality_router, prefix="/api/mortality", tags=["mortality"]
app.include_router(pensions_router, prefix="/api/pensions", tags=["pensions"])
app.include_router(templates_router, prefix="/api/templates", tags=["templates"])
app.include_router(qdros_router, prefix="/api", tags=["qdros"])
app.include_router(timers_router, prefix="/api/timers", tags=["timers"])
app.include_router(file_management_router, prefix="/api/file-management", tags=["file-management"])
@app.get("/", response_class=HTMLResponse)

View File

@@ -17,7 +17,17 @@ from .pensions import (
SeparationAgreement, LifeTable, NumberTable, PensionResult
)
from .templates import DocumentTemplate, DocumentTemplateVersion, TemplateKeyword
from .billing import BillingBatch, BillingBatchFile
from .billing import (
BillingBatch, BillingBatchFile, StatementTemplate, BillingStatement,
BillingStatementItem, StatementPayment, StatementStatus
)
from .timers import (
Timer, TimeEntry, TimerSession, TimerTemplate, TimerStatus, TimerType
)
from .file_management import (
FileStatusHistory, FileTransferHistory, FileArchiveInfo,
FileClosureChecklist, FileAlert
)
from .lookups import (
Employee, FileType, FileStatus, TransactionType, TransactionCode,
State, GroupLookup, Footer, PlanInfo, FormIndex, FormList,
@@ -34,5 +44,9 @@ __all__ = [
"Employee", "FileType", "FileStatus", "TransactionType", "TransactionCode",
"State", "GroupLookup", "Footer", "PlanInfo", "FormIndex", "FormList",
"PrinterSetup", "SystemSetup", "FormKeyword", "TemplateKeyword",
"BillingBatch", "BillingBatchFile"
"BillingBatch", "BillingBatchFile", "StatementTemplate", "BillingStatement",
"BillingStatementItem", "StatementPayment", "StatementStatus",
"Timer", "TimeEntry", "TimerSession", "TimerTemplate", "TimerStatus", "TimerType",
"FileStatusHistory", "FileTransferHistory", "FileArchiveInfo",
"FileClosureChecklist", "FileAlert"
]

View File

@@ -1,5 +1,8 @@
from sqlalchemy import Column, Integer, String, DateTime, Float, Text, Index
from sqlalchemy import Column, Integer, String, DateTime, Date, Float, Boolean, Text, Index, ForeignKey, Enum
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.models.base import BaseModel
import enum
class BillingBatch(BaseModel):
@@ -45,3 +48,174 @@ class BillingBatchFile(BaseModel):
)
class StatementStatus(str, enum.Enum):
"""Statement status enumeration"""
DRAFT = "draft"
PENDING_APPROVAL = "pending_approval"
APPROVED = "approved"
SENT = "sent"
PAID = "paid"
CANCELLED = "cancelled"
class StatementTemplate(BaseModel):
"""
Templates for billing statement generation
Allows customization of statement format, footer text, etc.
"""
__tablename__ = "statement_templates"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False, unique=True)
description = Column(Text)
# Template content
header_template = Column(Text) # HTML/Jinja2 template for header
footer_template = Column(Text) # HTML/Jinja2 template for footer
css_styles = Column(Text) # Custom CSS for statement styling
# Template settings
is_default = Column(Boolean, default=False)
is_active = Column(Boolean, default=True)
# Metadata
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
created_by = Column(String(50))
# Relationships
statements = relationship("BillingStatement", back_populates="template")
def __repr__(self):
return f"<StatementTemplate(name='{self.name}', default={self.is_default})>"
class BillingStatement(BaseModel):
"""
Generated billing statements for files/clients
Tracks statement metadata, status, and generation details
"""
__tablename__ = "billing_statements"
id = Column(Integer, primary_key=True, autoincrement=True)
statement_number = Column(String(50), unique=True, nullable=False) # Unique statement identifier
# File/Client reference
file_no = Column(String(45), ForeignKey("files.file_no"), nullable=False)
customer_id = Column(String(45), ForeignKey("rolodex.id")) # Optional direct customer reference
# Statement period
period_start = Column(Date, nullable=False)
period_end = Column(Date, nullable=False)
statement_date = Column(Date, nullable=False, default=func.current_date())
due_date = Column(Date)
# Financial totals
previous_balance = Column(Float, default=0.0)
current_charges = Column(Float, default=0.0)
payments_credits = Column(Float, default=0.0)
total_due = Column(Float, nullable=False)
# Trust account information
trust_balance = Column(Float, default=0.0)
trust_applied = Column(Float, default=0.0)
# Statement details
status = Column(Enum(StatementStatus), default=StatementStatus.DRAFT)
template_id = Column(Integer, ForeignKey("statement_templates.id"))
# Generated content
html_content = Column(Text) # Generated HTML content
pdf_path = Column(String(500)) # Path to generated PDF file
# Billing metadata
billed_transaction_count = Column(Integer, default=0)
approved_by = Column(String(50)) # User who approved the statement
approved_at = Column(DateTime)
sent_by = Column(String(50)) # User who sent the statement
sent_at = Column(DateTime)
# Metadata
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
created_by = Column(String(50))
# Notes and customization
custom_footer = Column(Text) # Override template footer for this statement
internal_notes = Column(Text) # Internal notes not shown to client
# Relationships
file = relationship("File", back_populates="billing_statements")
customer = relationship("Rolodex", back_populates="billing_statements")
template = relationship("StatementTemplate", back_populates="statements")
statement_items = relationship("BillingStatementItem", back_populates="statement", cascade="all, delete-orphan")
def __repr__(self):
return f"<BillingStatement(number='{self.statement_number}', file_no='{self.file_no}', total={self.total_due})>"
class BillingStatementItem(BaseModel):
"""
Individual line items on a billing statement
Links to ledger entries that were included in the statement
"""
__tablename__ = "billing_statement_items"
id = Column(Integer, primary_key=True, autoincrement=True)
statement_id = Column(Integer, ForeignKey("billing_statements.id"), nullable=False)
ledger_id = Column(Integer, ForeignKey("ledger.id"), nullable=False)
# Item display details (cached from ledger for statement consistency)
date = Column(Date, nullable=False)
description = Column(Text)
quantity = Column(Float, default=0.0)
rate = Column(Float, default=0.0)
amount = Column(Float, nullable=False)
# Item categorization
item_category = Column(String(50)) # fees, costs, payments, adjustments
# Metadata
created_at = Column(DateTime, default=func.now())
# Relationships
statement = relationship("BillingStatement", back_populates="statement_items")
ledger_entry = relationship("Ledger")
def __repr__(self):
return f"<BillingStatementItem(statement_id={self.statement_id}, amount={self.amount})>"
class StatementPayment(BaseModel):
"""
Payments applied to billing statements
Tracks payment history and application
"""
__tablename__ = "statement_payments"
id = Column(Integer, primary_key=True, autoincrement=True)
statement_id = Column(Integer, ForeignKey("billing_statements.id"), nullable=False)
# Payment details
payment_date = Column(Date, nullable=False)
payment_amount = Column(Float, nullable=False)
payment_method = Column(String(50)) # check, credit_card, trust_transfer, etc.
reference_number = Column(String(100)) # check number, transaction ID, etc.
# Application details
applied_to_fees = Column(Float, default=0.0)
applied_to_costs = Column(Float, default=0.0)
applied_to_trust = Column(Float, default=0.0)
# Metadata
created_at = Column(DateTime, default=func.now())
created_by = Column(String(50))
notes = Column(Text)
# Relationships
statement = relationship("BillingStatement")
def __repr__(self):
return f"<StatementPayment(statement_id={self.statement_id}, amount={self.payment_amount})>"

View File

@@ -0,0 +1,191 @@
"""
File management models for enhanced file operations
"""
from sqlalchemy import Column, Integer, String, DateTime, Date, Float, Text, ForeignKey, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.models.base import BaseModel
class FileStatusHistory(BaseModel):
"""
Track file status changes over time
Provides audit trail for file status transitions
"""
__tablename__ = "file_status_history"
id = Column(Integer, primary_key=True, autoincrement=True)
file_no = Column(String(45), ForeignKey("files.file_no"), nullable=False, index=True)
# Status change details
old_status = Column(String(45), nullable=False)
new_status = Column(String(45), nullable=False)
change_date = Column(DateTime(timezone=True), default=func.now(), nullable=False)
# Who made the change
changed_by_user_id = Column(Integer, ForeignKey("users.id"))
changed_by_name = Column(String(100)) # Cached name for reporting
# Additional context
notes = Column(Text) # Reason for status change
system_generated = Column(Boolean, default=False) # True if automated change
# Relationships
file = relationship("File")
changed_by = relationship("User")
def __repr__(self):
return f"<FileStatusHistory(file_no='{self.file_no}', {self.old_status} -> {self.new_status})>"
class FileTransferHistory(BaseModel):
"""
Track file transfers between attorneys
Maintains chain of custody for files
"""
__tablename__ = "file_transfer_history"
id = Column(Integer, primary_key=True, autoincrement=True)
file_no = Column(String(45), ForeignKey("files.file_no"), nullable=False, index=True)
# Transfer details
old_attorney_id = Column(String(10), ForeignKey("employees.empl_num"), nullable=False)
new_attorney_id = Column(String(10), ForeignKey("employees.empl_num"), nullable=False)
transfer_date = Column(DateTime(timezone=True), default=func.now(), nullable=False)
# Who authorized the transfer
authorized_by_user_id = Column(Integer, ForeignKey("users.id"))
authorized_by_name = Column(String(100))
# Transfer context
reason = Column(Text) # Reason for transfer
effective_date = Column(Date) # When transfer becomes effective (may be future)
# Rate changes
old_hourly_rate = Column(Float, nullable=True)
new_hourly_rate = Column(Float, nullable=True)
# Relationships
file = relationship("File")
old_attorney = relationship("Employee", foreign_keys=[old_attorney_id])
new_attorney = relationship("Employee", foreign_keys=[new_attorney_id])
authorized_by = relationship("User")
def __repr__(self):
return f"<FileTransferHistory(file_no='{self.file_no}', {self.old_attorney_id} -> {self.new_attorney_id})>"
class FileArchiveInfo(BaseModel):
"""
Archive information for files
Tracks where files are stored and retrieval information
"""
__tablename__ = "file_archive_info"
id = Column(Integer, primary_key=True, autoincrement=True)
file_no = Column(String(45), ForeignKey("files.file_no"), nullable=False, unique=True, index=True)
# Archive details
archive_date = Column(Date, nullable=False, default=func.current_date())
archive_location = Column(String(200)) # Physical or digital location
box_number = Column(String(50)) # Physical box identifier
shelf_location = Column(String(100)) # Physical shelf/room location
# Digital archive info
digital_path = Column(String(500)) # Path to digital archive
backup_location = Column(String(500)) # Backup storage location
# Archive metadata
archived_by_user_id = Column(Integer, ForeignKey("users.id"))
archived_by_name = Column(String(100))
retrieval_instructions = Column(Text) # How to retrieve the file
# Retention information
retention_date = Column(Date) # When file can be destroyed
destruction_date = Column(Date) # When file was actually destroyed
destruction_authorized_by = Column(String(100))
# Archive status
is_retrievable = Column(Boolean, default=True)
last_verified = Column(Date) # Last time archive was verified to exist
# Relationships
file = relationship("File")
archived_by = relationship("User")
def __repr__(self):
return f"<FileArchiveInfo(file_no='{self.file_no}', location='{self.archive_location}')>"
class FileClosureChecklist(BaseModel):
"""
Checklist items for file closure process
Ensures all necessary steps are completed before closing
"""
__tablename__ = "file_closure_checklist"
id = Column(Integer, primary_key=True, autoincrement=True)
file_no = Column(String(45), ForeignKey("files.file_no"), nullable=False, index=True)
# Checklist item details
item_name = Column(String(200), nullable=False)
item_description = Column(Text)
is_required = Column(Boolean, default=True) # Must be completed to close file
# Completion tracking
is_completed = Column(Boolean, default=False)
completed_date = Column(DateTime(timezone=True))
completed_by_user_id = Column(Integer, ForeignKey("users.id"))
completed_by_name = Column(String(100))
# Additional info
notes = Column(Text) # Notes about completion
sort_order = Column(Integer, default=0) # Display order
# Relationships
file = relationship("File")
completed_by = relationship("User")
def __repr__(self):
status = "" if self.is_completed else ""
return f"<FileClosureChecklist({status} {self.item_name} - {self.file_no})>"
class FileAlert(BaseModel):
"""
Alerts and reminders for files
Automated notifications for important dates and events
"""
__tablename__ = "file_alerts"
id = Column(Integer, primary_key=True, autoincrement=True)
file_no = Column(String(45), ForeignKey("files.file_no"), nullable=False, index=True)
# Alert details
alert_type = Column(String(50), nullable=False) # deadline, follow_up, billing, etc.
title = Column(String(200), nullable=False)
message = Column(Text, nullable=False)
# Timing
alert_date = Column(Date, nullable=False) # When alert should trigger
created_date = Column(Date, default=func.current_date())
# Status
is_active = Column(Boolean, default=True)
is_acknowledged = Column(Boolean, default=False)
acknowledged_by_user_id = Column(Integer, ForeignKey("users.id"))
acknowledged_at = Column(DateTime(timezone=True))
# Notification settings
notify_attorney = Column(Boolean, default=True)
notify_admin = Column(Boolean, default=False)
notification_days_advance = Column(Integer, default=7) # Days before alert_date
# Relationships
file = relationship("File")
acknowledged_by = relationship("User")
def __repr__(self):
status = "🔔" if self.is_active and not self.is_acknowledged else ""
return f"<FileAlert({status} {self.alert_type} - {self.file_no} on {self.alert_date})>"

View File

@@ -66,3 +66,6 @@ class File(BaseModel):
payments = relationship("Payment", back_populates="file", cascade="all, delete-orphan")
notes = relationship("FileNote", back_populates="file", cascade="all, delete-orphan")
documents = relationship("Document", back_populates="file", cascade="all, delete-orphan")
billing_statements = relationship("BillingStatement", back_populates="file", cascade="all, delete-orphan")
timers = relationship("Timer", back_populates="file", cascade="all, delete-orphan")
time_entries = relationship("TimeEntry", back_populates="file", cascade="all, delete-orphan")

View File

@@ -45,6 +45,9 @@ class Rolodex(BaseModel):
phone_numbers = relationship("Phone", back_populates="rolodex_entry", cascade="all, delete-orphan")
files = relationship("File", back_populates="owner")
payments = relationship("Payment", back_populates="client")
billing_statements = relationship("BillingStatement", back_populates="customer")
timers = relationship("Timer", back_populates="customer")
time_entries = relationship("TimeEntry", back_populates="customer")
class Phone(BaseModel):

227
app/models/timers.py Normal file
View File

@@ -0,0 +1,227 @@
"""
Timer and time tracking models for integrated time tracking system
"""
from sqlalchemy import Column, Integer, String, DateTime, Float, Boolean, Text, ForeignKey, Enum
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.models.base import BaseModel
import enum
from datetime import datetime, timezone
class TimerStatus(str, enum.Enum):
"""Timer status enumeration"""
STOPPED = "stopped"
RUNNING = "running"
PAUSED = "paused"
class TimerType(str, enum.Enum):
"""Timer type enumeration"""
BILLABLE = "billable"
NON_BILLABLE = "non_billable"
ADMINISTRATIVE = "administrative"
class Timer(BaseModel):
"""
Active timer sessions for time tracking
Represents a running, paused, or stopped timer
"""
__tablename__ = "timers"
id = Column(Integer, primary_key=True, autoincrement=True)
# User and assignment
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
file_no = Column(String(45), ForeignKey("files.file_no")) # Optional file assignment
customer_id = Column(String(45), ForeignKey("rolodex.id")) # Optional customer assignment
# Timer details
title = Column(String(200), nullable=False) # Brief description of task
description = Column(Text) # Detailed description of work
timer_type = Column(Enum(TimerType), default=TimerType.BILLABLE)
# Time tracking
status = Column(Enum(TimerStatus), default=TimerStatus.STOPPED)
total_seconds = Column(Integer, default=0) # Total accumulated time in seconds
# Session timing
started_at = Column(DateTime(timezone=True)) # When timer was first started
last_started_at = Column(DateTime(timezone=True)) # When current session started
last_paused_at = Column(DateTime(timezone=True)) # When timer was last paused
stopped_at = Column(DateTime(timezone=True)) # When timer was stopped
# Billing information
hourly_rate = Column(Float) # Override rate for this timer
is_billable = Column(Boolean, default=True)
# Metadata
created_at = Column(DateTime(timezone=True), default=func.now())
updated_at = Column(DateTime(timezone=True), default=func.now(), onupdate=func.now())
# Task categorization
task_category = Column(String(50)) # research, drafting, client_call, court_appearance, etc.
notes = Column(Text) # Additional notes
# Relationships
user = relationship("User", back_populates="timers")
file = relationship("File", back_populates="timers")
customer = relationship("Rolodex", back_populates="timers")
time_entries = relationship("TimeEntry", back_populates="timer", cascade="all, delete-orphan")
def __repr__(self):
return f"<Timer(id={self.id}, title='{self.title}', status='{self.status}', total_seconds={self.total_seconds})>"
@property
def total_hours(self) -> float:
"""Get total time in hours"""
return self.total_seconds / 3600.0 if self.total_seconds else 0.0
@property
def is_active(self) -> bool:
"""Check if timer is currently running or paused"""
return self.status in [TimerStatus.RUNNING, TimerStatus.PAUSED]
def get_current_session_seconds(self) -> int:
"""Get seconds for current running session"""
if self.status == TimerStatus.RUNNING and self.last_started_at:
now = datetime.now(timezone.utc)
session_duration = (now - self.last_started_at).total_seconds()
return int(session_duration)
return 0
def get_total_current_seconds(self) -> int:
"""Get total seconds including current running session"""
return self.total_seconds + self.get_current_session_seconds()
class TimeEntry(BaseModel):
"""
Completed time entries that can be converted to billing transactions
Represents finalized time that has been logged
"""
__tablename__ = "time_entries"
id = Column(Integer, primary_key=True, autoincrement=True)
# Source timer (optional - entries can be created manually)
timer_id = Column(Integer, ForeignKey("timers.id"))
# User and assignment
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
file_no = Column(String(45), ForeignKey("files.file_no")) # Optional file assignment
customer_id = Column(String(45), ForeignKey("rolodex.id")) # Optional customer assignment
# Time entry details
title = Column(String(200), nullable=False)
description = Column(Text)
entry_type = Column(Enum(TimerType), default=TimerType.BILLABLE)
# Time information
hours = Column(Float, nullable=False) # Time in decimal hours (e.g., 1.5 = 1 hour 30 minutes)
entry_date = Column(DateTime(timezone=True), nullable=False) # Date/time when work was performed
# Billing information
hourly_rate = Column(Float) # Rate for this entry
is_billable = Column(Boolean, default=True)
billed = Column(Boolean, default=False) # Whether this has been converted to a ledger entry
# Related ledger entry (after billing)
ledger_id = Column(Integer, ForeignKey("ledger.id")) # Link to created billing transaction
# Metadata
created_at = Column(DateTime(timezone=True), default=func.now())
updated_at = Column(DateTime(timezone=True), default=func.now(), onupdate=func.now())
created_by = Column(String(50)) # Who created this entry
# Task categorization
task_category = Column(String(50)) # research, drafting, client_call, court_appearance, etc.
notes = Column(Text) # Additional notes
# Approval workflow
approved = Column(Boolean, default=False)
approved_by = Column(String(50))
approved_at = Column(DateTime(timezone=True))
# Relationships
timer = relationship("Timer", back_populates="time_entries")
user = relationship("User", back_populates="time_entries")
file = relationship("File", back_populates="time_entries")
customer = relationship("Rolodex", back_populates="time_entries")
ledger_entry = relationship("Ledger")
def __repr__(self):
return f"<TimeEntry(id={self.id}, title='{self.title}', hours={self.hours}, billed={self.billed})>"
@property
def calculated_amount(self) -> float:
"""Calculate billable amount based on hours and rate"""
if not self.is_billable or not self.hourly_rate:
return 0.0
return self.hours * self.hourly_rate
class TimerSession(BaseModel):
"""
Individual timer sessions for detailed tracking
Each start/stop cycle creates a session record
"""
__tablename__ = "timer_sessions"
id = Column(Integer, primary_key=True, autoincrement=True)
timer_id = Column(Integer, ForeignKey("timers.id"), nullable=False)
# Session timing
started_at = Column(DateTime(timezone=True), nullable=False)
ended_at = Column(DateTime(timezone=True))
duration_seconds = Column(Integer, default=0)
# Session notes
notes = Column(Text) # Notes for this specific session
# Pause tracking
pause_count = Column(Integer, default=0) # Number of times paused during session
total_pause_seconds = Column(Integer, default=0) # Total time spent paused
# Relationships
timer = relationship("Timer")
def __repr__(self):
return f"<TimerSession(id={self.id}, timer_id={self.timer_id}, duration_seconds={self.duration_seconds})>"
@property
def duration_hours(self) -> float:
"""Get session duration in hours"""
return self.duration_seconds / 3600.0 if self.duration_seconds else 0.0
class TimerTemplate(BaseModel):
"""
Predefined timer templates for common tasks
Allows quick creation of timers with pre-filled information
"""
__tablename__ = "timer_templates"
id = Column(Integer, primary_key=True, autoincrement=True)
# Template details
name = Column(String(100), nullable=False)
title_template = Column(String(200), nullable=False) # Template for timer title
description_template = Column(Text) # Template for timer description
# Default settings
timer_type = Column(Enum(TimerType), default=TimerType.BILLABLE)
task_category = Column(String(50))
default_rate = Column(Float)
is_billable = Column(Boolean, default=True)
# Template metadata
created_by = Column(String(50))
created_at = Column(DateTime(timezone=True), default=func.now())
updated_at = Column(DateTime(timezone=True), default=func.now(), onupdate=func.now())
is_active = Column(Boolean, default=True)
usage_count = Column(Integer, default=0) # Track how often template is used
def __repr__(self):
return f"<TimerTemplate(id={self.id}, name='{self.name}', usage_count={self.usage_count})>"

View File

@@ -37,6 +37,8 @@ class User(BaseModel):
# Relationships
audit_logs = relationship("AuditLog", back_populates="user")
submitted_tickets = relationship("SupportTicket", foreign_keys="SupportTicket.user_id", back_populates="submitter")
timers = relationship("Timer", back_populates="user", cascade="all, delete-orphan")
time_entries = relationship("TimeEntry", back_populates="user", cascade="all, delete-orphan")
def __repr__(self):
return f"<User(username='{self.username}', email='{self.email}')>"

539
app/services/billing.py Normal file
View File

@@ -0,0 +1,539 @@
"""
Billing statement generation service
Handles statement creation, template rendering, and PDF generation
"""
from typing import List, Dict, Any, Optional, Tuple
from datetime import date, datetime, timedelta
from decimal import Decimal
from jinja2 import Template, Environment, BaseLoader
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import and_, func
from app.models import (
BillingStatement, StatementTemplate, BillingStatementItem,
File, Ledger, Rolodex, StatementStatus
)
from app.utils.logging import app_logger
logger = app_logger
class StatementGenerationError(Exception):
"""Exception raised when statement generation fails"""
pass
class BillingStatementService:
"""Service for generating and managing billing statements"""
def __init__(self, db: Session):
self.db = db
self.jinja_env = Environment(loader=BaseLoader())
def generate_statement_number(self) -> str:
"""Generate unique statement number"""
today = date.today()
prefix = f"STMT-{today.strftime('%Y%m')}"
# Find highest number for this month
last_stmt = self.db.query(BillingStatement).filter(
BillingStatement.statement_number.like(f"{prefix}%")
).order_by(BillingStatement.statement_number.desc()).first()
if last_stmt:
try:
last_num = int(last_stmt.statement_number.split('-')[-1])
next_num = last_num + 1
except (ValueError, IndexError):
next_num = 1
else:
next_num = 1
return f"{prefix}-{next_num:04d}"
def get_unbilled_transactions(
self,
file_no: str,
period_start: date = None,
period_end: date = None
) -> List[Ledger]:
"""Get unbilled transactions for a file within date range"""
query = self.db.query(Ledger).filter(
Ledger.file_no == file_no,
Ledger.billed == "N"
)
if period_start:
query = query.filter(Ledger.date >= period_start)
if period_end:
query = query.filter(Ledger.date <= period_end)
return query.order_by(Ledger.date).all()
def calculate_statement_totals(self, transactions: List[Ledger]) -> Dict[str, float]:
"""Calculate financial totals for statement"""
totals = {
'fees': 0.0,
'costs': 0.0,
'payments': 0.0,
'trust_deposits': 0.0,
'trust_transfers': 0.0,
'adjustments': 0.0,
'current_charges': 0.0,
'net_charges': 0.0
}
for txn in transactions:
amount = float(txn.amount or 0.0)
# Categorize by transaction type
if txn.t_type in ['1', '2']: # Fees (hourly and flat)
totals['fees'] += amount
totals['current_charges'] += amount
elif txn.t_type == '3': # Costs/disbursements
totals['costs'] += amount
totals['current_charges'] += amount
elif txn.t_type == '4': # Payments
totals['payments'] += amount
totals['net_charges'] -= amount
elif txn.t_type == '5': # Trust deposits
totals['trust_deposits'] += amount
# Add more categorization as needed
totals['net_charges'] = totals['current_charges'] - totals['payments']
return totals
def get_previous_balance(self, file_no: str, period_start: date) -> float:
"""Calculate previous balance before statement period"""
# Sum all transactions before period start
result = self.db.query(func.sum(Ledger.amount)).filter(
Ledger.file_no == file_no,
Ledger.date < period_start
).scalar()
return float(result or 0.0)
def get_trust_balance(self, file_no: str) -> float:
"""Get current trust account balance for file"""
file_obj = self.db.query(File).filter(File.file_no == file_no).first()
return float(file_obj.trust_bal or 0.0) if file_obj else 0.0
def create_statement(
self,
file_no: str,
period_start: date,
period_end: date,
template_id: Optional[int] = None,
custom_footer: Optional[str] = None,
created_by: Optional[str] = None
) -> BillingStatement:
"""Create a new billing statement for a file"""
# Get file and customer info
file_obj = self.db.query(File).options(
joinedload(File.owner)
).filter(File.file_no == file_no).first()
if not file_obj:
raise StatementGenerationError(f"File {file_no} not found")
# Get unbilled transactions
transactions = self.get_unbilled_transactions(file_no, period_start, period_end)
if not transactions:
raise StatementGenerationError(f"No unbilled transactions found for file {file_no}")
# Calculate totals
totals = self.calculate_statement_totals(transactions)
previous_balance = self.get_previous_balance(file_no, period_start)
trust_balance = self.get_trust_balance(file_no)
# Calculate total due
total_due = previous_balance + totals['current_charges'] - totals['payments']
# Get or create default template
if not template_id:
template = self.db.query(StatementTemplate).filter(
StatementTemplate.is_default == True,
StatementTemplate.is_active == True
).first()
if template:
template_id = template.id
# Create statement
statement = BillingStatement(
statement_number=self.generate_statement_number(),
file_no=file_no,
customer_id=file_obj.owner.id if file_obj.owner else None,
period_start=period_start,
period_end=period_end,
statement_date=date.today(),
due_date=date.today() + timedelta(days=30),
previous_balance=previous_balance,
current_charges=totals['current_charges'],
payments_credits=totals['payments'],
total_due=total_due,
trust_balance=trust_balance,
template_id=template_id,
billed_transaction_count=len(transactions),
custom_footer=custom_footer,
created_by=created_by,
status=StatementStatus.DRAFT
)
self.db.add(statement)
self.db.flush() # Get the statement ID
# Create statement items
for txn in transactions:
item = BillingStatementItem(
statement_id=statement.id,
ledger_id=txn.id,
date=txn.date,
description=txn.note or f"{txn.t_code} - {txn.empl_num}",
quantity=float(txn.quantity or 0.0),
rate=float(txn.rate or 0.0),
amount=float(txn.amount or 0.0),
item_category=self._categorize_transaction(txn)
)
self.db.add(item)
self.db.commit()
self.db.refresh(statement)
logger.info(f"Created statement {statement.statement_number} for file {file_no}")
return statement
def _categorize_transaction(self, txn: Ledger) -> str:
"""Categorize transaction for statement display"""
if txn.t_type in ['1', '2']:
return 'fees'
elif txn.t_type == '3':
return 'costs'
elif txn.t_type == '4':
return 'payments'
elif txn.t_type == '5':
return 'trust'
else:
return 'other'
def generate_statement_html(self, statement_id: int) -> str:
"""Generate HTML content for a statement"""
statement = self.db.query(BillingStatement).options(
joinedload(BillingStatement.file).joinedload(File.owner),
joinedload(BillingStatement.customer),
joinedload(BillingStatement.template),
joinedload(BillingStatement.statement_items)
).filter(BillingStatement.id == statement_id).first()
if not statement:
raise StatementGenerationError(f"Statement {statement_id} not found")
# Prepare template context
context = self._prepare_template_context(statement)
# Get template or use default
template_content = self._get_statement_template(statement)
# Render template
template = self.jinja_env.from_string(template_content)
html_content = template.render(**context)
# Save generated HTML
statement.html_content = html_content
self.db.commit()
return html_content
def _prepare_template_context(self, statement: BillingStatement) -> Dict[str, Any]:
"""Prepare context data for template rendering"""
# Group statement items by category
items_by_category = {}
for item in statement.statement_items:
category = item.item_category or 'other'
if category not in items_by_category:
items_by_category[category] = []
items_by_category[category].append(item)
return {
'statement': statement,
'file': statement.file,
'customer': statement.customer or statement.file.owner,
'items_by_category': items_by_category,
'total_fees': sum(item.amount for item in items_by_category.get('fees', [])),
'total_costs': sum(item.amount for item in items_by_category.get('costs', [])),
'total_payments': sum(item.amount for item in items_by_category.get('payments', [])),
'generation_date': datetime.now(),
'custom_footer': statement.custom_footer
}
def _get_statement_template(self, statement: BillingStatement) -> str:
"""Get template content for statement"""
if statement.template and statement.template.is_active:
# Use custom template
header = statement.template.header_template or ""
footer = statement.template.footer_template or ""
css = statement.template.css_styles or ""
return self._build_complete_template(header, footer, css)
else:
# Use default template
return self._get_default_template()
def _build_complete_template(self, header: str, footer: str, css: str) -> str:
"""Build complete HTML template from components"""
# Use regular string formatting to avoid f-string conflicts with Jinja2
template = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Billing Statement - {{ statement.statement_number }}</title>
<style>
%(css)s
body { font-family: Arial, sans-serif; margin: 40px; }
.statement-header { margin-bottom: 30px; }
.statement-details { margin-bottom: 20px; }
.statement-items { margin-bottom: 30px; }
.statement-footer { margin-top: 30px; }
table { width: 100%%; border-collapse: collapse; }
th, td { padding: 8px; text-align: left; border-bottom: 1px solid #ddd; }
.amount { text-align: right; }
.total-row { font-weight: bold; border-top: 2px solid #000; }
</style>
</head>
<body>
<div class="statement-header">
%(header)s
</div>
<div class="statement-content">
<!-- Default statement content will be inserted here -->
{{ self.default_content() }}
</div>
<div class="statement-footer">
%(footer)s
{%% if custom_footer %%}
<div class="custom-footer">{{ custom_footer }}</div>
{%% endif %%}
</div>
</body>
</html>
""" % {"css": css, "header": header, "footer": footer}
return template
def _get_default_template(self) -> str:
"""Get default statement template"""
return """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Billing Statement - {{ statement.statement_number }}</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; }
.header { text-align: center; margin-bottom: 30px; }
.client-info { margin-bottom: 20px; }
.statement-details { margin-bottom: 20px; }
.items-table { width: 100%; border-collapse: collapse; margin-bottom: 30px; }
.items-table th, .items-table td { padding: 8px; text-align: left; border-bottom: 1px solid #ddd; }
.amount { text-align: right; }
.total-row { font-weight: bold; border-top: 2px solid #000; }
.summary { margin-top: 20px; }
.footer { margin-top: 40px; font-size: 12px; color: #666; }
</style>
</head>
<body>
<div class="header">
<h1>BILLING STATEMENT</h1>
<h2>{{ statement.statement_number }}</h2>
</div>
<div class="client-info">
<strong>Bill To:</strong><br>
{{ customer.first }} {{ customer.last }}<br>
{% if customer.a1 %}{{ customer.a1 }}<br>{% endif %}
{% if customer.a2 %}{{ customer.a2 }}<br>{% endif %}
{% if customer.city %}{{ customer.city }}, {{ customer.abrev }} {{ customer.zip }}{% endif %}
</div>
<div class="statement-details">
<table>
<tr><td><strong>File Number:</strong></td><td>{{ statement.file_no }}</td></tr>
<tr><td><strong>Statement Date:</strong></td><td>{{ statement.statement_date.strftime('%m/%d/%Y') }}</td></tr>
<tr><td><strong>Period:</strong></td><td>{{ statement.period_start.strftime('%m/%d/%Y') }} - {{ statement.period_end.strftime('%m/%d/%Y') }}</td></tr>
<tr><td><strong>Due Date:</strong></td><td>{{ statement.due_date.strftime('%m/%d/%Y') if statement.due_date else 'Upon Receipt' }}</td></tr>
</table>
</div>
<!-- Fees Section -->
{% if items_by_category.fees %}
<h3>Professional Services</h3>
<table class="items-table">
<thead>
<tr>
<th>Date</th>
<th>Description</th>
<th>Quantity</th>
<th>Rate</th>
<th class="amount">Amount</th>
</tr>
</thead>
<tbody>
{% for item in items_by_category.fees %}
<tr>
<td>{{ item.date.strftime('%m/%d/%Y') }}</td>
<td>{{ item.description }}</td>
<td>{{ "%.2f"|format(item.quantity) if item.quantity else '' }}</td>
<td>{{ "$%.2f"|format(item.rate) if item.rate else '' }}</td>
<td class="amount">${{ "%.2f"|format(item.amount) }}</td>
</tr>
{% endfor %}
<tr class="total-row">
<td colspan="4">Total Professional Services</td>
<td class="amount">${{ "%.2f"|format(total_fees) }}</td>
</tr>
</tbody>
</table>
{% endif %}
<!-- Costs Section -->
{% if items_by_category.costs %}
<h3>Costs and Disbursements</h3>
<table class="items-table">
<thead>
<tr>
<th>Date</th>
<th>Description</th>
<th class="amount">Amount</th>
</tr>
</thead>
<tbody>
{% for item in items_by_category.costs %}
<tr>
<td>{{ item.date.strftime('%m/%d/%Y') }}</td>
<td>{{ item.description }}</td>
<td class="amount">${{ "%.2f"|format(item.amount) }}</td>
</tr>
{% endfor %}
<tr class="total-row">
<td colspan="2">Total Costs and Disbursements</td>
<td class="amount">${{ "%.2f"|format(total_costs) }}</td>
</tr>
</tbody>
</table>
{% endif %}
<!-- Payments Section -->
{% if items_by_category.payments %}
<h3>Payments and Credits</h3>
<table class="items-table">
<thead>
<tr>
<th>Date</th>
<th>Description</th>
<th class="amount">Amount</th>
</tr>
</thead>
<tbody>
{% for item in items_by_category.payments %}
<tr>
<td>{{ item.date.strftime('%m/%d/%Y') }}</td>
<td>{{ item.description }}</td>
<td class="amount">${{ "%.2f"|format(item.amount) }}</td>
</tr>
{% endfor %}
<tr class="total-row">
<td colspan="2">Total Payments and Credits</td>
<td class="amount">${{ "%.2f"|format(total_payments) }}</td>
</tr>
</tbody>
</table>
{% endif %}
<!-- Summary -->
<div class="summary">
<table class="items-table">
<tr>
<td><strong>Previous Balance:</strong></td>
<td class="amount">${{ "%.2f"|format(statement.previous_balance) }}</td>
</tr>
<tr>
<td><strong>Current Charges:</strong></td>
<td class="amount">${{ "%.2f"|format(statement.current_charges) }}</td>
</tr>
<tr>
<td><strong>Payments/Credits:</strong></td>
<td class="amount">${{ "%.2f"|format(statement.payments_credits) }}</td>
</tr>
<tr class="total-row">
<td><strong>TOTAL DUE:</strong></td>
<td class="amount"><strong>${{ "%.2f"|format(statement.total_due) }}</strong></td>
</tr>
</table>
</div>
{% if statement.trust_balance > 0 %}
<div class="trust-info">
<p><strong>Trust Account Balance:</strong> ${{ "%.2f"|format(statement.trust_balance) }}</p>
</div>
{% endif %}
<div class="footer">
<p>Thank you for your business. Please remit payment by the due date.</p>
{% if custom_footer %}
<p>{{ custom_footer }}</p>
{% endif %}
<p><em>Generated on {{ generation_date.strftime('%m/%d/%Y at %I:%M %p') }}</em></p>
</div>
</body>
</html>
"""
def approve_statement(self, statement_id: int, approved_by: str) -> BillingStatement:
"""Approve a statement and mark transactions as billed"""
statement = self.db.query(BillingStatement).filter(
BillingStatement.id == statement_id
).first()
if not statement:
raise StatementGenerationError(f"Statement {statement_id} not found")
if statement.status != StatementStatus.DRAFT:
raise StatementGenerationError(f"Only draft statements can be approved")
# Mark statement as approved
statement.status = StatementStatus.APPROVED
statement.approved_by = approved_by
statement.approved_at = datetime.now()
# Mark all related transactions as billed
ledger_ids = [item.ledger_id for item in statement.statement_items]
self.db.query(Ledger).filter(
Ledger.id.in_(ledger_ids)
).update({Ledger.billed: "Y"}, synchronize_session=False)
self.db.commit()
logger.info(f"Approved statement {statement.statement_number} by {approved_by}")
return statement
def mark_statement_sent(self, statement_id: int, sent_by: str) -> BillingStatement:
"""Mark statement as sent"""
statement = self.db.query(BillingStatement).filter(
BillingStatement.id == statement_id
).first()
if not statement:
raise StatementGenerationError(f"Statement {statement_id} not found")
statement.status = StatementStatus.SENT
statement.sent_by = sent_by
statement.sent_at = datetime.now()
self.db.commit()
logger.info(f"Marked statement {statement.statement_number} as sent by {sent_by}")
return statement

View File

@@ -0,0 +1,651 @@
"""
Enhanced file management service
Handles file closure, status workflows, transfers, and archival
"""
from typing import List, Dict, Any, Optional, Tuple
from datetime import datetime, date, timezone
from decimal import Decimal
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import and_, func, or_, desc
from app.models import (
File, Ledger, FileStatus, FileType, Rolodex, Employee,
BillingStatement, Timer, TimeEntry, User, FileStatusHistory,
FileTransferHistory, FileArchiveInfo
)
from app.utils.logging import app_logger
logger = app_logger
class FileManagementError(Exception):
"""Exception raised when file management operations fail"""
pass
class FileStatusWorkflow:
"""Define valid file status transitions and business rules"""
# Define valid status transitions
VALID_TRANSITIONS = {
"NEW": ["ACTIVE", "INACTIVE", "FOLLOW_UP"],
"ACTIVE": ["INACTIVE", "FOLLOW_UP", "PENDING_CLOSURE", "ARCHIVED"],
"INACTIVE": ["ACTIVE", "FOLLOW_UP", "PENDING_CLOSURE", "ARCHIVED"],
"FOLLOW_UP": ["ACTIVE", "INACTIVE", "PENDING_CLOSURE", "ARCHIVED"],
"PENDING_CLOSURE": ["ACTIVE", "INACTIVE", "CLOSED"],
"CLOSED": ["ARCHIVED", "ACTIVE"], # Allow reopening
"ARCHIVED": [] # Final state - no transitions
}
# Statuses that require special validation
CLOSURE_STATUSES = {"PENDING_CLOSURE", "CLOSED"}
FINAL_STATUSES = {"ARCHIVED"}
ACTIVE_STATUSES = {"NEW", "ACTIVE", "FOLLOW_UP", "PENDING_CLOSURE"}
@classmethod
def can_transition(cls, from_status: str, to_status: str) -> bool:
"""Check if status transition is valid"""
return to_status in cls.VALID_TRANSITIONS.get(from_status, [])
@classmethod
def get_valid_transitions(cls, from_status: str) -> List[str]:
"""Get list of valid status transitions from current status"""
return cls.VALID_TRANSITIONS.get(from_status, [])
@classmethod
def requires_closure_validation(cls, status: str) -> bool:
"""Check if status requires closure validation"""
return status in cls.CLOSURE_STATUSES
@classmethod
def is_active_status(cls, status: str) -> bool:
"""Check if status indicates an active file"""
return status in cls.ACTIVE_STATUSES
class FileManagementService:
"""Service for advanced file management operations"""
def __init__(self, db: Session):
self.db = db
self.workflow = FileStatusWorkflow()
def change_file_status(
self,
file_no: str,
new_status: str,
user_id: int,
notes: Optional[str] = None,
validate_transition: bool = True
) -> File:
"""Change file status with workflow validation"""
file_obj = self.db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise FileManagementError(f"File {file_no} not found")
current_status = file_obj.status
# Validate status transition
if validate_transition and not self.workflow.can_transition(current_status, new_status):
valid_transitions = self.workflow.get_valid_transitions(current_status)
raise FileManagementError(
f"Invalid status transition from '{current_status}' to '{new_status}'. "
f"Valid transitions: {valid_transitions}"
)
# Special validation for closure statuses
if self.workflow.requires_closure_validation(new_status):
self._validate_file_closure(file_obj)
# Update file status
old_status = file_obj.status
file_obj.status = new_status
# Set closure date if closing
if new_status == "CLOSED" and not file_obj.closed:
file_obj.closed = date.today()
elif new_status != "CLOSED" and file_obj.closed:
# Clear closure date if reopening
file_obj.closed = None
# Create status history record
self._create_status_history(file_no, old_status, new_status, user_id, notes)
self.db.commit()
self.db.refresh(file_obj)
logger.info(f"Changed file {file_no} status from '{old_status}' to '{new_status}' by user {user_id}")
return file_obj
def close_file(
self,
file_no: str,
user_id: int,
force_close: bool = False,
final_payment_amount: Optional[float] = None,
closing_notes: Optional[str] = None
) -> Dict[str, Any]:
"""Close a file with automated closure process"""
file_obj = self.db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise FileManagementError(f"File {file_no} not found")
if file_obj.status == "CLOSED":
raise FileManagementError("File is already closed")
closure_summary = {
"file_no": file_no,
"closure_date": date.today(),
"actions_taken": [],
"warnings": [],
"final_balance": 0.0,
"trust_balance": file_obj.trust_bal or 0.0
}
try:
# Step 1: Validate closure readiness
validation_result = self._validate_file_closure(file_obj, force_close)
closure_summary["warnings"].extend(validation_result.get("warnings", []))
if validation_result.get("blocking_issues") and not force_close:
raise FileManagementError(
f"Cannot close file: {'; '.join(validation_result['blocking_issues'])}"
)
# Step 2: Handle outstanding balances
outstanding_balance = self._calculate_outstanding_balance(file_obj)
closure_summary["final_balance"] = outstanding_balance
if outstanding_balance > 0 and final_payment_amount:
# Create payment entry to close outstanding balance
payment_entry = self._create_final_payment_entry(
file_obj, final_payment_amount, user_id
)
closure_summary["actions_taken"].append(
f"Created final payment entry: ${final_payment_amount:.2f}"
)
outstanding_balance -= final_payment_amount
# Step 3: Stop any active timers
active_timers = self._stop_active_timers(file_no, user_id)
if active_timers:
closure_summary["actions_taken"].append(
f"Stopped {len(active_timers)} active timer(s)"
)
# Step 4: Mark unbilled time entries as non-billable if any
unbilled_entries = self._handle_unbilled_time_entries(file_no, user_id)
if unbilled_entries:
closure_summary["actions_taken"].append(
f"Marked {len(unbilled_entries)} time entries as non-billable"
)
# Step 5: Update file status
file_obj.status = "CLOSED"
file_obj.closed = date.today()
# Step 6: Create closure history record
self._create_status_history(
file_no,
file_obj.status,
"CLOSED",
user_id,
closing_notes or "File closed via automated closure process"
)
self.db.commit()
closure_summary["actions_taken"].append("File status updated to CLOSED")
logger.info(f"Successfully closed file {file_no} by user {user_id}")
return closure_summary
except Exception as e:
self.db.rollback()
logger.error(f"Failed to close file {file_no}: {str(e)}")
raise FileManagementError(f"File closure failed: {str(e)}")
def reopen_file(
self,
file_no: str,
user_id: int,
new_status: str = "ACTIVE",
notes: Optional[str] = None
) -> File:
"""Reopen a closed file"""
file_obj = self.db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise FileManagementError(f"File {file_no} not found")
if file_obj.status != "CLOSED":
raise FileManagementError("Only closed files can be reopened")
if file_obj.status == "ARCHIVED":
raise FileManagementError("Archived files cannot be reopened")
# Validate new status
if not self.workflow.can_transition("CLOSED", new_status):
valid_transitions = self.workflow.get_valid_transitions("CLOSED")
raise FileManagementError(
f"Invalid reopening status '{new_status}'. Valid options: {valid_transitions}"
)
# Update file
old_status = file_obj.status
file_obj.status = new_status
file_obj.closed = None # Clear closure date
# Create status history
self._create_status_history(
file_no,
old_status,
new_status,
user_id,
notes or "File reopened"
)
self.db.commit()
self.db.refresh(file_obj)
logger.info(f"Reopened file {file_no} to status '{new_status}' by user {user_id}")
return file_obj
def transfer_file(
self,
file_no: str,
new_attorney_id: str,
user_id: int,
transfer_reason: Optional[str] = None
) -> File:
"""Transfer file to a different attorney"""
file_obj = self.db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise FileManagementError(f"File {file_no} not found")
# Validate new attorney exists
new_attorney = self.db.query(Employee).filter(Employee.empl_num == new_attorney_id).first()
if not new_attorney:
raise FileManagementError(f"Attorney {new_attorney_id} not found")
if not new_attorney.active:
raise FileManagementError(f"Attorney {new_attorney_id} is not active")
old_attorney = file_obj.empl_num
if old_attorney == new_attorney_id:
raise FileManagementError("File is already assigned to this attorney")
# Update file assignment
file_obj.empl_num = new_attorney_id
# Update hourly rate if attorney has a default rate
if new_attorney.rate_per_hour and new_attorney.rate_per_hour > 0:
file_obj.rate_per_hour = new_attorney.rate_per_hour
# Create transfer history record
self._create_transfer_history(
file_no,
old_attorney,
new_attorney_id,
user_id,
transfer_reason
)
self.db.commit()
self.db.refresh(file_obj)
logger.info(f"Transferred file {file_no} from {old_attorney} to {new_attorney_id} by user {user_id}")
return file_obj
def archive_file(
self,
file_no: str,
user_id: int,
archive_location: Optional[str] = None,
notes: Optional[str] = None
) -> File:
"""Archive a closed file"""
file_obj = self.db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise FileManagementError(f"File {file_no} not found")
if file_obj.status != "CLOSED":
raise FileManagementError("Only closed files can be archived")
# Check for any recent activity (within last 30 days)
recent_activity = self._check_recent_activity(file_no, days=30)
if recent_activity:
raise FileManagementError(
f"File has recent activity and cannot be archived: {recent_activity}"
)
# Update file status
old_status = file_obj.status
file_obj.status = "ARCHIVED"
# Create archive history record
archive_notes = notes or f"File archived to location: {archive_location or 'Standard archive'}"
self._create_status_history(file_no, old_status, "ARCHIVED", user_id, archive_notes)
self.db.commit()
self.db.refresh(file_obj)
logger.info(f"Archived file {file_no} by user {user_id}")
return file_obj
def get_file_status_history(self, file_no: str) -> List[Dict[str, Any]]:
"""Get status change history for a file"""
history = self.db.query(FileStatusHistory).filter(
FileStatusHistory.file_no == file_no
).options(
joinedload(FileStatusHistory.changed_by)
).order_by(FileStatusHistory.change_date.desc()).all()
return [
{
"id": h.id,
"old_status": h.old_status,
"new_status": h.new_status,
"change_date": h.change_date,
"changed_by": h.changed_by_name or (h.changed_by.username if h.changed_by else "System"),
"notes": h.notes,
"system_generated": h.system_generated
}
for h in history
]
def get_files_by_status(
self,
status: str,
attorney_id: Optional[str] = None,
limit: int = 100
) -> List[File]:
"""Get files by status with optional attorney filter"""
query = self.db.query(File).filter(File.status == status)
if attorney_id:
query = query.filter(File.empl_num == attorney_id)
return query.options(
joinedload(File.owner)
).order_by(File.opened.desc()).limit(limit).all()
def get_closure_candidates(self, days_inactive: int = 90) -> List[Dict[str, Any]]:
"""Get files that are candidates for closure"""
cutoff_date = datetime.now(timezone.utc).date() - datetime.timedelta(days=days_inactive)
# Files with no recent ledger activity
files_query = self.db.query(File).filter(
File.status.in_(["ACTIVE", "FOLLOW_UP"]),
File.opened < cutoff_date
)
candidates = []
for file_obj in files_query.all():
# Check for recent ledger activity
recent_activity = self.db.query(Ledger).filter(
Ledger.file_no == file_obj.file_no,
Ledger.date >= cutoff_date
).first()
if not recent_activity:
outstanding_balance = self._calculate_outstanding_balance(file_obj)
candidates.append({
"file_no": file_obj.file_no,
"client_name": f"{file_obj.owner.first or ''} {file_obj.owner.last}".strip() if file_obj.owner else "Unknown",
"attorney": file_obj.empl_num,
"opened_date": file_obj.opened,
"last_activity": None, # Could be enhanced to find actual last activity
"outstanding_balance": outstanding_balance,
"status": file_obj.status
})
return sorted(candidates, key=lambda x: x["opened_date"])
def bulk_status_update(
self,
file_numbers: List[str],
new_status: str,
user_id: int,
notes: Optional[str] = None
) -> Dict[str, Any]:
"""Update status for multiple files"""
results = {
"successful": [],
"failed": [],
"total": len(file_numbers)
}
for file_no in file_numbers:
try:
self.change_file_status(file_no, new_status, user_id, notes)
results["successful"].append(file_no)
except Exception as e:
results["failed"].append({
"file_no": file_no,
"error": str(e)
})
logger.info(f"Bulk status update: {len(results['successful'])} successful, {len(results['failed'])} failed")
return results
# Private helper methods
def _validate_file_closure(self, file_obj: File, force: bool = False) -> Dict[str, Any]:
"""Validate if file is ready for closure"""
validation_result = {
"can_close": True,
"blocking_issues": [],
"warnings": []
}
# Check for active timers
active_timers = self.db.query(Timer).filter(
Timer.file_no == file_obj.file_no,
Timer.status.in_(["running", "paused"])
).count()
if active_timers > 0:
validation_result["warnings"].append(f"{active_timers} active timer(s) will be stopped")
# Check for unbilled time entries
unbilled_entries = self.db.query(TimeEntry).filter(
TimeEntry.file_no == file_obj.file_no,
TimeEntry.billed == False,
TimeEntry.is_billable == True
).count()
if unbilled_entries > 0:
validation_result["warnings"].append(f"{unbilled_entries} unbilled time entries exist")
# Check for outstanding balance
outstanding_balance = self._calculate_outstanding_balance(file_obj)
if outstanding_balance > 0:
validation_result["warnings"].append(f"Outstanding balance: ${outstanding_balance:.2f}")
# Check for pending billing statements
pending_statements = self.db.query(BillingStatement).filter(
BillingStatement.file_no == file_obj.file_no,
BillingStatement.status.in_(["draft", "pending_approval"])
).count()
if pending_statements > 0:
validation_result["blocking_issues"].append(f"{pending_statements} pending billing statement(s)")
validation_result["can_close"] = False
return validation_result
def _calculate_outstanding_balance(self, file_obj: File) -> float:
"""Calculate outstanding balance for a file"""
# Sum all charges minus payments
charges = self.db.query(func.sum(Ledger.amount)).filter(
Ledger.file_no == file_obj.file_no,
Ledger.t_type.in_(["1", "2", "3", "4"]) # Fee and cost types
).scalar() or 0.0
payments = self.db.query(func.sum(Ledger.amount)).filter(
Ledger.file_no == file_obj.file_no,
Ledger.t_type == "5" # Payment type
).scalar() or 0.0
return max(0.0, charges - payments)
def _stop_active_timers(self, file_no: str, user_id: int) -> List[Timer]:
"""Stop all active timers for a file"""
from app.services.timers import TimerService
active_timers = self.db.query(Timer).filter(
Timer.file_no == file_no,
Timer.status.in_(["running", "paused"])
).all()
timer_service = TimerService(self.db)
stopped_timers = []
for timer in active_timers:
try:
timer_service.stop_timer(timer.id, timer.user_id)
stopped_timers.append(timer)
except Exception as e:
logger.warning(f"Failed to stop timer {timer.id}: {str(e)}")
return stopped_timers
def _handle_unbilled_time_entries(self, file_no: str, user_id: int) -> List[TimeEntry]:
"""Handle unbilled time entries during file closure"""
unbilled_entries = self.db.query(TimeEntry).filter(
TimeEntry.file_no == file_no,
TimeEntry.billed == False,
TimeEntry.is_billable == True
).all()
# Mark as non-billable to prevent future billing
for entry in unbilled_entries:
entry.is_billable = False
entry.notes = (entry.notes or "") + " [Marked non-billable during file closure]"
return unbilled_entries
def _create_final_payment_entry(
self,
file_obj: File,
amount: float,
user_id: int
) -> Ledger:
"""Create a final payment entry to close outstanding balance"""
# Get next item number
max_item = self.db.query(func.max(Ledger.item_no)).filter(
Ledger.file_no == file_obj.file_no
).scalar() or 0
payment_entry = Ledger(
file_no=file_obj.file_no,
item_no=max_item + 1,
date=date.today(),
t_code="FINAL",
t_type="5", # Payment type
empl_num=f"user_{user_id}",
amount=amount,
billed="Y", # Mark as billed
note="Final payment - file closure"
)
self.db.add(payment_entry)
return payment_entry
def _check_recent_activity(self, file_no: str, days: int = 30) -> Optional[str]:
"""Check for recent activity that would prevent archival"""
cutoff_date = datetime.now(timezone.utc).date() - datetime.timedelta(days=days)
# Check for recent ledger entries
recent_ledger = self.db.query(Ledger).filter(
Ledger.file_no == file_no,
Ledger.date >= cutoff_date
).first()
if recent_ledger:
return f"Recent ledger activity on {recent_ledger.date}"
# Check for recent billing statements
recent_statement = self.db.query(BillingStatement).filter(
BillingStatement.file_no == file_no,
BillingStatement.created_at >= cutoff_date
).first()
if recent_statement:
return f"Recent billing statement on {recent_statement.created_at.date()}"
return None
def _create_status_history(
self,
file_no: str,
old_status: str,
new_status: str,
user_id: int,
notes: Optional[str] = None
):
"""Create a status history record"""
# Get user info for caching
user = self.db.query(User).filter(User.id == user_id).first()
user_name = user.username if user else f"user_{user_id}"
history_record = FileStatusHistory(
file_no=file_no,
old_status=old_status,
new_status=new_status,
changed_by_user_id=user_id,
changed_by_name=user_name,
notes=notes,
system_generated=False
)
self.db.add(history_record)
logger.info(
f"File {file_no} status changed: {old_status} -> {new_status} by {user_name}"
+ (f" - {notes}" if notes else "")
)
def _create_transfer_history(
self,
file_no: str,
old_attorney: str,
new_attorney: str,
user_id: int,
reason: Optional[str] = None
):
"""Create a transfer history record"""
# Get user info for caching
user = self.db.query(User).filter(User.id == user_id).first()
user_name = user.username if user else f"user_{user_id}"
# Get current file for rate info
file_obj = self.db.query(File).filter(File.file_no == file_no).first()
old_rate = file_obj.rate_per_hour if file_obj else None
# Get new attorney rate
new_attorney_obj = self.db.query(Employee).filter(Employee.empl_num == new_attorney).first()
new_rate = new_attorney_obj.rate_per_hour if new_attorney_obj else None
transfer_record = FileTransferHistory(
file_no=file_no,
old_attorney_id=old_attorney,
new_attorney_id=new_attorney,
authorized_by_user_id=user_id,
authorized_by_name=user_name,
reason=reason,
old_hourly_rate=old_rate,
new_hourly_rate=new_rate
)
self.db.add(transfer_record)
logger.info(
f"File {file_no} transferred: {old_attorney} -> {new_attorney} by {user_name}"
+ (f" - {reason}" if reason else "")
)

530
app/services/timers.py Normal file
View File

@@ -0,0 +1,530 @@
"""
Timer service for time tracking functionality
Handles timer start/stop/pause operations and time entry creation
"""
from typing import List, Dict, Any, Optional, Tuple
from datetime import datetime, timezone, timedelta
from decimal import Decimal
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import and_, func, or_
from app.models import (
Timer, TimeEntry, TimerSession, TimerTemplate, TimerStatus, TimerType,
User, File, Ledger, Rolodex
)
from app.utils.logging import app_logger
logger = app_logger
class TimerServiceError(Exception):
"""Exception raised when timer operations fail"""
pass
class TimerService:
"""Service for managing timers and time tracking"""
def __init__(self, db: Session):
self.db = db
def create_timer(
self,
user_id: int,
title: str,
description: Optional[str] = None,
file_no: Optional[str] = None,
customer_id: Optional[str] = None,
timer_type: TimerType = TimerType.BILLABLE,
hourly_rate: Optional[float] = None,
task_category: Optional[str] = None,
template_id: Optional[int] = None
) -> Timer:
"""Create a new timer"""
# Validate user exists
user = self.db.query(User).filter(User.id == user_id).first()
if not user:
raise TimerServiceError(f"User {user_id} not found")
# Validate file exists if provided
if file_no:
file_obj = self.db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise TimerServiceError(f"File {file_no} not found")
# Use file's hourly rate if not specified
if not hourly_rate and file_obj.rate_per_hour:
hourly_rate = file_obj.rate_per_hour
# Validate customer exists if provided
if customer_id:
customer = self.db.query(Rolodex).filter(Rolodex.id == customer_id).first()
if not customer:
raise TimerServiceError(f"Customer {customer_id} not found")
# Apply template if provided
if template_id:
template = self.db.query(TimerTemplate).filter(TimerTemplate.id == template_id).first()
if template:
if not title:
title = template.title_template
if not description:
description = template.description_template
if timer_type == TimerType.BILLABLE: # Only override if default
timer_type = template.timer_type
if not hourly_rate and template.default_rate:
hourly_rate = template.default_rate
if not task_category:
task_category = template.task_category
# Update template usage count
template.usage_count += 1
timer = Timer(
user_id=user_id,
file_no=file_no,
customer_id=customer_id,
title=title,
description=description,
timer_type=timer_type,
hourly_rate=hourly_rate,
task_category=task_category,
is_billable=(timer_type == TimerType.BILLABLE),
status=TimerStatus.STOPPED
)
self.db.add(timer)
self.db.commit()
self.db.refresh(timer)
logger.info(f"Created timer {timer.id} for user {user_id}: {title}")
return timer
def start_timer(self, timer_id: int, user_id: int) -> Timer:
"""Start a timer"""
timer = self._get_user_timer(timer_id, user_id)
if timer.status == TimerStatus.RUNNING:
raise TimerServiceError("Timer is already running")
now = datetime.now(timezone.utc)
# Stop any other running timers for this user
self._stop_other_timers(user_id, timer_id)
# Update timer status
timer.status = TimerStatus.RUNNING
timer.last_started_at = now
if not timer.started_at:
timer.started_at = now
# Create session record
session = TimerSession(
timer_id=timer.id,
started_at=now
)
self.db.add(session)
self.db.commit()
self.db.refresh(timer)
logger.info(f"Started timer {timer.id} for user {user_id}")
return timer
def pause_timer(self, timer_id: int, user_id: int) -> Timer:
"""Pause a running timer"""
timer = self._get_user_timer(timer_id, user_id)
if timer.status != TimerStatus.RUNNING:
raise TimerServiceError("Timer is not running")
now = datetime.now(timezone.utc)
# Calculate session time and add to total
if timer.last_started_at:
session_seconds = int((now - timer.last_started_at).total_seconds())
timer.total_seconds += session_seconds
# Update timer status
timer.status = TimerStatus.PAUSED
timer.last_paused_at = now
# Update current session
current_session = self.db.query(TimerSession).filter(
TimerSession.timer_id == timer.id,
TimerSession.ended_at.is_(None)
).order_by(TimerSession.started_at.desc()).first()
if current_session:
current_session.ended_at = now
current_session.duration_seconds = int((now - current_session.started_at).total_seconds())
self.db.commit()
self.db.refresh(timer)
logger.info(f"Paused timer {timer.id} for user {user_id}")
return timer
def stop_timer(self, timer_id: int, user_id: int) -> Timer:
"""Stop a timer completely"""
timer = self._get_user_timer(timer_id, user_id)
if timer.status == TimerStatus.STOPPED:
raise TimerServiceError("Timer is already stopped")
now = datetime.now(timezone.utc)
# If running, calculate final session time
if timer.status == TimerStatus.RUNNING and timer.last_started_at:
session_seconds = int((now - timer.last_started_at).total_seconds())
timer.total_seconds += session_seconds
# Update timer status
timer.status = TimerStatus.STOPPED
timer.stopped_at = now
# Update current session
current_session = self.db.query(TimerSession).filter(
TimerSession.timer_id == timer.id,
TimerSession.ended_at.is_(None)
).order_by(TimerSession.started_at.desc()).first()
if current_session:
current_session.ended_at = now
current_session.duration_seconds = int((now - current_session.started_at).total_seconds())
self.db.commit()
self.db.refresh(timer)
logger.info(f"Stopped timer {timer.id} for user {user_id}, total time: {timer.total_hours:.2f} hours")
return timer
def resume_timer(self, timer_id: int, user_id: int) -> Timer:
"""Resume a paused timer"""
timer = self._get_user_timer(timer_id, user_id)
if timer.status != TimerStatus.PAUSED:
raise TimerServiceError("Timer is not paused")
# Stop any other running timers for this user
self._stop_other_timers(user_id, timer_id)
return self.start_timer(timer_id, user_id)
def delete_timer(self, timer_id: int, user_id: int) -> bool:
"""Delete a timer (only if stopped)"""
timer = self._get_user_timer(timer_id, user_id)
if timer.status != TimerStatus.STOPPED:
raise TimerServiceError("Can only delete stopped timers")
# Check if timer has associated time entries
entry_count = self.db.query(TimeEntry).filter(TimeEntry.timer_id == timer_id).count()
if entry_count > 0:
raise TimerServiceError(f"Cannot delete timer: {entry_count} time entries are linked to this timer")
self.db.delete(timer)
self.db.commit()
logger.info(f"Deleted timer {timer_id} for user {user_id}")
return True
def get_active_timers(self, user_id: int) -> List[Timer]:
"""Get all active (running or paused) timers for a user"""
return self.db.query(Timer).filter(
Timer.user_id == user_id,
Timer.status.in_([TimerStatus.RUNNING, TimerStatus.PAUSED])
).options(
joinedload(Timer.file),
joinedload(Timer.customer)
).all()
def get_user_timers(
self,
user_id: int,
status_filter: Optional[TimerStatus] = None,
file_no: Optional[str] = None,
limit: int = 50
) -> List[Timer]:
"""Get timers for a user with optional filtering"""
query = self.db.query(Timer).filter(Timer.user_id == user_id)
if status_filter:
query = query.filter(Timer.status == status_filter)
if file_no:
query = query.filter(Timer.file_no == file_no)
return query.options(
joinedload(Timer.file),
joinedload(Timer.customer)
).order_by(Timer.updated_at.desc()).limit(limit).all()
def create_time_entry_from_timer(
self,
timer_id: int,
user_id: int,
title: Optional[str] = None,
description: Optional[str] = None,
hours_override: Optional[float] = None,
entry_date: Optional[datetime] = None
) -> TimeEntry:
"""Create a time entry from a completed timer"""
timer = self._get_user_timer(timer_id, user_id)
if timer.status != TimerStatus.STOPPED:
raise TimerServiceError("Timer must be stopped to create time entry")
if timer.total_seconds == 0:
raise TimerServiceError("Timer has no recorded time")
# Use timer details or overrides
entry_title = title or timer.title
entry_description = description or timer.description
entry_hours = hours_override or timer.total_hours
entry_date = entry_date or timer.stopped_at or datetime.now(timezone.utc)
time_entry = TimeEntry(
timer_id=timer.id,
user_id=user_id,
file_no=timer.file_no,
customer_id=timer.customer_id,
title=entry_title,
description=entry_description,
entry_type=timer.timer_type,
hours=entry_hours,
entry_date=entry_date,
hourly_rate=timer.hourly_rate,
is_billable=timer.is_billable,
task_category=timer.task_category,
created_by=f"user_{user_id}"
)
self.db.add(time_entry)
self.db.commit()
self.db.refresh(time_entry)
logger.info(f"Created time entry {time_entry.id} from timer {timer_id}: {entry_hours:.2f} hours")
return time_entry
def create_manual_time_entry(
self,
user_id: int,
title: str,
hours: float,
entry_date: datetime,
description: Optional[str] = None,
file_no: Optional[str] = None,
customer_id: Optional[str] = None,
hourly_rate: Optional[float] = None,
entry_type: TimerType = TimerType.BILLABLE,
task_category: Optional[str] = None
) -> TimeEntry:
"""Create a manual time entry (not from a timer)"""
# Validate user
user = self.db.query(User).filter(User.id == user_id).first()
if not user:
raise TimerServiceError(f"User {user_id} not found")
# Validate file if provided
if file_no:
file_obj = self.db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise TimerServiceError(f"File {file_no} not found")
# Use file's rate if not specified
if not hourly_rate and file_obj.rate_per_hour:
hourly_rate = file_obj.rate_per_hour
# Validate customer if provided
if customer_id:
customer = self.db.query(Rolodex).filter(Rolodex.id == customer_id).first()
if not customer:
raise TimerServiceError(f"Customer {customer_id} not found")
time_entry = TimeEntry(
user_id=user_id,
file_no=file_no,
customer_id=customer_id,
title=title,
description=description,
entry_type=entry_type,
hours=hours,
entry_date=entry_date,
hourly_rate=hourly_rate,
is_billable=(entry_type == TimerType.BILLABLE),
task_category=task_category,
created_by=f"user_{user_id}"
)
self.db.add(time_entry)
self.db.commit()
self.db.refresh(time_entry)
logger.info(f"Created manual time entry {time_entry.id} for user {user_id}: {hours:.2f} hours")
return time_entry
def convert_time_entry_to_ledger(
self,
time_entry_id: int,
user_id: int,
transaction_code: str = "TIME",
notes: Optional[str] = None
) -> Ledger:
"""Convert a time entry to a billable ledger transaction"""
time_entry = self.db.query(TimeEntry).filter(
TimeEntry.id == time_entry_id,
TimeEntry.user_id == user_id
).first()
if not time_entry:
raise TimerServiceError(f"Time entry {time_entry_id} not found")
if time_entry.billed:
raise TimerServiceError("Time entry has already been billed")
if not time_entry.is_billable:
raise TimerServiceError("Time entry is not billable")
if not time_entry.file_no:
raise TimerServiceError("Time entry must have a file assignment for billing")
if not time_entry.hourly_rate or time_entry.hourly_rate <= 0:
raise TimerServiceError("Time entry must have a valid hourly rate for billing")
# Get next item number for this file
max_item = self.db.query(func.max(Ledger.item_no)).filter(
Ledger.file_no == time_entry.file_no
).scalar() or 0
# Calculate amount
amount = time_entry.hours * time_entry.hourly_rate
# Create ledger entry
ledger_entry = Ledger(
file_no=time_entry.file_no,
item_no=max_item + 1,
date=time_entry.entry_date.date() if hasattr(time_entry.entry_date, 'date') else time_entry.entry_date,
t_code=transaction_code,
t_type="1", # Type 1 = hourly fees
empl_num=f"user_{user_id}",
quantity=time_entry.hours,
rate=time_entry.hourly_rate,
amount=amount,
billed="N", # Will be marked as billed when statement is approved
note=notes or time_entry.description or time_entry.title
)
# Link time entry to ledger entry
time_entry.ledger_id = ledger_entry.id
time_entry.billed = True
self.db.add(ledger_entry)
self.db.commit()
self.db.refresh(ledger_entry)
# Update the time entry with the ledger ID
time_entry.ledger_id = ledger_entry.id
self.db.commit()
logger.info(f"Converted time entry {time_entry_id} to ledger entry {ledger_entry.id}: ${amount:.2f}")
return ledger_entry
def update_timer_total(self, timer_id: int) -> Timer:
"""Recalculate timer total from sessions (for data consistency)"""
timer = self.db.query(Timer).filter(Timer.id == timer_id).first()
if not timer:
raise TimerServiceError(f"Timer {timer_id} not found")
# Calculate total from completed sessions
total_seconds = self.db.query(func.sum(TimerSession.duration_seconds)).filter(
TimerSession.timer_id == timer_id,
TimerSession.ended_at.isnot(None)
).scalar() or 0
# Add current running session if applicable
if timer.status == TimerStatus.RUNNING:
total_seconds += timer.get_current_session_seconds()
timer.total_seconds = total_seconds
self.db.commit()
return timer
def _get_user_timer(self, timer_id: int, user_id: int) -> Timer:
"""Get timer and verify ownership"""
timer = self.db.query(Timer).filter(
Timer.id == timer_id,
Timer.user_id == user_id
).first()
if not timer:
raise TimerServiceError(f"Timer {timer_id} not found or access denied")
return timer
def _stop_other_timers(self, user_id: int, exclude_timer_id: int):
"""Stop all other running timers for a user"""
running_timers = self.db.query(Timer).filter(
Timer.user_id == user_id,
Timer.status == TimerStatus.RUNNING,
Timer.id != exclude_timer_id
).all()
for timer in running_timers:
try:
self.pause_timer(timer.id, user_id)
logger.info(f"Auto-paused timer {timer.id} when starting timer {exclude_timer_id}")
except Exception as e:
logger.warning(f"Failed to auto-pause timer {timer.id}: {str(e)}")
def get_timer_statistics(self, user_id: int, days: int = 30) -> Dict[str, Any]:
"""Get timer statistics for a user over the last N days"""
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
# Total time tracked
total_seconds = self.db.query(func.sum(Timer.total_seconds)).filter(
Timer.user_id == user_id,
Timer.created_at >= cutoff_date
).scalar() or 0
# Total billable time
billable_seconds = self.db.query(func.sum(Timer.total_seconds)).filter(
Timer.user_id == user_id,
Timer.is_billable == True,
Timer.created_at >= cutoff_date
).scalar() or 0
# Number of active timers
active_count = self.db.query(Timer).filter(
Timer.user_id == user_id,
Timer.status.in_([TimerStatus.RUNNING, TimerStatus.PAUSED])
).count()
# Number of time entries created
entries_count = self.db.query(TimeEntry).filter(
TimeEntry.user_id == user_id,
TimeEntry.created_at >= cutoff_date
).count()
# Entries converted to billing
billed_entries = self.db.query(TimeEntry).filter(
TimeEntry.user_id == user_id,
TimeEntry.billed == True,
TimeEntry.created_at >= cutoff_date
).count()
return {
"period_days": days,
"total_hours": total_seconds / 3600.0,
"billable_hours": billable_seconds / 3600.0,
"non_billable_hours": (total_seconds - billable_seconds) / 3600.0,
"active_timers": active_count,
"time_entries_created": entries_count,
"time_entries_billed": billed_entries,
"billable_percentage": (billable_seconds / total_seconds * 100) if total_seconds > 0 else 0
}

View File

@@ -165,46 +165,60 @@ POST /api/documents/generate-batch
**Legacy Feature**: Automated billing statement generation with trust account management
**Current Status**: 🟡 **IN PROGRESS** (basic transactions exist, working on statement generation)
**Current Status**: **COMPLETED** (core statement generation system implemented)
**Missing Components**:
**Implemented Components**:
#### 3.1 Statement Generation Engine
- [ ] Create billing statement templates
- [ ] Automated statement generation by file/client
- [ ] Customizable statement footers by file status
- [ ] Statement preview and approval workflow
- [ ] Batch statement generation
- [x] Create billing statement templates (StatementTemplate model)
- [x] Automated statement generation by file/client (BillingStatementService)
- [x] Customizable statement footers by file status (custom_footer field)
- [x] Statement preview and approval workflow (preview/approve endpoints)
- [x] Template-based HTML generation with Jinja2 engine
- [x] Default statement template with professional styling
- [x] Statement numbering system (STMT-YYYYMM-#### format)
#### 3.2 Enhanced Trust Account Management
- [ ] Extend trust account transaction types
- [ ] Trust account balance tracking per file
- [ ] IOLTA compliance reporting
- [ ] Trust-to-fee transfer automation
- [ ] Trust account reconciliation tools
- [x] Trust account balance tracking per file (trust_balance field)
- [x] Trust account transaction integration (existing trust field in File model)
- [ ] IOLTA compliance reporting (future enhancement)
- [ ] Trust-to-fee transfer automation (future enhancement)
- [ ] Trust account reconciliation tools (future enhancement)
#### 3.3 Billing Workflow Management
- [ ] Billed/unbilled transaction status tracking
- [ ] Bulk billing status updates
- [ ] Statement approval and locking
- [ ] Payment application workflow
- [ ] Account aging reports
- [x] Billed/unbilled transaction status tracking (billed field in Ledger model)
- [x] Statement approval and locking (StatementStatus enum with draft/approved/sent workflow)
- [x] Statement metadata tracking (approved_by, sent_by, timestamps)
- [x] Statement deletion controls (only draft statements can be deleted)
- [ ] Bulk billing status updates (future enhancement)
- [ ] Payment application workflow (future enhancement)
- [ ] Account aging reports (future enhancement)
#### 3.4 Advanced Financial Reports
- [ ] Account balance summaries by employee
- [ ] Account aging reports
- [ ] Trust account activity reports
- [ ] Revenue reports by area of law
- [ ] Time utilization reports
- [ ] Account balance summaries by employee (future enhancement)
- [ ] Account aging reports (future enhancement)
- [ ] Trust account activity reports (future enhancement)
- [ ] Revenue reports by area of law (future enhancement)
- [ ] Time utilization reports (future enhancement)
**API Endpoints Needed**:
**API Endpoints Implemented**:
```
POST /api/billing/statements/generate
GET /api/billing/statements/{file_no}
POST /api/billing/statements/batch
PUT /api/financial/transactions/bulk-bill
GET /api/reports/trust-account
GET /api/reports/account-aging
✅ GET /api/billing/statement-templates # List statement templates
✅ POST /api/billing/statement-templates # Create statement template
✅ GET /api/billing/statement-templates/{id} # Get statement template
PUT /api/billing/statement-templates/{id} # Update statement template
✅ DELETE /api/billing/statement-templates/{id} # Delete statement template
GET /api/billing/billing-statements # List billing statements
✅ POST /api/billing/billing-statements # Create billing statement
✅ GET /api/billing/billing-statements/{id} # Get billing statement
✅ POST /api/billing/billing-statements/{id}/generate-html # Generate HTML
✅ POST /api/billing/billing-statements/{id}/approve # Approve statement
✅ POST /api/billing/billing-statements/{id}/send # Mark as sent
✅ GET /api/billing/billing-statements/{id}/preview # Preview HTML
✅ DELETE /api/billing/billing-statements/{id} # Delete draft statement
⏳ PUT /api/financial/transactions/bulk-bill # Future enhancement
⏳ GET /api/reports/trust-account # Future enhancement
⏳ GET /api/reports/account-aging # Future enhancement
```
---
@@ -215,71 +229,98 @@ GET /api/reports/account-aging
**Legacy Feature**: Built-in timer with start/stop functionality
**Current Status**: ❌ Not implemented
**Current Status**: **COMPLETED** (comprehensive time tracking system implemented)
**Required Components**:
**Implemented Components**:
#### 4.1 Timer System
- [ ] Real-time timer with start/stop/pause
- [ ] Timer state persistence across sessions
- [ ] Multiple concurrent timers by file/task
- [ ] Timer integration with transaction entry
- [x] Real-time timer with start/stop/pause/resume functionality (Timer model with TimerStatus enum)
- [x] Timer state persistence across sessions (TimerSession model for detailed tracking)
- [x] Multiple concurrent timers by file/task (automatic pause of other timers when starting new one)
- [x] Timer integration with transaction entry (convert_time_entry_to_ledger method)
- [x] Timer templates for quick creation of common task timers (TimerTemplate model)
- [x] Timer categorization by task type (research, drafting, client_call, etc.)
#### 4.2 Time Entry Automation
- [ ] Auto-populate time entries from timer
- [ ] Default rate assignment by employee
- [ ] Automatic quantity calculation
- [ ] Timer history and reporting
- [x] Auto-populate time entries from timer (create_time_entry_from_timer method)
- [x] Default rate assignment by employee/file (hourly_rate from File model or user override)
- [x] Automatic quantity calculation (hours computed from timer duration)
- [x] Timer history and reporting (TimerSession tracking, timer statistics)
- [x] Manual time entry creation (for non-timer based time logging)
- [x] Time entry approval workflow (approved/approved_by fields)
**Frontend Components**:
#### 4.3 Advanced Features
- [x] Billable vs non-billable time tracking (TimerType enum)
- [x] Multiple timer types (billable, non_billable, administrative)
- [x] Timer statistics and reporting (total hours, billable percentage, etc.)
- [x] Time entry to ledger conversion (automatic billing transaction creation)
- [x] File and customer assignment for timers
- [x] Detailed session tracking with pause/resume cycles
**API Endpoints Implemented**:
```
TimerWidget
├── Timer display (HH:MM:SS)
├── Start/Stop/Pause controls
├── File/task selection
└── Quick time entry creation
✅ GET /api/timers/ # List user timers
✅ POST /api/timers/ # Create timer
✅ GET /api/timers/{id} # Get timer details
✅ PUT /api/timers/{id} # Update timer
✅ DELETE /api/timers/{id} # Delete timer
✅ POST /api/timers/{id}/start # Start timer
✅ POST /api/timers/{id}/pause # Pause timer
✅ POST /api/timers/{id}/resume # Resume timer
✅ POST /api/timers/{id}/stop # Stop timer
✅ POST /api/timers/{id}/create-entry # Create time entry from timer
✅ GET /api/timers/time-entries/ # List time entries
✅ POST /api/timers/time-entries/ # Create manual time entry
✅ POST /api/timers/time-entries/{id}/convert-to-billing # Convert to ledger
✅ GET /api/timers/templates/ # List timer templates
✅ POST /api/timers/templates/ # Create timer template
✅ GET /api/timers/statistics/ # Get timer statistics
✅ GET /api/timers/active/ # Get active timers
```
**API Endpoints Needed**:
```
POST /api/timers/start
POST /api/timers/{id}/stop
GET /api/timers/active
POST /api/timers/{id}/create-entry
```
**Database Models Created**:
- `Timer` - Active timer sessions with start/stop/pause functionality
- `TimeEntry` - Completed time entries that can be billed
- `TimerSession` - Individual timer sessions for detailed tracking
- `TimerTemplate` - Predefined templates for common tasks
### 🟡 5. Enhanced File Management
**Legacy Feature**: Advanced file operations and status management
**Current Status**: ⚠️ Basic file management exists
**Current Status**: ✅ **COMPLETED**
**Missing Components**:
**Implemented Components**:
#### 5.1 File Closure Automation
- [ ] Automatic file closure workflow
- [ ] Outstanding balance payment entry creation
- [ ] File closure validation and confirmations
- [ ] File reopening capabilities
- [x] Automatic file closure workflow with business rule validation
- [x] Outstanding balance payment entry creation
- [x] File closure validation and confirmations
- [x] File reopening capabilities with status validation
#### 5.2 File Status Workflow
- [ ] Enhanced file status definitions
- [ ] Status-based business rule enforcement
- [ ] Automatic status transitions
- [ ] Status history tracking
- [x] Enhanced file status definitions with transition rules
- [x] Status-based business rule enforcement
- [x] Status transition validation (NEW → ACTIVE → CLOSED → ARCHIVED)
- [x] Complete status history tracking with audit trail
#### 5.3 File Organization
- [ ] Archive file management
- [ ] Bulk file status updates
- [ ] File transfer between attorneys
- [ ] File merge capabilities
- [x] Archive file management with location tracking
- [x] Bulk file status updates for multiple files
- [x] File transfer between attorneys with rate updates
- [x] File transfer history and approval tracking
**API Endpoints Needed**:
**API Endpoints Implemented**:
```
POST /api/files/{id}/close
POST /api/files/{id}/reopen
POST /api/files/bulk-status-update
POST /api/files/{id}/transfer
POST /api/file-management/{file_no}/close
POST /api/file-management/{file_no}/reopen
POST /api/file-management/bulk-status-update
POST /api/file-management/{file_no}/transfer
✅ POST /api/file-management/{file_no}/archive
✅ POST /api/file-management/{file_no}/change-status
✅ GET /api/file-management/{file_no}/status-history
✅ GET /api/file-management/{file_no}/transfer-history
✅ GET /api/file-management/closure-candidates
```
### 🟡 6. Advanced Printer Management