"""Functional tests for POST /dp-payment/payoneer-webhook endpoint.""" import http import json from sqlalchemy import select from collaborator.connectors import mysql from collaborator.constants import error from collaborator.constants.features import ( ABACUS_COLLABORATORS_PAYONEER_API_INTEGRATION, ) from collaborator.constants.transaction import TYPE_CREDIT from collaborator.models.rds.dp_payment import ( DpPayment, PayoneerEventType, PayoneerPaymentStatus, ) from collaborator.models.rds.transaction import Transaction from tests.testutils import db, mock_auth _PAYONEER_PAYMENT_ID = "split:1:1" _DP_PAYMENT_ID = 1 _PAYONEER_PAYMENT_ID_ALREADY_LINKED = "split:1:10" _DP_PAYMENT_ID_ALREADY_LINKED = 3 _DP_PAYMENT_ID_ALREADY_LINKED_TRANSACTION_ID = ( 17 # collaborator_transaction_id from seed ) @mysql.db_session def _get_dp_payment_status(dp_payment_id: int, session): return session.execute( select(DpPayment.payoneer_payment_status).where( DpPayment.dp_payment_id == dp_payment_id ) ).scalar() @mysql.db_session def _get_dp_payment_transaction_id(dp_payment_id: int, session): return session.execute( select(DpPayment.collaborator_transaction_id).where( DpPayment.dp_payment_id == dp_payment_id ) ).scalar() def _post_webhook(test_client, client_reference_id, event_type, **extra): body = { "client_reference_id": client_reference_id, "event_type": event_type, **extra, } return test_client.post( "/dp-payment/payoneer-webhook", content_type="application/json", data=json.dumps(body), ) @db.test_schema_default_seed def test_post_payoneer_webhook_payment_accepted( auth_client, mock_features, mock_account, mocker ): """payment_accepted event sets status to RUNNING.""" mock_auth(mocker, mock_account.id) mock_features({ABACUS_COLLABORATORS_PAYONEER_API_INTEGRATION: True}) result = _post_webhook( auth_client, _PAYONEER_PAYMENT_ID, PayoneerEventType.PAYMENT_ACCEPTED ) assert result.status_code == http.HTTPStatus.NO_CONTENT assert _get_dp_payment_status(_DP_PAYMENT_ID) == PayoneerPaymentStatus.RUNNING @db.test_schema_default_seed def test_post_payoneer_webhook_payment_completed( auth_client, mock_features, mock_account, mocker ): """payment_completed event sets status to COMPLETE with amount and currency.""" mock_auth(mocker, mock_account.id) mock_features({ABACUS_COLLABORATORS_PAYONEER_API_INTEGRATION: True}) result = _post_webhook( auth_client, _PAYONEER_PAYMENT_ID, PayoneerEventType.PAYMENT_COMPLETED, amount="1500.00", currency_code="USD", ) assert result.status_code == http.HTTPStatus.NO_CONTENT assert _get_dp_payment_status(_DP_PAYMENT_ID) == PayoneerPaymentStatus.COMPLETE @db.test_schema_default_seed def test_post_payoneer_webhook_payment_cancelled( auth_client, mock_features, mock_account, mocker ): """payment_cancelled event sets status to REJECTED with reason description.""" mock_auth(mocker, mock_account.id) mock_features({ABACUS_COLLABORATORS_PAYONEER_API_INTEGRATION: True}) result = _post_webhook( auth_client, _PAYONEER_PAYMENT_ID, PayoneerEventType.PAYMENT_CANCELLED, reason_description="Payment cancelled by payer", ) assert result.status_code == http.HTTPStatus.NO_CONTENT assert _get_dp_payment_status(_DP_PAYMENT_ID) == PayoneerPaymentStatus.REJECTED @db.test_schema_default_seed def test_post_payoneer_webhook_iach_failed( auth_client, mock_features, mock_account, mocker ): """iach_failed event sets status to ERROR.""" mock_auth(mocker, mock_account.id) mock_features({ABACUS_COLLABORATORS_PAYONEER_API_INTEGRATION: True}) result = _post_webhook( auth_client, _PAYONEER_PAYMENT_ID, PayoneerEventType.IACH_FAILED ) assert result.status_code == http.HTTPStatus.NO_CONTENT assert _get_dp_payment_status(_DP_PAYMENT_ID) == PayoneerPaymentStatus.ERROR @db.test_schema_default_seed def test_post_payoneer_webhook_not_found( auth_client, mock_features, mock_account, mocker ): """Unknown client_reference_id returns 404 not found.""" mock_auth(mocker, mock_account.id) mock_features({ABACUS_COLLABORATORS_PAYONEER_API_INTEGRATION: True}) result = _post_webhook( auth_client, "unknown-reference-id", PayoneerEventType.PAYMENT_ACCEPTED ) assert result.status_code == http.HTTPStatus.NOT_FOUND result_data = json.loads(result.data.decode()) assert result_data["code"] == error.ERROR_CODE_DP_PAYMENT_NOT_FOUND assert result_data["message"] == error.ERROR_MESSAGE_DP_PAYMENT_NOT_FOUND @db.test_schema_default_seed def test_post_payoneer_webhook_payment_accepted_creates_transaction( auth_client, mock_features, mock_account, mocker ): """payment_accepted creates a transaction and links it to the dp_payment.""" mock_auth(mocker, mock_account.id) mock_features({ABACUS_COLLABORATORS_PAYONEER_API_INTEGRATION: True}) result = _post_webhook( auth_client, _PAYONEER_PAYMENT_ID, PayoneerEventType.PAYMENT_ACCEPTED ) assert result.status_code == http.HTTPStatus.NO_CONTENT transaction_id = _get_dp_payment_transaction_id(_DP_PAYMENT_ID) assert transaction_id is not None @db.test_schema_default_seed def test_post_payoneer_webhook_payment_accepted_idempotent( auth_client, mock_features, mock_account, mocker ): """payment_accepted is a no-op if collaborator_transaction_id is already set.""" mock_auth(mocker, mock_account.id) mock_features({ABACUS_COLLABORATORS_PAYONEER_API_INTEGRATION: True}) result = _post_webhook( auth_client, _PAYONEER_PAYMENT_ID_ALREADY_LINKED, PayoneerEventType.PAYMENT_ACCEPTED, ) assert result.status_code == http.HTTPStatus.NO_CONTENT transaction_id = _get_dp_payment_transaction_id(_DP_PAYMENT_ID_ALREADY_LINKED) assert transaction_id == 17 # unchanged from seed @db.test_schema_default_seed def test_post_payoneer_webhook_feature_disabled( auth_client, mock_features, mock_account, mocker ): """payment_accepted event is a no-op when feature flag is disabled.""" mock_auth(mocker, mock_account.id) result = _post_webhook( auth_client, _PAYONEER_PAYMENT_ID, PayoneerEventType.PAYMENT_ACCEPTED ) assert result.status_code == http.HTTPStatus.NO_CONTENT assert _get_dp_payment_status(_DP_PAYMENT_ID) == PayoneerPaymentStatus.COMPLETE assert _get_dp_payment_transaction_id(_DP_PAYMENT_ID) is None @mysql.db_session def _get_credit_transaction_for(original_transaction_id: int, session): return session.execute( select(Transaction).where( Transaction.credited_payment_id == original_transaction_id, Transaction.transaction_type == TYPE_CREDIT, ) ).scalar() @db.test_schema_default_seed def test_post_payoneer_webhook_payment_cancelled_creates_credit_transaction( auth_client, mock_features, mock_account, mocker ): """payment_cancelled creates a TYPE_CREDIT transaction reversing the original payment.""" mock_auth(mocker, mock_account.id) mock_features({ABACUS_COLLABORATORS_PAYONEER_API_INTEGRATION: True}) result = _post_webhook( auth_client, _PAYONEER_PAYMENT_ID_ALREADY_LINKED, PayoneerEventType.PAYMENT_CANCELLED, reason_description="Payment cancelled by payer", ) assert result.status_code == http.HTTPStatus.NO_CONTENT credit = _get_credit_transaction_for(_DP_PAYMENT_ID_ALREADY_LINKED_TRANSACTION_ID) assert credit is not None assert credit.credited_payment_id == _DP_PAYMENT_ID_ALREADY_LINKED_TRANSACTION_ID assert credit.description == "Returned Payment: Direct Payment" @db.test_schema_default_seed def test_post_payoneer_webhook_iach_failed_does_not_create_credit_transaction( auth_client, mock_features, mock_account, mocker ): """iach_failed does not create a credit transaction.""" mock_auth(mocker, mock_account.id) mock_features({ABACUS_COLLABORATORS_PAYONEER_API_INTEGRATION: True}) result = _post_webhook( auth_client, _PAYONEER_PAYMENT_ID_ALREADY_LINKED, PayoneerEventType.IACH_FAILED, ) assert result.status_code == http.HTTPStatus.NO_CONTENT credit = _get_credit_transaction_for(_DP_PAYMENT_ID_ALREADY_LINKED_TRANSACTION_ID) assert credit is None @db.test_schema_default_seed def test_post_payoneer_webhook_payment_cancelled_skips_if_no_transaction( auth_client, mock_features, mock_account, mocker ): """payment_cancelled is a no-op when no direct payment transaction exists yet.""" mock_auth(mocker, mock_account.id) mock_features({ABACUS_COLLABORATORS_PAYONEER_API_INTEGRATION: True}) result = _post_webhook( auth_client, _PAYONEER_PAYMENT_ID, PayoneerEventType.PAYMENT_CANCELLED, reason_description="Payment cancelled by payer", ) assert result.status_code == http.HTTPStatus.NO_CONTENT assert _get_dp_payment_transaction_id(_DP_PAYMENT_ID) is None @db.test_schema_default_seed def test_post_payoneer_webhook_payment_cancelled_idempotent( auth_client, mock_features, mock_account, mocker ): """Calling payment_cancelled twice does not create duplicate credit transactions.""" mock_auth(mocker, mock_account.id) mock_features({ABACUS_COLLABORATORS_PAYONEER_API_INTEGRATION: True}) _post_webhook( auth_client, _PAYONEER_PAYMENT_ID_ALREADY_LINKED, PayoneerEventType.PAYMENT_CANCELLED, reason_description="Payment cancelled by payer", ) result = _post_webhook( auth_client, _PAYONEER_PAYMENT_ID_ALREADY_LINKED, PayoneerEventType.PAYMENT_CANCELLED, reason_description="Payment cancelled by payer", ) assert result.status_code == http.HTTPStatus.NO_CONTENT credit_count = _count_credit_transactions_for( _DP_PAYMENT_ID_ALREADY_LINKED_TRANSACTION_ID ) assert credit_count == 1 @mysql.db_session def _count_credit_transactions_for(original_transaction_id: int, session): return ( session.execute( select(Transaction).where( Transaction.credited_payment_id == original_transaction_id, Transaction.transaction_type == TYPE_CREDIT, ) ) .scalars() .all() .__len__() )