"""Tests for the calculation engine (src/calculator.py).""" from __future__ import annotations from src.calculator import ( _get_previously_transferred_amount, block_reason, calculate_amount, evaluate, ) from src.enums import RateType from src.types import CalculationResult, ContractRef from tests.unit.helpers import make_record as _record # ═══════════════════════════════════════════════════════════════════════════════ # calculate_amount # ═══════════════════════════════════════════════════════════════════════════════ class TestCalculateAmount: """Tests for the calculate_amount function.""" def test_basic_calculation(self) -> None: r = _record(closing_balance=1000.0, transfer_amount=0.5) assert calculate_amount(r) == 500.0 def test_none_balance_returns_zero(self) -> None: r = _record(closing_balance=None, transfer_amount=0.5) assert calculate_amount(r) == 0.0 def test_none_transfer_amount_returns_zero(self) -> None: r = _record(closing_balance=1000.0, transfer_amount=None) assert calculate_amount(r) == 0.0 def test_both_none_returns_zero(self) -> None: r = _record(closing_balance=None, transfer_amount=None) assert calculate_amount(r) == 0.0 def test_negative_balance_blocked_when_negative_false(self) -> None: r = _record(closing_balance=-500.0, transfer_amount=0.5) assert calculate_amount(r) == 0.0 def test_negative_true_does_not_bypass_selected_balance_gate_for_percent( self, ) -> None: # negative=True skips the closing-balance gate, but the selected-balance # gate still blocks when the selected balance is non-positive. r = _record(closing_balance=-500.0, transfer_amount=0.5, negative=True) assert calculate_amount(r) == 0.0 def test_zero_balance_returns_zero(self) -> None: r = _record(closing_balance=0.0, transfer_amount=0.5) assert calculate_amount(r) == 0.0 def test_zero_transfer_amount(self) -> None: r = _record(closing_balance=1000.0, transfer_amount=0.0) assert calculate_amount(r) == 0.0 def test_net_revenue_input(self) -> None: r = _record( closing_balance=9999.0, net_revenue=400.0, transfer_amount=0.5, input='net_revenue', ) assert calculate_amount(r) == 200.0 def test_gross_revenue_input(self) -> None: r = _record( closing_balance=9999.0, gross_revenue=800.0, transfer_amount=0.25, input='gross_revenue', ) assert calculate_amount(r) == 200.0 # Scenarios 10 & 11: negative=False blocks when closing balance is negative, # even when the input source is net/gross revenue. def test_percent_net_revenue_blocked_when_closing_balance_negative(self) -> None: r = _record( closing_balance=-600.0, net_revenue=400.0, transfer_amount=0.05, input='net_revenue', negative=False, ) assert calculate_amount(r) == 0.0 def test_percent_gross_revenue_blocked_when_closing_balance_negative(self) -> None: r = _record( closing_balance=-600.0, gross_revenue=500.0, transfer_amount=0.05, input='gross_revenue', negative=False, ) assert calculate_amount(r) == 0.0 def test_percent_net_revenue_allowed_when_negative_true(self) -> None: r = _record( closing_balance=-600.0, net_revenue=400.0, transfer_amount=0.05, input='net_revenue', negative=True, ) assert calculate_amount(r) == 20.0 def test_percent_gross_revenue_allowed_when_negative_true(self) -> None: r = _record( closing_balance=-600.0, gross_revenue=500.0, transfer_amount=0.05, input='gross_revenue', negative=True, ) assert calculate_amount(r) == 25.0 def test_closing_balance_input_default(self) -> None: r = _record(closing_balance=200.0, transfer_amount=0.25) assert calculate_amount(r) == 50.0 # flat_rate — negative=False (default): only blocks when balance is already # negative. Transfers that would make balance go negative are still allowed. def test_flat_rate_allows_when_amount_exceeds_balance(self) -> None: r = _record( closing_balance=1000.0, transfer_amount=6300.0, rate_type=RateType.FLAT_RATE, ) assert calculate_amount(r) == 6300.0 def test_flat_rate_none_transfer_amount_returns_zero(self) -> None: r = _record( closing_balance=1000.0, transfer_amount=None, rate_type=RateType.FLAT_RATE ) assert calculate_amount(r) == 0.0 def test_flat_rate_allows_when_balance_covers_amount(self) -> None: r = _record( closing_balance=100.0, transfer_amount=50.0, rate_type=RateType.FLAT_RATE ) assert calculate_amount(r) == 50.0 def test_flat_rate_allows_when_amount_would_go_negative(self) -> None: r = _record( closing_balance=50.0, transfer_amount=100.0, rate_type=RateType.FLAT_RATE ) assert calculate_amount(r) == 100.0 def test_flat_rate_blocks_negative_balance(self) -> None: r = _record( closing_balance=-100.0, transfer_amount=50.0, rate_type=RateType.FLAT_RATE ) assert calculate_amount(r) == 0.0 def test_flat_rate_none_balance_allowed(self) -> None: # None balance is treated as 0, which is not negative, so the # transfer is allowed. In practice _validate_enriched flags these # as error rows before they reach the calculator. r = _record( closing_balance=None, transfer_amount=50.0, rate_type=RateType.FLAT_RATE ) assert calculate_amount(r) == 50.0 # flat_rate — negative=True: always transfer regardless of balance or deficit def test_flat_rate_negative_true_allows_negative_balance(self) -> None: r = _record( closing_balance=-100.0, transfer_amount=50.0, rate_type=RateType.FLAT_RATE, negative=True, ) assert calculate_amount(r) == 50.0 def test_flat_rate_negative_true_allows_when_amount_exceeds_balance(self) -> None: r = _record( closing_balance=50.0, transfer_amount=200.0, rate_type=RateType.FLAT_RATE, negative=True, ) assert calculate_amount(r) == 200.0 def test_flat_rate_rounds_to_two_decimals(self) -> None: r = _record( closing_balance=1000.0, transfer_amount=100.123456, rate_type=RateType.FLAT_RATE, ) assert calculate_amount(r) == 100.12 def test_percent_rounds_to_two_decimals(self) -> None: r = _record(closing_balance=1000.0, transfer_amount=0.333) assert calculate_amount(r) == 333.0 # previously_transferred — percent path uses the remaining balance def test_percent_subtracts_previously_transferred(self) -> None: r = _record(closing_balance=1000.0, transfer_amount=0.2) assert calculate_amount(r, previously_transferred=200.0) == 160.0 def test_percent_previously_transferred_equal_to_balance(self) -> None: r = _record(closing_balance=1000.0, transfer_amount=0.5) assert calculate_amount(r, previously_transferred=1000.0) == 0.0 def test_percent_previously_transferred_exceeds_balance(self) -> None: r = _record(closing_balance=1000.0, transfer_amount=0.5) assert calculate_amount(r, previously_transferred=1500.0) == 0.0 def test_percent_previously_transferred_zero_default(self) -> None: r = _record(closing_balance=1000.0, transfer_amount=0.2) assert calculate_amount(r) == 200.0 # previously_transferred — flat_rate path applies the same remaining-balance check def test_flat_rate_previously_transferred_makes_balance_negative_blocks( self, ) -> None: r = _record( closing_balance=100.0, transfer_amount=50.0, rate_type=RateType.FLAT_RATE, ) assert calculate_amount(r, previously_transferred=200.0) == 0.0 def test_flat_rate_previously_transferred_makes_balance_negative_blocks_revenue_input( self, ) -> None: r = _record( closing_balance=100.0, net_revenue=800.0, input='net_revenue', transfer_amount=50.0, rate_type=RateType.FLAT_RATE, ) assert calculate_amount(r, previously_transferred=200.0) == 0.0 def test_flat_rate_negative_true_ignores_already_transferred(self) -> None: r = _record( closing_balance=100.0, transfer_amount=50.0, rate_type=RateType.FLAT_RATE, negative=True, ) assert calculate_amount(r, previously_transferred=200.0) == 50.0 # ═══════════════════════════════════════════════════════════════════════════════ # block_reason # ═══════════════════════════════════════════════════════════════════════════════ class TestBlockReason: """Tests for the block_reason function. These document, for QA/audit visibility, *why* a $0 calculated_amount happened — the underlying scenarios (July UAT feedback: 7, 35, 38, 39, 41, and config #2/#1 of 32/36/37/40) were all correctly blocked per the calculation rules but silently disappeared from the Adjustments sheet (calculated_amount == 0 and no error). block_reason surfaces the reason on the Summary sheet instead of leaving the row unexplained. """ def test_none_when_amount_nonzero(self) -> None: r = _record(closing_balance=1000.0, transfer_amount=0.5) assert block_reason(r, calculate_amount(r)) is None def test_none_when_error_already_explains_it(self) -> None: r = _record(closing_balance=1000.0, transfer_amount=0.5, error='some error') assert block_reason(r, 0.0) is None def test_percent_blocked_negative_closing_balance(self) -> None: # Scenario 7: 5% of Closing Balance, closing_balance=-600, negative=False. r = _record(closing_balance=-600.0, transfer_amount=0.05, negative=False) amt = calculate_amount(r) reason = block_reason(r, amt) assert amt == 0.0 assert reason is not None assert 'negative' in reason def test_percent_blocked_non_positive_selected_balance(self) -> None: # Scenario 38/39: real balance for the selected input field is 0. r = _record( closing_balance=0.0, gross_revenue=0.0, transfer_amount=0.1025, input='gross_revenue', ) amt = calculate_amount(r) reason = block_reason(r, amt) assert amt == 0.0 assert reason is not None assert 'non-positive' in reason def test_percent_blocked_zero_net_revenue_input(self) -> None: # Scenario 32 config #2 / 36 config #1 / 40 config #1: input=net_revenue # but the record's net_revenue happens to be 0. r = _record( closing_balance=20.92, net_revenue=0.0, transfer_amount=0.1, input='net_revenue', ) amt = calculate_amount(r) reason = block_reason(r, amt) assert amt == 0.0 assert reason is not None assert 'net_revenue' in reason def test_flat_rate_blocked_negative_balance(self) -> None: # Scenario 41: flat_rate blocked because closing_balance is deeply negative. r = _record( closing_balance=-70456.5, transfer_amount=500.75, rate_type=RateType.FLAT_RATE, ) amt = calculate_amount(r) reason = block_reason(r, amt) assert amt == 0.0 assert reason is not None assert 'negative' in reason def test_flat_rate_not_blocked_when_allowed(self) -> None: r = _record( closing_balance=1000.0, transfer_amount=500.0, rate_type=RateType.FLAT_RATE, ) amt = calculate_amount(r) assert block_reason(r, amt) is None def test_evaluate_populates_block_reason_on_result(self) -> None: r = _record(closing_balance=-600.0, transfer_amount=0.05) results = evaluate([r]) assert results[0].calculated_amount == 0.0 assert results[0].block_reason is not None def test_evaluate_leaves_block_reason_none_when_transfer_succeeds(self) -> None: r = _record(closing_balance=1000.0, transfer_amount=0.5) results = evaluate([r]) assert results[0].calculated_amount == 500.0 assert results[0].block_reason is None def test_evaluate_allocation_error_has_no_duplicate_block_reason(self) -> None: # Scenario 4-style: percent sum > 100% already explains the $0 via # record.error; block_reason should stay None to avoid a confusing # second explanation for the same row. r1 = _record( closing_balance=900.0, transfer_amount=0.8, earnings_transfer_id=1, to_contract=ContractRef(contract_name='To1', contract_id=201), ) r2 = _record( closing_balance=900.0, transfer_amount=0.8, earnings_transfer_id=2, to_contract=ContractRef(contract_name='To2', contract_id=202), ) results = evaluate([r1, r2]) for r in results: assert r.record.error is not None assert r.block_reason is None # ═══════════════════════════════════════════════════════════════════════════════ # _get_previously_transferred_amount # ═══════════════════════════════════════════════════════════════════════════════ class TestGetPreviouslyTransferredAmount: """Tests for the _get_previously_transferred_amount function.""" def test_no_results_returns_zero(self) -> None: assert _get_previously_transferred_amount(contract_id=1, results=[]) == 0.0 def test_single_matching_result(self) -> None: r = _record(closing_balance=1000.0, transfer_amount=0.5) results = [CalculationResult(record=r, calculated_amount=500.0)] assert ( _get_previously_transferred_amount( contract_id=r.from_contract.contract_id, results=results ) == 500.0 ) def test_multiple_matching_results_sums_amounts(self) -> None: r1 = _record(closing_balance=1000.0, transfer_amount=0.5) r2 = _record(closing_balance=200.0, transfer_amount=0.25) results = [ CalculationResult(record=r1, calculated_amount=500.0), CalculationResult(record=r2, calculated_amount=50.0), ] assert ( _get_previously_transferred_amount( contract_id=r1.from_contract.contract_id, results=results ) == 550.0 ) def test_no_matching_contract_id_returns_zero(self) -> None: r = _record(closing_balance=1000.0, transfer_amount=0.5) results = [CalculationResult(record=r, calculated_amount=500.0)] assert ( _get_previously_transferred_amount(contract_id=9999, results=results) == 0.0 ) def test_mixed_contract_ids_sums_only_matching(self) -> None: r1 = _record( closing_balance=1000.0, transfer_amount=0.5, earnings_transfer_id=1 ) r2 = _record( closing_balance=200.0, transfer_amount=0.25, earnings_transfer_id=2, from_contract=ContractRef( account_name='From Acct', account_id=1, contract_name='From Ctr', contract_id=999, ), ) results = [ CalculationResult(record=r1, calculated_amount=500.0), CalculationResult(record=r2, calculated_amount=50.0), ] assert ( _get_previously_transferred_amount( contract_id=r1.from_contract.contract_id, results=results ) == 500.0 ) def test_incoming_transfer_subtracts_from_total(self) -> None: # A prior result whose to_contract matches the queried contract is a # positive correction (credit) and should reduce already-transferred. credit_record = _record( earnings_transfer_id=1, from_contract=ContractRef( account_name='Src Acct', account_id=10, contract_name='Src Ctr', contract_id=500, ), to_contract=ContractRef( account_name='Dst Acct', account_id=20, contract_name='Dst Ctr', contract_id=100, ), ) debit_record = _record( earnings_transfer_id=2, from_contract=ContractRef( account_name='Dst Acct', account_id=20, contract_name='Dst Ctr', contract_id=100, ), to_contract=ContractRef( account_name='Other Acct', account_id=30, contract_name='Other Ctr', contract_id=600, ), ) results = [ CalculationResult(record=credit_record, calculated_amount=300.0), CalculationResult(record=debit_record, calculated_amount=500.0), ] # contract 100: +500 outflow, -300 inflow = 200 net assert ( _get_previously_transferred_amount(contract_id=100, results=results) == 200.0 ) def test_only_incoming_transfer_returns_negative(self) -> None: credit_record = _record( earnings_transfer_id=1, from_contract=ContractRef( account_name='Src Acct', account_id=10, contract_name='Src Ctr', contract_id=500, ), to_contract=ContractRef( account_name='Dst Acct', account_id=20, contract_name='Dst Ctr', contract_id=100, ), ) results = [CalculationResult(record=credit_record, calculated_amount=150.0)] assert ( _get_previously_transferred_amount(contract_id=100, results=results) == -150.0 ) def test_zero_calculated_amounts(self) -> None: r1 = _record(closing_balance=1000.0, transfer_amount=0.0) r2 = _record(closing_balance=200.0, transfer_amount=0.0) results = [ CalculationResult(record=r1, calculated_amount=0.0), CalculationResult(record=r2, calculated_amount=0.0), ] assert ( _get_previously_transferred_amount( contract_id=r1.from_contract.contract_id, results=results ) == 0.0 ) # ═══════════════════════════════════════════════════════════════════════════════ # evaluate # ═══════════════════════════════════════════════════════════════════════════════ class TestEvaluate: """Tests for the main evaluate function.""" def test_basic_calculation(self) -> None: r = _record(closing_balance=1000.0, transfer_amount=0.5) results = evaluate([r]) assert len(results) == 1 assert results[0].calculated_amount == 500.0 def test_multiple_records(self) -> None: r1 = _record(closing_balance=1000.0, transfer_amount=0.5) r2 = _record( closing_balance=200.0, transfer_amount=0.25, earnings_transfer_id=2, from_contract=ContractRef( account_name='Other Acct', account_id=2, contract_name='Other Ctr', contract_id=999, ), ) results = evaluate([r1, r2]) assert results[0].calculated_amount == 500.0 assert results[1].calculated_amount == 50.0 def test_empty_records(self) -> None: results = evaluate([]) assert results == [] def test_result_record_is_preserved(self) -> None: r = _record(closing_balance=1000.0, transfer_amount=0.5) results = evaluate([r]) assert results[0].record is r def test_flat_rate_record(self) -> None: r = _record( closing_balance=500.0, transfer_amount=100.0, rate_type=RateType.FLAT_RATE, ) results = evaluate([r]) assert results[0].calculated_amount == 100.0 def test_flat_rate_negative_true_proceeds_through_evaluate(self) -> None: r = _record( closing_balance=-200.0, transfer_amount=50.0, rate_type=RateType.FLAT_RATE, negative=True, ) results = evaluate([r]) assert results[0].calculated_amount == 50.0 def test_net_revenue_input(self) -> None: r = _record( closing_balance=9999.0, net_revenue=800.0, transfer_amount=0.5, input='net_revenue', ) results = evaluate([r]) assert results[0].calculated_amount == 400.0 def test_gross_revenue_input(self) -> None: r = _record( closing_balance=9999.0, gross_revenue=600.0, transfer_amount=0.25, input='gross_revenue', ) results = evaluate([r]) assert results[0].calculated_amount == 150.0 def test_percent_cascades_with_already_transferred(self) -> None: # First record transfers 20% of 1000 = 200. Second record on the same # from-contract sees remaining balance 800 and transfers 20% = 160. r1 = _record( closing_balance=1000.0, transfer_amount=0.2, earnings_transfer_id=1 ) r2 = _record( closing_balance=1000.0, transfer_amount=0.2, earnings_transfer_id=2 ) results = evaluate([r1, r2]) assert results[0].calculated_amount == 200.0 assert results[1].calculated_amount == 160.0 def test_use_static_balance_disables_cascade(self) -> None: # use_static_balance=True: both records evaluate against the original # 1000 balance instead of the post-transfer remainder. r1 = _record( closing_balance=1000.0, transfer_amount=0.2, earnings_transfer_id=1, use_static_balance=True, ) r2 = _record( closing_balance=1000.0, transfer_amount=0.2, earnings_transfer_id=2, use_static_balance=True, ) results = evaluate([r1, r2]) assert results[0].calculated_amount == 200.0 assert results[1].calculated_amount == 200.0 def test_net_revenue_input_disables_cascade(self) -> None: # input != closing_balance: cascade does not apply. r1 = _record( closing_balance=9999.0, net_revenue=1000.0, transfer_amount=0.2, input='net_revenue', earnings_transfer_id=1, ) r2 = _record( closing_balance=9999.0, net_revenue=1000.0, transfer_amount=0.2, input='net_revenue', earnings_transfer_id=2, ) results = evaluate([r1, r2]) assert results[0].calculated_amount == 200.0 assert results[1].calculated_amount == 200.0 def test_incoming_credit_increases_available_balance(self) -> None: # r1 transfers 100 INTO contract 100. r2 transfers FROM contract 100 # with balance 1000 at 20%. The 100 credit offsets the cascade, so # the effective balance is 1000 - (0 outflows - 100 inflows) = 1100, # and the transfer is 1100 * 0.2 = 220. r1 = _record( earnings_transfer_id=1, closing_balance=500.0, transfer_amount=100.0, rate_type=RateType.FLAT_RATE, from_contract=ContractRef( account_name='Src Acct', account_id=10, contract_name='Src Ctr', contract_id=500, ), to_contract=ContractRef( account_name='Dst Acct', account_id=20, contract_name='Dst Ctr', contract_id=100, ), ) r2 = _record( earnings_transfer_id=2, closing_balance=1000.0, transfer_amount=0.2, from_contract=ContractRef( account_name='Dst Acct', account_id=20, contract_name='Dst Ctr', contract_id=100, ), to_contract=ContractRef( account_name='Other Acct', account_id=30, contract_name='Other Ctr', contract_id=600, ), ) results = evaluate([r1, r2]) assert results[0].calculated_amount == 100.0 assert results[1].calculated_amount == 220.0 # Scenario 4: percentages > 100% from a single FROM contract should # produce an error on all percent records in that group. def test_evaluate_flags_error_when_percent_sum_exceeds_100(self) -> None: r1 = _record( closing_balance=900.0, transfer_amount=0.4, earnings_transfer_id=1, to_contract=ContractRef(contract_name='To1', contract_id=201), ) r2 = _record( closing_balance=900.0, transfer_amount=0.4, earnings_transfer_id=2, to_contract=ContractRef(contract_name='To2', contract_id=202), ) r3 = _record( closing_balance=900.0, transfer_amount=0.2, earnings_transfer_id=3, to_contract=ContractRef(contract_name='To3', contract_id=203), ) r4 = _record( closing_balance=900.0, transfer_amount=0.2, earnings_transfer_id=4, to_contract=ContractRef(contract_name='To4', contract_id=204), ) results = evaluate([r1, r2, r3, r4]) assert len(results) == 4 for r in results: assert r.calculated_amount == 0.0 assert r.record.error is not None assert '120' in r.record.error # 120% total def test_evaluate_allows_percent_sum_exactly_100(self) -> None: r1 = _record( closing_balance=900.0, transfer_amount=0.5, earnings_transfer_id=1, to_contract=ContractRef(contract_name='To1', contract_id=201), ) r2 = _record( closing_balance=900.0, transfer_amount=0.5, earnings_transfer_id=2, to_contract=ContractRef(contract_name='To2', contract_id=202), ) results = evaluate([r1, r2]) assert all(r.record.error is None for r in results) def test_evaluate_allows_percent_sum_100_with_float_rounding(self) -> None: # Simulate a sum that lands just above 1.0 due to float imprecision. # math.nextafter(1.0, 2.0) yields 1.0 + 2**-52 (~2.22e-16). import math just_above = math.nextafter(1.0, 2.0) # 1.0000000000000002 r1 = _record( closing_balance=900.0, transfer_amount=just_above / 2, earnings_transfer_id=1, to_contract=ContractRef(contract_name='To1', contract_id=201), ) r2 = _record( closing_balance=900.0, transfer_amount=just_above / 2, earnings_transfer_id=2, to_contract=ContractRef(contract_name='To2', contract_id=202), ) assert r1.transfer_amount + r2.transfer_amount > 1.0 results = evaluate([r1, r2]) assert all(r.record.error is None for r in results) def test_evaluate_allows_percent_sum_under_100(self) -> None: r1 = _record( closing_balance=900.0, transfer_amount=0.3, earnings_transfer_id=1, to_contract=ContractRef(contract_name='To1', contract_id=201), ) r2 = _record( closing_balance=900.0, transfer_amount=0.3, earnings_transfer_id=2, to_contract=ContractRef(contract_name='To2', contract_id=202), ) results = evaluate([r1, r2]) assert all(r.record.error is None for r in results) def test_evaluate_percent_validation_ignores_flat_rate(self) -> None: r1 = _record( closing_balance=900.0, transfer_amount=0.8, earnings_transfer_id=1, to_contract=ContractRef(contract_name='To1', contract_id=201), ) r2 = _record( closing_balance=900.0, transfer_amount=500.0, earnings_transfer_id=2, rate_type=RateType.FLAT_RATE, to_contract=ContractRef(contract_name='To2', contract_id=202), ) results = evaluate([r1, r2]) assert all(r.record.error is None for r in results) def test_already_transferred_scoped_to_from_contract(self) -> None: # Transfers from a different contract should not reduce the balance. r1 = _record( closing_balance=1000.0, transfer_amount=0.2, earnings_transfer_id=1, from_contract=ContractRef( account_name='Acct A', account_id=1, contract_name='Ctr A', contract_id=111, ), ) r2 = _record( closing_balance=1000.0, transfer_amount=0.2, earnings_transfer_id=2, from_contract=ContractRef( account_name='Acct B', account_id=2, contract_name='Ctr B', contract_id=222, ), ) results = evaluate([r1, r2]) assert results[0].calculated_amount == 200.0 assert results[1].calculated_amount == 200.0 def test_already_transferred_should_force_sort(self) -> None: # Input transfer records are not ordered, so evalute should order them by id. r1 = _record( closing_balance=1000.0, transfer_amount=0.2, earnings_transfer_id=1, ) r2 = _record( closing_balance=1000.0, transfer_amount=0.2, earnings_transfer_id=2, ) results = evaluate([r2, r1]) # r1 and r2 order reversed in the input param assert results[0].calculated_amount == 200.0 assert results[0].record.earnings_transfer_id == 1 assert results[1].calculated_amount == 160 assert results[1].record.earnings_transfer_id == 2