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