fix: Distribute multi-month payments by per-month expected fee
All checks were successful
Build and Push / build (push) Successful in 33s
Deploy to K8s / deploy (push) Successful in 12s

reconcile() previously split a multi-month payment evenly across months,
which falsely flagged months as underpaid when their expected fees
differed (e.g. 1250 CZK for 02+03+04 2026 with rates 750/350/150 was
shown as 416/month with two months red).

The allocation now runs per matched member: greedy when the share covers
the total expected (each month gets its expected fee, surplus -> credit),
proportional by expected fee otherwise. Out-of-window months keep the
previous even-split-to-credit behavior. 6 new test cases.

Also adds CHANGELOG.md and a changelog convention in CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-03 19:38:10 +02:00
parent ced238385e
commit dfdf2aacb8
4 changed files with 255 additions and 28 deletions

View File

@@ -52,5 +52,108 @@ class TestReconcileWithExceptions(unittest.TestCase):
alice_data = result['members']['Alice']
self.assertEqual(alice_data['months']['2026-01']['expected'], 750, "Should fallback to attendance fee")
def _tx(person, purpose, amount):
return {
'date': '2026-01-01',
'amount': amount,
'person': person,
'purpose': purpose,
'inferred_amount': amount,
'sender': 'Sender',
'message': 'fee',
}
class TestMultiMonthAllocation(unittest.TestCase):
"""Fee-aware allocation across multiple months in a single payment."""
def test_greedy_exact_match(self):
"""Payment equals total expected → every month fully covered (green)."""
members = [('Alice', 'A', {'2026-02': (750, 3), '2026-03': (350, 3), '2026-04': (150, 2)})]
sorted_months = ['2026-02', '2026-03', '2026-04']
tx = _tx('Alice', '2026-02, 2026-03, 2026-04', 1250)
result = reconcile(members, sorted_months, [tx])
months = result['members']['Alice']['months']
self.assertEqual(int(months['2026-02']['paid']), 750)
self.assertEqual(int(months['2026-03']['paid']), 350)
self.assertEqual(int(months['2026-04']['paid']), 150)
def test_greedy_overpayment_goes_to_credit(self):
"""Payment exceeds total expected → each month fully covered, surplus → credit."""
members = [('Alice', 'A', {'2026-01': (750, 3), '2026-02': (750, 3)})]
sorted_months = ['2026-01', '2026-02']
tx = _tx('Alice', '2026-01, 2026-02', 2000)
result = reconcile(members, sorted_months, [tx])
months = result['members']['Alice']['months']
self.assertEqual(int(months['2026-01']['paid']), 750)
self.assertEqual(int(months['2026-02']['paid']), 750)
self.assertEqual(result['credits'].get('Alice', 0), 500)
def test_proportional_underpayment(self):
"""Payment < total expected → proportional split; sum of paid == payment amount."""
members = [('Alice', 'A', {'2026-02': (750, 3), '2026-03': (350, 3), '2026-04': (750, 3)})]
sorted_months = ['2026-02', '2026-03', '2026-04']
amount = 1250
tx = _tx('Alice', '2026-02, 2026-03, 2026-04', amount)
result = reconcile(members, sorted_months, [tx])
months = result['members']['Alice']['months']
paid_02 = months['2026-02']['paid']
paid_03 = months['2026-03']['paid']
paid_04 = months['2026-04']['paid']
# All months should be partial (underpaid)
self.assertLess(paid_02, 750)
self.assertLess(paid_03, 350)
self.assertLess(paid_04, 750)
# Sum must equal the original payment (no CZK lost)
self.assertAlmostEqual(paid_02 + paid_03 + paid_04, amount, places=2)
# 02 and 04 have equal expected → equal allocation
self.assertAlmostEqual(paid_02, paid_04, places=2)
def test_single_month_unchanged(self):
"""Single-month payment: full amount goes to that month (regression guard)."""
members = [('Alice', 'A', {'2026-01': (750, 3)})]
sorted_months = ['2026-01']
tx = _tx('Alice', '2026-01', 750)
result = reconcile(members, sorted_months, [tx])
self.assertAlmostEqual(result['members']['Alice']['months']['2026-01']['paid'], 750, places=2)
def test_two_members_multi_month(self):
"""Two members, 2 months: each member gets member_share, then fee-aware per month."""
members = [
('Alice', 'A', {'2026-01': (750, 3), '2026-02': (350, 3)}),
('Bob', 'A', {'2026-01': (750, 3), '2026-02': (350, 3)}),
]
sorted_months = ['2026-01', '2026-02']
# Both members pay together; total expected per member = 1100
tx = _tx('Alice, Bob', '2026-01, 2026-02', 2200)
result = reconcile(members, sorted_months, [tx])
for name in ('Alice', 'Bob'):
months = result['members'][name]['months']
self.assertAlmostEqual(months['2026-01']['paid'], 750, places=2)
self.assertAlmostEqual(months['2026-02']['paid'], 350, places=2)
def test_fallback_even_split_when_no_expected(self):
"""All matched months have expected=0 → falls back to even split."""
members = [('Alice', 'A', {'2026-01': (0, 0), '2026-02': (0, 0)})]
sorted_months = ['2026-01', '2026-02']
tx = _tx('Alice', '2026-01, 2026-02', 300)
result = reconcile(members, sorted_months, [tx])
months = result['members']['Alice']['months']
self.assertAlmostEqual(months['2026-01']['paid'], 150, places=2)
self.assertAlmostEqual(months['2026-02']['paid'], 150, places=2)
if __name__ == '__main__':
unittest.main()