Files
delphi-database/app/api/financial.py
2025-08-13 18:53:35 -05:00

919 lines
28 KiB
Python

"""
Financial/Ledger API endpoints
"""
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import or_, func, and_, desc, asc, text
from datetime import date, datetime, timedelta
from app.database.base import get_db
from app.models.ledger import Ledger
from app.models.files import File
from app.models.rolodex import Rolodex
from app.models.lookups import Employee, TransactionType, TransactionCode
from app.models.user import User
from app.auth.security import get_current_user
router = APIRouter()
# Pydantic schemas
from pydantic import BaseModel
class LedgerBase(BaseModel):
file_no: str
date: date
t_code: str
t_type: str
t_type_l: Optional[str] = None
empl_num: str
quantity: float = 0.0
rate: float = 0.0
amount: float
billed: str = "N"
note: Optional[str] = None
class LedgerCreate(LedgerBase):
pass
class LedgerUpdate(BaseModel):
date: Optional[date] = None
t_code: Optional[str] = None
t_type: Optional[str] = None
t_type_l: Optional[str] = None
empl_num: Optional[str] = None
quantity: Optional[float] = None
rate: Optional[float] = None
amount: Optional[float] = None
billed: Optional[str] = None
note: Optional[str] = None
class LedgerResponse(LedgerBase):
id: int
item_no: int
class Config:
from_attributes = True
class FinancialSummary(BaseModel):
"""Financial summary for a file"""
file_no: str
total_hours: float
total_hourly_fees: float
total_flat_fees: float
total_disbursements: float
total_credits: float
total_charges: float
amount_owing: float
unbilled_amount: float
billed_amount: float
@router.get("/ledger/{file_no}", response_model=List[LedgerResponse])
async def get_file_ledger(
file_no: str,
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=500),
billed_only: Optional[bool] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get ledger entries for specific file"""
query = db.query(Ledger).filter(Ledger.file_no == file_no).order_by(Ledger.date.desc())
if billed_only is not None:
billed_filter = "Y" if billed_only else "N"
query = query.filter(Ledger.billed == billed_filter)
entries = query.offset(skip).limit(limit).all()
return entries
@router.post("/ledger/", response_model=LedgerResponse)
async def create_ledger_entry(
entry_data: LedgerCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create new ledger entry"""
# Verify file exists
file_obj = db.query(File).filter(File.file_no == entry_data.file_no).first()
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
# Get next item number for this file
max_item = db.query(func.max(Ledger.item_no)).filter(
Ledger.file_no == entry_data.file_no
).scalar() or 0
entry = Ledger(
**entry_data.model_dump(),
item_no=max_item + 1
)
db.add(entry)
db.commit()
db.refresh(entry)
# Update file balances (simplified version)
await _update_file_balances(file_obj, db)
return entry
@router.put("/ledger/{entry_id}", response_model=LedgerResponse)
async def update_ledger_entry(
entry_id: int,
entry_data: LedgerUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update ledger entry"""
entry = db.query(Ledger).filter(Ledger.id == entry_id).first()
if not entry:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ledger entry not found"
)
# Update fields
for field, value in entry_data.model_dump(exclude_unset=True).items():
setattr(entry, field, value)
db.commit()
db.refresh(entry)
# Update file balances
file_obj = db.query(File).filter(File.file_no == entry.file_no).first()
if file_obj:
await _update_file_balances(file_obj, db)
return entry
@router.delete("/ledger/{entry_id}")
async def delete_ledger_entry(
entry_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete ledger entry"""
entry = db.query(Ledger).filter(Ledger.id == entry_id).first()
if not entry:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ledger entry not found"
)
file_no = entry.file_no
db.delete(entry)
db.commit()
# Update file balances
file_obj = db.query(File).filter(File.file_no == file_no).first()
if file_obj:
await _update_file_balances(file_obj, db)
return {"message": "Ledger entry deleted successfully"}
@router.get("/reports/{file_no}", response_model=FinancialSummary)
async def get_financial_report(
file_no: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get financial summary report for 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"
)
# Calculate totals from ledger entries
ledger_entries = db.query(Ledger).filter(Ledger.file_no == file_no).all()
total_hours = 0.0
total_hourly_fees = 0.0
total_flat_fees = 0.0
total_disbursements = 0.0
total_credits = 0.0
unbilled_amount = 0.0
billed_amount = 0.0
for entry in ledger_entries:
if entry.t_type == "2": # Hourly fees
total_hours += entry.quantity
total_hourly_fees += entry.amount
elif entry.t_type == "3": # Flat fees
total_flat_fees += entry.amount
elif entry.t_type == "4": # Disbursements
total_disbursements += entry.amount
elif entry.t_type == "5": # Credits
total_credits += entry.amount
if entry.billed == "Y":
billed_amount += entry.amount
else:
unbilled_amount += entry.amount
total_charges = total_hourly_fees + total_flat_fees + total_disbursements
amount_owing = total_charges - total_credits
return FinancialSummary(
file_no=file_no,
total_hours=total_hours,
total_hourly_fees=total_hourly_fees,
total_flat_fees=total_flat_fees,
total_disbursements=total_disbursements,
total_credits=total_credits,
total_charges=total_charges,
amount_owing=amount_owing,
unbilled_amount=unbilled_amount,
billed_amount=billed_amount
)
async def _update_file_balances(file_obj: File, db: Session):
"""Update file balance totals (simplified version of Tally_Ledger)"""
ledger_entries = db.query(Ledger).filter(Ledger.file_no == file_obj.file_no).all()
# Reset balances
file_obj.trust_bal = 0.0
file_obj.hours = 0.0
file_obj.hourly_fees = 0.0
file_obj.flat_fees = 0.0
file_obj.disbursements = 0.0
file_obj.credit_bal = 0.0
# Calculate totals
for entry in ledger_entries:
if entry.t_type == "1": # Trust
file_obj.trust_bal += entry.amount
elif entry.t_type == "2": # Hourly fees
file_obj.hours += entry.quantity
file_obj.hourly_fees += entry.amount
elif entry.t_type == "3": # Flat fees
file_obj.flat_fees += entry.amount
elif entry.t_type == "4": # Disbursements
file_obj.disbursements += entry.amount
elif entry.t_type == "5": # Credits
file_obj.credit_bal += entry.amount
file_obj.total_charges = file_obj.hourly_fees + file_obj.flat_fees + file_obj.disbursements
file_obj.amount_owing = file_obj.total_charges - file_obj.credit_bal
# Calculate transferable amount
if file_obj.amount_owing > 0 and file_obj.trust_bal > 0:
if file_obj.trust_bal >= file_obj.amount_owing:
file_obj.transferable = file_obj.amount_owing
else:
file_obj.transferable = file_obj.trust_bal
else:
file_obj.transferable = 0.0
db.commit()
# Additional Financial Management Endpoints
@router.get("/time-entries/recent")
async def get_recent_time_entries(
days: int = Query(7, ge=1, le=30),
employee: Optional[str] = Query(None),
status: Optional[str] = Query(None, description="billed|unbilled"),
q: Optional[str] = Query(None, description="text search across description, file, employee, matter, client name"),
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=200),
sort_by: str = Query("date"),
sort_dir: str = Query("desc"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get recent time entries across all files with server-side sorting and pagination"""
cutoff_date = date.today() - timedelta(days=days)
# Base query with joins for sorting/searching by client/matter
base_query = db.query(Ledger) \
.join(File, Ledger.file_no == File.file_no) \
.outerjoin(Rolodex, File.id == Rolodex.id) \
.options(joinedload(Ledger.file).joinedload(File.owner)) \
.filter(and_(
Ledger.date >= cutoff_date,
Ledger.t_type == "2"
))
if employee:
base_query = base_query.filter(Ledger.empl_num == employee)
# Status/billed filtering
if status:
status_l = str(status).strip().lower()
if status_l in ("billed", "unbilled"):
billed_value = "Y" if status_l == "billed" else "N"
base_query = base_query.filter(Ledger.billed == billed_value)
# Text search across multiple fields
if q:
query_text = f"%{q.strip()}%"
base_query = base_query.filter(
or_(
Ledger.note.ilike(query_text),
Ledger.file_no.ilike(query_text),
Ledger.empl_num.ilike(query_text),
File.regarding.ilike(query_text),
Rolodex.first.ilike(query_text),
Rolodex.last.ilike(query_text)
)
)
# Sorting mapping (supported columns)
sort_map = {
"date": Ledger.date,
"file_no": Ledger.file_no,
"client_name": Rolodex.last, # best-effort: sort by client last name
"empl_num": Ledger.empl_num,
"quantity": Ledger.quantity,
"hours": Ledger.quantity, # alias
"rate": Ledger.rate,
"amount": Ledger.amount,
"billed": Ledger.billed,
"description": Ledger.note,
}
sort_column = sort_map.get(sort_by.lower(), Ledger.date)
direction = desc if str(sort_dir).lower() == "desc" else asc
# Total count for pagination (distinct on Ledger.id to avoid join-induced dupes)
total_count = base_query.with_entities(func.count(func.distinct(Ledger.id))).scalar()
# Apply sorting and pagination
offset = (page - 1) * limit
page_query = base_query.order_by(direction(sort_column)).offset(offset).limit(limit)
entries = page_query.all()
# Format results with file and client information
results = []
for entry in entries:
file_obj = entry.file
client = file_obj.owner if file_obj else None
results.append({
"id": entry.id,
"date": entry.date.isoformat(),
"file_no": entry.file_no,
"client_name": f"{client.first or ''} {client.last}".strip() if client else "Unknown",
"matter": file_obj.regarding if file_obj else "",
"employee": entry.empl_num,
"hours": entry.quantity,
"rate": entry.rate,
"amount": entry.amount,
"description": entry.note,
"billed": entry.billed == "Y"
})
return {
"entries": results,
"total_count": total_count,
"page": page,
"limit": limit,
"sort_by": sort_by,
"sort_dir": sort_dir,
}
@router.post("/time-entry/quick")
async def create_quick_time_entry(
file_no: str,
hours: float,
description: str,
entry_date: Optional[date] = None,
employee: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Quick time entry creation"""
# Verify file exists and get default rate
file_obj = db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise HTTPException(status_code=404, detail="File not found")
# Use file's default rate and employee if not provided
rate = file_obj.rate_per_hour
empl_num = employee or file_obj.empl_num
entry_date = entry_date or date.today()
# Get next item number
max_item = db.query(func.max(Ledger.item_no)).filter(
Ledger.file_no == file_no
).scalar() or 0
# Create time entry
entry = Ledger(
file_no=file_no,
item_no=max_item + 1,
date=entry_date,
t_code="TIME",
t_type="2",
t_type_l="D",
empl_num=empl_num,
quantity=hours,
rate=rate,
amount=hours * rate,
billed="N",
note=description
)
db.add(entry)
db.commit()
db.refresh(entry)
# Update file balances
await _update_file_balances(file_obj, db)
return {
"id": entry.id,
"message": f"Time entry created: {hours} hours @ ${rate}/hr = ${entry.amount}",
"entry": {
"date": entry.date.isoformat(),
"hours": hours,
"rate": rate,
"amount": entry.amount,
"description": description
}
}
@router.get("/unbilled-entries")
async def get_unbilled_entries(
file_no: Optional[str] = Query(None),
employee: Optional[str] = Query(None),
start_date: Optional[date] = Query(None),
end_date: Optional[date] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get all unbilled entries for billing preparation"""
query = db.query(Ledger)\
.options(joinedload(Ledger.file).joinedload(File.owner))\
.filter(Ledger.billed == "N")\
.order_by(Ledger.file_no, Ledger.date)
if file_no:
query = query.filter(Ledger.file_no == file_no)
if employee:
query = query.filter(Ledger.empl_num == employee)
if start_date:
query = query.filter(Ledger.date >= start_date)
if end_date:
query = query.filter(Ledger.date <= end_date)
entries = query.all()
# Group by file for easier billing
files_data = {}
total_unbilled = 0.0
for entry in entries:
file_no = entry.file_no
if file_no not in files_data:
file_obj = entry.file
client = file_obj.owner if file_obj else None
files_data[file_no] = {
"file_no": file_no,
"client_name": f"{client.first or ''} {client.last}".strip() if client else "Unknown",
"client_id": file_obj.id if file_obj else "",
"matter": file_obj.regarding if file_obj else "",
"entries": [],
"total_amount": 0.0,
"total_hours": 0.0
}
entry_data = {
"id": entry.id,
"date": entry.date.isoformat(),
"type": entry.t_code,
"employee": entry.empl_num,
"description": entry.note,
"quantity": entry.quantity,
"rate": entry.rate,
"amount": entry.amount
}
files_data[file_no]["entries"].append(entry_data)
files_data[file_no]["total_amount"] += entry.amount
if entry.t_type == "2": # Time entries
files_data[file_no]["total_hours"] += entry.quantity
total_unbilled += entry.amount
return {
"files": list(files_data.values()),
"total_unbilled_amount": total_unbilled,
"total_files": len(files_data)
}
@router.post("/bill-entries")
async def mark_entries_as_billed(
entry_ids: List[int],
bill_date: Optional[date] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Mark multiple entries as billed"""
bill_date = bill_date or date.today()
# Get entries to bill
entries = db.query(Ledger).filter(Ledger.id.in_(entry_ids)).all()
if not entries:
raise HTTPException(status_code=404, detail="No entries found")
# Update entries to billed status
billed_amount = 0.0
affected_files = set()
for entry in entries:
entry.billed = "Y"
billed_amount += entry.amount
affected_files.add(entry.file_no)
db.commit()
# Update file balances for affected files
for file_no in affected_files:
file_obj = db.query(File).filter(File.file_no == file_no).first()
if file_obj:
await _update_file_balances(file_obj, db)
return {
"message": f"Marked {len(entries)} entries as billed",
"billed_amount": billed_amount,
"bill_date": bill_date.isoformat(),
"affected_files": list(affected_files)
}
@router.get("/financial-dashboard")
async def get_financial_dashboard(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get financial dashboard summary"""
# Total financial metrics
total_charges = db.query(func.sum(File.total_charges)).scalar() or 0
total_owing = db.query(func.sum(File.amount_owing)).scalar() or 0
total_trust = db.query(func.sum(File.trust_bal)).scalar() or 0
total_hours = db.query(func.sum(File.hours)).scalar() or 0
# Unbilled amounts
unbilled_total = db.query(func.sum(Ledger.amount))\
.filter(Ledger.billed == "N").scalar() or 0
# Recent activity (last 30 days)
thirty_days_ago = date.today() - timedelta(days=30)
recent_entries = db.query(func.count(Ledger.id))\
.filter(Ledger.date >= thirty_days_ago).scalar() or 0
recent_amount = db.query(func.sum(Ledger.amount))\
.filter(Ledger.date >= thirty_days_ago).scalar() or 0
# Top files by balance
top_files = db.query(File.file_no, File.amount_owing, File.total_charges)\
.filter(File.amount_owing > 0)\
.order_by(desc(File.amount_owing))\
.limit(10).all()
# Employee activity
employee_stats = db.query(
Ledger.empl_num,
func.sum(Ledger.quantity).label('total_hours'),
func.sum(Ledger.amount).label('total_amount'),
func.count(Ledger.id).label('entry_count')
).filter(
and_(
Ledger.date >= thirty_days_ago,
Ledger.t_type == "2" # Time entries only
)
).group_by(Ledger.empl_num).all()
return {
"summary": {
"total_charges": float(total_charges),
"total_owing": float(total_owing),
"total_trust": float(total_trust),
"total_hours": float(total_hours),
"unbilled_amount": float(unbilled_total)
},
"recent_activity": {
"entries_count": recent_entries,
"total_amount": float(recent_amount),
"period_days": 30
},
"top_files": [
{
"file_no": f[0],
"amount_owing": float(f[1]),
"total_charges": float(f[2])
} for f in top_files
],
"employee_stats": [
{
"employee": stat[0],
"hours": float(stat[1] or 0),
"amount": float(stat[2] or 0),
"entries": stat[3]
} for stat in employee_stats
]
}
@router.get("/lookups/transaction-codes")
async def get_transaction_codes(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get available transaction codes"""
codes = db.query(TransactionCode).filter(TransactionCode.active == True).all()
return [
{
"code": c.t_code,
"description": c.description,
"type": c.t_type,
"default_rate": c.default_rate
} for c in codes
]
@router.get("/lookups/transaction-types")
async def get_transaction_types(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get available transaction types"""
types = db.query(TransactionType).filter(TransactionType.active == True).all()
return [
{
"type": t.t_type,
"description": t.description,
"debit_credit": t.debit_credit
} for t in types
]
@router.get("/reports/time-summary")
async def get_time_summary_report(
start_date: date = Query(...),
end_date: date = Query(...),
employee: Optional[str] = Query(None),
file_no: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Generate time summary report"""
query = db.query(Ledger)\
.options(joinedload(Ledger.file).joinedload(File.owner))\
.filter(and_(
Ledger.date >= start_date,
Ledger.date <= end_date,
Ledger.t_type == "2" # Time entries only
))\
.order_by(Ledger.date)
if employee:
query = query.filter(Ledger.empl_num == employee)
if file_no:
query = query.filter(Ledger.file_no == file_no)
entries = query.all()
# Summarize by employee and file
summary = {}
total_hours = 0.0
total_amount = 0.0
for entry in entries:
emp = entry.empl_num
file_no = entry.file_no
if emp not in summary:
summary[emp] = {
"employee": emp,
"files": {},
"total_hours": 0.0,
"total_amount": 0.0
}
if file_no not in summary[emp]["files"]:
file_obj = entry.file
client = file_obj.owner if file_obj else None
summary[emp]["files"][file_no] = {
"file_no": file_no,
"client_name": f"{client.first or ''} {client.last}".strip() if client else "Unknown",
"matter": file_obj.regarding if file_obj else "",
"hours": 0.0,
"amount": 0.0,
"entries": []
}
# Add entry details
summary[emp]["files"][file_no]["entries"].append({
"date": entry.date.isoformat(),
"hours": entry.quantity,
"rate": entry.rate,
"amount": entry.amount,
"description": entry.note,
"billed": entry.billed == "Y"
})
# Update totals
summary[emp]["files"][file_no]["hours"] += entry.quantity
summary[emp]["files"][file_no]["amount"] += entry.amount
summary[emp]["total_hours"] += entry.quantity
summary[emp]["total_amount"] += entry.amount
total_hours += entry.quantity
total_amount += entry.amount
# Convert to list format
report_data = []
for emp_data in summary.values():
emp_data["files"] = list(emp_data["files"].values())
report_data.append(emp_data)
return {
"report_period": {
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat()
},
"summary": {
"total_hours": total_hours,
"total_amount": total_amount,
"total_entries": len(entries)
},
"employees": report_data
}
@router.post("/payments/")
async def record_payment(
file_no: str,
amount: float,
payment_date: Optional[date] = None,
payment_method: str = "CHECK",
reference: Optional[str] = None,
notes: Optional[str] = None,
apply_to_trust: bool = False,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Record a payment against 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=404, detail="File not found")
payment_date = payment_date or date.today()
# Get next item number
max_item = db.query(func.max(Ledger.item_no)).filter(
Ledger.file_no == file_no
).scalar() or 0
# Determine transaction type and code based on whether it goes to trust
if apply_to_trust:
t_type = "1" # Trust
t_code = "TRUST"
description = f"Trust deposit - {payment_method}"
else:
t_type = "5" # Credit/Payment
t_code = "PMT"
description = f"Payment received - {payment_method}"
if reference:
description += f" - Ref: {reference}"
if notes:
description += f" - {notes}"
# Create payment entry
entry = Ledger(
file_no=file_no,
item_no=max_item + 1,
date=payment_date,
t_code=t_code,
t_type=t_type,
t_type_l="C", # Credit
empl_num=file_obj.empl_num,
quantity=0.0,
rate=0.0,
amount=amount,
billed="Y", # Payments are automatically considered "billed"
note=description
)
db.add(entry)
db.commit()
db.refresh(entry)
# Update file balances
await _update_file_balances(file_obj, db)
return {
"id": entry.id,
"message": f"Payment of ${amount} recorded successfully",
"payment": {
"amount": amount,
"date": payment_date.isoformat(),
"method": payment_method,
"applied_to": "trust" if apply_to_trust else "balance",
"reference": reference
},
"new_balance": {
"amount_owing": file_obj.amount_owing,
"trust_balance": file_obj.trust_bal
}
}
@router.post("/expenses/")
async def record_expense(
file_no: str,
amount: float,
description: str,
expense_date: Optional[date] = None,
category: str = "MISC",
employee: Optional[str] = None,
receipts: bool = False,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Record an expense/disbursement against 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=404, detail="File not found")
expense_date = expense_date or date.today()
empl_num = employee or file_obj.empl_num
# Get next item number
max_item = db.query(func.max(Ledger.item_no)).filter(
Ledger.file_no == file_no
).scalar() or 0
# Add receipt info to description
full_description = description
if receipts:
full_description += " (Receipts on file)"
# Create expense entry
entry = Ledger(
file_no=file_no,
item_no=max_item + 1,
date=expense_date,
t_code=category,
t_type="4", # Disbursements
t_type_l="D", # Debit
empl_num=empl_num,
quantity=0.0,
rate=0.0,
amount=amount,
billed="N",
note=full_description
)
db.add(entry)
db.commit()
db.refresh(entry)
# Update file balances
await _update_file_balances(file_obj, db)
return {
"id": entry.id,
"message": f"Expense of ${amount} recorded successfully",
"expense": {
"amount": amount,
"date": expense_date.isoformat(),
"category": category,
"description": description,
"employee": empl_num
}
}