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()