diff --git a/CHANGELOG.md b/CHANGELOG.md index 25893ee..e78a939 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 2026-05-03 20:37 CEST — Fix Balance column to correctly reflect past-month debt + +- Balance (and Pay-All) are now computed as `sum(paid − expected)` over past months only, iterating directly over the ledger entries from `reconcile()`. +- Previously the balance used `total_balance` (which includes current/future-month activity and out-of-window credits) plus a one-sided current-month debt adjustment. Current-month *surplus* leaked through, making the balance appear less negative than the actual past-month debt. +- Pay-All is now `max(0, −balance)` so the two values are derived from a single source and can never disagree. +- Affected: `adults_view()` and `juniors_view()` in `app.py`. + ## 2026-05-03 19:26 CEST — Fee-aware allocation for multi-month payments - `reconcile()` no longer splits a multi-month payment evenly. Allocation is now per-member with two phases: greedy (if amount ≥ total expected, each month gets exactly its expected fee and overflow → credit) and proportional (otherwise distribute by each month's expected). Fixes the case where e.g. 1250 CZK covering 3 months with mixed fees (750/350/150) marked two months red. diff --git a/app.py b/app.py index 9625b09..d960bc3 100644 --- a/app.py +++ b/app.py @@ -192,7 +192,6 @@ def adults_view(): row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""} unpaid_months = [] raw_unpaid_months = [] - payable_amount = 0 for m in sorted_months: mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None}) expected = mdata.get("expected", 0) @@ -228,14 +227,12 @@ def adults_view(): if m < current_month: unpaid_months.append(month_labels[m]) raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y")) - payable_amount += amount_to_pay else: status = "unpaid" cell_text = f"0/{fee_display}" if m < current_month: unpaid_months.append(month_labels[m]) raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y")) - payable_amount += amount_to_pay elif paid > 0: status = "surplus" cell_text = f"PAID {paid}" @@ -258,17 +255,17 @@ def adults_view(): "tooltip": tooltip }) - # Compute balance excluding current/future months - current_month_debt = 0 - for m in sorted_months: + # Balance = sum of (paid - expected) for past months only; current/future months ignored. + settled_balance = 0 + for m, mdata in data["months"].items(): if m >= current_month: - mdata = data["months"].get(m, {"expected": 0, "paid": 0}) - exp = mdata.get("expected", 0) - pd = int(mdata.get("paid", 0)) - current_month_debt += max(0, exp - pd) - settled_balance = data["total_balance"] + current_month_debt + continue + exp = mdata.get("expected", 0) + if isinstance(exp, int): + settled_balance += int(mdata.get("paid", 0)) - exp - row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if settled_balance < 0 and payable_amount == 0 else "") + payable_amount = max(0, -settled_balance) + row["unpaid_periods"] = ", ".join(unpaid_months) row["raw_unpaid_periods"] = "+".join(raw_unpaid_months) row["balance"] = settled_balance row["payable_amount"] = payable_amount @@ -293,8 +290,14 @@ def adults_view(): def settled_balance(name): data = result["members"][name] - debt = sum(max(0, data["months"].get(m, {"expected": 0, "paid": 0}).get("expected", 0) - int(data["months"].get(m, {"expected": 0, "paid": 0}).get("paid", 0))) for m in sorted_months if m >= current_month) - return data["total_balance"] + debt + total = 0 + for m, mdata in data["months"].items(): + if m >= current_month: + continue + exp = mdata.get("expected", 0) + if isinstance(exp, int): + total += int(mdata.get("paid", 0)) - exp + return total credits = sorted([{"name": n, "amount": settled_balance(n)} for n in adult_names if settled_balance(n) > 0], key=lambda x: x["name"]) debts = sorted([{"name": n, "amount": abs(settled_balance(n))} for n in adult_names if settled_balance(n) < 0], key=lambda x: x["name"]) @@ -373,7 +376,6 @@ def juniors_view(): row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""} unpaid_months = [] raw_unpaid_months = [] - payable_amount = 0 for m in sorted_months: mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None}) expected = mdata.get("expected", 0) @@ -429,7 +431,6 @@ def juniors_view(): if m < current_month: unpaid_months.append(month_labels[m]) raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y")) - payable_amount += amount_to_pay else: status = "unpaid" cell_text = f"0/{fee_display}" @@ -437,7 +438,6 @@ def juniors_view(): if m < current_month: unpaid_months.append(month_labels[m]) raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y")) - payable_amount += amount_to_pay elif paid > 0: status = "surplus" cell_text = f"PAID {paid}" @@ -457,18 +457,17 @@ def juniors_view(): "tooltip": tooltip }) - # Compute balance excluding current/future months - current_month_debt = 0 - for m in sorted_months: + # Balance = sum of (paid - expected) for past months only; current/future months ignored. + settled_balance = 0 + for m, mdata in data["months"].items(): if m >= current_month: - mdata = data["months"].get(m, {"expected": 0, "paid": 0}) - exp = mdata.get("expected", 0) - if isinstance(exp, int): - pd = int(mdata.get("paid", 0)) - current_month_debt += max(0, exp - pd) - settled_balance = data["total_balance"] + current_month_debt + continue + exp = mdata.get("expected", 0) + if isinstance(exp, int): + settled_balance += int(mdata.get("paid", 0)) - exp - row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if settled_balance < 0 and payable_amount == 0 else "") + payable_amount = max(0, -settled_balance) + row["unpaid_periods"] = ", ".join(unpaid_months) row["raw_unpaid_periods"] = "+".join(raw_unpaid_months) row["balance"] = settled_balance row["payable_amount"] = payable_amount @@ -494,14 +493,14 @@ def juniors_view(): # Format credits and debts def junior_settled_balance(name): data = result["members"][name] - debt = 0 - for m in sorted_months: + total = 0 + for m, mdata in data["months"].items(): if m >= current_month: - mdata = data["months"].get(m, {"expected": 0, "paid": 0}) - exp = mdata.get("expected", 0) - if isinstance(exp, int): - debt += max(0, exp - int(mdata.get("paid", 0))) - return data["total_balance"] + debt + continue + exp = mdata.get("expected", 0) + if isinstance(exp, int): + total += int(mdata.get("paid", 0)) - exp + return total junior_all_names = [name for name, _, _ in adapted_members] credits = sorted([{"name": n, "amount": junior_settled_balance(n)} for n in junior_all_names if junior_settled_balance(n) > 0], key=lambda x: x["name"])