"""Lambda process function module.""" from collections.abc import Callable, Generator from contextlib import contextmanager from http import HTTPStatus from typing import Any from abacus_common_logic.utils.features import ( # type: ignore[import-untyped] is_feature_enabled, ) import pydantic from config import app_logger as logger from src import constants from src.connectors.exceptions import OwsEventException from src.connectors.ows_event import create_event from src.connectors.ows_payment import ( delete_balance_entries_after_tax, delete_payable_details_entries, get_payment_group, get_payment_group_payment, ) from src.connectors.ows_state import ( ErrorMsg, get_state, OwsStateException, StateStatus, update_abacus_state_by_id, ) from src.models import Event, LambdaResponse, PaymentGroup from src.processors.base.processor import Processor from src.processors.check_payments.check_processor import CheckProcessor from src.processors.payoneer_payments.payoneer_processor import PayoneerProcessor from src.processors.payoneer_payments.payoneer_refresh_processor import ( PayoneerRefreshProcessor, ) def handler(event: dict[str, Any], context: Any) -> dict[str, Any]: """Lambda entry point.""" logger.info('Received event', extra={'event': event}) try: valid_event: Event = Event.model_validate(event) except pydantic.ValidationError as exc: return _response_handler(f'Failed to parse event: {exc.errors()}') with _errors_handler(valid_event) as get_error: payment_group_payment = get_payment_group_payment(valid_event.target_id) payment_group = get_payment_group(payment_group_payment.payment_group_id) # Resolved here rather than inside get_payment_processor so the refresh # processor can apply the same check-payment VAT handling. is_check_payment = _is_check_payment(payment_group) is_draft_payments_ff_enabled = is_feature_enabled( constants.TAP_DRAFT_PAYMENTS_FEATURE ) is_retry = valid_event.is_retry # Event model defaults this to False. if is_retry: if not is_draft_payments_ff_enabled: # Refresh events are only emitted while draft-payments is on; # gated defensively in case an unexpected one arrives. raise Exception(constants.REFRESH_DISABLED_ERR_MSG) # Refresh is type-agnostic; check payments skip VAT corrections. # Rollback is skipped for retries (see _errors_handler). PayoneerRefreshProcessor( payment_group=payment_group, abacus_event=valid_event, is_check_payment=is_check_payment, ).process() else: # Initial calculate flow. processor = get_payment_processor(is_check_payment) processor(payment_group=payment_group, abacus_event=valid_event).process() # The draft-payments FF is a global, all-payments switch: while it is on, # no payments_generate event is emitted for any processor type (payments # are held as drafts). When off, behaviour is unchanged. if not is_draft_payments_ff_enabled: create_event( constants.GENERATE_PAYMENTS_EVENT_NAME, valid_event.target_type, valid_event.target_id, ) return _response_handler(get_error()) def _is_check_payment(payment_group: PaymentGroup) -> bool: """Return True if the group is a (supported) check payment group.""" reference_payment_type_id = payment_group.group_criteria.get( 'reference_payment_type_id' ) if reference_payment_type_id is None: return False if not validate_reference_payment_type_id(int(reference_payment_type_id)): raise Exception( constants.INVALID_REFERENCE_PAYMENT_TYPE_ID_ERR.format( reference_payment_type_id ) ) return True def get_payment_processor(is_check_payment: bool) -> type[Processor]: """Get the payment processor for the initial calculate flow.""" if is_check_payment: # only check payments are based on payment type currently return CheckProcessor return PayoneerProcessor @contextmanager def _errors_handler(valid_event: Event) -> Generator[Callable[[], None], None, None]: err_status_description = None try: yield lambda: err_status_description except OwsEventException as exc: err_status_description = constants.GENERATE_PAYMENTS_EVENT_ERROR.format(exc) except Exception as exc: err_status_description = constants.ERROR_MSG.format(exc) action_status = ( StateStatus.ERROR if err_status_description else StateStatus.COMPLETE ) try: _state_update_handler( constants.PARENT_TABLE_NAME, valid_event.target_id, action_status ) except OwsStateException as exc: err_status_description = ErrorMsg.UPDATE_STATE.format(exc, action_status) if err_status_description: logger.error(err_status_description) if not valid_event.is_retry: # Only roll back for an initial calculate. A retry reuses the original # abacus_event_id, and _rollback_changes deletes by event_id — so a # retry must never roll back or it would soft-delete the original # calculate's real worksheet entries. _rollback_changes(valid_event) def validate_reference_payment_type_id( reference_payment_type_id: int, ) -> bool: """Validate reference_payment_type_id, only check is supported.""" return reference_payment_type_id == constants.ReferencePaymentTypes.check def _response_handler(err_status_description: str | None = None) -> dict[str, Any]: if err_status_description: logger.error(err_status_description) return LambdaResponse( **dict( status_code=( HTTPStatus.BAD_REQUEST if err_status_description else HTTPStatus.OK ), status_description=err_status_description or constants.SUCCESS_MSG, ) ).model_dump(by_alias=True) def _state_update_handler( parent_table_name: str, target_id: int, action_status: str ) -> None: update_abacus_state_by_id( abacus_state_id=get_state(parent_table_name, target_id).abacus_state_id, body={'action_status': action_status}, ) def _rollback_changes(event: Event) -> None: """Delete contract balance after tax and payable details entries.""" logger.info(constants.ROLLBACK_ENTRIES) event_id = event.abacus_event_id delete_balance_entries_after_tax(event_id) delete_payable_details_entries(event_id)