maybe good
This commit is contained in:
863
app/api/financial.py
Normal file
863
app/api/financial.py
Normal file
@@ -0,0 +1,863 @@
|
||||
"""
|
||||
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),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get recent time entries across all files"""
|
||||
cutoff_date = date.today() - timedelta(days=days)
|
||||
|
||||
query = db.query(Ledger)\
|
||||
.options(joinedload(Ledger.file).joinedload(File.owner))\
|
||||
.filter(and_(
|
||||
Ledger.date >= cutoff_date,
|
||||
Ledger.t_type == "2" # Time entries
|
||||
))\
|
||||
.order_by(desc(Ledger.date))
|
||||
|
||||
if employee:
|
||||
query = query.filter(Ledger.empl_num == employee)
|
||||
|
||||
entries = query.offset(skip).limit(limit).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_entries": len(results)}
|
||||
|
||||
|
||||
@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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user