Files
fuj-management/tests/test_reconcile_exceptions.py
Jan Novak 8734089223
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
fix(reconcile): fill earliest month deficit first in multi-month allocations
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>
2026-05-11 23:59:36 +02:00

170 lines
6.9 KiB
Python

import unittest
from scripts.match_payments import reconcile
class TestReconcileWithExceptions(unittest.TestCase):
def test_reconcile_applies_exceptions(self):
# 1. Setup mock data
# Member: Alice, Tier A, expected 750 (attendance-based)
members = [
('Alice', 'A', {'2026-01': (750, 4)})
]
sorted_months = ['2026-01']
# Exception: Alice should only pay 400 in 2026-01 (normalized keys, no accents)
exceptions = {
('alice', '2026-01'): {'amount': 400, 'note': 'Test exception'}
}
# Transaction: Alice paid 400
transactions = [{
'date': '2026-01-05',
'amount': 400,
'person': 'Alice',
'purpose': '2026-01',
'inferred_amount': 400,
'sender': 'Alice Sender',
'message': 'fee'
}]
# 2. Reconcile
result = reconcile(members, sorted_months, transactions, exceptions)
# 3. Assertions
alice_data = result['members']['Alice']
jan_data = alice_data['months']['2026-01']
self.assertEqual(jan_data['expected'], 400, "Expected amount should be overridden by exception")
self.assertEqual(jan_data['paid'], 400, "Paid amount should be 400")
self.assertEqual(alice_data['total_balance'], 0, "Balance should be 0 because 400/400")
def test_reconcile_fallback_to_attendance(self):
# Alice has attendance-based fee 750, NO exception
members = [
('Alice', 'A', {'2026-01': (750, 4)})
]
sorted_months = ['2026-01']
exceptions = {} # No exceptions
transactions = []
result = reconcile(members, sorted_months, transactions, exceptions)
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_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
tx = _tx('Alice', '2026-02, 2026-03, 2026-04', amount)
result = reconcile(members, sorted_months, [tx])
months = result['members']['Alice']['months']
# 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,
)
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)."""
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()