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
|
# 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
|
## 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.
|
- `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.
|
||||||
|
|||||||
67
app.py
67
app.py
@@ -192,7 +192,6 @@ def adults_view():
|
|||||||
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""}
|
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""}
|
||||||
unpaid_months = []
|
unpaid_months = []
|
||||||
raw_unpaid_months = []
|
raw_unpaid_months = []
|
||||||
payable_amount = 0
|
|
||||||
for m in sorted_months:
|
for m in sorted_months:
|
||||||
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None})
|
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None})
|
||||||
expected = mdata.get("expected", 0)
|
expected = mdata.get("expected", 0)
|
||||||
@@ -228,14 +227,12 @@ def adults_view():
|
|||||||
if m < current_month:
|
if m < current_month:
|
||||||
unpaid_months.append(month_labels[m])
|
unpaid_months.append(month_labels[m])
|
||||||
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
||||||
payable_amount += amount_to_pay
|
|
||||||
else:
|
else:
|
||||||
status = "unpaid"
|
status = "unpaid"
|
||||||
cell_text = f"0/{fee_display}"
|
cell_text = f"0/{fee_display}"
|
||||||
if m < current_month:
|
if m < current_month:
|
||||||
unpaid_months.append(month_labels[m])
|
unpaid_months.append(month_labels[m])
|
||||||
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
||||||
payable_amount += amount_to_pay
|
|
||||||
elif paid > 0:
|
elif paid > 0:
|
||||||
status = "surplus"
|
status = "surplus"
|
||||||
cell_text = f"PAID {paid}"
|
cell_text = f"PAID {paid}"
|
||||||
@@ -258,17 +255,17 @@ def adults_view():
|
|||||||
"tooltip": tooltip
|
"tooltip": tooltip
|
||||||
})
|
})
|
||||||
|
|
||||||
# Compute balance excluding current/future months
|
# Balance = sum of (paid - expected) for past months only; current/future months ignored.
|
||||||
current_month_debt = 0
|
settled_balance = 0
|
||||||
for m in sorted_months:
|
for m, mdata in data["months"].items():
|
||||||
if m >= current_month:
|
if m >= current_month:
|
||||||
mdata = data["months"].get(m, {"expected": 0, "paid": 0})
|
continue
|
||||||
exp = mdata.get("expected", 0)
|
exp = mdata.get("expected", 0)
|
||||||
pd = int(mdata.get("paid", 0))
|
if isinstance(exp, int):
|
||||||
current_month_debt += max(0, exp - pd)
|
settled_balance += int(mdata.get("paid", 0)) - exp
|
||||||
settled_balance = data["total_balance"] + current_month_debt
|
|
||||||
|
|
||||||
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["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
|
||||||
row["balance"] = settled_balance
|
row["balance"] = settled_balance
|
||||||
row["payable_amount"] = payable_amount
|
row["payable_amount"] = payable_amount
|
||||||
@@ -293,8 +290,14 @@ def adults_view():
|
|||||||
|
|
||||||
def settled_balance(name):
|
def settled_balance(name):
|
||||||
data = result["members"][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)
|
total = 0
|
||||||
return data["total_balance"] + debt
|
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"])
|
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"])
|
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": ""}
|
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""}
|
||||||
unpaid_months = []
|
unpaid_months = []
|
||||||
raw_unpaid_months = []
|
raw_unpaid_months = []
|
||||||
payable_amount = 0
|
|
||||||
for m in sorted_months:
|
for m in sorted_months:
|
||||||
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None})
|
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None})
|
||||||
expected = mdata.get("expected", 0)
|
expected = mdata.get("expected", 0)
|
||||||
@@ -429,7 +431,6 @@ def juniors_view():
|
|||||||
if m < current_month:
|
if m < current_month:
|
||||||
unpaid_months.append(month_labels[m])
|
unpaid_months.append(month_labels[m])
|
||||||
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
||||||
payable_amount += amount_to_pay
|
|
||||||
else:
|
else:
|
||||||
status = "unpaid"
|
status = "unpaid"
|
||||||
cell_text = f"0/{fee_display}"
|
cell_text = f"0/{fee_display}"
|
||||||
@@ -437,7 +438,6 @@ def juniors_view():
|
|||||||
if m < current_month:
|
if m < current_month:
|
||||||
unpaid_months.append(month_labels[m])
|
unpaid_months.append(month_labels[m])
|
||||||
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
||||||
payable_amount += amount_to_pay
|
|
||||||
elif paid > 0:
|
elif paid > 0:
|
||||||
status = "surplus"
|
status = "surplus"
|
||||||
cell_text = f"PAID {paid}"
|
cell_text = f"PAID {paid}"
|
||||||
@@ -457,18 +457,17 @@ def juniors_view():
|
|||||||
"tooltip": tooltip
|
"tooltip": tooltip
|
||||||
})
|
})
|
||||||
|
|
||||||
# Compute balance excluding current/future months
|
# Balance = sum of (paid - expected) for past months only; current/future months ignored.
|
||||||
current_month_debt = 0
|
settled_balance = 0
|
||||||
for m in sorted_months:
|
for m, mdata in data["months"].items():
|
||||||
if m >= current_month:
|
if m >= current_month:
|
||||||
mdata = data["months"].get(m, {"expected": 0, "paid": 0})
|
continue
|
||||||
exp = mdata.get("expected", 0)
|
exp = mdata.get("expected", 0)
|
||||||
if isinstance(exp, int):
|
if isinstance(exp, int):
|
||||||
pd = int(mdata.get("paid", 0))
|
settled_balance += int(mdata.get("paid", 0)) - exp
|
||||||
current_month_debt += max(0, exp - pd)
|
|
||||||
settled_balance = data["total_balance"] + current_month_debt
|
|
||||||
|
|
||||||
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["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
|
||||||
row["balance"] = settled_balance
|
row["balance"] = settled_balance
|
||||||
row["payable_amount"] = payable_amount
|
row["payable_amount"] = payable_amount
|
||||||
@@ -494,14 +493,14 @@ def juniors_view():
|
|||||||
# Format credits and debts
|
# Format credits and debts
|
||||||
def junior_settled_balance(name):
|
def junior_settled_balance(name):
|
||||||
data = result["members"][name]
|
data = result["members"][name]
|
||||||
debt = 0
|
total = 0
|
||||||
for m in sorted_months:
|
for m, mdata in data["months"].items():
|
||||||
if m >= current_month:
|
if m >= current_month:
|
||||||
mdata = data["months"].get(m, {"expected": 0, "paid": 0})
|
continue
|
||||||
exp = mdata.get("expected", 0)
|
exp = mdata.get("expected", 0)
|
||||||
if isinstance(exp, int):
|
if isinstance(exp, int):
|
||||||
debt += max(0, exp - int(mdata.get("paid", 0)))
|
total += int(mdata.get("paid", 0)) - exp
|
||||||
return data["total_balance"] + debt
|
return total
|
||||||
|
|
||||||
junior_all_names = [name for name, _, _ in adapted_members]
|
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"])
|
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