955 lines
29 KiB
Python
955 lines
29 KiB
Python
"""
|
|
Financial/Ledger API endpoints
|
|
"""
|
|
from typing import List, Optional, Dict, Any, Union
|
|
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, timezone
|
|
|
|
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
|
|
from app.services.cache import invalidate_search_cache
|
|
from app.services.query_utils import apply_sorting, paginate_with_total
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# Pydantic schemas
|
|
from pydantic import BaseModel, ConfigDict
|
|
|
|
|
|
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
|
|
|
|
model_config = ConfigDict(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
|
|
|
|
|
|
class PaginatedLedgerResponse(BaseModel):
|
|
items: List[LedgerResponse]
|
|
total: int
|
|
|
|
|
|
@router.get("/ledger/{file_no}", response_model=Union[List[LedgerResponse], PaginatedLedgerResponse])
|
|
async def get_file_ledger(
|
|
file_no: str,
|
|
skip: int = Query(0, ge=0, description="Offset for pagination"),
|
|
limit: int = Query(100, ge=1, le=500, description="Page size"),
|
|
billed_only: Optional[bool] = Query(None, description="Filter billed vs unbilled entries"),
|
|
sort_by: Optional[str] = Query("date", description="Sort by: date, item_no, amount, billed"),
|
|
sort_dir: Optional[str] = Query("desc", description="Sort direction: asc or desc"),
|
|
include_total: bool = Query(False, description="When true, returns {items, total} instead of a plain list"),
|
|
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)
|
|
|
|
if billed_only is not None:
|
|
billed_filter = "Y" if billed_only else "N"
|
|
query = query.filter(Ledger.billed == billed_filter)
|
|
|
|
# Sorting (whitelisted)
|
|
query = apply_sorting(
|
|
query,
|
|
sort_by,
|
|
sort_dir,
|
|
allowed={
|
|
"date": [Ledger.date, Ledger.item_no],
|
|
"item_no": [Ledger.item_no],
|
|
"amount": [Ledger.amount],
|
|
"billed": [Ledger.billed, Ledger.date],
|
|
},
|
|
)
|
|
|
|
entries, total = paginate_with_total(query, skip, limit, include_total)
|
|
if include_total:
|
|
return {"items": entries, "total": total or 0}
|
|
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)
|
|
|
|
try:
|
|
await invalidate_search_cache()
|
|
except Exception:
|
|
pass
|
|
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)
|
|
|
|
try:
|
|
await invalidate_search_cache()
|
|
except Exception:
|
|
pass
|
|
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)
|
|
|
|
try:
|
|
await invalidate_search_cache()
|
|
except Exception:
|
|
pass
|
|
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
|
|
}
|
|
} |