"""Test PaymentBatchRefreshProcessor.""" from decimal import Decimal from typing import Optional from unittest.mock import MagicMock, patch import pytest from src.constants import ( CORRECTION_DETAIL_GROUPS, PayableDetailTypes, REFRESH_CLOSING_BALANCE_NOT_FOUND_ERR, TaxCorrectionTypes, ) from src.models import ( Event, PayableBalanceAfterTaxBulkUpdate, PayableBalanceAfterTaxEntry, ) from src.processors.base import payment_batch_refresh_processor as mod from src.processors.base.payment_batch_refresh_processor import ( PaymentBatchRefreshProcessor, ) from tests.unit.factories import ( ContractCloseBalanceFactory, EventFactory, PayableBalanceAfterTaxEntryFactory, PayableDetailEntryFactory, TaxCorrectionFactory, TaxCorrectionVATFactory, ) def _build_processor( batch: list[PayableBalanceAfterTaxEntry], append_vat_corrections: bool = True, statement_period_id: int = 1, abacus_event_id: int = 99, ) -> tuple[PaymentBatchRefreshProcessor, Event]: """Build a processor for a batch with a controlled event.""" event = EventFactory.build( statement_period_id=statement_period_id, abacus_event_id=abacus_event_id ) processor = PaymentBatchRefreshProcessor( event, batch, append_vat_corrections=append_vat_corrections ) return processor, event @patch.object(mod, 'fetch_all_contract_closing_balance_entries_bulk') def test_set_contracts_by_period_groups_by_period(mock_fetch_ccb: MagicMock) -> None: """Contracts are grouped by the statement period of their closing balance.""" entry_a = PayableBalanceAfterTaxEntryFactory.build( contract_id=10, worksheet_account_contract_closing_balance_id=100 ) entry_b = PayableBalanceAfterTaxEntryFactory.build( contract_id=11, worksheet_account_contract_closing_balance_id=101 ) processor, _ = _build_processor([entry_a, entry_b]) mock_fetch_ccb.return_value = [ ContractCloseBalanceFactory.build( contract_id=10, statement_period_id=1, worksheet_account_contract_closing_balance_id=100, ), ContractCloseBalanceFactory.build( contract_id=11, statement_period_id=2, worksheet_account_contract_closing_balance_id=101, ), ] processor._set_contracts_by_period() assert processor._contracts_by_period == {1: [10], 2: [11]} mock_fetch_ccb.assert_called_once_with([100, 101]) @patch.object(mod, 'fetch_all_contract_closing_balance_entries_bulk') def test_set_contracts_by_period_raises_when_closing_balance_missing( mock_fetch_ccb: MagicMock, ) -> None: """A contract with no resolvable closing balance raises a ValueError.""" entry = PayableBalanceAfterTaxEntryFactory.build( contract_id=10, worksheet_account_contract_closing_balance_id=100 ) processor, _ = _build_processor([entry]) mock_fetch_ccb.return_value = [] with pytest.raises( ValueError, match=REFRESH_CLOSING_BALANCE_NOT_FOUND_ERR.format(10) ): processor._set_contracts_by_period() @patch.object(mod, 'fetch_all_pending_tax_corrections_vat') @patch.object(mod, 'fetch_all_pending_tax_corrections') def test_set_pending_corrections(mock_wht: MagicMock, mock_vat: MagicMock) -> None: """Pending wht/vat corrections are grouped by contract id.""" processor, _ = _build_processor([]) processor._contracts_by_period = {1: [10, 11]} wht_10 = TaxCorrectionFactory.build(contract_id=10) wht_11 = TaxCorrectionFactory.build(contract_id=11) vat_10 = TaxCorrectionVATFactory.build(contract_id=10) mock_wht.return_value = [wht_10, wht_11] mock_vat.return_value = [vat_10] processor._set_pending_corrections() assert processor._pending_wht_by_contract == {10: [wht_10], 11: [wht_11]} assert processor._pending_vat_by_contract == {10: [vat_10]} mock_wht.assert_called_once_with(TaxCorrectionTypes.wht, [10, 11], 1) mock_vat.assert_called_once_with([10, 11], 1) @patch.object(mod, 'fetch_all_pending_tax_corrections_vat') @patch.object(mod, 'fetch_all_pending_tax_corrections') def test_set_pending_corrections_skips_vat_when_disabled( mock_wht: MagicMock, mock_vat: MagicMock ) -> None: """VAT corrections are not fetched when append_vat_corrections is False.""" processor, _ = _build_processor([], append_vat_corrections=False) processor._contracts_by_period = {1: [10]} mock_wht.return_value = [TaxCorrectionFactory.build(contract_id=10)] processor._set_pending_corrections() mock_vat.assert_not_called() assert processor._pending_vat_by_contract == {} @patch.object(mod, 'fetch_all_payable_details_entries') def test_set_current_detail_sums_filters_correction_types( mock_fetch_details: MagicMock, ) -> None: """Only wht/vat correction detail types (4, 5) are summed per after-tax id.""" entry1 = PayableBalanceAfterTaxEntryFactory.build( worksheet_account_contract_payable_after_tax_id=1 ) entry2 = PayableBalanceAfterTaxEntryFactory.build( worksheet_account_contract_payable_after_tax_id=2 ) processor, _ = _build_processor([entry1, entry2], statement_period_id=7) mock_fetch_details.return_value = [ # base wht (type 2) - skipped PayableDetailEntryFactory.build( worksheet_account_contract_payable_after_tax_id=1, payable_detail_type_id=PayableDetailTypes.withholding_tax, amount_payable=Decimal('100'), ), PayableDetailEntryFactory.build( worksheet_account_contract_payable_after_tax_id=1, payable_detail_type_id=PayableDetailTypes.withholding_tax_correction, amount_payable=Decimal('10'), ), PayableDetailEntryFactory.build( worksheet_account_contract_payable_after_tax_id=1, payable_detail_type_id=PayableDetailTypes.withholding_tax_correction, amount_payable=Decimal('5'), ), PayableDetailEntryFactory.build( worksheet_account_contract_payable_after_tax_id=1, payable_detail_type_id=PayableDetailTypes.vat_correction, amount_payable=Decimal('7'), ), # base vat (type 3) - skipped PayableDetailEntryFactory.build( worksheet_account_contract_payable_after_tax_id=2, payable_detail_type_id=PayableDetailTypes.vat, amount_payable=Decimal('3'), ), PayableDetailEntryFactory.build( worksheet_account_contract_payable_after_tax_id=2, payable_detail_type_id=PayableDetailTypes.vat_correction, amount_payable=Decimal('3'), ), ] processor._set_current_detail_sums() assert processor._detail_sums_by_after_tax == { 1: { PayableDetailTypes.withholding_tax_correction: Decimal('15'), PayableDetailTypes.vat_correction: Decimal('7'), }, 2: {PayableDetailTypes.vat_correction: Decimal('3')}, } mock_fetch_details.assert_called_once_with(7, [1, 2], CORRECTION_DETAIL_GROUPS) def test_sum_amounts() -> None: """_sum_amounts returns the sum of correction amounts.""" corrections = [ TaxCorrectionFactory.build(amount=Decimal('10')), TaxCorrectionFactory.build(amount=Decimal('-3')), ] assert PaymentBatchRefreshProcessor._sum_amounts(corrections) == Decimal('7') @pytest.mark.parametrize( 'entry_wht, entry_vat, detail_wht, detail_vat, pending_wht, pending_vat, expected', [ # everything in sync -> no refresh ( Decimal('10'), Decimal('5'), Decimal('10'), Decimal('5'), Decimal('10'), Decimal('5'), False, ), # stored wht disagrees with details/pending -> refresh ( Decimal('5'), Decimal('5'), Decimal('10'), Decimal('5'), Decimal('10'), Decimal('5'), True, ), # pending vat appeared but stored/details are empty -> refresh ( Decimal('10'), None, Decimal('10'), Decimal('0'), Decimal('10'), Decimal('7'), True, ), # all zero / None and nothing pending -> no refresh ( None, None, Decimal('0'), Decimal('0'), Decimal('0'), Decimal('0'), False, ), ], ) def test_needs_refresh( entry_wht: Optional[Decimal], entry_vat: Optional[Decimal], detail_wht: Decimal, detail_vat: Decimal, pending_wht: Decimal, pending_vat: Decimal, expected: bool, ) -> None: """_needs_refresh is True only when stored/details/pending amounts disagree.""" entry = PayableBalanceAfterTaxEntryFactory.build( worksheet_account_contract_payable_after_tax_id=1, contract_id=10, tax_withholding_amount=entry_wht, vat_amount=entry_vat, ) processor, _ = _build_processor([entry]) processor._detail_sums_by_after_tax = { 1: { PayableDetailTypes.withholding_tax_correction: detail_wht, PayableDetailTypes.vat_correction: detail_vat, } } processor._pending_wht_by_contract = ( {10: [TaxCorrectionFactory.build(amount=pending_wht)]} if pending_wht else {} ) processor._pending_vat_by_contract = ( {10: [TaxCorrectionVATFactory.build(vat_amount_payee_currency=pending_vat)]} if pending_vat else {} ) assert processor._needs_refresh(entry) is expected def test_build_refreshed_calculator_appends_corrections() -> None: """The rebuilt calculator carries the pending wht/vat corrections.""" entry = PayableBalanceAfterTaxEntryFactory.build( contract_id=10, payable_amount_pre_tax=Decimal('1000'), ) processor, _ = _build_processor([entry]) processor._pending_wht_by_contract = { 10: [ TaxCorrectionFactory.build( contract_id=10, amount=Decimal('-50'), worksheet_tax_correction_id=500, payable_detail_type_id=PayableDetailTypes.withholding_tax_correction, ) ] } processor._pending_vat_by_contract = { 10: [ TaxCorrectionVATFactory.build( contract_id=10, vat_amount_payee_currency=Decimal('20'), worksheet_tax_correction_vat_id=600, payable_detail_type_id=PayableDetailTypes.vat_correction, ) ] } calculator = processor._build_refreshed_calculator(entry) assert calculator.tax_withholding_amount == Decimal('-50') assert calculator.vat_amount == Decimal('20') # 1000 - 50 + 20 = 970 assert calculator.payable_amount_post_tax == Decimal('970') details = calculator.get_payable_detail_items(1) assert {d.target_id for d in details} == {500, 600} assert {d.payable_detail_type_id for d in details} == { PayableDetailTypes.withholding_tax_correction, PayableDetailTypes.vat_correction, } @patch.object(mod, 'bulk_create_contract_payable_details') @patch.object(mod, 'bulk_update_worksheet_contract_balance_after_tax') @patch.object(mod, 'delete_payable_details_wht_vat_corrections') def test_process_empty_batch_does_nothing( mock_delete: MagicMock, mock_update: MagicMock, mock_create: MagicMock ) -> None: """An empty batch returns before doing any work.""" processor, _ = _build_processor([]) processor.process() mock_delete.assert_not_called() mock_update.assert_not_called() mock_create.assert_not_called() @patch.object(mod, 'bulk_create_contract_payable_details') @patch.object(mod, 'bulk_update_worksheet_contract_balance_after_tax') @patch.object(mod, 'delete_payable_details_wht_vat_corrections') @patch.object(PaymentBatchRefreshProcessor, '_set_current_detail_sums') @patch.object(PaymentBatchRefreshProcessor, '_set_pending_corrections') @patch.object(PaymentBatchRefreshProcessor, '_set_contracts_by_period') def test_process_no_entries_need_refresh( mock_cbp: MagicMock, mock_pc: MagicMock, mock_cds: MagicMock, mock_delete: MagicMock, mock_update: MagicMock, mock_create: MagicMock, ) -> None: """When nothing drifted, no mutating connector calls are made.""" entry = PayableBalanceAfterTaxEntryFactory.build( worksheet_account_contract_payable_after_tax_id=1, contract_id=10, tax_withholding_amount=Decimal('0'), vat_amount=None, ) processor, _ = _build_processor([entry]) processor._pending_wht_by_contract = {} processor._pending_vat_by_contract = {} processor._detail_sums_by_after_tax = {} processor.process() mock_delete.assert_not_called() mock_update.assert_not_called() mock_create.assert_not_called() @patch.object(mod, 'bulk_create_contract_payable_details') @patch.object(mod, 'bulk_update_worksheet_contract_balance_after_tax') @patch.object(mod, 'delete_payable_details_wht_vat_corrections') @patch.object(PaymentBatchRefreshProcessor, '_set_current_detail_sums') @patch.object(PaymentBatchRefreshProcessor, '_set_pending_corrections') @patch.object(PaymentBatchRefreshProcessor, '_set_contracts_by_period') def test_process_refreshes_drifted_entries( mock_cbp: MagicMock, mock_pc: MagicMock, mock_cds: MagicMock, mock_delete: MagicMock, mock_update: MagicMock, mock_create: MagicMock, ) -> None: """A drifted entry is soft-deleted, bulk-updated and re-inserted.""" entry = PayableBalanceAfterTaxEntryFactory.build( worksheet_account_contract_payable_after_tax_id=1, worksheet_account_contract_closing_balance_id=100, contract_id=10, account_id=2, currency_code='USD', payable_amount_pre_tax=Decimal('1000'), tax_withholding_amount=Decimal('0'), vat_amount=None, payable_amount_post_tax=Decimal('1000'), country_of_tax_residence='USA', country_of_tax_policy='USA', ) processor, event = _build_processor( [entry], statement_period_id=7, abacus_event_id=99 ) wht_correction = TaxCorrectionFactory.build( contract_id=10, amount=Decimal('-50'), worksheet_tax_correction_id=500, payable_detail_type_id=PayableDetailTypes.withholding_tax_correction, ) processor._pending_wht_by_contract = {10: [wht_correction]} processor._pending_vat_by_contract = {} processor._detail_sums_by_after_tax = {} processor.process() # soft delete the drifted after-tax id mock_delete.assert_called_once_with([1]) # bulk update built from the recomputed calculator mock_update.assert_called_once() (updates,) = mock_update.call_args.args assert len(updates) == 1 update = updates[0] assert isinstance(update, PayableBalanceAfterTaxBulkUpdate) assert update.worksheet_account_contract_payable_after_tax_id == 1 assert update.tax_withholding_amount == Decimal('-50') assert update.vat_amount is None assert update.payable_amount_post_tax == Decimal('950') # only the rebuilt wht correction detail is re-inserted mock_create.assert_called_once() event_id_arg, statement_period_arg, details_arg = mock_create.call_args.args assert event_id_arg == event.abacus_event_id assert statement_period_arg == 7 assert len(details_arg) == 1 assert details_arg[0].worksheet_account_contract_payable_after_tax_id == 1 assert ( details_arg[0].payable_detail_type_id == PayableDetailTypes.withholding_tax_correction ) assert details_arg[0].amount_payable == Decimal('-50') assert details_arg[0].target_id == 500