"""Calculation engine for earnings transfers. Computes transfer amounts via two paths: - Flat rate + negative=True: always transfer the fixed dollar amount. - Flat rate + negative=False: block only when the balance is negative. - Percent + negative=False: block when the closing balance is negative (regardless of input source) or the selected balance is non-positive. - Percent + negative=True: block only when the selected balance is non-positive; a negative closing balance does not prevent the transfer. The balance is selected by the record's ``input`` field (closing_balance / net_revenue / gross_revenue). """ from __future__ import annotations from collections import defaultdict from lambdacommon.common_config import logger from src.enums import RateType from src.types import CalculationResult, TransferRecord # ─── Low-level calculation ──────────────────────────────────────────────────── def _selected_balance(record: TransferRecord) -> float | None: """Return the balance field indicated by record.input.""" if record.input == 'net_revenue': return record.net_revenue if record.input == 'gross_revenue': return record.gross_revenue return record.closing_balance def _can_apply_flat_rate(previously_transferred: float, record: TransferRecord) -> bool: """Return True if the flat rate can be applied to the record.""" if record.negative: return True if not record.closing_balance: return True estimated_closing_balance = record.closing_balance if not record.use_static_balance: estimated_closing_balance -= previously_transferred return estimated_closing_balance >= 0 def calculate_amount( record: TransferRecord, previously_transferred: float = 0.0 ) -> float: """Compute the transfer amount for a single record. ``previously_transferred`` is subtracted from the selected balance before the rate is applied, so cascading transfers from the same contract operate on the remaining balance rather than the original. The subtraction is only applied when ``record.input == 'closing_balance'`` and ``record.use_static_balance`` is False — other inputs (net/gross revenue) are not drawn down by prior transfers, and ``use_static_balance`` opts the record out of the cascade entirely. """ balance = _selected_balance(record) bal = balance if balance is not None else 0.0 if record.input == 'closing_balance' and not record.use_static_balance: bal -= previously_transferred if record.rate_type == RateType.FLAT_RATE: if _can_apply_flat_rate(previously_transferred, record): return round(record.transfer_amount or 0.0, 2) else: return 0.0 # percent: block when closing balance is negative and negative=False, # regardless of which input source is selected (net/gross revenue). if ( not record.negative and record.closing_balance is not None and record.closing_balance < 0 ): return 0.0 # block if selected balance is non-positive or transfer_amount is missing if bal <= 0 or record.transfer_amount is None: return 0.0 return round(bal * record.transfer_amount, 2) def block_reason( record: TransferRecord, calculated_amount: float, previously_transferred: float = 0.0, ) -> str | None: """Explain why ``calculated_amount`` is zero, for audit/QA visibility. Returns ``None`` when the record was not blocked (non-zero amount), or when an allocation error already explains it (see ``evaluate()``'s error path; the Summary sheet's Error column covers that case separately). This mirrors the exact gating logic in ``calculate_amount`` / ``_can_apply_flat_rate`` so the reason always matches what actually happened — it must be kept in sync with those functions. """ if calculated_amount != 0 or record.error: return None if record.rate_type == RateType.FLAT_RATE: if record.transfer_amount is None: return 'flat_rate blocked: transfer_amount is missing' if not _can_apply_flat_rate(previously_transferred, record): estimated = record.closing_balance or 0.0 if not record.use_static_balance: estimated -= previously_transferred return ( f'flat_rate blocked: closing_balance would be {estimated:.2f} ' f'(negative) and negative=False' ) # Reached here only if the flat_rate amount itself rounds to 0.00. return ( f'flat_rate rounds to zero: {record.transfer_amount} -> ' f'{round(record.transfer_amount, 2)}' ) # percent if ( not record.negative and record.closing_balance is not None and record.closing_balance < 0 ): return ( f'percent blocked: closing_balance={record.closing_balance:.2f} is ' f'negative and negative=False' ) balance = _selected_balance(record) bal = balance if balance is not None else 0.0 if record.input == 'closing_balance' and not record.use_static_balance: bal -= previously_transferred if bal <= 0: return ( f'percent blocked: selected balance ({record.input})={bal:.2f} is ' f'non-positive' ) if record.transfer_amount is None: return 'percent blocked: transfer_amount is missing' return ( f'percent rounds to zero: {bal:.2f} x {record.transfer_amount} = ' f'{bal * record.transfer_amount}' ) def _get_previously_transferred_amount( contract_id: int | None, results: list[CalculationResult] ) -> float: """Net amount already moved out of ``contract_id`` by prior results. Outflows (the contract is the ``from`` side) add to the total; inflows (the contract is the ``to`` side, i.e. a positive correction) subtract from it. """ if contract_id is None: return 0.0 total = 0.0 for r in results: if r.record.from_contract.contract_id == contract_id: total += r.calculated_amount if r.record.to_contract.contract_id == contract_id: total -= r.calculated_amount return total # ─── Main evaluation ────────────────────────────────────────────────────────── def _validate_percent_allocations( records: list[TransferRecord], ) -> dict[int, str]: """Check that percent allocations from each FROM contract don't exceed 100%. Returns a dict mapping from_contract_id → error message for invalid groups. """ groups: dict[int, list[TransferRecord]] = defaultdict(list) for r in records: cid = r.from_contract.contract_id if cid is not None and r.rate_type == RateType.PERCENT: groups[cid].append(r) errors: dict[int, str] = {} for cid, group in groups.items(): total = sum(r.transfer_amount or 0.0 for r in group) if total > 1.0 + 1e-9: pct_str = f'{total * 100:.7f}'.rstrip('0').rstrip('.') errors[cid] = ( f'Percent allocations from contract {cid} sum to {pct_str}% (exceeds 100%)' ) return errors def evaluate(records: list[TransferRecord]) -> list[CalculationResult]: """Evaluate a batch of transfer records.""" alloc_errors = _validate_percent_allocations(records) logged_alloc_errors: set[int] = set() results: list[CalculationResult] = [] sorted_records = sorted(records, key=lambda rec: rec.earnings_transfer_id) for r in sorted_records: from_cid = r.from_contract.contract_id if ( from_cid is not None and from_cid in alloc_errors and r.rate_type == RateType.PERCENT ): error_msg = alloc_errors[from_cid] if from_cid not in logged_alloc_errors: logger.warning( 'Allocation validation failed: from_contract_id=%s error=%s', from_cid, error_msg, ) logged_alloc_errors.add(from_cid) update: dict[str, object] = {'error_contract_id': from_cid} if r.error: update['error'] = f'{r.error}; {error_msg}' else: update['error'] = error_msg flagged = r.model_copy(update=update) results.append(CalculationResult(record=flagged, calculated_amount=0.0)) continue logger.info( 'Calculator input: et_id=%s from_cid=%s input=%s closing_balance=%s net_revenue=%s gross_revenue=%s selected_balance=%s', r.earnings_transfer_id, r.from_contract.contract_id, r.input, r.closing_balance, r.net_revenue, r.gross_revenue, _selected_balance(r), ) already_transferred = _get_previously_transferred_amount( r.from_contract.contract_id, results ) amt = calculate_amount(r, already_transferred) logger.info( 'Calculator output: et_id=%s already_transferred=%s calculated_amount=%s', r.earnings_transfer_id, already_transferred, amt, ) reason = block_reason(r, amt, already_transferred) results.append( CalculationResult(record=r, calculated_amount=amt, block_reason=reason) ) return results