"""Logic for Payoneer integration. Provides logic to register new users on Payoneer. """ import datetime from hashlib import sha512 from http import HTTPStatus import json import logging import os import re import typing from typing import Optional from flask import current_app, g, request from owsrequest import flask_request from owsresponse import response from payee.config import Config, secrets_manager_client from payee.connectors.graphql import graphql_client from payee.constants.constants import ( ORCHARD_ROLES, PAYMENT_ENTITIES, PAYONEER_ADOPT_PAYEE_ENDPOINT, PAYONEER_CLIENT_ID, PAYONEER_CLIENT_NAME, PAYONEER_CLIENT_PASSWORD, PAYONEER_COUNTRY_MAPPING, PAYONEER_COUNTRY_REVERSE_MAPPING, PAYONEER_EDIT_PROFILE_ENDPOINT, PAYONEER_GET_ACCOUNT_ID_ENDPOINT, PAYONEER_GET_PAYEE_DETAILS_ENDPOINT, PAYONEER_GET_PAYEE_REGISTER_FORMAT_ENDPOINT, PAYONEER_GET_PAYEE_STATUS_ENDPOINT, PAYONEER_MASS_PAYOUTS_ENDPOINT, PAYONEER_PAYOUT_METHODS_ENDPOINT, PAYONEER_REGISTER_PAYEE_ENDPOINT, PAYONEER_REGISTER_PAYOUT_METHOD_ENDPOINT, PAYONEER_RELEASE_PAYEE_ENDPOINT, PAYONEER_SERVICE_STATUSES, PAYONEER_STATUS_CODE, PAYONEER_STATUS_ENDPOINT, PREEXISTING_PAYONEER_ACCOUNT_PROGRAM_IDS, ) from payee.constants.error import ( ERROR_ACCOUNT_ID_NOT_FOUND, ERROR_COMPOSE_PAYONEER_REQUEST, ERROR_PAYEE_DETAILS_NOT_FOUND, ERROR_PAYONEER_CREDS_NOT_FOUND, ) from payee.constants.queries import ( COMPANY_BRAND_BY_VENDOR_ID, CORP_PAYMENT_ENTITY_NAME_BY_ACCOUNT_ID, PROGRAM_ID_BY_ACCOUNT_PAYEE_ID, ) from payee.constants.url import HTTP, HTTPS from payee.logic.account_payee import get_account_payees_by_ids from payee.logic.exceptions import LogicError from payee.logic.graphql import get_payment_entity_name_by_account_id from payee.models.account_payee import AccountPayee from payee.models.payee import Payee from payee.utils.backoff_requests import delete, get, post, put from payee.utils.exception import ( BrandFetchException, CorpEntityFetchException, CreatePayoneerRegistrationLinkException, GetPayeeDetailsException, GetRegisterPayeeFormatException, HttpMassPayoutsException, PayoneerMassPayoutsException, PayoneerPayeeDetailsException, RegisterPayeeException, ReleasePayeeException, UpdatePayeeException, ) from payee.utils.payoneer import PayoneerAuthHelper, PayoneerDataHelper from payee.utils.validations import get_account_id_by_account_payee_id logger = logging.getLogger('payoneer') def create_registration_link(create_params: dict) -> dict: """Create a Payoneer registration link. Allowed only for preexisting Payoneer accounts. """ payoneer_headers = _build_payoneer_headers() if not payoneer_headers: return {} if not create_params.get('payoneer_session_id'): # Generate session id. It has a maximum length of 50 chars create_params['payoneer_session_id'] = generate_payee_payoneer_session_id( create_params.get('account_payee_id') ) endpoint = PAYONEER_REGISTER_PAYEE_ENDPOINT.format( program_id=create_params['payoneer_program_id'] ) request_body = _format_create_registration_link_request(create_params) payoneer_response = post( f'{Config.PAYONEER_API_URL}{endpoint}', json=request_body, headers=payoneer_headers, ).json() return _format_payoneer_response(payoneer_response, create_params) def create_mass_payouts(program_id: int, payments: list): """Create Mass Payouts in Payoneer.""" if not (payoneer_headers := _build_payoneer_headers()): raise PayoneerMassPayoutsException('Payoneer credentials not found') request_body = _format_create_mass_payouts_request(payments) mass_payouts_uri = PAYONEER_MASS_PAYOUTS_ENDPOINT.format(program_id=program_id) try: payoneer_result = post( f'{Config.PAYONEER_API_URL}{mass_payouts_uri}', json=request_body, headers=payoneer_headers, ).json() except Exception as exc: logger.error(f'Error creating mass payouts: {str(exc)}') raise HttpMassPayoutsException(str(exc)) if error := payoneer_result.get('error'): logger.error(f'Error creating mass payouts: {str(payoneer_result)}') raise PayoneerMassPayoutsException(str(error)) return response.Response(message=payoneer_result, status=201) def register_payee(payee: Payee | AccountPayee, params: dict): """Register new Payee in Payoneer.""" register_payee_uri = PAYONEER_REGISTER_PAYOUT_METHOD_ENDPOINT.format( program_id=payee.payoneer_program_id ) payoneer_result = post( f'{Config.PAYONEER_API_URL}{register_payee_uri}', json=PayoneerDataHelper.format_register_payee_request( payee.payoneer_client_reference_id, params ), headers=PayoneerAuthHelper.build_headers(), ).json() RegisterPayeeException.raise_if_error_response(payoneer_result) return payoneer_result def release_payee(program_id: int, payoneer_client_reference_id: int | str): """Release Payee from Payoneer.""" release_payee_uri = PAYONEER_RELEASE_PAYEE_ENDPOINT.format( payee_id=payoneer_client_reference_id, program_id=program_id ) payoneer_result = delete( f'{Config.PAYONEER_API_URL}{release_payee_uri}', headers=PayoneerAuthHelper.build_headers(), ).json() ReleasePayeeException.raise_if_error_response(payoneer_result) return payoneer_result def update_payout_methods(program_id: int, account_payee_id: int, params: dict): """Update Payee payout methods in Payoneer""" payout_methods_uri = PAYONEER_PAYOUT_METHODS_ENDPOINT.format( program_id=program_id, payee_id=account_payee_id ) payoneer_result = post( f'{Config.PAYONEER_API_URL}{payout_methods_uri}', json=PayoneerDataHelper.format_update_payee_request(params), headers=PayoneerAuthHelper.build_headers(), ).json() UpdatePayeeException.raise_if_error_response(payoneer_result) return payoneer_result def update_profile(program_id: int, account_payee_id: int, params: dict): """Update Payee profile in Payoneer""" edit_profile_uri = PAYONEER_EDIT_PROFILE_ENDPOINT.format( program_id=program_id, payee_id=account_payee_id ) payoneer_result = put( f'{Config.PAYONEER_API_URL}{edit_profile_uri}', json=PayoneerDataHelper.format_edit_profile_request(params), headers=PayoneerAuthHelper.build_headers(), ).json() UpdatePayeeException.raise_if_error_response(payoneer_result) return payoneer_result def get_payee_details( program_id: int, payoneer_client_reference_id: int | str ) -> typing.Optional[dict]: """ Get payee details from Payoneer. More efficient version of get_payoneer_payee_details that is considered as deprecated. Returns either details data or None if there is no payee exists. """ account_uri = PAYONEER_GET_PAYEE_DETAILS_ENDPOINT.format( program_id=program_id, payee_id=payoneer_client_reference_id ) payoneer_response = get( f'{Config.PAYONEER_API_URL}{account_uri}', headers=PayoneerAuthHelper.build_headers(), ) # 404 is a legitimate case and we return None if payoneer_response.status_code == HTTPStatus.NOT_FOUND: return None payoneer_result = payoneer_response.json() GetPayeeDetailsException.raise_if_error_response(payoneer_result) return payoneer_result['result'] def payoneer_release_payee( payoneer_client_reference_id: int | str, payoneer_program_id: int | None = None ): """Release payee from payoneer. Args: payoneer_client_reference_id (int|str): payoneer client reference id payoneer_program_id (int or None): Payoneer program ID Returns: result (dict): payoneer response """ if not payoneer_program_id: payoneer_program_id = _get_account_payee_payoneer_program_id( payoneer_client_reference_id ) if not payoneer_program_id: return response.Response( message={ 'error': ERROR_PAYEE_DETAILS_NOT_FOUND.format( id=payoneer_client_reference_id ) }, status=404, ) try: release_payee(int(payoneer_program_id), payoneer_client_reference_id) except ReleasePayeeException as ex: return response.create_error_response( message=ex.message, status=400, code='error' ) return response.Response( message={ # TODO: Remove account_payee_id field once graphql # uses payoneer_client_reference_id 'account_payee_id': payoneer_client_reference_id, 'payoneer_client_reference_id': str(payoneer_client_reference_id), }, status=200, ) def _format_create_registration_link_request(params: dict): """Format request data to create a Payoneer registration link.""" account_payee_id = params.get('account_payee_id') redirect_url = build_payoneer_redirect_uri(account_payee_id) if ( params.get('payoneer_program_id') not in PREEXISTING_PAYONEER_ACCOUNT_PROGRAM_IDS ): raise LogicError('Forbidden for selected Payoneer program') return { 'payee_id': account_payee_id, 'client_session_id': params.get('payoneer_session_id'), 'redirect_url': redirect_url, 'redirect_time': current_app.config.get('PAYONEER_REDIRECT_TIME'), # we are defaulting this to True as there is only one case with # preexisting Payoneer account exists for now 'already_have_an_account': True, } def _format_create_mass_payouts_request(payout_list: list): """Format request data to create Payoneer mass payouts. Args: payout_list: List of payout dictionaries Returns: Formatted request body for Payoneer mass payouts API """ payments = [] account_payee_ids = [ int(payout['account_payee_id']) for payout in payout_list or [] if payout.get('account_payee_id') is not None ] account_payees_map = get_account_payees_by_ids(account_payee_ids) for payout in payout_list or []: account_payee_id = payout.get('account_payee_id') description = payout.get('description') or '' if account_payee_id is not None: account_payee_id_int = int(account_payee_id) resolved_payee_id = str(account_payee_id_int) account_payee = account_payees_map.get(account_payee_id_int) if account_payee and account_payee.payment_description: description = account_payee.payment_description else: resolved_payee_id = str(payout.get('payee_id')) payments.append( { 'client_reference_id': payout.get('client_reference_id'), 'payee_id': resolved_payee_id, 'description': _sanitize_description(description), 'currency': payout.get('currency'), 'amount': payout.get('amount'), } ) return {'Payments': payments} def _sanitize_description(description: str) -> str: """Sanitize description by removing unsupported characters and truncating to 250 chars.""" pattern = re.compile(r'[^a-zA-Z0-9\s()/,.\-@%!_:$"\'+>&]+') sanitized_description = pattern.sub('', description)[:250] if len(sanitized_description) == 0: raise ValueError( 'Description is empty after sanitization, original description: %s' % description ) return sanitized_description def _format_payoneer_response(payoneer_response: dict, create_params: dict): """Format payoneer response.""" if not payoneer_response: raise CreatePayoneerRegistrationLinkException('Empty response from Payoneer') CreatePayoneerRegistrationLinkException.raise_if_error_response(payoneer_response) formatted_response = { 'registration_link': _http_to_https( payoneer_response['result'].get('registration_link') ), 'account_payee_id': create_params['account_payee_id'], } return formatted_response def _build_payoneer_headers() -> dict: """Build Payoneer headers. This logic can't be in the config because that logic only runs when the micro-service is deployed. Since the token expires, we need to retrive the latest token from secrets manager every time we need to use it in the headers. """ # The Payoneer auth token expires # We need to retrieve it from secrets manager as it handles the rotation payoneer_auth_token = os.environ.get('PAYONEER_AUTH_TOKEN') if Config.ENVIRONMENT in [ Config.QA_ENVIRONMENT, Config.UAT_ENVIRONMENT, Config.PROD_ENVIRONMENT, ]: payoneer_auth_token = _get_payoneer_auth_token() payoneer_headers = {} # Ignore staged dummy credentials if payoneer_auth_token and len(payoneer_auth_token) > 20: payoneer_headers = { 'content-type': 'application/json', 'Authorization': f'Bearer {payoneer_auth_token}', } return payoneer_headers def _build_payoneer_wform_request_body(params: dict) -> Optional[dict]: """Build Payoneer Response for Wform information request. We expect that PAYONEER_WFORM_CLIENT_CREDS will be valid stringified json object with credentials by program_id e.g.: '{"PROGRAM_ID_1": {"client_name": "CLIENT_NAME","client_password":"CLIENT_PASSWORD", 'client_id': "CLIENT_ID"},"PROGRAM_ID_2": {"client_name": "CLIENT_NAME", "client_password":"CLIENT_PASSWORD","client_id":"CLIENT_ID"}}' """ account_payee_id = params['PartnerPayeeID'] payoneer_program_id = _get_account_payee_payoneer_program_id(account_payee_id) if not payoneer_program_id: return None # Check the Payee exists is set up in Payoneer payoneer_response = get_payoneer_payee_details(account_payee_id) if payoneer_response.status != 200: return None payoneer_creds = json.loads(Config.PAYONEER_WFORM_CLIENT_CREDS) if not payoneer_creds or payoneer_program_id not in payoneer_creds: raise Exception( ERROR_PAYONEER_CREDS_NOT_FOUND.format(program_id=payoneer_program_id) ) client_creds = payoneer_creds[payoneer_program_id] if client_creds is None: return None body = { 'p1': client_creds[PAYONEER_CLIENT_NAME], 'p2': client_creds[PAYONEER_CLIENT_PASSWORD], 'p3': client_creds[PAYONEER_CLIENT_ID], 'FromDate': '01/01/2021', 'ToDate': datetime.datetime.now().strftime('%m/%d/%Y'), **params, } return body def _get_payoneer_auth_token() -> str: """Return latest payoneer auth token form secrets manager.""" return secrets_manager_client.get_secret_value( SecretId=Config.PAYONEER_AUTH_TOKEN_ARN, VersionStage='AWSCURRENT' )['SecretString'] def _http_to_https(url: str) -> str: """Convert http URLs to https.""" if url and url[: len(HTTP)].lower() == HTTP: return HTTPS + url[len(HTTP) :] return url def get_document_item_fields(item: dict, fields: list = None): """Retrieve document item with specified fields in a list. Args: item (dict): item fields (list): list of fields we need to output Return: output (dict): dict of needed fields """ if fields is None: fields = ['account_payee_id', 'refresh_token_expires_at'] output = {} for field in fields: if field in item: output[field] = item.get(field) return output def build_payoneer_redirect_uri(account_payee_id) -> str: """Build payoneer redirect_uri based on account brand name. Args: account_payee_id (int): account payee id Returns: redirect_url (str): payoneer redirect_uri """ headers = flask_request.get_ows_headers() headers[ORCHARD_ROLES] = str(request.headers.get(ORCHARD_ROLES)) account_id = get_account_id_by_account_payee_id( account_payee_id=account_payee_id, headers=headers ) if not account_id: raise BrandFetchException( ERROR_ACCOUNT_ID_NOT_FOUND.format(account_payee_id=account_payee_id) ) brand = _get_account_brand(account_id) redirect_url = Config.PAYONEER_REDIRECT_URL[brand['brand']] return redirect_url def _get_account_brand(account_id: int) -> dict: """Get account brand name. Get brand by account_id. If account_id doesn't exist, get account_id by account_payee_id first Args: account_id (int): vendor id for getting company brand Returns: brand (dict): account brand name or error instead """ # Note: Brand is an internal field, so we cannot forward the client headers # and should use the service-specific headers to make this request instead. graphql_client.set_headers(Config.SERVICE_PROFILE_HEADERS) data = graphql_client.execute(COMPANY_BRAND_BY_VENDOR_ID, {'vendorId': account_id}) brand = data['data']['orchardLabel']['companyBrand']['name'].upper() data = {'brand': brand} return data def _get_account_payee_payoneer_program_id(account_payee_id: int) -> Optional[str]: """Get payee payoneer program_id.""" # Note: program_id is an internal field, so we cannot forward the client headers # and should use the service-specific headers to make this request instead. graphql_client.set_headers(Config.SERVICE_PROFILE_HEADERS) data = graphql_client.execute( PROGRAM_ID_BY_ACCOUNT_PAYEE_ID, {'accountPayeeId': account_payee_id} ) account_payee = data['data']['abacusAccountPayee'] if account_payee is None: return None payoneer_program = account_payee.get('payoneerProgram') if payoneer_program is None: return None payoneer_program_id = payoneer_program.get('payoneerProgramId') return str(payoneer_program_id) if payoneer_program_id else None def get_payee_corp_entity(account_payee_id: int): """Get payee corp entity. Args: account_payee_id (int): payee id Return: corp_entity (str) """ headers = flask_request.get_ows_headers() headers[ORCHARD_ROLES] = str(request.headers.get(ORCHARD_ROLES)) account_id = get_account_id_by_account_payee_id( account_payee_id=account_payee_id, headers=headers ) if not account_id: raise CorpEntityFetchException( ERROR_ACCOUNT_ID_NOT_FOUND.format(account_payee_id=account_payee_id) ) payment_entity_name = get_payment_entity_name_by_account_id(account_id) if not payment_entity_name or payment_entity_name not in PAYMENT_ENTITIES: return '' corp_entity = payment_entity_name return corp_entity def get_payoneer_account( payoneer_client_reference_id: int | str, payoneer_program_id: int | None = None ) -> response.Response: """Get account from payoneer by payee_id.""" payoneer_headers = _build_payoneer_headers() if not payoneer_headers: return response.create_error_response( message=ERROR_COMPOSE_PAYONEER_REQUEST, status=400, code='error' ) if not payoneer_program_id: payoneer_program_id = _get_account_payee_payoneer_program_id( payoneer_client_reference_id ) if not payoneer_program_id: return response.Response( message={ 'error': ERROR_PAYEE_DETAILS_NOT_FOUND.format( id=payoneer_client_reference_id ) }, status=404, ) account_uri = PAYONEER_GET_ACCOUNT_ID_ENDPOINT.format( program_id=payoneer_program_id, payee_id=payoneer_client_reference_id, ) try: payoneer_response = get( f'{Config.PAYONEER_API_URL}{account_uri}', headers=payoneer_headers ) payoneer_result = payoneer_response.json() except Exception as exc: raise PayoneerPayeeDetailsException(str(exc)) g.log.info( f'Payoneer account status response for payee:{payoneer_client_reference_id}.' f'Response code:{payoneer_response.status_code}' ) errors_response = _handle_payoneer_error_response(payoneer_response) if errors_response is not None: return errors_response return response.Response(message=payoneer_result) def get_payoneer_payee_details(account_payee_id: int) -> response.Response: """ Get payoneer payee details. Deprecated. Use more efficient get_payee_details instead. """ payoneer_headers = _build_payoneer_headers() if not payoneer_headers: return response.create_error_response( message=ERROR_COMPOSE_PAYONEER_REQUEST, status=400, code='error' ) payoneer_program_id = _get_account_payee_payoneer_program_id(account_payee_id) account_uri = PAYONEER_GET_PAYEE_DETAILS_ENDPOINT.format( program_id=payoneer_program_id, payee_id=account_payee_id ) try: payoneer_response = get( f'{Config.PAYONEER_API_URL}{account_uri}', headers=payoneer_headers ) payoneer_result = payoneer_response.json() except Exception as exc: raise PayoneerPayeeDetailsException(str(exc)) errors_response = _handle_payoneer_error_response(payoneer_response) if errors_response is not None: return errors_response payoneer_result = payoneer_result['result'] return response.Response(message=payoneer_result) def get_payoneer_payee_name( payoneer_payee_details: dict, name_format: str = '{first_name} {last_name}' ): """Get payoneer payee name in specified format. Args: payoneer_payee_details (dict): Payoneer account details in format: { "account_id": "account_id", "type": "INDIVIDUAL", "contact": { "first_name": "John", "last_name": "Doe", "email": "testaccountholder@mailinator.com", "mobile": "", "phone": "1412341234" }, } name_format (str): output format string Return: name (string) """ first_name = payoneer_payee_details['contact']['first_name'] last_name = payoneer_payee_details['contact']['last_name'] return name_format.format(first_name=first_name, last_name=last_name).strip() def generate_payee_payoneer_session_id(account_payee_id: int) -> str: """Generate deterministic session id. Args: account_payee_id (int): payee id Return: session_id (str): 50 chars payee session_id """ str_to_hash = '{}{}'.format(Config.PAYONEER_SHARED_SECRET, account_payee_id) h = sha512(bytes(str_to_hash, 'utf8')) session_id = h.hexdigest()[:50] return session_id def _handle_payoneer_error_response(payoneer_response): """Format payoneer error response.""" if payoneer_response.status_code != 200: payoneer_json = payoneer_response.json() if payoneer_response.status_code == 404: return response.create_not_found_response( message=str(payoneer_json), ) else: raise PayoneerPayeeDetailsException(str(payoneer_json)) return None def move_payoneer_program( account_payee_id: int, existing_payoneer_program_id: int, new_payoneer_program_id: int, ): """Move account payee to new program.""" payoneer_headers = _build_payoneer_headers() if not payoneer_headers: return response.Response(message={}, status=400) endpoint = PAYONEER_ADOPT_PAYEE_ENDPOINT.format(program_id=new_payoneer_program_id) request_body = _format_adopt_payee_request( existing_payoneer_program_id, account_payee_id ) payoneer_result = post( f'{Config.PAYONEER_API_URL}{endpoint}', json=request_body, headers=payoneer_headers, ) return response.Response( status=payoneer_result.status_code, message=payoneer_result.json() ) def _format_adopt_payee_request( existing_payoneer_program_id: str, account_payee_id: int ): """Format request data to adopt a Payoneer program.""" return { 'partner_id_to_adopt': existing_payoneer_program_id, 'payee_id_to_adopt': account_payee_id, 'calling_partner_payee_id': account_payee_id, } def get_payee_status(account_payee_id: int, payoneer_program_id: int): """Move account payee to new program.""" payoneer_headers = _build_payoneer_headers() if not payoneer_headers: return response.Response(message={}, status=400) endpoint = PAYONEER_GET_PAYEE_STATUS_ENDPOINT.format( program_id=payoneer_program_id, payee_id=account_payee_id ) payoneer_result = get( f'{Config.PAYONEER_API_URL}{endpoint}', headers=payoneer_headers ) return response.Response( status=payoneer_result.status_code, message=payoneer_result.json() ) def get_payee_register_format( payee: Payee | AccountPayee, bank_account_type: str, bank_country: str, bank_currency: str, ) -> dict: """Get payee registration format.""" endpoint = PAYONEER_GET_PAYEE_REGISTER_FORMAT_ENDPOINT.format( program_id=payee.payoneer_program_id, payee_type=bank_account_type, country=PAYONEER_COUNTRY_MAPPING.get(bank_country, bank_country), currency=bank_currency, ) payoneer_result = get( f'{Config.PAYONEER_API_URL}{endpoint}', headers=PayoneerAuthHelper.build_headers(), ) try: payout_method = payoneer_result.json().get('result', {}).get('payout_method') if payout_method: payout_method['country'] = PAYONEER_COUNTRY_REVERSE_MAPPING.get( bank_country, bank_country ) return payout_method except (AttributeError, KeyError): pass raise GetRegisterPayeeFormatException() def get_payoneer_service_status(): """Get Payoneer service status.""" payoneer_operational, payoneer_outage, payoneer_unknown = ( PAYONEER_STATUS_CODE[PAYONEER_SERVICE_STATUSES.OPERATIONAL], PAYONEER_STATUS_CODE[PAYONEER_SERVICE_STATUSES.OUTAGE], PAYONEER_STATUS_CODE[PAYONEER_SERVICE_STATUSES.UNKNOWN], ) try: payoneer_status_response = get( f'{Config.PAYONEER_STATUS_URL}{PAYONEER_STATUS_ENDPOINT}' ).json() indicator = payoneer_status_response.get('status', {}).get('indicator') if not indicator: return payoneer_unknown if indicator == 'none': return payoneer_operational return payoneer_outage except Exception as exc: logger.error(f'Error fetching Payoneer status: {str(exc)}') return payoneer_unknown