fix: Balance now sums past-month (paid - expected) directly, ignoring current/future months
The previous calculation derived balance from total_balance (which includes current/future-month activity and out-of-window credits) plus a one-sided debt-only adjustment. Current-month surplus leaked through, making the balance appear less negative than actual past-month debt (e.g. Mauric Daniel -1250 vs correct -1750). Pay-All is now max(0, -balance) so the two values share a single source and cannot disagree. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
57
app.py
57
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})
|
||||
continue
|
||||
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
|
||||
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})
|
||||
continue
|
||||
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
|
||||
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})
|
||||
continue
|
||||
exp = mdata.get("expected", 0)
|
||||
if isinstance(exp, int):
|
||||
debt += max(0, exp - int(mdata.get("paid", 0)))
|
||||
return data["total_balance"] + debt
|
||||
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"])
|
||||
|
||||
Reference in New Issue
Block a user