fix(reconcile): fill earliest month deficit first in multi-month allocations
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s

Replace proportional split with a fill-first loop that allocates
min(remaining, deficit) to each matched month in user-supplied order,
where deficit = expected - already_paid. Prior transactions' contributions
are now properly accounted for, so a second payment on overlapping months
fills only what's still owed instead of splitting proportionally by total
expected. Surplus after all deficits are covered goes to the credit bucket.

Fixes: Matyáš Thér 200+550 showing 566/183 instead of 500/250.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-11 23:59:36 +02:00
parent aaa876e593
commit 8734089223
8 changed files with 336 additions and 109 deletions

View File

@@ -93,8 +93,8 @@ class TestMultiMonthAllocation(unittest.TestCase):
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."""
def test_underpayment_fills_earliest_first(self):
"""Payment < total expected → fill earliest months first, spill remainder to later."""
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
@@ -103,18 +103,28 @@ class TestMultiMonthAllocation(unittest.TestCase):
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']
# 02 filled first (750), then 03 (350), then remainder 150 to 04
self.assertAlmostEqual(months['2026-02']['paid'], 750, places=2)
self.assertAlmostEqual(months['2026-03']['paid'], 350, places=2)
self.assertAlmostEqual(months['2026-04']['paid'], 150, places=2)
# No CZK lost
self.assertAlmostEqual(
months['2026-02']['paid'] + months['2026-03']['paid'] + months['2026-04']['paid'],
amount, places=2,
)
# 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_fill_first_across_two_transactions(self):
"""Prior txn fills 02 partially; later txn finishes 02 then spills to 03."""
members = [('Matyáš', 'A', {'2026-02': (500, 2), '2026-03': (250, 1)})]
sorted_months = ['2026-02', '2026-03']
tx1 = _tx('Matyáš', '2026-02', 200)
tx2 = _tx('Matyáš', '2026-02, 2026-03', 550)
result = reconcile(members, sorted_months, [tx1, tx2])
months = result['members']['Matyáš']['months']
self.assertAlmostEqual(months['2026-02']['paid'], 500, places=2)
self.assertAlmostEqual(months['2026-03']['paid'], 250, places=2)
def test_single_month_unchanged(self):
"""Single-month payment: full amount goes to that month (regression guard)."""