Merge pull request #4672 from luisramos0/stripe_sca_method

Add new Stripe payment method compatible with the new Stripe Payment Intents API
This commit is contained in:
Luis Ramos
2020-03-02 17:19:05 +00:00
committed by GitHub
30 changed files with 914 additions and 49 deletions

View File

@@ -412,6 +412,8 @@ Metrics/AbcSize:
- app/services/create_order_cycle.rb
- app/services/order_syncer.rb
- app/services/subscription_validator.rb
- lib/active_merchant/billing/gateways/stripe_decorator.rb
- lib/active_merchant/billing/gateways/stripe_payment_intents.rb
- lib/discourse/single_sign_on.rb
- lib/open_food_network/bulk_coop_report.rb
- lib/open_food_network/customers_report.rb
@@ -505,6 +507,7 @@ Metrics/CyclomaticComplexity:
- app/models/spree/product_decorator.rb
- app/models/variant_override_set.rb
- app/services/cart_service.rb
- lib/active_merchant/billing/gateways/stripe_payment_intents.rb
- lib/discourse/single_sign_on.rb
- lib/open_food_network/bulk_coop_report.rb
- lib/open_food_network/enterprise_issue_validator.rb
@@ -530,6 +533,7 @@ Metrics/PerceivedComplexity:
- app/models/spree/ability_decorator.rb
- app/models/spree/order_decorator.rb
- app/models/spree/product_decorator.rb
- lib/active_merchant/billing/gateways/stripe_payment_intents.rb
- lib/discourse/single_sign_on.rb
- lib/open_food_network/bulk_coop_report.rb
- lib/open_food_network/enterprise_issue_validator.rb
@@ -599,6 +603,7 @@ Metrics/MethodLength:
- app/serializers/api/cached_enterprise_serializer.rb
- app/services/order_cycle_form.rb
- engines/order_management/app/services/order_management/reports/enterprise_fee_summary/scope.rb
- lib/active_merchant/billing/gateways/stripe_payment_intents.rb
- lib/discourse/single_sign_on.rb
- lib/open_food_network/bulk_coop_report.rb
- lib/open_food_network/column_preference_defaults.rb
@@ -662,6 +667,7 @@ Metrics/ClassLength:
- app/serializers/api/enterprise_shopfront_serializer.rb
- app/services/cart_service.rb
- engines/order_management/app/services/order_management/reports/enterprise_fee_summary/scope.rb
- lib/active_merchant/billing/gateways/stripe_payment_intents.rb
- lib/open_food_network/bulk_coop_report.rb
- lib/open_food_network/enterprise_fee_calculator.rb
- lib/open_food_network/order_cycle_form_applicator.rb

View File

@@ -21,7 +21,7 @@ angular.module('admin.payments').factory 'Payment', (AdminStripeElements, curren
year: @form_data.card_year
verification_value: @form_data.card_verification_value
}
when 'stripe'
when 'stripe', 'stripe_sca'
angular.extend munged_payment.payment, {
source_attributes:
gateway_payment_profile_id: @form_data.token
@@ -35,6 +35,8 @@ angular.module('admin.payments').factory 'Payment', (AdminStripeElements, curren
purchase: ->
if @paymentMethodType() == 'stripe'
AdminStripeElements.requestToken(@form_data, @submit)
else if @paymentMethodType() == 'stripe_sca'
AdminStripeElements.createPaymentMethod(@form_data, @submit)
else
@submit()

View File

@@ -5,7 +5,7 @@ angular.module("admin.payments").factory 'AdminStripeElements', ($rootScope, Sta
stripe: null
card: null
# New Stripe Elements method
# Create Token to be used with the Stripe Charges API
requestToken: (secrets, submit) ->
return unless @stripe? && @card?
@@ -20,6 +20,21 @@ angular.module("admin.payments").factory 'AdminStripeElements', ($rootScope, Sta
secrets.card = response.token.card
submit()
# Create Payment Method to be used with the Stripe Payment Intents API
createPaymentMethod: (secrets, submit) ->
return unless @stripe? && @card?
cardData = @makeCardData(secrets)
@stripe.createPaymentMethod({ type: 'card', card: @card }, @card, cardData).then (response) =>
if(response.error)
StatusMessage.display 'error', response.error.message
else
secrets.token = response.paymentMethod.id
secrets.cc_type = response.paymentMethod.card.brand
secrets.card = response.paymentMethod.card
submit()
# Maps the brand returned by Stripe to that required by activemerchant
mapCC: (ccType) ->
switch ccType

View File

@@ -16,7 +16,7 @@ angular.module("admin.subscriptions").controller "DetailsController", ($scope, $
return if !newValue?
paymentMethod = ($scope.paymentMethods.filter (pm) -> pm.id == newValue)[0]
return unless paymentMethod?
$scope.cardRequired = (paymentMethod.type == "Spree::Gateway::StripeConnect")
$scope.cardRequired = (paymentMethod.type == "Spree::Gateway::StripeConnect" || paymentMethod.type == "Spree::Gateway::StripeSCA")
$scope.loadCustomer() if $scope.cardRequired && !$scope.customer
$scope.loadCustomer = ->

View File

@@ -7,6 +7,8 @@ Darkswarm.factory 'Checkout', ($injector, CurrentOrder, ShippingMethods, StripeE
purchase: ->
if @paymentMethod()?.method_type == 'stripe' && !@secrets.selected_card
StripeElements.requestToken(@secrets, @submit)
else if @paymentMethod()?.method_type == 'stripe_sca' && !@secrets.selected_card
StripeElements.createPaymentMethod(@secrets, @submit)
else
@submit()
@@ -59,7 +61,7 @@ Darkswarm.factory 'Checkout', ($injector, CurrentOrder, ShippingMethods, StripeE
last_name: @order.bill_address.lastname
}
if @paymentMethod()?.method_type == 'stripe'
if @paymentMethod()?.method_type == 'stripe' || @paymentMethod()?.method_type == 'stripe_sca'
if @secrets.selected_card
angular.extend munged_order, {
existing_card_id: @secrets.selected_card

View File

@@ -1,12 +1,10 @@
Darkswarm.factory 'StripeElements', ($rootScope, Loading, RailsFlashLoader) ->
new class StripeElements
# TODO: add locale here for translations of error messages etc. from Stripe
# These are both set from the StripeElements directive
stripe: null
card: null
# New Stripe Elements method
# Create Token to be used with the Stripe Charges API
requestToken: (secrets, submit, loading_message = t("processing_payment")) ->
return unless @stripe? && @card?
@@ -23,6 +21,23 @@ Darkswarm.factory 'StripeElements', ($rootScope, Loading, RailsFlashLoader) ->
secrets.card = response.token.card
submit()
# Create Payment Method to be used with the Stripe Payment Intents API
createPaymentMethod: (secrets, submit, loading_message = t("processing_payment")) ->
return unless @stripe? && @card?
Loading.message = loading_message
cardData = @makeCardData(secrets)
@stripe.createPaymentMethod({ type: 'card', card: @card }, @card, cardData).then (response) =>
if(response.error)
Loading.clear()
RailsFlashLoader.loadFlash({error: t("error") + ": #{response.error.message}"})
else
secrets.token = response.paymentMethod.id
secrets.cc_type = response.paymentMethod.card.brand
secrets.card = response.paymentMethod.card
submit()
# Maps the brand returned by Stripe to that required by activemerchant
mapCC: (ccType) ->
switch ccType

View File

@@ -110,7 +110,7 @@ module Spree
else
Gateway.providers.reject{ |p| p.name.include? "Bogus" }.sort_by(&:name)
end
@providers.reject!{ |p| p.name.ends_with? "StripeConnect" } unless show_stripe?
@providers.reject!{ |provider| stripe_provider?(provider) } unless show_stripe?
@calculators = PaymentMethod.calculators.sort_by(&:name)
end
@@ -134,12 +134,12 @@ module Spree
# current payment_method is already a Stripe method
def show_stripe?
Spree::Config.stripe_connect_enabled ||
@payment_method.try(:type) == "Spree::Gateway::StripeConnect"
stripe_payment_method?
end
def restrict_stripe_account_change
return unless @payment_method
return unless @payment_method.type == "Spree::Gateway::StripeConnect"
return unless stripe_payment_method?
return unless @payment_method.preferred_enterprise_id.andand > 0
@stripe_account_holder = Enterprise.find(@payment_method.preferred_enterprise_id)
@@ -147,6 +147,15 @@ module Spree
params[:payment_method][:preferred_enterprise_id] = @stripe_account_holder.id
end
def stripe_payment_method?
["Spree::Gateway::StripeConnect",
"Spree::Gateway::StripeSCA"].include? @payment_method.try(:type)
end
def stripe_provider?(provider)
provider.name.ends_with?("StripeConnect", "StripeSCA")
end
end
end
end

View File

@@ -10,10 +10,14 @@ module Spree
render json: @credit_card, serializer: ::Api::CreditCardSerializer, status: :ok
else
message = t(:card_could_not_be_saved)
render json: { flash: { error: I18n.t(:spree_gateway_error_flash_for_checkout, error: message) } }, status: :bad_request
render json: { flash: { error: I18n.t(:spree_gateway_error_flash_for_checkout,
error: message) } },
status: :bad_request
end
rescue Stripe::CardError => e
render json: { flash: { error: I18n.t(:spree_gateway_error_flash_for_checkout, error: e.message) } }, status: :bad_request
render json: { flash: { error: I18n.t(:spree_gateway_error_flash_for_checkout,
error: e.message) } },
status: :bad_request
end
def update
@@ -54,10 +58,23 @@ module Spree
# Currently can only destroy the whole customer object
def destroy_at_stripe
stripe_customer = Stripe::Customer.retrieve(@credit_card.gateway_customer_profile_id)
if @credit_card.payment_method &&
@credit_card.payment_method.type == "Spree::Gateway::StripeSCA"
options = { stripe_account: stripe_account_id }
end
stripe_customer = Stripe::Customer.retrieve(@credit_card.gateway_customer_profile_id,
options || {})
stripe_customer.delete if stripe_customer
end
def stripe_account_id
StripeAccount.
find_by_enterprise_id(@credit_card.payment_method.preferred_enterprise_id).
andand.
stripe_user_id
end
def create_customer(token)
Stripe::Customer.create(email: spree_current_user.email, source: token)
end

View File

@@ -9,12 +9,6 @@ module Spree
attr_accessible :preferred_enterprise_id
CARD_TYPE_MAPPING = {
'American Express' => 'american_express',
'Diners Club' => 'diners_club',
'Visa' => 'visa'
}.freeze
def method_type
'stripe'
end
@@ -77,11 +71,6 @@ module Spree
[money, creditcard, options]
end
def update_source!(source)
source.cc_type = CARD_TYPE_MAPPING[source.cc_type] if CARD_TYPE_MAPPING.include?(source.cc_type)
source
end
def token_from_card_profile_ids(creditcard)
token_or_card_id = creditcard.gateway_payment_profile_id
customer = creditcard.gateway_customer_profile_id

View File

@@ -0,0 +1,90 @@
# frozen_string_literal: true
require 'stripe/profile_storer'
require 'active_merchant/billing/gateways/stripe_payment_intents'
require 'active_merchant/billing/gateways/stripe_decorator'
module Spree
class Gateway
class StripeSCA < Gateway
preference :enterprise_id, :integer
validate :ensure_enterprise_selected
attr_accessible :preferred_enterprise_id
def method_type
'stripe_sca'
end
def provider_class
ActiveMerchant::Billing::StripePaymentIntentsGateway
end
def payment_profiles_supported?
true
end
def stripe_account_id
StripeAccount.find_by_enterprise_id(preferred_enterprise_id).andand.stripe_user_id
end
# NOTE: the name of this method is determined by Spree::Payment::Processing
def purchase(money, creditcard, gateway_options)
provider.purchase(*options_for_purchase_or_auth(money, creditcard, gateway_options))
rescue Stripe::StripeError => e
# This will be an error caused by generating a stripe token
failed_activemerchant_billing_response(e.message)
end
# NOTE: the name of this method is determined by Spree::Payment::Processing
def void(response_code, _creditcard, gateway_options)
gateway_options[:stripe_account] = stripe_account_id
provider.void(response_code, gateway_options)
end
# NOTE: the name of this method is determined by Spree::Payment::Processing
def credit(money, _creditcard, response_code, gateway_options)
gateway_options[:stripe_account] = stripe_account_id
provider.refund(money, response_code, gateway_options)
end
def create_profile(payment)
return unless payment.source.gateway_customer_profile_id.nil?
profile_storer = Stripe::ProfileStorer.new(payment, provider, stripe_account_id)
profile_storer.create_customer_from_token
end
private
# In this gateway, what we call 'secret_key' is the 'login'
def options
options = super
options.merge(login: Stripe.api_key)
end
def options_for_purchase_or_auth(money, creditcard, gateway_options)
options = {}
options[:description] = "Spree Order ID: #{gateway_options[:order_id]}"
options[:currency] = gateway_options[:currency]
options[:stripe_account] = stripe_account_id
options[:customer] = creditcard.gateway_customer_profile_id
creditcard = creditcard.gateway_payment_profile_id
[money, creditcard, options]
end
def failed_activemerchant_billing_response(error_message)
ActiveMerchant::Billing::Response.new(false, error_message)
end
def ensure_enterprise_selected
return if preferred_enterprise_id.andand.positive?
errors.add(:stripe_account_owner, I18n.t(:error_required))
end
end
end
end

View File

@@ -68,6 +68,8 @@ Spree::PaymentMethod.class_eval do
"Pin Payments"
when "Spree::Gateway::StripeConnect"
"Stripe"
when "Spree::Gateway::StripeSCA"
"Stripe SCA"
when "Spree::Gateway::PayPalExpress"
"PayPal Express"
else

View File

@@ -1,5 +1,7 @@
class Subscription < ActiveRecord::Base
ALLOWED_PAYMENT_METHOD_TYPES = ["Spree::PaymentMethod::Check", "Spree::Gateway::StripeConnect"].freeze
ALLOWED_PAYMENT_METHOD_TYPES = ["Spree::PaymentMethod::Check",
"Spree::Gateway::StripeConnect",
"Spree::Gateway::StripeSCA"].freeze
belongs_to :shop, class_name: 'Enterprise'
belongs_to :customer

View File

@@ -4,7 +4,8 @@ module Api
delegate :serializable_hash, to: :method_serializer
def method_serializer
if object.type == 'Spree::Gateway::StripeConnect'
if object.type == 'Spree::Gateway::StripeConnect' ||
object.type == 'Spree::Gateway::StripeSCA'
Api::Admin::PaymentMethod::StripeSerializer.new(object)
else
Api::Admin::PaymentMethod::BaseSerializer.new(object)

View File

@@ -82,13 +82,18 @@ class SubscriptionValidator
def credit_card_ok?
return unless customer && payment_method
return unless payment_method.type == "Spree::Gateway::StripeConnect"
return unless stripe_payment_method?(payment_method)
return errors.add(:payment_method, :charges_not_allowed) unless customer.allow_charges
return if customer.user.andand.default_card.present?
errors.add(:payment_method, :no_default_card)
end
def stripe_payment_method?(payment_method)
payment_method.type == "Spree::Gateway::StripeConnect" ||
payment_method.type == "Spree::Gateway::StripeSCA"
end
def subscription_line_items_present?
return if subscription_line_items.reject(&:marked_for_destruction?).any?

View File

@@ -1,8 +1,3 @@
- content_for :injection_data do
- if Stripe.publishable_key
:javascript
angular.module('Darkswarm').value("stripeObject", Stripe("#{Stripe.publishable_key}"))
%fieldset#payment
%ng-form{"ng-controller" => "PaymentCtrl", name: "payment"}

View File

@@ -1,6 +1,9 @@
= @payment_method
- case @payment_method
- when Spree::Gateway::StripeConnect
= render 'stripe_connect'
- when Spree::Gateway::StripeSCA
= render 'stripe_connect'
- else
- if @payment_method.preferences.present?
%fieldset.alpha.eleven.columns.no-border-bottom#gateway_fields

View File

@@ -0,0 +1,16 @@
.stripe
%script{:src => "https://js.stripe.com/v3/", :type => "text/javascript"}
- if Stripe.publishable_key
:javascript
angular.module('admin.payments').value("stripeObject", Stripe("#{Stripe.publishable_key}", { stripeAccount: "#{StripeAccount.find_by_enterprise_id(payment_method.preferred_enterprise_id).andand.stripe_user_id}" }))
.row
.three.columns
= label_tag :cardholder_name, t(:cardholder_name)
.six.columns
= text_field_tag :cardholder_name, nil, {size: 40, "ng-model" => 'form_data.name'}
.row
.three.columns
= label_tag :card_details, t(:card_details)
.six.columns
%stripe-elements

View File

@@ -1,3 +1,8 @@
- content_for :injection_data do
- if Stripe.publishable_key
:javascript
angular.module('Darkswarm').value("stripeObject", Stripe("#{Stripe.publishable_key}"))
.row{ "ng-show" => "savedCreditCards.length > 0" }
.small-12.columns
%h6= t('.used_saved_card')

View File

@@ -0,0 +1,22 @@
- content_for :injection_data do
- if Stripe.publishable_key
:javascript
angular.module('Darkswarm').value("stripeObject", Stripe("#{Stripe.publishable_key}", { stripeAccount: "#{StripeAccount.find_by_enterprise_id(payment_method.preferred_enterprise_id).andand.stripe_user_id}" }))
.row{ "ng-show" => "savedCreditCards.length > 0" }
.small-12.columns
%h6= t('.used_saved_card')
%select{ name: "selected_card", required: false, ng: { model: "secrets.selected_card", options: "card.id as card.formatted for card in savedCreditCards" } }
%option{ value: "" }= "{{ secrets.selected_card ? '#{t('.enter_new_card')}' : '#{t('.choose_one')}' }}"
%h6{ ng: { if: '!secrets.selected_card' } }
= t('.or_enter_new_card')
%div{ ng: { if: '!secrets.selected_card' } }
%stripe-elements
- if spree_current_user
.row
.small-12.columns.text-right
= check_box_tag 'secrets.save_requested_by_customer', '1', false, 'ng-model' => 'secrets.save_requested_by_customer'
= label_tag 'secrets.save_requested_by_customer', t('.remember_this_card')

View File

@@ -92,6 +92,7 @@ module Openfoodnetwork
app.config.spree.payment_methods << Spree::Gateway::Migs
app.config.spree.payment_methods << Spree::Gateway::Pin
app.config.spree.payment_methods << Spree::Gateway::StripeConnect
app.config.spree.payment_methods << Spree::Gateway::StripeSCA
end
# Settings in config/environments/* take precedence over those specified here.

View File

@@ -3383,6 +3383,12 @@ See the %{link} to find out more about %{sitename}'s features and to start using
used_saved_card: "Use a saved card:"
or_enter_new_card: "Or, enter details for a new card:"
remember_this_card: Remember this card?
stripe_sca:
choose_one: Choose one
enter_new_card: Enter details for a new card
used_saved_card: "Use a saved card:"
or_enter_new_card: "Or, enter details for a new card:"
remember_this_card: Remember this card?
date_picker:
format: ! '%Y-%m-%d'
js_format: 'yy-mm-dd'

View File

@@ -0,0 +1,19 @@
# frozen_string_literal: true
# Here we bring commit 823faaeab0d6d3bd75ee037ec894ab7c9d95d3a9 from ActiveMerchant v1.98.0
# This is needed to make StripePaymentIntents work correctly
# This can be removed once we upgrade to ActiveMerchant v1.98.0
ActiveMerchant::Billing::StripeGateway.class_eval do
def authorization_from(success, url, method, response)
return response.fetch('error', {})['charge'] unless success
if url == 'customers'
[response['id'], response.dig('sources', 'data').first&.dig('id')].join('|')
elsif method == :post &&
(url.match(%r{customers/.*/cards}) || url.match(%r{payment_methods/.*/attach}))
[response['customer'], response['id']].join('|')
else
response['id']
end
end
end

View File

@@ -0,0 +1,277 @@
# frozen_string_literal: true
# Here we bring commit 823faaeab0d6d3bd75ee037ec894ab7c9d95d3a9 from ActiveMerchant v1.98.0
# This class integrates with the new StripePaymentIntents API
# This can be removed once we upgrade to ActiveMerchant v1.98.0
require 'active_support/core_ext/hash/slice'
module ActiveMerchant #:nodoc:
module Billing #:nodoc:
# This gateway uses the current Stripe
# {Payment Intents API}[https://stripe.com/docs/api/payment_intents].
# For the legacy API, see the Stripe gateway
class StripePaymentIntentsGateway < StripeGateway
ALLOWED_METHOD_STATES = %w[automatic manual].freeze
ALLOWED_CANCELLATION_REASONS = %w[duplicate fraudulent requested_by_customer abandoned].freeze
CREATE_INTENT_ATTRIBUTES =
%i[description statement_descriptor receipt_email save_payment_method].freeze
CONFIRM_INTENT_ATTRIBUTES =
%i[receipt_email return_url save_payment_method setup_future_usage off_session].freeze
UPDATE_INTENT_ATTRIBUTES =
%i[description statement_descriptor receipt_email setup_future_usage].freeze
DEFAULT_API_VERSION = '2019-05-16'
def create_intent(money, payment_method, options = {})
post = {}
add_amount(post, money, options, true)
add_capture_method(post, options)
add_confirmation_method(post, options)
add_customer(post, options)
add_payment_method_token(post, payment_method, options)
add_metadata(post, options)
add_return_url(post, options)
add_connected_account(post, options)
add_shipping_address(post, options)
setup_future_usage(post, options)
CREATE_INTENT_ATTRIBUTES.each do |attribute|
add_whitelisted_attribute(post, options, attribute)
end
commit(:post, 'payment_intents', post, options)
end
def show_intent(intent_id, options)
commit(:get, "payment_intents/#{intent_id}", nil, options)
end
def confirm_intent(intent_id, payment_method, options = {})
post = {}
add_payment_method_token(post, payment_method, options)
CONFIRM_INTENT_ATTRIBUTES.each do |attribute|
add_whitelisted_attribute(post, options, attribute)
end
commit(:post, "payment_intents/#{intent_id}/confirm", post, options)
end
def create_payment_method(payment_method, options = {})
post = {}
post[:type] = 'card'
post[:card] = {}
post[:card][:number] = payment_method.number
post[:card][:exp_month] = payment_method.month
post[:card][:exp_year] = payment_method.year
post[:card][:cvc] = payment_method.verification_value if payment_method.verification_value
commit(:post, 'payment_methods', post, options)
end
def update_intent(money, intent_id, payment_method, options = {})
post = {}
post[:amount] = money if money
add_payment_method_token(post, payment_method, options)
add_payment_method_types(post, options)
add_customer(post, options)
add_metadata(post, options)
add_shipping_address(post, options)
add_connected_account(post, options)
UPDATE_INTENT_ATTRIBUTES.each do |attribute|
add_whitelisted_attribute(post, options, attribute)
end
commit(:post, "payment_intents/#{intent_id}", post, options)
end
def authorize(money, payment_method, options = {})
create_intent(money,
payment_method,
options.merge!(confirm: true, capture_method: 'manual'))
end
def purchase(money, payment_method, options = {})
create_intent(money,
payment_method,
options.merge!(confirm: true, capture_method: 'automatic'))
end
def capture(money, intent_id, options = {})
post = {}
post[:amount_to_capture] = money
add_connected_account(post, options)
commit(:post, "payment_intents/#{intent_id}/capture", post, options)
end
def void(intent_id, options = {})
post = {}
if ALLOWED_CANCELLATION_REASONS.include?(options[:cancellation_reason])
post[:cancellation_reason] = options[:cancellation_reason]
end
commit(:post, "payment_intents/#{intent_id}/cancel", post, options)
end
def refund(money, intent_id, options = {})
intent = commit(:get, "payment_intents/#{intent_id}", nil, options)
charge_id = intent.params.dig('charges', 'data')[0].dig('id')
super(money, charge_id, options)
end
# Note: Not all payment methods are currently supported by the
# {Payment Methods API}[https://stripe.com/docs/payments/payment-methods]
# Current implementation will create
# a PaymentMethod object if the method is a token or credit card
# All other types will default to legacy Stripe store
def store(payment_method, options = {})
params = {}
post = {}
# If customer option is provided, create a payment method and attach to customer id
# Otherwise, create a customer, then attach
# if payment_method.is_a?(StripePaymentToken) ||
# payment_method.is_a?(ActiveMerchant::Billing::CreditCard)
add_payment_method_token(params, payment_method, options)
if options[:customer]
customer_id = options[:customer]
else
post[:validate] = options[:validate] unless options[:validate].nil?
post[:description] = options[:description] if options[:description]
post[:email] = options[:email] if options[:email]
customer = commit(:post, 'customers', post, options)
customer_id = customer.params['id']
# return the stripe response if expected customer id is not present
return customer if customer_id.nil?
end
commit(:post,
"payment_methods/#{params[:payment_method]}/attach",
{ customer: customer_id }, options)
# else
# super(payment, options)
# end
end
def unstore(identification, options = {}, deprecated_options = {})
if identification.include?('pm_')
_, payment_method = identification.split('|')
commit(:post, "payment_methods/#{payment_method}/detach", nil, options)
else
super(identification, options, deprecated_options)
end
end
private
def add_whitelisted_attribute(post, options, attribute)
post[attribute] = options[attribute] if options[attribute]
post
end
def add_capture_method(post, options)
capture_method = options[:capture_method].to_s
post[:capture_method] = capture_method if ALLOWED_METHOD_STATES.include?(capture_method)
post
end
def add_confirmation_method(post, options)
confirmation_method = options[:confirmation_method].to_s
if ALLOWED_METHOD_STATES.include?(confirmation_method)
post[:confirmation_method] = confirmation_method
end
post
end
def add_customer(post, options)
customer = options[:customer].to_s
post[:customer] = customer if customer.start_with?('cus_')
post
end
def add_return_url(post, options)
return unless options[:confirm]
post[:confirm] = options[:confirm]
post[:return_url] = options[:return_url] if options[:return_url]
post
end
def add_payment_method_token(post, payment_method, options)
return if payment_method.nil?
if payment_method.is_a?(ActiveMerchant::Billing::CreditCard)
p = create_payment_method(payment_method, options)
payment_method = p.params['id']
end
if payment_method.is_a?(StripePaymentToken)
post[:payment_method] = payment_method.payment_data['id']
elsif payment_method.is_a?(String)
if payment_method.include?('|')
customer_id, payment_method_id = payment_method.split('|')
token = payment_method_id
post[:customer] = customer_id
else
token = payment_method
end
post[:payment_method] = token
end
end
def add_payment_method_types(post, options)
payment_method_types = options[:payment_method_types] if options[:payment_method_types]
return if payment_method_types.nil?
post[:payment_method_types] = Array(payment_method_types)
post
end
def setup_future_usage(post, options = {})
if %w(on_session off_session).include?(options[:setup_future_usage])
post[:setup_future_usage] = options[:setup_future_usage]
end
if options[:off_session] && options[:confirm] == true
post[:off_session] = options[:off_session]
end
post
end
def add_connected_account(post, options = {})
return unless transfer_data = options[:transfer_data]
post[:transfer_data] = {}
if transfer_data[:destination]
post[:transfer_data][:destination] = transfer_data[:destination]
end
post[:transfer_data][:amount] = transfer_data[:amount] if transfer_data[:amount]
post[:on_behalf_of] = options[:on_behalf_of] if options[:on_behalf_of]
post[:transfer_group] = options[:transfer_group] if options[:transfer_group]
post[:application_fee_amount] = options[:application_fee] if options[:application_fee]
post
end
def add_shipping_address(post, options = {})
return unless shipping = options[:shipping]
post[:shipping] = {}
post[:shipping][:address] = {}
post[:shipping][:address][:line1] = shipping[:address][:line1]
post[:shipping][:address][:city] = shipping[:address][:city] if shipping[:address][:city]
if shipping[:address][:country]
post[:shipping][:address][:country] = shipping[:address][:country]
end
post[:shipping][:address][:line2] = shipping[:address][:line2] if shipping[:address][:line2]
if shipping[:address][:postal_code]
post[:shipping][:address][:postal_code] = shipping[:address][:postal_code]
end
post[:shipping][:address][:state] = shipping[:address][:state] if shipping[:address][:state]
post[:shipping][:name] = shipping[:name]
post[:shipping][:carrier] = shipping[:carrier] if shipping[:carrier]
post[:shipping][:phone] = shipping[:phone] if shipping[:phone]
post[:shipping][:tracking_number] = shipping[:tracking_number] if shipping[:tracking_number]
post
end
end
end
end

View File

@@ -30,7 +30,8 @@ module OpenFoodNetwork
end
def card_required?
payment.payment_method.is_a? Spree::Gateway::StripeConnect
[Spree::Gateway::StripeConnect,
Spree::Gateway::StripeSCA].include? payment.payment_method.class
end
def card_set?

View File

@@ -4,9 +4,10 @@
module Stripe
class ProfileStorer
def initialize(payment, provider)
def initialize(payment, provider, stripe_account_id = nil)
@payment = payment
@provider = provider
@stripe_account_id = stripe_account_id
end
def create_customer_from_token
@@ -28,7 +29,13 @@ module Stripe
email: @payment.order.email,
login: Stripe.api_key,
address: address_for(@payment)
}
}.merge(stripe_account_option)
end
def stripe_account_option
return {} if @stripe_account_id.blank?
{ stripe_account: @stripe_account_id }
end
def address_for(payment)
@@ -54,10 +61,22 @@ module Stripe
def source_attrs_from(response)
{
cc_type: @payment.source.cc_type, # side-effect of update_source!
gateway_customer_profile_id: response.params['id'],
gateway_payment_profile_id: response.params['default_source'] || response.params['default_card']
cc_type: @payment.source.cc_type,
gateway_customer_profile_id: customer_profile_id(response),
gateway_payment_profile_id: payment_profile_id(response)
}
end
def customer_profile_id(response)
response.params['customer'] || response.params['id']
end
def payment_profile_id(response)
if response.params['customer'] # Payment Intents API
response.params['id']
else
response.params['default_source'] || response.params['default_card']
end
end
end
end

View File

@@ -172,6 +172,22 @@ describe Spree::CreditCardsController, type: :controller do
expect(response).to redirect_to spree.account_path(anchor: 'cards')
end
end
context "where the payment method is StripeSCA" do
let(:stripe_payment_method) { create(:stripe_sca_payment_method) }
let!(:card) { create(:credit_card, gateway_customer_profile_id: 'cus_AZNMJ', payment_method: stripe_payment_method) }
before do
stub_request(:delete, "https://api.stripe.com/v1/customers/cus_AZNMJ").
to_return(status: 200, body: JSON.generate(deleted: true, id: "cus_AZNMJ"))
end
it "the request to destroy the Stripe customer includes the stripe_account_id" do
expect(Stripe::Customer).to receive(:retrieve).with(card.gateway_customer_profile_id, { stripe_account: "abc123" })
expect{ delete :destroy, params }.to change(Spree::CreditCard, :count).by(-1)
end
end
end
end
end

View File

@@ -151,8 +151,15 @@ FactoryBot.define do
preferred_enterprise_id { distributors.first.id }
end
factory :stripe_sca_payment_method, class: Spree::Gateway::StripeSCA do
name 'StripeSCA'
environment 'test'
distributors { [FactoryBot.create(:stripe_account).enterprise] }
preferred_enterprise_id { distributors.first.id }
end
factory :stripe_account do
enterprise { FactoryBot.create :distributor_enterprise }
enterprise { FactoryBot.create(:distributor_enterprise) }
stripe_user_id "abc123"
stripe_publishable_key "xyz456"
end

View File

@@ -0,0 +1,52 @@
# frozen_string_literal: true
require 'spec_helper'
module Stripe
describe ProfileStorer do
describe "create_customer_from_token" do
let(:payment) { create(:payment) }
let(:stripe_payment_method) { create(:stripe_payment_method) }
let(:stripe_account_id) { "12312" }
let(:profile_storer) { Stripe::ProfileStorer.new(payment, stripe_payment_method.provider) }
let(:customer_id) { "cus_A123" }
let(:card_id) { "card_2342" }
let(:customer_response_mock) { { status: 200, body: customer_response_body } }
before do
allow(Stripe).to receive(:api_key) { "sk_test_12345" }
stub_request(:post, "https://api.stripe.com/v1/customers")
.with(basic_auth: ["sk_test_12345", ""], body: { email: payment.order.email })
.to_return(customer_response_mock)
end
context "when called from Stripe Connect" do
let(:customer_response_body) {
JSON.generate(id: customer_id, default_card: card_id, sources: { data: [{ id: "1" }] })
}
it "fetches the customer id and the card id from the correct response fields" do
profile_storer.create_customer_from_token
expect(payment.source.gateway_customer_profile_id).to eq customer_id
expect(payment.source.gateway_payment_profile_id).to eq card_id
end
end
context "when called from Stripe SCA" do
let(:customer_response_body) {
JSON.generate(customer: customer_id, id: card_id, sources: { data: [{ id: "1" }] })
}
it "fetches the customer id and the card id from the correct response fields" do
profile_storer.create_customer_from_token
expect(payment.source.gateway_customer_profile_id).to eq customer_id
expect(payment.source.gateway_payment_profile_id).to eq card_id
end
end
end
end
end

View File

@@ -7,16 +7,6 @@ describe "checking out an order with a Stripe Connect payment method", type: :re
let!(:order_cycle) { create(:simple_order_cycle) }
let!(:enterprise) { create(:distributor_enterprise) }
let!(:exchange) do
create(
:exchange,
order_cycle: order_cycle,
sender: order_cycle.coordinator,
receiver: enterprise,
incoming: false,
pickup_time: "Monday"
)
end
let!(:shipping_method) do
create(
:shipping_method,

View File

@@ -0,0 +1,281 @@
require 'spec_helper'
describe "checking out an order with a Stripe SCA payment method", type: :request do
include ShopWorkflow
include AuthenticationWorkflow
include OpenFoodNetwork::ApiHelper
let!(:order_cycle) { create(:simple_order_cycle) }
let!(:enterprise) { create(:distributor_enterprise) }
let!(:shipping_method) do
create(
:shipping_method,
calculator: Spree::Calculator::FlatRate.new(preferred_amount: 0),
distributors: [enterprise]
)
end
let!(:payment_method) { create(:stripe_sca_payment_method, distributors: [enterprise]) }
let!(:stripe_account) { create(:stripe_account, enterprise: enterprise) }
let!(:line_item) { create(:line_item, price: 12.34) }
let!(:order) { line_item.order }
let(:address) { create(:address) }
let(:stripe_payment_method) { "pm_123" }
let(:new_stripe_payment_method) { "new_pm_123" }
let(:customer_id) { "cus_A123" }
let(:payments_attributes) do
{
payment_method_id: payment_method.id,
source_attributes: {
gateway_payment_profile_id: stripe_payment_method,
cc_type: "visa",
last_digits: "4242",
month: 10,
year: 2025,
first_name: 'Jill',
last_name: 'Jeffreys'
}
}
end
let(:allowed_address_attributes) do
[
"firstname",
"lastname",
"address1",
"address2",
"phone",
"city",
"zipcode",
"state_id",
"country_id"
]
end
let(:params) do
{
format: :json, order: {
shipping_method_id: shipping_method.id,
payments_attributes: [payments_attributes],
bill_address_attributes: address.attributes.slice(*allowed_address_attributes),
ship_address_attributes: address.attributes.slice(*allowed_address_attributes)
}
}
end
let(:payment_intent_response_mock) do
{ status: 200, body: JSON.generate(object: "payment_intent", amount: 2000, charges: { data: [{ id: "ch_1234", amount: 2000 }]}) }
end
before do
order_cycle_distributed_variants = double(:order_cycle_distributed_variants)
allow(OrderCycleDistributedVariants).to receive(:new) { order_cycle_distributed_variants }
allow(order_cycle_distributed_variants).to receive(:distributes_order_variants?) { true }
allow(Stripe).to receive(:api_key) { "sk_test_12345" }
order.update_attributes(distributor_id: enterprise.id, order_cycle_id: order_cycle.id)
order.reload.update_totals
set_order order
end
context "when a new card is submitted" do
context "and the user doesn't request that the card is saved for later" do
before do
# Charges the card
stub_request(:post, "https://api.stripe.com/v1/payment_intents")
.with(basic_auth: ["sk_test_12345", ""], body: /#{stripe_payment_method}.*#{order.number}/)
.to_return(payment_intent_response_mock)
end
context "and the paymeent intent request is successful" do
it "should process the payment without storing card details" do
put update_checkout_path, params
expect(json_response["path"]).to eq spree.order_path(order)
expect(order.payments.completed.count).to be 1
card = order.payments.completed.first.source
expect(card.gateway_customer_profile_id).to eq nil
expect(card.gateway_payment_profile_id).to eq stripe_payment_method
expect(card.cc_type).to eq "visa"
expect(card.last_digits).to eq "4242"
expect(card.first_name).to eq "Jill"
expect(card.last_name).to eq "Jeffreys"
end
end
context "when the payment intent request returns an error message" do
let(:payment_intent_response_mock) do
{ status: 402, body: JSON.generate(error: { message: "payment-intent-failure" }) }
end
it "should not process the payment" do
put update_checkout_path, params
expect(response.status).to be 400
expect(json_response["flash"]["error"]).to eq "payment-intent-failure"
expect(order.payments.completed.count).to be 0
end
end
end
context "and the customer requests that the card is saved for later" do
let(:payment_method_response_mock) do
{
status: 200,
body: JSON.generate(id: new_stripe_payment_method, customer: customer_id)
}
end
let(:customer_response_mock) do
{
status: 200,
body: JSON.generate(id: customer_id, sources: { data: [{ id: "1" }] })
}
end
before do
source_attributes = params[:order][:payments_attributes][0][:source_attributes]
source_attributes[:save_requested_by_customer] = '1'
# Saves the card against the user
stub_request(:post, "https://api.stripe.com/v1/customers")
.with(basic_auth: ["sk_test_12345", ""], body: { email: order.email })
.to_return(customer_response_mock)
# Requests a payment method from the newly saved card
stub_request(:post, "https://api.stripe.com/v1/payment_methods/#{stripe_payment_method}/attach")
.with(body: { customer: customer_id })
.to_return(payment_method_response_mock)
# Charges the card
stub_request(:post, "https://api.stripe.com/v1/payment_intents")
.with(
basic_auth: ["sk_test_12345", ""],
body: /.*#{order.number}/
).to_return(payment_intent_response_mock)
end
context "and the customer, payment_method and payment_intent requests are successful" do
it "should process the payment, and stores the card/customer details" do
put update_checkout_path, params
expect(json_response["path"]).to eq spree.order_path(order)
expect(order.payments.completed.count).to be 1
card = order.payments.completed.first.source
expect(card.gateway_customer_profile_id).to eq customer_id
expect(card.gateway_payment_profile_id).to eq new_stripe_payment_method
expect(card.cc_type).to eq "visa"
expect(card.last_digits).to eq "4242"
expect(card.first_name).to eq "Jill"
expect(card.last_name).to eq "Jeffreys"
end
end
context "when the customer request returns an error message" do
let(:customer_response_mock) do
{ status: 402, body: JSON.generate(error: { message: "customer-store-failure" }) }
end
it "should not process the payment" do
put update_checkout_path, params
expect(response.status).to be 400
expect(json_response["flash"]["error"])
.to eq(I18n.t(:spree_gateway_error_flash_for_checkout, error: 'customer-store-failure'))
expect(order.payments.completed.count).to be 0
end
end
context "when the payment intent request returns an error message" do
let(:payment_intent_response_mock) do
{ status: 402, body: JSON.generate(error: { message: "payment-intent-failure" }) }
end
it "should not process the payment" do
put update_checkout_path, params
expect(response.status).to be 400
expect(json_response["flash"]["error"]).to eq "payment-intent-failure"
expect(order.payments.completed.count).to be 0
end
end
context "when the payment_method request returns an error message" do
let(:payment_method_response_mock) do
{ status: 402, body: JSON.generate(error: { message: "payment-method-failure" }) }
end
it "should not process the payment" do
put update_checkout_path, params
expect(response.status).to be 400
expect(json_response["flash"]["error"]).to include "payment-method-failure"
expect(order.payments.completed.count).to be 0
end
end
end
end
context "when an existing card is submitted" do
let(:credit_card) do
create(
:credit_card,
user_id: order.user_id,
gateway_payment_profile_id: stripe_payment_method,
gateway_customer_profile_id: customer_id,
last_digits: "4321",
cc_type: "master",
first_name: "Sammy",
last_name: "Signpost",
month: 11, year: 2026
)
end
before do
params[:order][:existing_card_id] = credit_card.id
quick_login_as(order.user)
# Charges the card
stub_request(:post, "https://api.stripe.com/v1/payment_intents")
.with(basic_auth: ["sk_test_12345", ""], body: %r{#{customer_id}.*#{stripe_payment_method}})
.to_return(payment_intent_response_mock)
end
context "and the payment intent and payment method requests are accepted" do
it "should process the payment, and keep the profile ids and other card details" do
put update_checkout_path, params
expect(json_response["path"]).to eq spree.order_path(order)
expect(order.payments.completed.count).to be 1
card = order.payments.completed.first.source
expect(card.gateway_customer_profile_id).to eq customer_id
expect(card.gateway_payment_profile_id).to eq stripe_payment_method
expect(card.cc_type).to eq "master"
expect(card.last_digits).to eq "4321"
expect(card.first_name).to eq "Sammy"
expect(card.last_name).to eq "Signpost"
end
end
context "when the payment intent request returns an error message" do
let(:payment_intent_response_mock) do
{ status: 402, body: JSON.generate(error: { message: "payment-intent-failure" }) }
end
it "should not process the payment" do
put update_checkout_path, params
expect(response.status).to be 400
expect(json_response["flash"]["error"]).to eq "payment-intent-failure"
expect(order.payments.completed.count).to be 0
end
end
end
end