Compare commits

...

1 Commits
0.29 ... 0.30

Author SHA1 Message Date
5a41cdae83 fix: Balance now sums past-month (paid - expected) directly, ignoring current/future months
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
Build and Push / build (push) Successful in 6s
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>
2026-05-03 20:57:13 +02:00
2 changed files with 40 additions and 34 deletions

View File

@@ -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
View File

@@ -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"])