diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc index 64d47ab..11e33a9 100644 Binary files a/app/__pycache__/main.cpython-313.pyc and b/app/__pycache__/main.cpython-313.pyc differ diff --git a/app/main.py b/app/main.py index f6e1e82..37b4256 100644 --- a/app/main.py +++ b/app/main.py @@ -940,6 +940,87 @@ def compute_case_totals_from_case(case_obj: Case) -> Dict[str, float]: } +def compute_case_totals_for_case_id(db: Session, case_id: int) -> Dict[str, float]: + """ + Compute billed, unbilled, and overall totals for a case by ID. + + This uses a simple in-Python aggregation over the case's transactions to + avoid SQL portability issues and to keep the logic consistent with + compute_case_totals_from_case. + """ + billed_total = 0.0 + unbilled_total = 0.0 + overall_total = 0.0 + + transactions: List[Transaction] = ( + db.query(Transaction).filter(Transaction.case_id == case_id).all() + ) + for t in transactions: + amt = float(t.amount) if t.amount is not None else 0.0 + overall_total += amt + if ((t.billed or '').upper()) == 'Y': + billed_total += amt + else: + unbilled_total += amt + + return { + 'billed_total': round(billed_total, 2), + 'unbilled_total': round(unbilled_total, 2), + 'overall_total': round(overall_total, 2), + } + + +def _ledger_keys_from_tx(tx: Optional["Transaction"]) -> Dict[str, Any]: + """ + Extract identifying keys for a ledger transaction for audit logs. + """ + if tx is None: + return {} + return { + 'transaction_id': getattr(tx, 'id', None), + 'case_id': getattr(tx, 'case_id', None), + 'item_no': getattr(tx, 'item_no', None), + 'transaction_date': getattr(tx, 'transaction_date', None), + 't_code': getattr(tx, 't_code', None), + 't_type_l': getattr(tx, 't_type_l', None), + 'employee_number': getattr(tx, 'employee_number', None), + 'billed': getattr(tx, 'billed', None), + 'amount': getattr(tx, 'amount', None), + } + + +def _log_ledger_audit( + *, + action: str, + user: "User", + case_id: int, + keys: Dict[str, Any], + pre: Dict[str, float], + post: Dict[str, float], +) -> None: + """ + Emit a structured audit log line for ledger mutations including user, action, + identifiers, and pre/post balances with deltas. + """ + delta = { + 'billed_total': round((post.get('billed_total', 0.0) - pre.get('billed_total', 0.0)), 2), + 'unbilled_total': round((post.get('unbilled_total', 0.0) - pre.get('unbilled_total', 0.0)), 2), + 'overall_total': round((post.get('overall_total', 0.0) - pre.get('overall_total', 0.0)), 2), + } + + logger.info( + "ledger_audit", + action=action, + user_id=getattr(user, 'id', None), + user_username=getattr(user, 'username', None), + case_id=case_id, + keys=keys, + pre_balances=pre, + post_balances=post, + delta_balances=delta, + ) + + @app.post("/case/{case_id}/ledger") async def ledger_create( request: Request, @@ -952,6 +1033,9 @@ async def ledger_create( form = await request.form() + # Pre-mutation totals for audit + pre_totals = compute_case_totals_for_case_id(db, case_id) + # Validate errors, parsed = validate_ledger_fields( transaction_date=form.get("transaction_date"), @@ -1000,6 +1084,16 @@ async def ledger_create( ) db.add(tx) db.commit() + # Post-mutation totals and audit log + post_totals = compute_case_totals_for_case_id(db, case_id) + _log_ledger_audit( + action="create", + user=user, + case_id=case_id, + keys=_ledger_keys_from_tx(tx), + pre=pre_totals, + post=post_totals, + ) logger.info("ledger_create", case_id=case_id, transaction_id=tx.id) return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302) except Exception as e: @@ -1027,6 +1121,9 @@ async def ledger_update( request.session["case_update_errors"] = ["Ledger entry not found"] return RedirectResponse(url=f"/case/{case_id}", status_code=302) + # Pre-mutation totals for audit + pre_totals = compute_case_totals_for_case_id(db, case_id) + errors, parsed = validate_ledger_fields( transaction_date=form.get("transaction_date"), t_code=form.get("t_code"), @@ -1058,6 +1155,16 @@ async def ledger_update( tx.description = (form.get("description") or "").strip() or None db.commit() + # Post-mutation totals and audit log + post_totals = compute_case_totals_for_case_id(db, case_id) + _log_ledger_audit( + action="update", + user=user, + case_id=case_id, + keys=_ledger_keys_from_tx(tx), + pre=pre_totals, + post=post_totals, + ) logger.info("ledger_update", case_id=case_id, transaction_id=tx.id) return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302) except Exception as e: @@ -1084,8 +1191,21 @@ async def ledger_delete( return RedirectResponse(url=f"/case/{case_id}", status_code=302) try: + # Capture pre-mutation totals and keys for audit before deletion + pre_totals = compute_case_totals_for_case_id(db, case_id) + tx_keys = _ledger_keys_from_tx(tx) db.delete(tx) db.commit() + # Post-mutation totals and audit log + post_totals = compute_case_totals_for_case_id(db, case_id) + _log_ledger_audit( + action="delete", + user=user, + case_id=case_id, + keys=tx_keys, + pre=pre_totals, + post=post_totals, + ) logger.info("ledger_delete", case_id=case_id, transaction_id=tx_id) return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302) except Exception as e: