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>
170 lines
6.9 KiB
Python
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()
|