From f7ca1f9f3ecd5dc48631db21788d33394a717581 Mon Sep 17 00:00:00 2001 From: drkjc Date: Thu, 14 Apr 2022 15:34:05 -0400 Subject: [PATCH] Airwallex: Add support for `original_transaction_id` The `original_transaction_id` field allows users to manually override the `network_transaction_id`. This is useful when testing MITs using Stored Credentials on Airwallex because they only allow specific values to be passed which they do not return, and would normally be passed automatically in a standard MIT Stored Credentials flow. This PR also cleans up remote and unit tests for Airwallex stored creds. CE-2560 Unit: 33 tests, 176 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications 100% passed Remote: 27 tests, 64 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications 100% passed --- CHANGELOG | 1 + .../billing/gateways/airwallex.rb | 9 +- test/remote/gateways/remote_airwallex_test.rb | 76 +++++---- test/unit/gateways/airwallex_test.rb | 147 +++++++++++++----- 4 files changed, 165 insertions(+), 68 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index b4b26b67676..4a246324312 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -75,6 +75,7 @@ * Multiple Gateways: Resolve when/case bug [naashton] #4399 * Airwallex: Add 3DS MPI support [drkjc] #4395 * Add Cartes Bancaires card bin ranges [leahriffell] #4398 +* Airwallex: Add support for `original_transaction_id` field [drkjc] #4401 == Version 1.125.0 (January 20, 2022) * Wompi: support gateway [therufs] #4173 diff --git a/lib/active_merchant/billing/gateways/airwallex.rb b/lib/active_merchant/billing/gateways/airwallex.rb index 17e51bfaa2a..7b9155c0cba 100644 --- a/lib/active_merchant/billing/gateways/airwallex.rb +++ b/lib/active_merchant/billing/gateways/airwallex.rb @@ -232,6 +232,7 @@ def add_stored_credential(post, options) return unless stored_credential = options[:stored_credential] external_recurring_data = post[:external_recurring_data] = {} + original_transaction_id = add_original_transaction_id(options) case stored_credential.dig(:reason_type) when 'recurring', 'installment' @@ -240,7 +241,7 @@ def add_stored_credential(post, options) external_recurring_data[:merchant_trigger_reason] = 'unscheduled' end - external_recurring_data[:original_transaction_id] = stored_credential.dig(:network_transaction_id) + external_recurring_data[:original_transaction_id] = original_transaction_id || stored_credential.dig(:network_transaction_id) external_recurring_data[:triggered_by] = stored_credential.dig(:initiator) == 'cardholder' ? 'customer' : 'merchant' end @@ -279,6 +280,12 @@ def three_ds_version_specific_fields(three_d_secure) end end + def add_original_transaction_id(options) + return unless options[:auto_capture] == false || original_transaction_id = options[:original_transaction_id] + + original_transaction_id + end + def authorization_only?(options = {}) options.include?(:auto_capture) && options[:auto_capture] == false end diff --git a/test/remote/gateways/remote_airwallex_test.rb b/test/remote/gateways/remote_airwallex_test.rb index b15c04bb997..4979bcc946d 100644 --- a/test/remote/gateways/remote_airwallex_test.rb +++ b/test/remote/gateways/remote_airwallex_test.rb @@ -10,6 +10,8 @@ def setup @credit_card = credit_card('4111 1111 1111 1111') @declined_card = credit_card('2223 0000 1018 1375') @options = { return_url: 'https://example.com', description: 'a test transaction' } + @stored_credential_cit_options = { initial_transaction: true, initiator: 'cardholder', reason_type: 'recurring', network_transaction_id: nil } + @stored_credential_mit_options = { initial_transaction: false, initiator: 'merchant', reason_type: 'recurring', network_transaction_id: '123456789012345' } end def test_successful_purchase @@ -131,52 +133,62 @@ def test_failed_verify assert_match %r{Invalid card number}, response.message end - def test_successful_cit_transaction_with_recurring_stored_credential - stored_credential_params = { - initial_transaction: true, - reason_type: 'recurring', - initiator: 'cardholder', - network_transaction_id: nil - } + def test_successful_cit_with_recurring_stored_credential + auth = @gateway.authorize(@amount, @credit_card, @options.merge(stored_credential: @stored_credential_cit_options)) + assert_success auth + end - auth = @gateway.authorize(@amount, @credit_card, @options.merge(stored_credential: stored_credential_params)) + def test_successful_mit_with_recurring_stored_credential + auth = @gateway.authorize(@amount, @credit_card, @options.merge(stored_credential: @stored_credential_cit_options)) assert_success auth + + purchase = @gateway.purchase(@amount, @credit_card, @options.merge(stored_credential: @stored_credential_mit_options)) + assert_success purchase end - def test_successful_mit_transaction_with_recurring_stored_credential - stored_credential_params = { - initial_transaction: false, - reason_type: 'recurring', - initiator: 'merchant', - network_transaction_id: 'MCC123ABC0101' - } + def test_successful_mit_with_unscheduled_stored_credential + @stored_credential_cit_options[:reason_type] = 'unscheduled' + @stored_credential_mit_options[:reason_type] = 'unscheduled' - auth = @gateway.authorize(@amount, @credit_card, @options.merge(stored_credential: stored_credential_params)) + auth = @gateway.authorize(@amount, @credit_card, @options.merge(stored_credential: @stored_credential_cit_options)) assert_success auth + + purchase = @gateway.purchase(@amount, @credit_card, @options.merge(stored_credential: @stored_credential_mit_options)) + assert_success purchase end - def test_successful_mit_transaction_with_unscheduled_stored_credential - stored_credential_params = { - initial_transaction: false, - reason_type: 'unscheduled', - initiator: 'merchant', - network_transaction_id: 'MCC123ABC0101' - } + def test_successful_mit_with_installment_stored_credential + @stored_credential_cit_options[:reason_type] = 'installment' + @stored_credential_mit_options[:reason_type] = 'installment' - auth = @gateway.authorize(@amount, @credit_card, @options.merge(stored_credential: stored_credential_params)) + auth = @gateway.authorize(@amount, @credit_card, @options.merge(stored_credential: @stored_credential_cit_options)) assert_success auth + + purchase = @gateway.purchase(@amount, @credit_card, @options.merge(stored_credential: @stored_credential_mit_options)) + assert_success purchase end - def test_successful_mit_transaction_with_installment_stored_credential - stored_credential_params = { - initial_transaction: false, - reason_type: 'installment', - initiator: 'cardholder', - network_transaction_id: 'MCC123ABC0101' - } + def test_successful_mit_with_original_transaction_id + mastercard = credit_card('2223 0000 1018 1375', { brand: 'master' }) + + auth = @gateway.authorize(@amount, mastercard, @options.merge(stored_credential: @stored_credential_cit_options)) + assert_success auth + + @options[:original_transaction_id] = 'MCC123ABC0101' - auth = @gateway.authorize(@amount, @credit_card, @options.merge(stored_credential: stored_credential_params)) + purchase = @gateway.purchase(@amount, mastercard, @options.merge(stored_credential: @stored_credential_mit_options)) + assert_success purchase + end + + def test_failed_mit_with_unapproved_ntid + auth = @gateway.authorize(@amount, @credit_card, @options.merge(stored_credential: @stored_credential_cit_options)) assert_success auth + + @stored_credential_mit_options[:network_transaction_id] = 'abc123' + + purchase = @gateway.purchase(@amount, @credit_card, @options.merge(stored_credential: @stored_credential_mit_options)) + assert_failure purchase + assert_equal 'external_recurring_data.original_transaction_id should be 13-15 characters long', purchase.message end def test_transcript_scrubbing diff --git a/test/unit/gateways/airwallex_test.rb b/test/unit/gateways/airwallex_test.rb index 7952aa6a5ba..6d245ea4fbe 100644 --- a/test/unit/gateways/airwallex_test.rb +++ b/test/unit/gateways/airwallex_test.rb @@ -24,6 +24,9 @@ def setup billing_address: address, return_url: 'https://example.com' } + + @stored_credential_cit_options = { initial_transaction: true, initiator: 'cardholder', reason_type: 'recurring', network_transaction_id: nil } + @stored_credential_mit_options = { initial_transaction: false, initiator: 'merchant', reason_type: 'recurring', network_transaction_id: '123456789012345' } end def test_gateway_has_access_token @@ -301,68 +304,138 @@ def test_invalid_login end def test_successful_cit_with_stored_credential - stored_credential_params = { - initial_transaction: true, - reason_type: 'recurring', - initiator: 'cardholder', - network_transaction_id: nil - } - auth = stub_comms do - @gateway.authorize(@amount, @credit_card, @options.merge({ stored_credential: stored_credential_params })) + @gateway.authorize(@amount, @credit_card, @options.merge!({ stored_credential: @stored_credential_cit_options })) end.check_request do |endpoint, data, _headers| - # This conditional asserts after the initial setup call is made - assert_match(/"external_recurring_data\":{\"merchant_trigger_reason\":\"scheduled\",\"original_transaction_id\":null,\"triggered_by\":\"customer\"}/, data) if endpoint != 'https://api-demo.airwallex.com/api/v1/pa/payment_intents/create' + # This conditional runs assertions after the initial setup call is made + unless endpoint == 'https://api-demo.airwallex.com/api/v1/pa/payment_intents/create' + assert_match(/"external_recurring_data\"/, data) + assert_match(/"merchant_trigger_reason\":\"scheduled\"/, data) + assert_match(/"original_transaction_id\":null,/, data) + assert_match(/"triggered_by\":\"customer\"/, data) + end end.respond_with(successful_authorize_response) assert_success auth end def test_successful_mit_with_recurring_stored_credential - stored_credential_params = { - initial_transaction: false, - reason_type: 'recurring', - initiator: 'merchant', - network_transaction_id: 'MCC123ABC0101' - } - auth = stub_comms do - @gateway.authorize(@amount, @credit_card, @options.merge({ stored_credential: stored_credential_params })) + @gateway.authorize(@amount, @credit_card, @options.merge!({ stored_credential: @stored_credential_cit_options })) end.check_request do |endpoint, data, _headers| - assert_match(/"external_recurring_data\":{\"merchant_trigger_reason\":\"scheduled\",\"original_transaction_id\":\"MCC123ABC0101\",\"triggered_by\":\"merchant\"}/, data) if endpoint != 'https://api-demo.airwallex.com/api/v1/pa/payment_intents/create' + unless endpoint == 'https://api-demo.airwallex.com/api/v1/pa/payment_intents/create' + assert_match(/"external_recurring_data\"/, data) + assert_match(/"merchant_trigger_reason\":\"scheduled\"/, data) + assert_match(/"original_transaction_id\":null,/, data) + assert_match(/"triggered_by\":\"customer\"/, data) + end end.respond_with(successful_authorize_response) assert_success auth + + purchase = stub_comms do + @gateway.purchase(@amount, @credit_card, @options.merge!({ stored_credential: @stored_credential_mit_options })) + end.check_request do |endpoint, data, _headers| + unless endpoint == 'https://api-demo.airwallex.com/api/v1/pa/payment_intents/create' + assert_match(/"external_recurring_data\"/, data) + assert_match(/"merchant_trigger_reason\":\"scheduled\"/, data) + assert_match(/"original_transaction_id\":\"123456789012345\"/, data) + assert_match(/"triggered_by\":\"merchant\"/, data) + end + end.respond_with(successful_purchase_response) + assert_success purchase end def test_successful_mit_with_unscheduled_stored_credential - stored_credential_params = { - initial_transaction: false, - reason_type: 'unscheduled', - initiator: 'merchant', - network_transaction_id: 'MCC123ABC0101' - } + @stored_credential_cit_options[:reason_type] = 'unscheduled' + @stored_credential_mit_options[:reason_type] = 'unscheduled' auth = stub_comms do - @gateway.authorize(@amount, @credit_card, @options.merge({ stored_credential: stored_credential_params })) + @gateway.authorize(@amount, @credit_card, @options.merge!({ stored_credential: @stored_credential_cit_options })) end.check_request do |endpoint, data, _headers| - assert_match(/"external_recurring_data\":{\"merchant_trigger_reason\":\"unscheduled\",\"original_transaction_id\":\"MCC123ABC0101\",\"triggered_by\":\"merchant\"}/, data) if endpoint != 'https://api-demo.airwallex.com/api/v1/pa/payment_intents/create' + unless endpoint == 'https://api-demo.airwallex.com/api/v1/pa/payment_intents/create' + assert_match(/"external_recurring_data\"/, data) + assert_match(/"merchant_trigger_reason\":\"unscheduled\"/, data) + assert_match(/"original_transaction_id\":null,/, data) + assert_match(/"triggered_by\":\"customer\"/, data) + end end.respond_with(successful_authorize_response) assert_success auth + + purchase = stub_comms do + @gateway.purchase(@amount, @credit_card, @options.merge!({ stored_credential: @stored_credential_mit_options })) + end.check_request do |endpoint, data, _headers| + unless endpoint == 'https://api-demo.airwallex.com/api/v1/pa/payment_intents/create' + assert_match(/"external_recurring_data\"/, data) + assert_match(/"merchant_trigger_reason\":\"unscheduled\"/, data) + assert_match(/"original_transaction_id\":\"123456789012345\"/, data) + assert_match(/"triggered_by\":\"merchant\"/, data) + end + end.respond_with(successful_purchase_response) + assert_success purchase end def test_successful_mit_with_installment_stored_credential - stored_credential_params = { - initial_transaction: false, - reason_type: 'installment', - initiator: 'merchant', - network_transaction_id: 'MCC123ABC0101' - } + @stored_credential_cit_options[:reason_type] = 'installment' + @stored_credential_mit_options[:reason_type] = 'installment' + + auth = stub_comms do + @gateway.authorize(@amount, @credit_card, @options.merge!({ stored_credential: @stored_credential_cit_options })) + end.check_request do |endpoint, data, _headers| + unless endpoint == 'https://api-demo.airwallex.com/api/v1/pa/payment_intents/create' + assert_match(/"external_recurring_data\"/, data) + assert_match(/"merchant_trigger_reason\":\"scheduled\"/, data) + assert_match(/"original_transaction_id\":null,/, data) + assert_match(/"triggered_by\":\"customer\"/, data) + end + end.respond_with(successful_authorize_response) + assert_success auth + + purchase = stub_comms do + @gateway.purchase(@amount, @credit_card, @options.merge!({ stored_credential: @stored_credential_mit_options })) + end.check_request do |endpoint, data, _headers| + unless endpoint == 'https://api-demo.airwallex.com/api/v1/pa/payment_intents/create' + assert_match(/"external_recurring_data\"/, data) + assert_match(/"merchant_trigger_reason\":\"scheduled\"/, data) + assert_match(/"original_transaction_id\":\"123456789012345\"/, data) + assert_match(/"triggered_by\":\"merchant\"/, data) + end + end.respond_with(successful_purchase_response) + assert_success purchase + end + + def test_successful_mit_with_original_transaction_id + mastercard = credit_card('2223 0000 1018 1375', { brand: 'master' }) + @options[:original_transaction_id] = 'MCC123ABC0101' auth = stub_comms do - @gateway.authorize(@amount, @credit_card, @options.merge({ stored_credential: stored_credential_params })) + @gateway.authorize(@amount, mastercard, @options.merge!({ stored_credential: @stored_credential_cit_options })) end.check_request do |endpoint, data, _headers| - assert_match(/"external_recurring_data\":{\"merchant_trigger_reason\":\"scheduled\",\"original_transaction_id\":\"MCC123ABC0101\",\"triggered_by\":\"merchant\"}/, data) if endpoint != 'https://api-demo.airwallex.com/api/v1/pa/payment_intents/create' + unless endpoint == 'https://api-demo.airwallex.com/api/v1/pa/payment_intents/create' + assert_match(/"external_recurring_data\"/, data) + assert_match(/"merchant_trigger_reason\":\"scheduled\"/, data) + assert_match(/"original_transaction_id\":null,/, data) + assert_match(/"triggered_by\":\"customer\"/, data) + end end.respond_with(successful_authorize_response) assert_success auth + + purchase = stub_comms do + @gateway.purchase(@amount, mastercard, @options.merge!({ stored_credential: @stored_credential_mit_options })) + end.check_request do |endpoint, data, _headers| + unless endpoint == 'https://api-demo.airwallex.com/api/v1/pa/payment_intents/create' + assert_match(/"external_recurring_data\"/, data) + assert_match(/"merchant_trigger_reason\":\"scheduled\"/, data) + assert_match(/"original_transaction_id\":\"MCC123ABC0101\"/, data) + assert_match(/"triggered_by\":\"merchant\"/, data) + end + end.respond_with(successful_purchase_response) + assert_success purchase + end + + def test_failed_mit_with_unapproved_ntid + @gateway.expects(:ssl_post).returns(failed_ntid_response) + assert_raise ArgumentError do + @gateway.authorize(@amount, @credit_card, @options.merge!({ stored_credential: @stored_credential_cit_options })) + end end def test_scrub @@ -438,4 +511,8 @@ def successful_void_response def failed_void_response %({"code":"not_found","message":"The requested endpoint does not exist [/api/v1/pa/payment_intents/12345/cancel]"}) end + + def failed_ntid_response + %({"code":"validation_error","source":"external_recurring_data.original_transaction_id","message":"external_recurring_data.original_transaction_id should be 13-15 characters long"}) + end end