"""Unit tests for payment allocation logic.""" from datetime import datetime, timezone from decimal import Decimal import re from unittest.mock import call, MagicMock, patch import pytest from payment.constants.constants import ( PAYMENT_ALLOCATION_LEDGER_STATUSES, PAYMENT_ALLOCATION_STATUSES, ) from payment.constants.error import ERROR_ALLOCATION_FLOWTHROUGH_MISSED from payment.logic import payment_allocation as logic from payment.logic.exceptions import LogicError from payment.logic.payment_allocation import get_flowthrough_allocations_bulk from tests.utils.factories import PaymentAllocationFlowthroughFactory def test_get_flowthrough_allocations_by_ids(fresh_db, mock_contracts): """Test retrieving flowthrough allocations by payment_allocation_ids.""" allocation1 = PaymentAllocationFlowthroughFactory.create( contract_id=1, amount_to_payment=Decimal('100.00') ) allocation2 = PaymentAllocationFlowthroughFactory.create( contract_id=2, amount_to_payment=Decimal('200.00') ) PaymentAllocationFlowthroughFactory.create(contract_id=3) result = get_flowthrough_allocations_bulk( payment_allocation_ids=[ allocation1.payment_allocation_id, allocation2.payment_allocation_id, ], limit=10, offset=0, ) assert result.total_count == 2 assert len(result.items) == 2 assert allocation1 in result.items assert allocation2 in result.items def test_get_flowthrough_allocations_by_contract_ids(fresh_db, mock_contracts): """Test retrieving flowthrough allocations by contract_ids.""" allocation1 = PaymentAllocationFlowthroughFactory.create(contract_id=1) allocation2 = PaymentAllocationFlowthroughFactory.create(contract_id=1) PaymentAllocationFlowthroughFactory.create(contract_id=2) result = get_flowthrough_allocations_bulk( contract_ids=[1], limit=10, offset=0, ) assert result.total_count == 2 assert len(result.items) == 2 assert allocation1 in result.items assert allocation2 in result.items def test_get_flowthrough_allocations_by_payment_status(fresh_db, mock_contracts): """Test retrieving flowthrough allocations by payment_status.""" allocation1 = PaymentAllocationFlowthroughFactory.create( payment_status=PAYMENT_ALLOCATION_STATUSES.INIT ) allocation2 = PaymentAllocationFlowthroughFactory.create( payment_status=PAYMENT_ALLOCATION_STATUSES.INIT ) # Create allocation with ATTACHED_TO_PAYMENT status allocation3 = PaymentAllocationFlowthroughFactory.create( payment_status=PAYMENT_ALLOCATION_STATUSES.INIT ) # Transition to ATTACHED_TO_PAYMENT then to PAID allocation3.payment_status = PAYMENT_ALLOCATION_STATUSES.ATTACHED_TO_PAYMENT allocation3.payment_status = PAYMENT_ALLOCATION_STATUSES.PAID allocation3.commit_changes() result = get_flowthrough_allocations_bulk( payment_statuses=[PAYMENT_ALLOCATION_STATUSES.INIT], limit=10, offset=0, ) assert result.total_count == 2 assert len(result.items) == 2 assert allocation1 in result.items assert allocation2 in result.items def test_get_flowthrough_allocations_by_ledger_status(fresh_db, mock_contracts): """Test retrieving flowthrough allocations by ledger_status.""" allocation1 = PaymentAllocationFlowthroughFactory.create( ledger_status=PAYMENT_ALLOCATION_LEDGER_STATUSES.DEBITED ) PaymentAllocationFlowthroughFactory.create( ledger_status=PAYMENT_ALLOCATION_LEDGER_STATUSES.RETURNED ) result = get_flowthrough_allocations_bulk( ledger_statuses=[PAYMENT_ALLOCATION_LEDGER_STATUSES.DEBITED], limit=10, offset=0, ) assert result.total_count == 1 assert len(result.items) == 1 assert allocation1 in result.items def test_get_flowthrough_allocations_with_multiple_filters(fresh_db, mock_contracts): """Test retrieving flowthrough allocations with multiple filters.""" allocation1 = PaymentAllocationFlowthroughFactory.create( contract_id=1, payment_status=PAYMENT_ALLOCATION_STATUSES.INIT, ledger_status=PAYMENT_ALLOCATION_LEDGER_STATUSES.DEBITED, ) allocation2 = PaymentAllocationFlowthroughFactory.create( contract_id=1, payment_status=PAYMENT_ALLOCATION_STATUSES.INIT, ledger_status=PAYMENT_ALLOCATION_LEDGER_STATUSES.DEBITED, ) # Transition to PAID properly allocation2.payment_status = PAYMENT_ALLOCATION_STATUSES.ATTACHED_TO_PAYMENT allocation2.payment_status = PAYMENT_ALLOCATION_STATUSES.PAID allocation2.commit_changes() PaymentAllocationFlowthroughFactory.create( contract_id=2, payment_status=PAYMENT_ALLOCATION_STATUSES.INIT, ledger_status=PAYMENT_ALLOCATION_LEDGER_STATUSES.DEBITED, ) result = get_flowthrough_allocations_bulk( contract_ids=[1], payment_statuses=[PAYMENT_ALLOCATION_STATUSES.INIT], ledger_statuses=[PAYMENT_ALLOCATION_LEDGER_STATUSES.DEBITED], limit=10, offset=0, ) assert result.total_count == 1 assert len(result.items) == 1 assert allocation1 in result.items def test_get_flowthrough_allocations_pagination(fresh_db, mock_contracts): """Test pagination for flowthrough allocations.""" allocations = [ PaymentAllocationFlowthroughFactory.create(contract_id=1) for _ in range(5) ] # First page result_page1 = get_flowthrough_allocations_bulk( contract_ids=[1], limit=2, offset=0, ) assert result_page1.total_count == 5 assert len(result_page1.items) == 2 # Second page result_page2 = get_flowthrough_allocations_bulk( contract_ids=[1], limit=2, offset=2, ) assert result_page2.total_count == 5 assert len(result_page2.items) == 2 # Third page result_page3 = get_flowthrough_allocations_bulk( contract_ids=[1], limit=2, offset=4, ) assert result_page3.total_count == 5 assert len(result_page3.items) == 1 def test_get_flowthrough_allocations_excludes_soft_deleted(fresh_db, mock_contracts): """Test that soft-deleted allocations are excluded.""" allocation1 = PaymentAllocationFlowthroughFactory.create(contract_id=1) allocation2 = PaymentAllocationFlowthroughFactory.create(contract_id=1) # Soft delete one allocation from payment.models.payment_allocation import PaymentAllocationFlowthrough PaymentAllocationFlowthrough.delete_by_id_or_error( allocation2.payment_allocation_id, soft_delete=True ) result = get_flowthrough_allocations_bulk( contract_ids=[1], limit=10, offset=0, ) assert result.total_count == 1 assert len(result.items) == 1 assert allocation1 in result.items assert allocation2 not in result.items def test_get_flowthrough_allocations_empty_result(fresh_db, mock_contracts): """Test that empty result is returned when no allocations match.""" PaymentAllocationFlowthroughFactory.create(contract_id=1) result = get_flowthrough_allocations_bulk( contract_ids=[999], limit=10, offset=0, ) assert result.total_count == 0 assert len(result.items) == 0 def test_get_flowthrough_allocations_nonexistent_id_excluded(fresh_db, mock_contracts): """Test that requesting IDs that exist but are not flowthrough returns empty.""" allocation = PaymentAllocationFlowthroughFactory.create() # Request with a mix of existing and non-existing IDs result = get_flowthrough_allocations_bulk( payment_allocation_ids=[allocation.payment_allocation_id, 9999], limit=10, offset=0, ) assert result.total_count == 1 assert len(result.items) == 1 assert allocation in result.items @patch('payment.logic.payment_allocation.db') @patch('payment.logic.payment_allocation.PaymentAllocationFlowthrough') def test_bulk_update_payment_allocations_flowthrough_success_updates_modified_timestamps( mock_model, mock_db ): alloc1 = _make_allocation(101) alloc2 = _make_allocation(202) mock_model.get_active_by_ids.return_value = [alloc1, alloc2] params = [ { 'payment_allocation_id': 101, 'payment_status': PAYMENT_ALLOCATION_STATUSES.INIT, 'ledger_status': PAYMENT_ALLOCATION_LEDGER_STATUSES.DEBITED, }, { 'payment_allocation_id': 202, 'payment_status': 'PAID', }, ] logic.bulk_update_payment_allocations_flowthrough(params) mock_model.get_active_by_ids.assert_called_once() assert alloc1.update_attributes.call_args_list == [call(**params[0])] assert alloc2.update_attributes.call_args_list == [call(**params[1])] assert alloc1.payment_status_modified == alloc1.last_modified assert alloc1.ledger_status_modified == alloc1.last_modified assert alloc2.payment_status_modified == alloc2.last_modified assert mock_db.session.commit.called assert not mock_db.session.rollback.called @patch('payment.logic.payment_allocation.db') @patch('payment.logic.payment_allocation.PaymentAllocationFlowthrough') def test_bulk_update_payment_allocations_flowthrough_missing_ids_raises_logic_error( mock_model, mock_db ): mock_model.get_active_by_ids.return_value = [_make_allocation(1)] params = [{'payment_allocation_id': 1}, {'payment_allocation_id': 2}] with pytest.raises( LogicError, match=re.escape(ERROR_ALLOCATION_FLOWTHROUGH_MISSED.format({2})) ) as exc: logic.bulk_update_payment_allocations_flowthrough(params) assert not mock_db.session.commit.called assert not mock_db.session.rollback.called @patch('payment.logic.payment_allocation.db') @patch('payment.logic.payment_allocation.PaymentAllocationFlowthrough') def test_bulk_update_payment_allocations_flowthrough_commit_failure_rolls_back_and_raises( mock_model, mock_db ): alloc = _make_allocation(1) mock_model.get_active_by_ids.return_value = [alloc] params = [ {'payment_allocation_id': 1, 'payment_status': PAYMENT_ALLOCATION_STATUSES.PAID} ] mock_db.session.commit.side_effect = Exception('test') with pytest.raises(LogicError, match='test'): logic.bulk_update_payment_allocations_flowthrough(params) assert mock_db.session.rollback.called def test_status_transition_validation_integration(fresh_db, mock_contracts): """Integration test for status transition validation logic.""" alloc = PaymentAllocationFlowthroughFactory( payment_status=PAYMENT_ALLOCATION_STATUSES.INIT, ledger_status=PAYMENT_ALLOCATION_LEDGER_STATUSES.DEBITED, ) with pytest.raises(LogicError, match='Invalid payment status transition') as exc: logic.bulk_update_payment_allocations_flowthrough( [ { 'payment_allocation_id': alloc.payment_allocation_id, 'payment_status': PAYMENT_ALLOCATION_STATUSES.PAID, } ] ) def _make_allocation(payment_allocation_id: int) -> MagicMock: allocation = MagicMock() allocation.payment_allocation_id = payment_allocation_id allocation.last_modified = datetime(2026, 1, 29, tzinfo=timezone.utc) return allocation