"""Logic for bank details.""" import logging import typing from flask import current_app from typing_extensions import Literal from payee.connectors.ows_abacus_state import ( create_payee_states, get_payee_states, update_state, ) from payee.connectors.secure_data.document import SecureDocument from payee.constants.constants import ( ACCOUNT_PAYEE_ACTION_NAMES, ACTION_STATUSES, BANK_DETAILS_REGISTRATION_STATE_MESSAGE, FINAL_ACTION_STATUSES, PAYEE_ACTION_NAMES, PAYEE_BANKING_DETAILS_PARTS, ) from payee.constants.error import ( ERROR_NO_BANKING_DETAILS_REVIEW, ERROR_PAYEE_VERIFICATION_IN_PROGRESS, ) from payee.constants.features import ( is_tap_awal_payment_eligibility_auto_approve_enabled, ) from payee.logic.exceptions import LogicError from payee.logic.payoneer import ( get_payee_details, register_payee, release_payee, update_payout_methods, update_profile, ) from payee.logic.secure_document import ( get_audit_fields, has_secure_document_details, purge_secure_document_details, save_secure_document_details, ) from payee.models.account_payee import AccountPayee from payee.models.bank_details import BankDetailsDocument from payee.models.payee import Payee from payee.utils.bank_details import BankDetailsDataHelper from payee.utils.exception import RegisterWhitelabelProfileException from payee.utils.payment_entities import is_awal_account logger = logging.getLogger('bank_details') def sync_bank_details(payee: Payee | AccountPayee, **params): """Sync payee details with Payoneer.""" payee_type = 'payee' if isinstance(payee, Payee) else 'account_payee' affected_parts = get_data_change_parts(payee.payoneer_client_reference_id, params) states = get_payee_states(payee.payee_entity_id, payee_type) manage_payee( payee, affected_parts, params, states, ) secure_document = save_bank_details_info(payee.payoneer_client_reference_id, params) set_states(payee.payee_entity_id, states, affected_parts, payee_type) message = BankDetailsDocument.build_response( payee.payoneer_client_reference_id, secure_document ) return message def get_data_change_parts(account_payee_id: int, new_data: dict) -> typing.Set[str]: """ Get data changes states between saved and new data. Returns set of changed information pars. We have to compare over our Dynamo db data, because Payoneer itself does not return all the needed fields in the details response, e.g. missed date_of_birth. """ secure_details = has_secure_document_details(account_payee_id, BankDetailsDocument) if not secure_details: return set(PAYEE_BANKING_DETAILS_PARTS) states = set() existing_data = BankDetailsDocument.build_response(account_payee_id, secure_details) if BankDetailsDataHelper.build_payee_identity( existing_data ) != BankDetailsDataHelper.build_payee_identity(new_data, True): states.add(PAYEE_BANKING_DETAILS_PARTS.PERSONAL_INFORMATION) if BankDetailsDataHelper.build_payee_address( existing_data.get('address') or {}, exclude_country_code=True ) != BankDetailsDataHelper.build_payee_address(new_data, exclude_country_code=True): states.add(PAYEE_BANKING_DETAILS_PARTS.ADDRESS_INFORMATION) new_email = BankDetailsDataHelper.get_email(new_data, True) if ( new_email is not None and BankDetailsDataHelper.get_email(existing_data) != new_email ): states.add(PAYEE_BANKING_DETAILS_PARTS.ADDRESS_INFORMATION) new_phone = ( BankDetailsDataHelper.get_phone_country(new_data, True), BankDetailsDataHelper.get_phone(new_data, True), ) old_phone = ( BankDetailsDataHelper.get_phone_country(existing_data), BankDetailsDataHelper.get_phone(existing_data), ) if new_phone != (None, None) and old_phone != new_phone: states.add(PAYEE_BANKING_DETAILS_PARTS.ADDRESS_INFORMATION) if existing_data.get('payout_method') != BankDetailsDataHelper.build_payout_method( new_data ): states.add(PAYEE_BANKING_DETAILS_PARTS.BANKING_INFORMATION) if BankDetailsDataHelper.get_account_holder_name( existing_data ) != BankDetailsDataHelper.get_account_holder_name(new_data, True): states.add(PAYEE_BANKING_DETAILS_PARTS.ACCOUNT_HOLDER_NAME) if BankDetailsDataHelper.get_country_code( existing_data ) != BankDetailsDataHelper.get_country_code(new_data, True): states.add(PAYEE_BANKING_DETAILS_PARTS.COUNTRY_CODE) return states def _is_account_payee(payee: Payee | AccountPayee): """Wrapper function for payee instance type checking. Makes mocking easier.""" return isinstance(payee, AccountPayee) def manage_payee( payee: Payee | AccountPayee, affected_parts: set, params: dict, states: dict, ): """ Manages payee registration, updates, and banking information based on account status and affected states. """ payoneer_client_reference_id = payee.payoneer_client_reference_id payoneer_program_id = payee.payoneer_program_id payee_exists = get_payee_details(payoneer_program_id, payoneer_client_reference_id) if not payee_exists: register_payee(payee, params) return banking_status = states.get(ACCOUNT_PAYEE_ACTION_NAMES.BANKING_DETAILS_REVIEW) if ( PAYEE_BANKING_DETAILS_PARTS.PERSONAL_INFORMATION in affected_parts or PAYEE_BANKING_DETAILS_PARTS.COUNTRY_CODE in affected_parts or ( banking_status and banking_status['action_status'] == ACTION_STATUSES.REJECTED ) ): if _is_account_payee(payee): validate_banking_details_review_status(banking_status) release_payee(payoneer_program_id, payoneer_client_reference_id) register_payee(payee, params) return if PAYEE_BANKING_DETAILS_PARTS.ADDRESS_INFORMATION in affected_parts: if _is_account_payee(payee): validate_banking_details_review_status(banking_status) update_profile(payoneer_program_id, payoneer_client_reference_id, params) if PAYEE_BANKING_DETAILS_PARTS.BANKING_INFORMATION in affected_parts: update_payout_methods(payoneer_program_id, payoneer_client_reference_id, params) def validate_banking_details_review_status( banking_status: dict[str, typing.Any] | None, ): """Validates banking_details_review is finished.""" if banking_status is None: raise LogicError(ERROR_NO_BANKING_DETAILS_REVIEW) if banking_status['action_status'] not in FINAL_ACTION_STATUSES: raise LogicError(ERROR_PAYEE_VERIFICATION_IN_PROGRESS) def save_payee_bank_details(**params): """Save payee bank details.""" account_payee_id = params.get('account_payee_id') bank_details_exist = has_secure_document_details( account_payee_id, BankDetailsDocument ) if bank_details_exist: raise LogicError('Payee bank details already exists') secure_document = save_bank_details_info(account_payee_id, params) message = BankDetailsDocument.build_response(account_payee_id, secure_document) return message def save_bank_details_info( payoneer_client_reference_id: int | str, params: dict ) -> SecureDocument: """ Saves payee's bank details data into storage """ try: secure_document = save_secure_document_details( payoneer_client_reference_id, current_app.config.get('SDM_CONFIG'), BankDetailsDocument, **get_audit_fields(), **params, ) except Exception as exc: logger.error(f'Error when saving payee: {str(exc)}') raise LogicError(str(exc)) return secure_document def purge_bank_details_info(account_payee_id: int) -> None: """Purge payee's bank details data from storage.""" purge_secure_document_details(account_payee_id, BankDetailsDocument) def _set_account_payee_states( payee_entity_id: int, states: dict[str, dict], affected_parts: set, ): """Set abacus actions states depending on changed data parts.""" review_state = states.get(ACCOUNT_PAYEE_ACTION_NAMES.BANKING_DETAILS_REVIEW) if not review_state: create_payee_states( payee_entity_id, 'account_payee', [ACCOUNT_PAYEE_ACTION_NAMES.BANKING_DETAILS_REVIEW], ) elif ( PAYEE_BANKING_DETAILS_PARTS.PERSONAL_INFORMATION in affected_parts or PAYEE_BANKING_DETAILS_PARTS.COUNTRY_CODE in affected_parts or review_state['action_status'] == ACTION_STATUSES.REJECTED ): update_state( review_state['abacus_state_id'], ACTION_STATUSES.INIT, BANK_DETAILS_REGISTRATION_STATE_MESSAGE, ) eligibility_state = states.get(ACCOUNT_PAYEE_ACTION_NAMES.PAYMENT_ELIGIBILITY) if not eligibility_state: raise LogicError('Inconsistent payee state') # If the personal information is changed, then we reset state to init # as the KYC process is being triggered and both states should be in init elif ( PAYEE_BANKING_DETAILS_PARTS.PERSONAL_INFORMATION in affected_parts or PAYEE_BANKING_DETAILS_PARTS.COUNTRY_CODE in affected_parts or (review_state and review_state['action_status'] == ACTION_STATUSES.REJECTED) ): update_state( eligibility_state['abacus_state_id'], ACTION_STATUSES.INIT, BANK_DETAILS_REGISTRATION_STATE_MESSAGE, ) # But if the change is the account holder name, so only the banking details # were changed, then we should trigger only internal review starting from # running state. # Or in case the eligibility was rejected before we have to restart # the internal review as well. elif ( PAYEE_BANKING_DETAILS_PARTS.ACCOUNT_HOLDER_NAME in affected_parts or eligibility_state['action_status'] == ACTION_STATUSES.REJECTED ) and (review_state and review_state['action_status'] == ACTION_STATUSES.APPROVED): # AWAL payees skip internal review, so re-approve immediately instead of # restarting it - a name-only change does not re-trigger KYC. if ( is_tap_awal_payment_eligibility_auto_approve_enabled() and (account_payee := AccountPayee.get_payee_by_id(payee_entity_id)) and is_awal_account(account_payee.account_id) ): if eligibility_state['action_status'] != ACTION_STATUSES.APPROVED: update_state( eligibility_state['abacus_state_id'], ACTION_STATUSES.APPROVED, BANK_DETAILS_REGISTRATION_STATE_MESSAGE, ) else: update_state( eligibility_state['abacus_state_id'], ACTION_STATUSES.RUNNING, BANK_DETAILS_REGISTRATION_STATE_MESSAGE, ) def _set_payee_states( states: dict[str, dict], affected_parts: set, ): """Set abacus actions states depending on changed data parts.""" banking_state = states.get(PAYEE_ACTION_NAMES.BANKING_ELIGIBILITY) if not banking_state: raise LogicError('Inconsistent payee state') elif ( PAYEE_BANKING_DETAILS_PARTS.PERSONAL_INFORMATION in affected_parts or PAYEE_BANKING_DETAILS_PARTS.COUNTRY_CODE in affected_parts ): update_state( banking_state['abacus_state_id'], ACTION_STATUSES.RUNNING, BANK_DETAILS_REGISTRATION_STATE_MESSAGE, ) def set_states( payee_entity_id: int, states: dict[str, dict], affected_parts: set, payee_type: Literal['payee', 'account_payee'], ): """Set abacus actions states depending on changed data parts.""" if payee_type == 'account_payee': _set_account_payee_states( payee_entity_id, states, affected_parts, ) elif payee_type == 'payee': _set_payee_states( states, affected_parts, ) def reset_banking_states(account_payee_id: int) -> None: """Reset banking_details_review and payment_eligibility states to INIT.""" states = get_payee_states(account_payee_id) review_state = states.get(ACCOUNT_PAYEE_ACTION_NAMES.BANKING_DETAILS_REVIEW) if review_state: update_state( review_state['abacus_state_id'], ACTION_STATUSES.INIT, BANK_DETAILS_REGISTRATION_STATE_MESSAGE, ) eligibility_state = states.get(ACCOUNT_PAYEE_ACTION_NAMES.PAYMENT_ELIGIBILITY) if eligibility_state: update_state( eligibility_state['abacus_state_id'], ACTION_STATUSES.INIT, BANK_DETAILS_REGISTRATION_STATE_MESSAGE, ) def register_whitelabel_profile(account_payee_id: int) -> None: """Register payee details with Payoneer.""" account_payee = AccountPayee.get_by_id_or_error(account_payee_id) bank_details_state = get_payee_states(account_payee_id).get( ACCOUNT_PAYEE_ACTION_NAMES.BANKING_DETAILS_REVIEW ) if bank_details_state: raise RegisterWhitelabelProfileException( f'{ACCOUNT_PAYEE_ACTION_NAMES.BANKING_DETAILS_REVIEW} status exists' ) bank_details = has_secure_document_details(account_payee_id, BankDetailsDocument) if not bank_details: raise RegisterWhitelabelProfileException('Bank details not found') register_payee( account_payee, bank_details.original_values | {'account_payee_id': account_payee_id}, ) create_payee_states( account_payee_id, 'account_payee', [ACCOUNT_PAYEE_ACTION_NAMES.BANKING_DETAILS_REVIEW], )