Skip to content

Commit

Permalink
Stripe PI: Add new stored credential flag
Browse files Browse the repository at this point in the history
Stripe has a field called `stored_credential_transaction_type` to assist
merchants who vault outside of Stripe to recognize card on file transactions
at Stripe. This field does require Stripe enabling your account with this
field.

The standard stored credential fields map to the various possibilities that
Stripe makes available.

Test Summary
Remote:
87 tests, 409 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed
  • Loading branch information
aenand committed Jun 22, 2023
1 parent c2ac257 commit aeaa33c
Show file tree
Hide file tree
Showing 4 changed files with 226 additions and 6 deletions.
1 change: 1 addition & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
= ActiveMerchant CHANGELOG

== HEAD
* Stripe Payment Intents: Add support for new card on file field [aenand] #4807

== Version 1.131.0 (June 21, 2023)
* Redsys: Add supported countries [jcreiff] #4811
Expand Down
58 changes: 52 additions & 6 deletions lib/active_merchant/billing/gateways/stripe_payment_intents.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ def create_intent(money, payment_method, options = {})
add_connected_account(post, options)
add_radar_data(post, options)
add_shipping_address(post, options)
add_stored_credentials(post, options)
setup_future_usage(post, options)
add_exemption(post, options)
add_stored_credentials(post, options)
add_ntid(post, options)
add_claim_without_transaction_id(post, options)
add_error_on_requires_action(post, options)
Expand Down Expand Up @@ -399,17 +399,19 @@ def add_exemption(post, options = {})
post[:payment_method_options][:card][:moto] = true if options[:moto]
end

# Stripe Payment Intents does not pass any parameters for cardholder/merchant initiated
# it also does not support installments for any country other than Mexico (reason for this is unknown)
# The only thing that Stripe PI requires for stored credentials to work currently is the network_transaction_id
# network_transaction_id is created when the card is authenticated using the field `setup_for_future_usage` with the value `off_session` see def setup_future_usage below
# Stripe Payment Intents now supports specifying on a transaction level basis stored credential information.
# The feature is currently gated but is listed as `stored_credential_transaction_type` inside the
# `post[:payment_method_options][:card]` hash. Since this is a beta field adding an extra check to use
# the existing logic by default. To be able to utilize this field, you must reach out to Stripe.

def add_stored_credentials(post, options = {})
return unless options[:stored_credential] && !options[:stored_credential].values.all?(&:nil?)

stored_credential = options[:stored_credential]
post[:payment_method_options] ||= {}
post[:payment_method_options][:card] ||= {}
add_stored_credential_transaction_type(post, options) if options[:stored_credential_transaction_type]

stored_credential = options[:stored_credential]
post[:payment_method_options][:card][:mit_exemption] = {}

# Stripe PI accepts network_transaction_id and ds_transaction_id via mit field under card.
Expand All @@ -419,6 +421,50 @@ def add_stored_credentials(post, options = {})
post[:payment_method_options][:card][:mit_exemption][:network_transaction_id] = stored_credential[:network_transaction_id] if stored_credential[:network_transaction_id]
end

def add_stored_credential_transaction_type(post, options = {})
stored_credential = options[:stored_credential]
# Do not add anything unless these are present.
return unless stored_credential[:reason_type] && stored_credential[:initiator]

# Not compatible with off_session parameter.
options.delete(:off_session)
if stored_credential[:initial_transaction]
# Initial transactions must by CIT
return unless stored_credential[:initiator] == 'cardholder'

initial_transaction_stored_credential(post, stored_credential[:reason_type])
else
# Subsequent transaction
subsequent_transaction_stored_credential(post, stored_credential[:initiator], stored_credential[:reason_type])
end
end

def initial_transaction_stored_credential(post, reason_type)
if reason_type == 'unscheduled'
# Charge on-session and store card for future one-off payment use
post[:payment_method_options][:card][:stored_credential_transaction_type] = 'setup_off_session_unscheduled'
elsif reason_type == 'recurring'
# Charge on-session and store card for future recurring payment use
post[:payment_method_options][:card][:stored_credential_transaction_type] = 'setup_off_session_recurring'
else
# Charge on-session and store card for future on-session payment use.
post[:payment_method_options][:card][:stored_credential_transaction_type] = 'setup_on_session'
end
end

def subsequent_transaction_stored_credential(post, initiator, reason_type)
if initiator == 'cardholder'
# Charge on-session customer using previously stored card.
post[:payment_method_options][:card][:stored_credential_transaction_type] = 'stored_on_session'
elsif reason_type == 'recurring'
# Charge off-session customer using previously stored card for recurring transaction
post[:payment_method_options][:card][:stored_credential_transaction_type] = 'stored_off_session_recurring'
else
# Charge off-session customer using previously stored card for one-off transaction
post[:payment_method_options][:card][:stored_credential_transaction_type] = 'stored_off_session_unscheduled'
end
end

def add_ntid(post, options = {})
return unless options[:network_transaction_id]

Expand Down
71 changes: 71 additions & 0 deletions test/remote/gateways/remote_stripe_payment_intents_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,77 @@ def test_succeeds_with_ntid_in_stored_credentials_and_separately
end
end

def test_succeeds_with_initial_cit
assert purchase = @gateway.purchase(@amount, @visa_card, {
currency: 'USD',
execute_threed: true,
confirm: true,
stored_credential_transaction_type: true,
stored_credential: {
initiator: 'cardholder',
reason_type: 'unscheduled',
initial_transaction: true
}
})
assert_success purchase
assert_equal 'succeeded', purchase.params['status']
assert purchase.params.dig('charges', 'data')[0]['captured']
assert purchase.params.dig('charges', 'data')[0]['payment_method_details']['card']['network_transaction_id']
end

def test_succeeds_with_initial_cit_3ds_required
assert purchase = @gateway.purchase(@amount, @three_ds_authentication_required_setup_for_off_session, {
currency: 'USD',
execute_threed: true,
confirm: true,
stored_credential_transaction_type: true,
stored_credential: {
initiator: 'cardholder',
reason_type: 'unscheduled',
initial_transaction: true
}
})
assert_success purchase
assert_equal 'requires_action', purchase.params['status']
end

def test_succeeds_with_mit
assert purchase = @gateway.purchase(@amount, @visa_card, {
currency: 'USD',
execute_threed: true,
confirm: true,
stored_credential_transaction_type: true,
stored_credential: {
initiator: 'merchant',
reason_type: 'recurring',
initial_transaction: false,
network_transaction_id: '1098510912210968'
}
})
assert_success purchase
assert_equal 'succeeded', purchase.params['status']
assert purchase.params.dig('charges', 'data')[0]['captured']
assert purchase.params.dig('charges', 'data')[0]['payment_method_details']['card']['network_transaction_id']
end

def test_succeeds_with_mit_3ds_required
assert purchase = @gateway.purchase(@amount, @three_ds_authentication_required_setup_for_off_session, {
currency: 'USD',
confirm: true,
stored_credential_transaction_type: true,
stored_credential: {
initiator: 'merchant',
reason_type: 'unscheduled',
initial_transaction: false,
network_transaction_id: '1098510912210968'
}
})
assert_success purchase
assert_equal 'succeeded', purchase.params['status']
assert purchase.params.dig('charges', 'data')[0]['captured']
assert purchase.params.dig('charges', 'data')[0]['payment_method_details']['card']['network_transaction_id']
end

def test_successful_off_session_purchase_when_claim_without_transaction_id_present
[@three_ds_off_session_credit_card, @three_ds_authentication_required_setup_for_off_session].each do |card_to_use|
assert response = @gateway.purchase(@amount, card_to_use, {
Expand Down
102 changes: 102 additions & 0 deletions test/unit/gateways/stripe_payment_intents_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,108 @@ def test_scrub_filter_token
assert_equal @gateway.scrub(pre_scrubbed), scrubbed
end

def test_succesful_purchase_with_initial_cit_unscheduled
stub_comms(@gateway, :ssl_request) do
@gateway.purchase(@amount, @visa_token, {
currency: 'USD',
confirm: true,
stored_credential_transaction_type: true,
stored_credential: {
initial_transaction: true,
initiator: 'cardholder',
reason_type: 'unscheduled'
}
})
end.check_request do |_method, _endpoint, data, _headers|
assert_match('payment_method_options[card][stored_credential_transaction_type]=setup_off_session_unscheduled', data)
end.respond_with(successful_create_intent_response)
end

def test_succesful_purchase_with_initial_cit_recurring
stub_comms(@gateway, :ssl_request) do
@gateway.purchase(@amount, @visa_token, {
currency: 'USD',
confirm: true,
stored_credential_transaction_type: true,
stored_credential: {
initial_transaction: true,
initiator: 'cardholder',
reason_type: 'recurring'
}
})
end.check_request do |_method, _endpoint, data, _headers|
assert_match('payment_method_options[card][stored_credential_transaction_type]=setup_off_session_recurring', data)
end.respond_with(successful_create_intent_response)
end

def test_succesful_purchase_with_initial_cit_installment
stub_comms(@gateway, :ssl_request) do
@gateway.purchase(@amount, @visa_token, {
currency: 'USD',
confirm: true,
stored_credential_transaction_type: true,
stored_credential: {
initial_transaction: true,
initiator: 'cardholder',
reason_type: 'installment'
}
})
end.check_request do |_method, _endpoint, data, _headers|
assert_match('payment_method_options[card][stored_credential_transaction_type]=setup_on_session', data)
end.respond_with(successful_create_intent_response)
end

def test_succesful_purchase_with_subsequent_cit
stub_comms(@gateway, :ssl_request) do
@gateway.purchase(@amount, @visa_token, {
currency: 'USD',
confirm: true,
stored_credential_transaction_type: true,
stored_credential: {
initial_transaction: false,
initiator: 'cardholder',
reason_type: 'installment'
}
})
end.check_request do |_method, _endpoint, data, _headers|
assert_match('payment_method_options[card][stored_credential_transaction_type]=stored_on_session', data)
end.respond_with(successful_create_intent_response)
end

def test_succesful_purchase_with_mit_recurring
stub_comms(@gateway, :ssl_request) do
@gateway.purchase(@amount, @visa_token, {
currency: 'USD',
confirm: true,
stored_credential_transaction_type: true,
stored_credential: {
initial_transaction: false,
initiator: 'merchant',
reason_type: 'recurring'
}
})
end.check_request do |_method, _endpoint, data, _headers|
assert_match('payment_method_options[card][stored_credential_transaction_type]=stored_off_session_recurring', data)
end.respond_with(successful_create_intent_response)
end

def test_succesful_purchase_with_mit_unscheduled
stub_comms(@gateway, :ssl_request) do
@gateway.purchase(@amount, @visa_token, {
currency: 'USD',
confirm: true,
stored_credential_transaction_type: true,
stored_credential: {
initial_transaction: false,
initiator: 'merchant',
reason_type: 'unscheduled'
}
})
end.check_request do |_method, _endpoint, data, _headers|
assert_match('payment_method_options[card][stored_credential_transaction_type]=stored_off_session_unscheduled', data)
end.respond_with(successful_create_intent_response)
end

private

def successful_setup_purchase
Expand Down

0 comments on commit aeaa33c

Please sign in to comment.