"""Payment processor.""" import itertools from typing import Iterable from config import app_logger as logger from src.connectors.ows_account import ( account_payee_dataloader, account_payment_term_dataloader, ) from src.connectors.ows_event import get_events_by_target_type from src.connectors.ows_payment import ( get_bulk_last_payments_v2, get_payable_balance_after_tax_entries, get_payment_minimums, ) from src.connectors.utils import fetch_all from src.constants import ( ACCOUNT_BATCH_SIZE, CALCULATE_PAYMENTS_ACTION_NAME, NO_ELIGIBLE_ACCOUNTS, ) from src.exceptions import ( DataConsistencyException, FlowthroughUpdateException, PaymentsCreationException, ) from src.models import ( AccountPayee, AccountPaymentDetails, AccountPaymentTerm, Event, PayableBalanceAfterTax, PaymentBatch, PaymentBatchItem, PaymentMethodMinimum, ) from src.processors.batch_flowthrough_processor import BatchFlowthroughProcessor from src.processors.batch_payments_processor import BatchPaymentsProcessor class Processor: """ Orchestrates end-to-end payment generation from payable worksheets. Aggregates data from multiple upstream sources (OWS account, OWS payment, OWS event), validates cross-source consistency, and drives batch payment creation and flowthrough updates. Workflow: 1. Locate the related ``calculate_payments`` Abacus event for the given event. 2. Fetch all pre-calculated payable worksheets produced by that event. 3. Retrieve supporting data: - Payment method minimums (by currency) - Last payment details (by account) - Payment terms (by account) - Payee information (by account) 4. Validate data consistency – raise early if any account is missing required data. 5. Partition accounts into fixed-size batches and process each sequentially: a. ``BatchPaymentsProcessor`` – creates payment records. b. ``BatchFlowthroughProcessor`` – updates flowthrough state. Raises: DataConsistencyException: Raised during validation when payment terms, payees, or payment minimums are missing for one or more accounts with worksheets. PaymentsCreationException: Raised when ``BatchPaymentsProcessor`` fails for a batch. FlowthroughUpdateException: Raised when ``BatchFlowthroughProcessor`` fails for a batch. Example:: processor = Processor(batch_size=100) processor.process(abacus_event) """ _batch_size: int _batch_payments_processor: BatchPaymentsProcessor _batch_flowthrough_processor: BatchFlowthroughProcessor _abacus_event: Event _payable_worksheets_by_account: dict[int, list[PayableBalanceAfterTax]] _last_payments_by_account: dict[int, AccountPaymentDetails] _payment_terms_by_account: dict[int, AccountPaymentTerm] _payees_by_account: dict[int, AccountPayee] _payment_minimums_by_currency: dict[str, PaymentMethodMinimum] def __init__( self, batch_size: int = ACCOUNT_BATCH_SIZE, batch_payments_processor: BatchPaymentsProcessor | None = None, batch_flowthrough_processor: BatchFlowthroughProcessor | None = None, ) -> None: """Initialise the processor.""" self._batch_size = batch_size self._batch_payments_processor = ( batch_payments_processor or BatchPaymentsProcessor() ) self._batch_flowthrough_processor = ( batch_flowthrough_processor or BatchFlowthroughProcessor() ) def process(self, abacus_event: Event) -> None: """ Entry point that orchestrates the full payment generation workflow. Stores the triggering event, populates all required data structures, validates consistency, then iterates over account batches to create payments and update flowthrough state. Args: abacus_event: The Abacus event that triggered this payment generation run. Returns early when no eligible accounts are found. """ self._abacus_event = abacus_event self._setup_payable_worksheets_by_account() if not self._payable_worksheets_by_account: logger.info(NO_ELIGIBLE_ACCOUNTS) return self._setup_payment_minimums() self._setup_last_payments_by_account() self._setup_payment_terms_by_account() self._setup_payees_by_account() self._validate_data_consistency() for ids_batch in itertools.batched( self._payable_worksheets_by_account, self._batch_size ): self._process_batch(ids_batch) def _setup_payment_minimums(self) -> None: """Fetch payment method minimums and index them by currency code.""" logger.info('Setting payment minimums') self._payment_minimums_by_currency = { m.currency_code: m for m in get_payment_minimums() } def _setup_payable_worksheets_by_account(self) -> None: """ Fetch and group payable worksheets for the related ``calculate_payments`` event. Resolves the linked ``calculate_payments`` event, retrieves all payable balance worksheets. """ logger.info('Setting payable worksheets') calculate_payments_event = self._get_calculate_payments_event() if not calculate_payments_event: self._payable_worksheets_by_account = {} return worksheets = fetch_all( get_payable_balance_after_tax_entries, calculate_payments_event.abacus_event_id, ) key = lambda w: w.account_id self._payable_worksheets_by_account = { account_id: list(items) for account_id, items in itertools.groupby( sorted(worksheets, key=key), key=key ) } def _get_calculate_payments_event(self) -> Event | None: """Find the ``calculate_payments`` event associated with the current event.""" events = get_events_by_target_type( self._abacus_event.target_type, self._abacus_event.target_id ) return next( filter( lambda e: e.event_name == CALCULATE_PAYMENTS_ACTION_NAME, # type: ignore events, ), None, ) def _setup_last_payments_by_account(self) -> None: """Fetch all related last_payments details.""" logger.info('Setting last payments') last_payments = fetch_all( get_bulk_last_payments_v2, list(self._payable_worksheets_by_account) ) self._last_payments_by_account = { payment.account_id: payment for payment in last_payments if payment.account_id is not None } def _setup_payment_terms_by_account(self) -> None: """Fetch all related payment terms.""" logger.info('Setting payment terms') self._payment_terms_by_account = { account_id: payment_term for ids_batch in itertools.batched( self._payable_worksheets_by_account, self._batch_size ) for account_id, payment_term in account_payment_term_dataloader( ids_batch ).items() } def _setup_payees_by_account(self) -> None: """Fetch all related payees.""" logger.info('Setting payees') self._payees_by_account = { account_id: account_payee for ids_batch in itertools.batched( self._payable_worksheets_by_account, self._batch_size ) for account_id, account_payee in account_payee_dataloader(ids_batch).items() } def _validate_data_consistency(self) -> None: """ Assert that every account with worksheets has all required supporting data. Checks three invariants in sequence: 1. Every account has a payment term. 2. Every account has a payee. 3. Every currency referenced by a worksheet has a corresponding base payment minimum. This is a defensive safeguard – in normal operation the upstream ``calculate_payments`` step and other business logic ensures this data is present. The check is intentionally cheap so that missing data is surfaced before any payments are created. Raises: DataConsistencyException: If any of the three invariants are violated, listing the affected account IDs or currency codes. """ missed_payment_terms = set(self._payable_worksheets_by_account) - set( self._payment_terms_by_account ) if missed_payment_terms: raise DataConsistencyException( f'Missing payment terms for accounts: {sorted(missed_payment_terms)}' ) missed_payees = set(self._payable_worksheets_by_account) - set( self._payees_by_account ) if missed_payees: raise DataConsistencyException( f'Missing payees for accounts: {sorted(missed_payees)}' ) missed_payment_minimums = set( w.currency_code for worksheets in self._payable_worksheets_by_account.values() for w in worksheets ) - set(self._payment_minimums_by_currency) if missed_payment_minimums: raise DataConsistencyException( f'Missing payment minimums for currencies: {sorted(missed_payment_minimums)}' ) def _process_batch(self, ids_batch: Iterable[int]) -> None: """ Process a single batch of account IDs. Builds a :class:`~src.models.PaymentBatch` from the pre-fetched data structures and delegates to the appropriate sub-processors: - :class:`BatchPaymentsProcessor` – always invoked; creates payment records and returns the resulting payment accounts. - :class:`BatchFlowthroughProcessor` – always invoked; updates flowthrough state for the newly created payments. Args: ids_batch: Iterable of account IDs to include in this batch. Raises: PaymentsCreationException: If :class:`BatchPaymentsProcessor` raises for this batch. FlowthroughUpdateException: If :class:`BatchFlowthroughProcessor` raises for this batch. """ items = [ PaymentBatchItem( account_id, self._payable_worksheets_by_account[account_id], self._last_payments_by_account.get(account_id), self._payment_terms_by_account[account_id], self._payees_by_account[account_id], ) for account_id in ids_batch ] batch = PaymentBatch( self._abacus_event, self._payment_minimums_by_currency, items ) try: payment_accounts = self._batch_payments_processor.process(batch) except Exception as e: raise PaymentsCreationException('batch error', account_ids=ids_batch) from e try: self._batch_flowthrough_processor.process( [acc.payment_group_payment_account_id for acc in payment_accounts] ) except Exception as e: raise FlowthroughUpdateException( 'batch error', account_ids=ids_batch ) from e