"""Unit tests for PaymentAllocationFlowthrough model.""" from datetime import datetime from decimal import Decimal from unittest.mock import patch from abacus_common_logic.connectors.database import db import pytest from sqlalchemy import func, select, update from payment.constants.constants import ( PAYEE_TYPES, PAYMENT_ALLOCATION_LEDGER_STATUSES, PAYMENT_ALLOCATION_STATUSES, PAYMENT_ALLOCATION_TYPES, ) from payment.models.payment_allocation import ( PaymentAllocationFlowthrough, ) from tests.utils.factories import PaymentAllocationFlowthroughFactory def test_create_flowthrough(fresh_db, mock_contracts): """Test creation of PaymentAllocationFlowthrough instance with factory.""" allocation = PaymentAllocationFlowthroughFactory.create() assert allocation.payment_allocation_type == PAYMENT_ALLOCATION_TYPES.FLOWTHROUGH assert allocation.amount_to_payment == Decimal('1000.00') assert allocation.amount_to_ledger == Decimal('1000.00') assert allocation.currency_code == 'USD' assert allocation.description == 'Test flowthrough allocation' assert allocation.payment_status == PAYMENT_ALLOCATION_STATUSES.INIT assert allocation.ledger_status == PAYMENT_ALLOCATION_LEDGER_STATUSES.DEBITED assert db.session.execute(select(PaymentAllocationFlowthrough)).scalars().all() == [ allocation ] def test_allocation_type_is_always_flowthrough(fresh_db, mock_contracts): """Test that allocation type is always FLOWTHROUGH due to polymorphic identity.""" allocation = PaymentAllocationFlowthroughFactory.create() assert allocation.payment_allocation_type == PAYMENT_ALLOCATION_TYPES.FLOWTHROUGH def test_allocation_type_is_always_flowthrough_regardless_of_input( fresh_db, mock_contracts ): """Test that allocation type is always FLOWTHROUGH regardless of input. Attempting to set payment_allocation_type to anything other than FLOWTHROUGH should raise a ValueError and not save any records to the database. """ initial_count = db.session.execute( select(func.count()).select_from( select(PaymentAllocationFlowthrough).subquery() ) ).scalar_one() assert initial_count == 0 with pytest.raises( ValueError, match=r'PaymentAllocationFlowthrough requires payment_allocation_type to be ' r'flowthrough, got SOMETHING_ELSE', ): PaymentAllocationFlowthroughFactory.create( payment_allocation_type='SOMETHING_ELSE' ) final_count = db.session.execute( select(func.count()).select_from( select(PaymentAllocationFlowthrough).subquery() ) ).scalar_one() assert final_count == 0 def test_invalid_payment_status_transition(fresh_db, mock_contracts): """Test that invalid payment status transitions raise ValueError.""" allocation = PaymentAllocationFlowthroughFactory.create( payment_status=PAYMENT_ALLOCATION_STATUSES.INIT ) with pytest.raises( ValueError, match=f'Invalid payment status transition: {PAYMENT_ALLOCATION_STATUSES.INIT} >> {PAYMENT_ALLOCATION_STATUSES.PAID}', ): allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.PAID def test_valid_payment_status_transition(fresh_db, mock_contracts): """Test valid payment status transitions.""" allocation = PaymentAllocationFlowthroughFactory.create( payment_status=PAYMENT_ALLOCATION_STATUSES.INIT ) allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.ATTACHED_TO_PAYMENT allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.PAID assert allocation.payment_status == PAYMENT_ALLOCATION_STATUSES.PAID def test_polymorphic_query_returns_only_flowthrough(fresh_db, mock_contracts): """Test that querying PaymentAllocationFlowthrough automatically returns only FLOWTHROUGH type. SQLAlchemy's single-table inheritance with polymorphic_on automatically filters by polymorphic identity when querying subclasses. """ # Create a FLOWTHROUGH allocation flowthrough = PaymentAllocationFlowthroughFactory.create() # Manually insert other allocation types to the same table from abacus_common_logic.models.base import db # Insert a COLLABORATOR allocation directly db.session.execute( db.text( """ INSERT INTO payment_allocation (contract_id, payee_type, payee_id, statement_period_id, payment_allocation_type, amount_to_payment, payment_status, amount_to_ledger, ledger_status, currency_code, description, created_at, created_by, last_modified, last_modified_by) VALUES (1, 'account_payee', 1, 1, 'collaborator', 500.00, 'init', 500.00, 'debited', 'USD', 'Test collaborator', NOW(), 'test_user', NOW(), 'test_user') """ ) ) # Insert a TRANSFER_OF_EARNING allocation directly db.session.execute( db.text( """ INSERT INTO payment_allocation (contract_id, payee_type, payee_id, statement_period_id, payment_allocation_type, amount_to_payment, payment_status, amount_to_ledger, ledger_status, currency_code, description, created_at, created_by, last_modified, last_modified_by) VALUES (1, 'account_payee', 1, 1, 'transfer_of_earning', 750.00, 'init', 750.00, 'debited', 'USD', 'Test transfer', NOW(), 'test_user', NOW(), 'test_user') """ ) ) db.session.commit() # Verify the base table has 3 records base_count = db.session.execute( db.text('SELECT COUNT(*) FROM payment_allocation') ).scalar() assert base_count == 3 # Query via PaymentAllocationFlowthrough automatically returns only FLOWTHROUGH records flowthrough_allocations = ( db.session.execute(select(PaymentAllocationFlowthrough)).scalars().all() ) assert len(flowthrough_allocations) == 1 assert ( flowthrough_allocations[0].payment_allocation_id == flowthrough.payment_allocation_id ) assert ( flowthrough_allocations[0].payment_allocation_type == PAYMENT_ALLOCATION_TYPES.FLOWTHROUGH ) def test_polymorphic_filter_with_contract_ids(fresh_db, mock_contracts): """Test that querying PaymentAllocationFlowthrough with filters returns only FLOWTHROUGH records. SQLAlchemy's single-table inheritance with polymorphic_on automatically filters by polymorphic identity when querying subclasses. """ # Create multiple FLOWTHROUGH allocations flowthrough1 = PaymentAllocationFlowthroughFactory.create(contract_id=1) flowthrough2 = PaymentAllocationFlowthroughFactory.create(contract_id=2) # Insert non-FLOWTHROUGH allocation from abacus_common_logic.models.base import db db.session.execute( db.text( """ INSERT INTO payment_allocation (contract_id, payee_type, payee_id, statement_period_id, payment_allocation_type, amount_to_payment, payment_status, amount_to_ledger, ledger_status, currency_code, created_at, created_by, last_modified, last_modified_by) VALUES (3, 'account_payee', 1, 1, 'collaborator', 300.00, 'init', 300.00, 'debited', 'USD', NOW(), 'test_user', NOW(), 'test_user') """ ) ) db.session.commit() # Query automatically filters by FLOWTHROUGH type results = ( db.session.execute( select(PaymentAllocationFlowthrough).where( PaymentAllocationFlowthrough.contract_id.in_([1, 2, 3]), ) ) .scalars() .all() ) assert len(results) == 2 assert all( r.payment_allocation_type == PAYMENT_ALLOCATION_TYPES.FLOWTHROUGH for r in results ) assert {r.contract_id for r in results} == {1, 2} def test_polymorphic_count_query(fresh_db, mock_contracts): """Test that COUNT queries automatically filter by polymorphic identity. SQLAlchemy's single-table inheritance with polymorphic_on automatically filters by polymorphic identity when querying subclasses. """ # Create FLOWTHROUGH allocations PaymentAllocationFlowthroughFactory.create_batch(3) # Insert other types from abacus_common_logic.models.base import db db.session.execute( db.text( """ INSERT INTO payment_allocation (contract_id, payee_type, payee_id, statement_period_id, payment_allocation_type, amount_to_payment, payment_status, amount_to_ledger, ledger_status, currency_code, created_at, created_by, last_modified, last_modified_by) VALUES (1, 'account_payee', 1, 1, 'collaborator', 100.00, 'init', 100.00, 'debited', 'USD', NOW(), 'test_user', NOW(), 'test_user'), (1, 'account_payee', 1, 1, 'transfer_of_earning', 200.00, 'init', 200.00, 'debited', 'USD', NOW(), 'test_user', NOW(), 'test_user') """ ) ) db.session.commit() # Count automatically returns only FLOWTHROUGH flowthrough_count = db.session.execute( select(func.count()).select_from( select(PaymentAllocationFlowthrough).subquery() ) ).scalar_one() assert flowthrough_count == 3 # Base table should have 5 records total_count = db.session.execute( db.text('SELECT COUNT(*) FROM payment_allocation') ).scalar() assert total_count == 5 def test_polymorphic_get_by_id(fresh_db, mock_contracts): """Test that get by ID automatically respects polymorphic filtering. SQLAlchemy's single-table inheritance with polymorphic_on automatically filters by polymorphic identity when querying subclasses. """ flowthrough = PaymentAllocationFlowthroughFactory.create() # Insert non-FLOWTHROUGH allocation from abacus_common_logic.models.base import db result = db.session.execute( db.text( """ INSERT INTO payment_allocation (contract_id, payee_type, payee_id, statement_period_id, payment_allocation_type, amount_to_payment, payment_status, amount_to_ledger, ledger_status, currency_code, created_at, created_by, last_modified, last_modified_by) VALUES (1, 'account_payee', 1, 1, 'collaborator', 100.00, 'init', 100.00, 'debited', 'USD', NOW(), 'test_user', NOW(), 'test_user') """ ) ) db.session.commit() collaborator_id = result.lastrowid # Get FLOWTHROUGH by ID - polymorphic filtering is automatic found_flowthrough = ( db.session.execute( select(PaymentAllocationFlowthrough).filter_by( payment_allocation_id=flowthrough.payment_allocation_id, ) ) .scalars() .first() ) assert found_flowthrough is not None assert ( found_flowthrough.payment_allocation_type == PAYMENT_ALLOCATION_TYPES.FLOWTHROUGH ) # Get COLLABORATOR by ID via PaymentAllocationFlowthrough should return None # because polymorphic filtering automatically excludes non-FLOWTHROUGH records not_found = ( db.session.execute( select(PaymentAllocationFlowthrough).filter_by( payment_allocation_id=collaborator_id, ) ) .scalars() .first() ) assert not_found is None def test_polymorphic_filter_with_deleted_at(fresh_db, mock_contracts): """Test that polymorphic filtering works correctly with soft delete filtering.""" # Create FLOWTHROUGH allocations active_flowthrough = PaymentAllocationFlowthroughFactory.create() deleted_flowthrough = PaymentAllocationFlowthroughFactory.create() # Soft delete one from datetime import datetime, timezone deleted_flowthrough.deleted_at = datetime.now(timezone.utc) from abacus_common_logic.models.base import db db.session.commit() # Query should only return non-deleted FLOWTHROUGH active_allocations = ( db.session.execute( select(PaymentAllocationFlowthrough).where( PaymentAllocationFlowthrough.deleted_at.is_(None) ) ) .scalars() .all() ) assert len(active_allocations) == 1 assert ( active_allocations[0].payment_allocation_id == active_flowthrough.payment_allocation_id ) def test_filter_active_excludes_deleted(fresh_db, mock_contracts): active = PaymentAllocationFlowthroughFactory() deleted = PaymentAllocationFlowthroughFactory() deleted.deleted_at = '2024-01-01' results = ( db.session.execute(PaymentAllocationFlowthrough.filter_active()).scalars().all() ) assert active in results assert deleted not in results def test_get_active_by_ids_returns_correct_records(fresh_db, mock_contracts): alloc1 = PaymentAllocationFlowthroughFactory() alloc2 = PaymentAllocationFlowthroughFactory() deleted = PaymentAllocationFlowthroughFactory() deleted.deleted_at = '2024-01-01' ids = [ alloc1.payment_allocation_id, alloc2.payment_allocation_id, deleted.payment_allocation_id, ] results = PaymentAllocationFlowthrough.get_active_by_ids(ids) assert alloc1 in results assert alloc2 in results assert deleted not in results # Object creation tests def test_create_flowthrough_with_account_payee_type(fresh_db, mock_contracts): """Test creation of PaymentAllocationFlowthrough with ACCOUNT_PAYEE payee type.""" allocation = PaymentAllocationFlowthroughFactory.create( payee_type=PAYEE_TYPES.ACCOUNT_PAYEE, payee_id=123, ) assert allocation.payee_type == PAYEE_TYPES.ACCOUNT_PAYEE assert allocation.payee_id == 123 def test_create_flowthrough_with_payee_type(fresh_db, mock_contracts): """Test creation of PaymentAllocationFlowthrough with PAYEE payee type.""" allocation = PaymentAllocationFlowthroughFactory.create( payee_type=PAYEE_TYPES.PAYEE, payee_id=456, ) assert allocation.payee_type == PAYEE_TYPES.PAYEE assert allocation.payee_id == 456 def test_create_flowthrough_with_zero_amount(fresh_db, mock_contracts): """Test creation of PaymentAllocationFlowthrough with zero amounts.""" allocation = PaymentAllocationFlowthroughFactory.create( amount_to_payment=Decimal('0.00'), amount_to_ledger=Decimal('0.00'), ) assert allocation.amount_to_payment == Decimal('0.00') assert allocation.amount_to_ledger == Decimal('0.00') def test_create_flowthrough_with_negative_amount(fresh_db, mock_contracts): """Test creation of PaymentAllocationFlowthrough with negative amounts.""" allocation = PaymentAllocationFlowthroughFactory.create( amount_to_payment=Decimal('-500.00'), amount_to_ledger=Decimal('-500.00'), ) assert allocation.amount_to_payment == Decimal('-500.00') assert allocation.amount_to_ledger == Decimal('-500.00') def test_create_flowthrough_with_large_amount(fresh_db, mock_contracts): """Test creation of PaymentAllocationFlowthrough with large amounts.""" large_amount = Decimal('999999999999999999.99') allocation = PaymentAllocationFlowthroughFactory.create( amount_to_payment=large_amount, amount_to_ledger=large_amount, ) assert allocation.amount_to_payment == large_amount assert allocation.amount_to_ledger == large_amount def test_create_flowthrough_with_different_currencies(fresh_db, mock_contracts): """Test creation of PaymentAllocationFlowthrough with different currency codes.""" currencies = ['USD', 'EUR', 'GBP', 'JPY', 'CAD'] for currency in currencies: allocation = PaymentAllocationFlowthroughFactory.create( currency_code=currency, ) assert allocation.currency_code == currency def test_create_flowthrough_with_null_description(fresh_db, mock_contracts): """Test creation of PaymentAllocationFlowthrough with null description.""" allocation = PaymentAllocationFlowthroughFactory.create( description=None, ) assert allocation.description is None def test_create_flowthrough_with_long_description(fresh_db, mock_contracts): """Test creation of PaymentAllocationFlowthrough with long description.""" long_description = 'A' * 5000 allocation = PaymentAllocationFlowthroughFactory.create( description=long_description, ) assert allocation.description == long_description def test_create_flowthrough_with_timestamps(fresh_db, mock_contracts): """Test creation of PaymentAllocationFlowthrough with payment and ledger status timestamps.""" from datetime import timezone now = datetime.now(timezone.utc) allocation = PaymentAllocationFlowthroughFactory.create( payment_status_modified=now, ledger_status_modified=now, ) assert allocation.payment_status_modified is not None assert allocation.ledger_status_modified is not None def test_create_flowthrough_with_null_timestamps(fresh_db, mock_contracts): """Test creation of PaymentAllocationFlowthrough with null timestamps.""" allocation = PaymentAllocationFlowthroughFactory.create( payment_status_modified=None, ledger_status_modified=None, ) assert allocation.payment_status_modified is None assert allocation.ledger_status_modified is None def test_create_flowthrough_with_all_ledger_statuses(fresh_db, mock_contracts): """Test creation of PaymentAllocationFlowthrough with different ledger statuses.""" allocation_debited = PaymentAllocationFlowthroughFactory.create( ledger_status=PAYMENT_ALLOCATION_LEDGER_STATUSES.DEBITED, ) assert ( allocation_debited.ledger_status == PAYMENT_ALLOCATION_LEDGER_STATUSES.DEBITED ) allocation_returned = PaymentAllocationFlowthroughFactory.create( ledger_status=PAYMENT_ALLOCATION_LEDGER_STATUSES.RETURNED, ) assert ( allocation_returned.ledger_status == PAYMENT_ALLOCATION_LEDGER_STATUSES.RETURNED ) def test_create_flowthrough_with_different_contract_ids(fresh_db, mock_contracts): """Test creation of PaymentAllocationFlowthrough with different contract IDs.""" # Use contract_ids that exist in mock_contracts fixture (1-8) allocations = [] for contract_id in [1, 2, 3, 4]: allocation = PaymentAllocationFlowthroughFactory.create( contract_id=contract_id, ) allocations.append(allocation) assert allocation.contract_id == contract_id assert ( len(db.session.execute(select(PaymentAllocationFlowthrough)).scalars().all()) == 4 ) def test_create_flowthrough_with_different_statement_period_ids( fresh_db, mock_contracts ): """Test creation of PaymentAllocationFlowthrough with different statement period IDs.""" # Use statement_period_ids that exist in mock_statement_periods fixture (1-8) for period_id in [1, 2, 3, 4]: allocation = PaymentAllocationFlowthroughFactory.create( statement_period_id=period_id, ) assert allocation.statement_period_id == period_id def test_create_multiple_flowthroughs(fresh_db, mock_contracts): """Test creation of multiple PaymentAllocationFlowthrough instances.""" allocations = PaymentAllocationFlowthroughFactory.create_batch(5) assert len(allocations) == 5 assert ( len(db.session.execute(select(PaymentAllocationFlowthrough)).scalars().all()) == 5 ) for allocation in allocations: assert ( allocation.payment_allocation_type == PAYMENT_ALLOCATION_TYPES.FLOWTHROUGH ) def test_create_flowthrough_auto_generates_primary_key(fresh_db, mock_contracts): """Test that payment_allocation_id is auto-generated.""" allocation1 = PaymentAllocationFlowthroughFactory.create() allocation2 = PaymentAllocationFlowthroughFactory.create() assert allocation1.payment_allocation_id is not None assert allocation2.payment_allocation_id is not None assert allocation1.payment_allocation_id != allocation2.payment_allocation_id def test_create_flowthrough_with_decimal_precision(fresh_db, mock_contracts): """Test creation of PaymentAllocationFlowthrough preserves decimal precision.""" amount = Decimal('12345.67') allocation = PaymentAllocationFlowthroughFactory.create( amount_to_payment=amount, amount_to_ledger=amount, ) assert allocation.amount_to_payment == amount assert allocation.amount_to_ledger == amount assert str(allocation.amount_to_payment) == '12345.67' def test_create_flowthrough_with_different_payment_and_ledger_amounts( fresh_db, mock_contracts ): """Test creation of PaymentAllocationFlowthrough with different payment and ledger amounts.""" allocation = PaymentAllocationFlowthroughFactory.create( amount_to_payment=Decimal('100.00'), amount_to_ledger=Decimal('200.00'), ) assert allocation.amount_to_payment == Decimal('100.00') assert allocation.amount_to_ledger == Decimal('200.00') # Payment status transition tests def test_payment_status_transition_eligible_to_attached(fresh_db, mock_contracts): """Test valid transition from INIT to ATTACHED_TO_PAYMENT.""" allocation = PaymentAllocationFlowthroughFactory.create( payment_status=PAYMENT_ALLOCATION_STATUSES.INIT ) allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.ATTACHED_TO_PAYMENT assert allocation.payment_status == PAYMENT_ALLOCATION_STATUSES.ATTACHED_TO_PAYMENT def test_payment_status_transition_attached_to_paid(fresh_db, mock_contracts): """Test valid transition from ATTACHED_TO_PAYMENT to PAID.""" allocation = PaymentAllocationFlowthroughFactory.create( payment_status=PAYMENT_ALLOCATION_STATUSES.INIT ) allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.ATTACHED_TO_PAYMENT allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.PAID assert allocation.payment_status == PAYMENT_ALLOCATION_STATUSES.PAID def test_payment_status_transition_attached_to_eligible(fresh_db, mock_contracts): """Test valid transition from ATTACHED_TO_PAYMENT to INIT.""" allocation = PaymentAllocationFlowthroughFactory.create( payment_status=PAYMENT_ALLOCATION_STATUSES.INIT ) allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.ATTACHED_TO_PAYMENT allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.INIT assert allocation.payment_status == PAYMENT_ALLOCATION_STATUSES.INIT def test_payment_status_transition_attached_to_returned(fresh_db, mock_contracts): """Test valid transition from ATTACHED_TO_PAYMENT to RETURNED.""" allocation = PaymentAllocationFlowthroughFactory.create( payment_status=PAYMENT_ALLOCATION_STATUSES.INIT ) allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.ATTACHED_TO_PAYMENT allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.RETURNED assert allocation.payment_status == PAYMENT_ALLOCATION_STATUSES.RETURNED def test_payment_status_transition_paid_to_returned(fresh_db, mock_contracts): """Test valid transition from PAID to RETURNED.""" allocation = PaymentAllocationFlowthroughFactory.create( payment_status=PAYMENT_ALLOCATION_STATUSES.INIT ) allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.ATTACHED_TO_PAYMENT allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.PAID allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.RETURNED assert allocation.payment_status == PAYMENT_ALLOCATION_STATUSES.RETURNED def test_payment_status_transition_returned_to_attached(fresh_db, mock_contracts): """Test valid transition from RETURNED to ATTACHED_TO_PAYMENT.""" allocation = PaymentAllocationFlowthroughFactory.create( payment_status=PAYMENT_ALLOCATION_STATUSES.INIT ) allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.ATTACHED_TO_PAYMENT allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.RETURNED allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.ATTACHED_TO_PAYMENT assert allocation.payment_status == PAYMENT_ALLOCATION_STATUSES.ATTACHED_TO_PAYMENT def test_invalid_transition_eligible_to_paid(fresh_db, mock_contracts): """Test invalid transition from INIT directly to PAID.""" allocation = PaymentAllocationFlowthroughFactory.create( payment_status=PAYMENT_ALLOCATION_STATUSES.INIT ) with pytest.raises(ValueError, match='Invalid payment status transition'): allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.PAID def test_invalid_transition_eligible_to_returned(fresh_db, mock_contracts): """Test invalid transition from INIT directly to RETURNED.""" allocation = PaymentAllocationFlowthroughFactory.create( payment_status=PAYMENT_ALLOCATION_STATUSES.INIT ) with pytest.raises(ValueError, match='Invalid payment status transition'): allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.RETURNED def test_invalid_transition_paid_to_eligible(fresh_db, mock_contracts): """Test invalid transition from PAID to INIT.""" allocation = PaymentAllocationFlowthroughFactory.create( payment_status=PAYMENT_ALLOCATION_STATUSES.INIT ) allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.ATTACHED_TO_PAYMENT allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.PAID with pytest.raises(ValueError, match='Invalid payment status transition'): allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.INIT def test_invalid_transition_returned_to_eligible(fresh_db, mock_contracts): """Test invalid transition from RETURNED to INIT.""" allocation = PaymentAllocationFlowthroughFactory.create( payment_status=PAYMENT_ALLOCATION_STATUSES.INIT ) allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.ATTACHED_TO_PAYMENT allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.RETURNED with pytest.raises(ValueError, match='Invalid payment status transition'): allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.INIT def test_invalid_transition_returned_to_paid(fresh_db, mock_contracts): """Test invalid transition from RETURNED to PAID.""" allocation = PaymentAllocationFlowthroughFactory.create( payment_status=PAYMENT_ALLOCATION_STATUSES.INIT ) allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.ATTACHED_TO_PAYMENT allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.RETURNED with pytest.raises(ValueError, match='Invalid payment status transition'): allocation.payment_status = PAYMENT_ALLOCATION_STATUSES.PAID # get_filtered_items class method tests def test_get_filtered_items_no_filters(fresh_db, mock_contracts): """Test get_filtered_items returns all items when no filters applied.""" allocations = PaymentAllocationFlowthroughFactory.create_batch(3) items, total_count = PaymentAllocationFlowthrough.get_filtered_items( limit=10, offset=0, ) assert total_count == 3 assert len(items) == 3 def test_get_filtered_items_by_payment_allocation_ids(fresh_db, mock_contracts): """Test get_filtered_items filters by payment_allocation_ids.""" allocation1 = PaymentAllocationFlowthroughFactory.create() allocation2 = PaymentAllocationFlowthroughFactory.create() PaymentAllocationFlowthroughFactory.create() items, total_count = PaymentAllocationFlowthrough.get_filtered_items( limit=10, offset=0, payment_allocation_ids=[ allocation1.payment_allocation_id, allocation2.payment_allocation_id, ], ) assert total_count == 2 assert len(items) == 2 assert allocation1 in items assert allocation2 in items def test_get_filtered_items_by_contract_ids(fresh_db, mock_contracts): """Test get_filtered_items filters by contract_ids.""" allocation1 = PaymentAllocationFlowthroughFactory.create(contract_id=1) allocation2 = PaymentAllocationFlowthroughFactory.create(contract_id=1) PaymentAllocationFlowthroughFactory.create(contract_id=2) items, total_count = PaymentAllocationFlowthrough.get_filtered_items( limit=10, offset=0, contract_ids=[1], ) assert total_count == 2 assert len(items) == 2 assert allocation1 in items assert allocation2 in items def test_get_filtered_items_by_payment_statuses(fresh_db, mock_contracts): """Test get_filtered_items filters by payment_statuses.""" allocation1 = PaymentAllocationFlowthroughFactory.create( payment_status=PAYMENT_ALLOCATION_STATUSES.INIT ) allocation2 = PaymentAllocationFlowthroughFactory.create( payment_status=PAYMENT_ALLOCATION_STATUSES.INIT ) allocation3 = PaymentAllocationFlowthroughFactory.create( payment_status=PAYMENT_ALLOCATION_STATUSES.INIT ) allocation3.payment_status = PAYMENT_ALLOCATION_STATUSES.ATTACHED_TO_PAYMENT allocation3.payment_status = PAYMENT_ALLOCATION_STATUSES.PAID allocation3.commit_changes() items, total_count = PaymentAllocationFlowthrough.get_filtered_items( limit=10, offset=0, payment_statuses=[PAYMENT_ALLOCATION_STATUSES.INIT], ) assert total_count == 2 assert len(items) == 2 assert allocation1 in items assert allocation2 in items def test_get_filtered_items_by_ledger_statuses(fresh_db, mock_contracts): """Test get_filtered_items filters by ledger_statuses.""" allocation1 = PaymentAllocationFlowthroughFactory.create( ledger_status=PAYMENT_ALLOCATION_LEDGER_STATUSES.DEBITED ) PaymentAllocationFlowthroughFactory.create( ledger_status=PAYMENT_ALLOCATION_LEDGER_STATUSES.RETURNED ) items, total_count = PaymentAllocationFlowthrough.get_filtered_items( limit=10, offset=0, ledger_statuses=[PAYMENT_ALLOCATION_LEDGER_STATUSES.DEBITED], ) assert total_count == 1 assert len(items) == 1 assert allocation1 in items def test_get_filtered_items_with_pagination(fresh_db, mock_contracts): """Test get_filtered_items pagination works correctly.""" PaymentAllocationFlowthroughFactory.create_batch(5) items_page1, total_count = PaymentAllocationFlowthrough.get_filtered_items( limit=2, offset=0, ) assert total_count == 5 assert len(items_page1) == 2 items_page2, total_count = PaymentAllocationFlowthrough.get_filtered_items( limit=2, offset=2, ) assert total_count == 5 assert len(items_page2) == 2 items_page3, total_count = PaymentAllocationFlowthrough.get_filtered_items( limit=2, offset=4, ) assert total_count == 5 assert len(items_page3) == 1 def test_get_filtered_items_excludes_soft_deleted(fresh_db, mock_contracts): """Test get_filtered_items excludes soft-deleted allocations.""" from datetime import timezone allocation1 = PaymentAllocationFlowthroughFactory.create() allocation2 = PaymentAllocationFlowthroughFactory.create() allocation2.deleted_at = datetime.now(timezone.utc) allocation2.commit_changes() items, total_count = PaymentAllocationFlowthrough.get_filtered_items( limit=10, offset=0, ) assert total_count == 1 assert len(items) == 1 assert allocation1 in items assert allocation2 not in items def test_get_filtered_items_with_multiple_filters(fresh_db, mock_contracts): """Test get_filtered_items with multiple filters combined.""" target_allocation = PaymentAllocationFlowthroughFactory.create( contract_id=1, payment_status=PAYMENT_ALLOCATION_STATUSES.INIT, ledger_status=PAYMENT_ALLOCATION_LEDGER_STATUSES.DEBITED, ) PaymentAllocationFlowthroughFactory.create( contract_id=2, payment_status=PAYMENT_ALLOCATION_STATUSES.INIT, ledger_status=PAYMENT_ALLOCATION_LEDGER_STATUSES.DEBITED, ) PaymentAllocationFlowthroughFactory.create( contract_id=1, payment_status=PAYMENT_ALLOCATION_STATUSES.INIT, ledger_status=PAYMENT_ALLOCATION_LEDGER_STATUSES.RETURNED, ) items, total_count = PaymentAllocationFlowthrough.get_filtered_items( limit=10, offset=0, contract_ids=[1], payment_statuses=[PAYMENT_ALLOCATION_STATUSES.INIT], ledger_statuses=[PAYMENT_ALLOCATION_LEDGER_STATUSES.DEBITED], ) assert total_count == 1 assert len(items) == 1 assert target_allocation in items def test_get_filtered_items_empty_result(fresh_db, mock_contracts): """Test get_filtered_items returns empty result when no matches.""" PaymentAllocationFlowthroughFactory.create(contract_id=1) items, total_count = PaymentAllocationFlowthrough.get_filtered_items( limit=10, offset=0, contract_ids=[999], ) assert total_count == 0 assert len(items) == 0 def test_get_filtered_items_last_page_total_count(fresh_db, mock_contracts): """Test that total_count is exact on the last page without needing COUNT query. When offset > 0 and there are no more results beyond the current page, total_count should equal offset + len(items). """ PaymentAllocationFlowthroughFactory.create_batch(5, contract_id=1) # Last page: offset=4, limit=2 → 1 item returned, total = 4 + 1 = 5 items, total_count = PaymentAllocationFlowthrough.get_filtered_items( limit=2, offset=4, contract_ids=[1], ) assert len(items) == 1 assert total_count == 5 def test_get_filtered_items_exact_page_boundary(fresh_db, mock_contracts): """Test pagination when total items are an exact multiple of page size. 4 items with limit=2: page 2 (offset=2) returns exactly 2 items and has_more is False because limit+1 fetch returns only 2. """ PaymentAllocationFlowthroughFactory.create_batch(4, contract_id=1) # Page 2: offset=2, limit=2 → fetches limit+1=3 but only 2 exist → has_more=False items, total_count = PaymentAllocationFlowthrough.get_filtered_items( limit=2, offset=2, contract_ids=[1], ) assert len(items) == 2 assert total_count == 4 def test_get_filtered_items_offset_beyond_results(fresh_db, mock_contracts): """Test that offset beyond total results returns correct total_count. When offset exceeds total items, no items are returned but total_count should still reflect the actual number of matching records. """ PaymentAllocationFlowthroughFactory.create_batch(3, contract_id=1) items, total_count = PaymentAllocationFlowthrough.get_filtered_items( limit=10, offset=100, contract_ids=[1], ) assert len(items) == 0 assert total_count == 3 def test_filter_active_excludes_deleted(fresh_db, mock_contracts): active = PaymentAllocationFlowthroughFactory() deleted = PaymentAllocationFlowthroughFactory() deleted.deleted_at = '2024-01-01' results = ( db.session.execute(PaymentAllocationFlowthrough.filter_active()).scalars().all() ) assert active in results assert deleted not in results def test_get_active_by_ids_returns_correct_records(fresh_db, mock_contracts): alloc1 = PaymentAllocationFlowthroughFactory() alloc2 = PaymentAllocationFlowthroughFactory() deleted = PaymentAllocationFlowthroughFactory() deleted.deleted_at = '2024-01-01' ids = [ alloc1.payment_allocation_id, alloc2.payment_allocation_id, deleted.payment_allocation_id, ] results = PaymentAllocationFlowthrough.get_active_by_ids(ids) assert alloc1 in results assert alloc2 in results assert deleted not in results @patch('payment.models.payment_allocation.get_flask_user_id', return_value='test_user') def test_soft_delete_by_ids_sets_deleted_fields_for_active_records( _mock_get_user_id, fresh_db, mock_contracts ): a1 = PaymentAllocationFlowthroughFactory.create() a2 = PaymentAllocationFlowthroughFactory.create() a3 = PaymentAllocationFlowthroughFactory.create() a4 = PaymentAllocationFlowthroughFactory.create() db.session.execute( update(PaymentAllocationFlowthrough) .where( PaymentAllocationFlowthrough.payment_allocation_id == a4.payment_allocation_id ) .values(payment_status=PAYMENT_ALLOCATION_STATUSES.PAID) .execution_options(synchronize_session=False) ) db.session.commit() PaymentAllocationFlowthrough.soft_delete_by_ids( [a1.payment_allocation_id, a2.payment_allocation_id] ) refreshed_a1 = db.session.get( PaymentAllocationFlowthrough, a1.payment_allocation_id ) refreshed_a2 = db.session.get( PaymentAllocationFlowthrough, a2.payment_allocation_id ) refreshed_a3 = db.session.get( PaymentAllocationFlowthrough, a3.payment_allocation_id ) refreshed_a4 = db.session.get( PaymentAllocationFlowthrough, a4.payment_allocation_id ) assert refreshed_a1.deleted_at is not None assert isinstance(refreshed_a1.deleted_at, datetime) assert refreshed_a1.deleted_by == 'test_user' assert refreshed_a2.deleted_at is not None assert isinstance(refreshed_a2.deleted_at, datetime) assert refreshed_a2.deleted_by == 'test_user' assert refreshed_a3.deleted_at is None assert refreshed_a3.deleted_by is None assert refreshed_a4.payment_status == PAYMENT_ALLOCATION_STATUSES.PAID assert refreshed_a4.deleted_at is None assert refreshed_a4.deleted_by is None