"""Tests for the top-level Processor.""" from decimal import Decimal from unittest import mock from unittest.mock import call, patch import pytest from src.connectors.ows_payment import get_payable_balance_after_tax_entries from src.constants import CALCULATE_PAYMENTS_ACTION_NAME from src.exceptions import ( DataConsistencyException, FlowthroughUpdateException, PaymentsCreationException, ) from src.models import Event, PaymentBatch, PaymentBatchItem from src.processors.processor import Processor from tests.unit.factories import ( AccountPayeeFactory, AccountPaymentDetailsFactory, AccountPaymentTermFactory, EventFactory, PayableBalanceAfterTaxFactory, PaymentAccountInstanceFactory, PaymentMethodMinimumFactory, ) @patch('src.processors.processor.account_payee_dataloader') @patch('src.processors.processor.account_payment_term_dataloader') @patch('src.processors.processor.get_payment_minimums') @patch('src.processors.processor.fetch_all') @patch('src.processors.processor.get_events_by_target_type') def test_process_no_calculate_payments_event_returns_early( mock_get_events: mock.MagicMock, mock_fetch_all: mock.MagicMock, mock_get_payment_minimums: mock.MagicMock, mock_term_loader: mock.MagicMock, mock_payee_loader: mock.MagicMock, ) -> None: """No calculate_payments event -> nothing else runs.""" abacus_event = EventFactory.build() mock_get_events.return_value = [ EventFactory.build(event_name='other_action'), ] processor, batch_payments, batch_flowthrough = _build_processor() processor.process(abacus_event) assert mock_get_events.call_args_list == [ call(abacus_event.target_type, abacus_event.target_id) ] assert not mock_fetch_all.called assert not mock_get_payment_minimums.called assert not mock_term_loader.called assert not mock_payee_loader.called assert not batch_payments.process.called assert not batch_flowthrough.process.called @patch('src.processors.processor.account_payee_dataloader') @patch('src.processors.processor.account_payment_term_dataloader') @patch('src.processors.processor.get_payment_minimums') @patch('src.processors.processor.fetch_all') @patch('src.processors.processor.get_events_by_target_type') def test_process_no_payable_worksheets_returns_early( mock_get_events: mock.MagicMock, mock_fetch_all: mock.MagicMock, mock_get_payment_minimums: mock.MagicMock, mock_term_loader: mock.MagicMock, mock_payee_loader: mock.MagicMock, ) -> None: """No payable worksheets -> nothing else runs.""" abacus_event = EventFactory.build() calc_event = _calculate_payments_event() mock_get_events.return_value = [calc_event] mock_fetch_all.return_value = iter([]) processor, batch_payments, batch_flowthrough = _build_processor() processor.process(abacus_event) assert mock_fetch_all.call_args_list == [ call(get_payable_balance_after_tax_entries, calc_event.abacus_event_id) ] assert not mock_get_payment_minimums.called assert not mock_term_loader.called assert not mock_payee_loader.called assert not batch_payments.process.called assert not batch_flowthrough.process.called @patch('src.processors.processor.account_payee_dataloader') @patch('src.processors.processor.account_payment_term_dataloader') @patch('src.processors.processor.get_payment_minimums') @patch('src.processors.processor.fetch_all') @patch('src.processors.processor.get_events_by_target_type') def test_process_full_flow( mock_get_events: mock.MagicMock, mock_fetch_all: mock.MagicMock, mock_get_payment_minimums: mock.MagicMock, mock_term_loader: mock.MagicMock, mock_payee_loader: mock.MagicMock, ) -> None: """Happy path: data is gathered, batched, and dispatched to the two processors.""" abacus_event = EventFactory.build() calc_event = _calculate_payments_event( target_type=abacus_event.target_type, target_id=abacus_event.target_id ) mock_get_events.return_value = [ EventFactory.build(event_name='other_action'), calc_event, ] worksheet_1 = PayableBalanceAfterTaxFactory.build( account_id=1, currency_code='USD', payable_amount_post_tax=Decimal('100') ) worksheet_2 = PayableBalanceAfterTaxFactory.build( account_id=2, currency_code='USD', payable_amount_post_tax=Decimal('200') ) last_payment_1 = AccountPaymentDetailsFactory.build(account_id=1) last_payment_2 = AccountPaymentDetailsFactory.build(account_id=2) # First fetch_all -> worksheets. Second fetch_all -> last payments. mock_fetch_all.side_effect = [ iter([worksheet_1, worksheet_2]), iter([last_payment_1, last_payment_2]), ] minimum = PaymentMethodMinimumFactory.build( currency_code='USD', check_amount=Decimal('1') ) mock_get_payment_minimums.return_value = [minimum] term_1 = AccountPaymentTermFactory.build(account_id=1, currency_code='USD') term_2 = AccountPaymentTermFactory.build(account_id=2, currency_code='USD') mock_term_loader.return_value = {1: term_1, 2: term_2} payee_1 = AccountPayeeFactory.build(account_id=1) payee_2 = AccountPayeeFactory.build(account_id=2) mock_payee_loader.return_value = {1: payee_1, 2: payee_2} payment_account = PaymentAccountInstanceFactory.build( payment_group_payment_account_id=987, ) processor, batch_payments, batch_flowthrough = _build_processor(batch_size=10) batch_payments.process.return_value = [payment_account] processor.process(abacus_event) expected_batch = PaymentBatch( abacus_event, {'USD': minimum}, [ PaymentBatchItem(1, [worksheet_1], last_payment_1, term_1, payee_1), PaymentBatchItem(2, [worksheet_2], last_payment_2, term_2, payee_2), ], ) assert batch_payments.process.call_args_list == [call(expected_batch)] assert batch_flowthrough.process.call_args_list == [call([987])] @patch('src.processors.processor.account_payee_dataloader') @patch('src.processors.processor.account_payment_term_dataloader') @patch('src.processors.processor.get_payment_minimums') @patch('src.processors.processor.fetch_all') @patch('src.processors.processor.get_events_by_target_type') def test_process_splits_into_batches( mock_get_events: mock.MagicMock, mock_fetch_all: mock.MagicMock, mock_get_payment_minimums: mock.MagicMock, mock_term_loader: mock.MagicMock, mock_payee_loader: mock.MagicMock, ) -> None: """Worksheets are split into batches of size `batch_size`.""" abacus_event = EventFactory.build() mock_get_events.return_value = [ _calculate_payments_event( target_type=abacus_event.target_type, target_id=abacus_event.target_id, ) ] worksheets = [ PayableBalanceAfterTaxFactory.build( account_id=i, currency_code='USD', payable_amount_post_tax=Decimal('100') ) for i in range(1, 6) ] last_payments = [ AccountPaymentDetailsFactory.build(account_id=i) for i in range(1, 6) ] mock_fetch_all.side_effect = [iter(worksheets), iter(last_payments)] minimum = PaymentMethodMinimumFactory.build( currency_code='USD', check_amount=Decimal('0') ) mock_get_payment_minimums.return_value = [minimum] terms = { i: AccountPaymentTermFactory.build(account_id=i, currency_code='USD') for i in range(1, 6) } payees = {i: AccountPayeeFactory.build(account_id=i) for i in range(1, 6)} mock_term_loader.return_value = terms mock_payee_loader.return_value = payees processor, batch_payments, _ = _build_processor(batch_size=2) batch_payments.process.return_value = [] processor.process(abacus_event) def _item(i: int) -> PaymentBatchItem: return PaymentBatchItem( account_id=i, payable_worksheets=[worksheets[i - 1]], last_payment=last_payments[i - 1], payment_term=terms[i], account_payee=payees[i], ) # 5 accounts with batch_size=2 -> 3 batches: (2, 2, 1) assert batch_payments.process.call_args_list == [ call(PaymentBatch(abacus_event, {'USD': minimum}, [_item(1), _item(2)])), call(PaymentBatch(abacus_event, {'USD': minimum}, [_item(3), _item(4)])), call(PaymentBatch(abacus_event, {'USD': minimum}, [_item(5)])), ] @patch('src.processors.processor.account_payee_dataloader') @patch('src.processors.processor.account_payment_term_dataloader') @patch('src.processors.processor.get_payment_minimums') @patch('src.processors.processor.fetch_all') @patch('src.processors.processor.get_events_by_target_type') def test_process_raises_when_payment_term_missing( mock_get_events: mock.MagicMock, mock_fetch_all: mock.MagicMock, mock_get_payment_minimums: mock.MagicMock, mock_term_loader: mock.MagicMock, mock_payee_loader: mock.MagicMock, ) -> None: """Missing payment term for an account -> DataConsistencyException.""" abacus_event = EventFactory.build() mock_get_events.return_value = [ _calculate_payments_event( target_type=abacus_event.target_type, target_id=abacus_event.target_id, ) ] worksheet = PayableBalanceAfterTaxFactory.build(account_id=1, currency_code='USD') mock_fetch_all.side_effect = [iter([worksheet]), iter([])] mock_get_payment_minimums.return_value = [ PaymentMethodMinimumFactory.build(currency_code='USD') ] mock_term_loader.return_value = {} mock_payee_loader.return_value = {1: AccountPayeeFactory.build(account_id=1)} processor, batch_payments, _ = _build_processor() with pytest.raises(DataConsistencyException, match='Missing payment terms'): processor.process(abacus_event) assert not batch_payments.process.called @patch('src.processors.processor.account_payee_dataloader') @patch('src.processors.processor.account_payment_term_dataloader') @patch('src.processors.processor.get_payment_minimums') @patch('src.processors.processor.fetch_all') @patch('src.processors.processor.get_events_by_target_type') def test_process_raises_when_payee_missing( mock_get_events: mock.MagicMock, mock_fetch_all: mock.MagicMock, mock_get_payment_minimums: mock.MagicMock, mock_term_loader: mock.MagicMock, mock_payee_loader: mock.MagicMock, ) -> None: """Missing payee for an account -> DataConsistencyException.""" abacus_event = EventFactory.build() mock_get_events.return_value = [ _calculate_payments_event( target_type=abacus_event.target_type, target_id=abacus_event.target_id, ) ] worksheet = PayableBalanceAfterTaxFactory.build(account_id=1, currency_code='USD') mock_fetch_all.side_effect = [iter([worksheet]), iter([])] mock_get_payment_minimums.return_value = [ PaymentMethodMinimumFactory.build(currency_code='USD') ] mock_term_loader.return_value = { 1: AccountPaymentTermFactory.build(account_id=1, currency_code='USD') } mock_payee_loader.return_value = {} processor, batch_payments, _ = _build_processor() with pytest.raises(DataConsistencyException, match='Missing payees'): processor.process(abacus_event) assert not batch_payments.process.called @patch('src.processors.processor.account_payee_dataloader') @patch('src.processors.processor.account_payment_term_dataloader') @patch('src.processors.processor.get_payment_minimums') @patch('src.processors.processor.fetch_all') @patch('src.processors.processor.get_events_by_target_type') def test_process_raises_when_payment_minimum_missing( mock_get_events: mock.MagicMock, mock_fetch_all: mock.MagicMock, mock_get_payment_minimums: mock.MagicMock, mock_term_loader: mock.MagicMock, mock_payee_loader: mock.MagicMock, ) -> None: """Missing payment minimum for a currency -> DataConsistencyException.""" abacus_event = EventFactory.build() mock_get_events.return_value = [ _calculate_payments_event( target_type=abacus_event.target_type, target_id=abacus_event.target_id, ) ] worksheet = PayableBalanceAfterTaxFactory.build(account_id=1, currency_code='GBP') mock_fetch_all.side_effect = [iter([worksheet]), iter([])] mock_get_payment_minimums.return_value = [ PaymentMethodMinimumFactory.build(currency_code='USD') ] mock_term_loader.return_value = { 1: AccountPaymentTermFactory.build(account_id=1, currency_code='GBP') } mock_payee_loader.return_value = {1: AccountPayeeFactory.build(account_id=1)} processor, batch_payments, _ = _build_processor() with pytest.raises(DataConsistencyException, match='Missing payment minimums'): processor.process(abacus_event) assert not batch_payments.process.called @patch('src.processors.processor.account_payee_dataloader') @patch('src.processors.processor.account_payment_term_dataloader') @patch('src.processors.processor.get_payment_minimums') @patch('src.processors.processor.fetch_all') @patch('src.processors.processor.get_events_by_target_type') def test_process_wraps_batch_processor_errors( mock_get_events: mock.MagicMock, mock_fetch_all: mock.MagicMock, mock_get_payment_minimums: mock.MagicMock, mock_term_loader: mock.MagicMock, mock_payee_loader: mock.MagicMock, ) -> None: """Errors from BatchPaymentsProcessor are wrapped in PaymentsCreationException.""" abacus_event = EventFactory.build() mock_get_events.return_value = [ _calculate_payments_event( target_type=abacus_event.target_type, target_id=abacus_event.target_id, ) ] worksheet = PayableBalanceAfterTaxFactory.build(account_id=1, currency_code='USD') mock_fetch_all.side_effect = [iter([worksheet]), iter([])] mock_get_payment_minimums.return_value = [ PaymentMethodMinimumFactory.build(currency_code='USD') ] mock_term_loader.return_value = { 1: AccountPaymentTermFactory.build(account_id=1, currency_code='USD') } mock_payee_loader.return_value = {1: AccountPayeeFactory.build(account_id=1)} processor, batch_payments, batch_flowthrough = _build_processor() batch_payments.process.side_effect = RuntimeError('boom') with pytest.raises(PaymentsCreationException): processor.process(abacus_event) assert not batch_flowthrough.process.called @patch('src.processors.processor.account_payee_dataloader') @patch('src.processors.processor.account_payment_term_dataloader') @patch('src.processors.processor.get_payment_minimums') @patch('src.processors.processor.fetch_all') @patch('src.processors.processor.get_events_by_target_type') def test_process_wraps_flowthrough_processor_errors( mock_get_events: mock.MagicMock, mock_fetch_all: mock.MagicMock, mock_get_payment_minimums: mock.MagicMock, mock_term_loader: mock.MagicMock, mock_payee_loader: mock.MagicMock, ) -> None: """Errors from BatchFlowthroughProcessor are wrapped in FlowthroughUpdateException.""" abacus_event = EventFactory.build() mock_get_events.return_value = [ _calculate_payments_event( target_type=abacus_event.target_type, target_id=abacus_event.target_id, ) ] worksheet = PayableBalanceAfterTaxFactory.build(account_id=1, currency_code='USD') mock_fetch_all.side_effect = [iter([worksheet]), iter([])] mock_get_payment_minimums.return_value = [ PaymentMethodMinimumFactory.build(currency_code='USD') ] mock_term_loader.return_value = { 1: AccountPaymentTermFactory.build(account_id=1, currency_code='USD') } mock_payee_loader.return_value = {1: AccountPayeeFactory.build(account_id=1)} payment_account = PaymentAccountInstanceFactory.build( payment_group_payment_account_id=10 ) processor, batch_payments, batch_flowthrough = _build_processor() batch_payments.process.return_value = [payment_account] batch_flowthrough.process.side_effect = RuntimeError('boom') with pytest.raises(FlowthroughUpdateException): processor.process(abacus_event) assert batch_flowthrough.process.call_args_list == [call([10])] @patch('src.processors.processor.account_payee_dataloader') @patch('src.processors.processor.account_payment_term_dataloader') @patch('src.processors.processor.get_payment_minimums') @patch('src.processors.processor.fetch_all') @patch('src.processors.processor.get_events_by_target_type') def test_process_filters_last_payments_with_null_account_id( mock_get_events: mock.MagicMock, mock_fetch_all: mock.MagicMock, mock_get_payment_minimums: mock.MagicMock, mock_term_loader: mock.MagicMock, mock_payee_loader: mock.MagicMock, ) -> None: """Last payments with account_id=None are dropped during setup.""" abacus_event = EventFactory.build() mock_get_events.return_value = [ _calculate_payments_event( target_type=abacus_event.target_type, target_id=abacus_event.target_id, ) ] worksheet = PayableBalanceAfterTaxFactory.build(account_id=1, currency_code='USD') valid_payment = AccountPaymentDetailsFactory.build(account_id=1) orphan_payment = AccountPaymentDetailsFactory.build(account_id=None) mock_fetch_all.side_effect = [ iter([worksheet]), iter([valid_payment, orphan_payment]), ] minimum = PaymentMethodMinimumFactory.build(currency_code='USD') mock_get_payment_minimums.return_value = [minimum] term = AccountPaymentTermFactory.build(account_id=1, currency_code='USD') payee = AccountPayeeFactory.build(account_id=1) mock_term_loader.return_value = {1: term} mock_payee_loader.return_value = {1: payee} processor, batch_payments, _ = _build_processor() batch_payments.process.return_value = [] processor.process(abacus_event) assert batch_payments.process.call_args_list == [ call( PaymentBatch( abacus_event, {'USD': minimum}, [PaymentBatchItem(1, [worksheet], valid_payment, term, payee)], ) ) ] def _build_processor( batch_size: int = 300, ) -> tuple[Processor, mock.MagicMock, mock.MagicMock]: batch_payments_processor = mock.MagicMock() batch_flowthrough_processor = mock.MagicMock() processor = Processor( batch_size=batch_size, batch_payments_processor=batch_payments_processor, batch_flowthrough_processor=batch_flowthrough_processor, ) return processor, batch_payments_processor, batch_flowthrough_processor def _calculate_payments_event( target_type: str = 'payment_group_payment', target_id: int = 1 ) -> Event: return EventFactory.build( event_name=CALCULATE_PAYMENTS_ACTION_NAME, target_type=target_type, target_id=target_id, )