Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow SCA cards to be setup and charged offline for subscriptions #6469

Merged
merged 25 commits into from
Dec 11, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b517d2f
guard against a stripe customer already being deleted
andrewpbrett Oct 27, 2020
215d1bb
create js setup intent when authing shop
andrewpbrett Oct 29, 2020
cad8a01
put SetupIntent on the connected Stripe account
andrewpbrett Nov 18, 2020
13ab25a
separate method for charging offline
andrewpbrett Nov 18, 2020
fea7576
update CreditCardCloner to find existing clone
andrewpbrett Nov 19, 2020
3d47ad7
add stubs for stripe requests
andrewpbrett Nov 19, 2020
a466886
fix rubocop warnings
andrewpbrett Nov 19, 2020
277d7f4
refactor api customers controller; resolve merge conflict
andrewpbrett Nov 19, 2020
bc3fd8c
remove unused method
andrewpbrett Nov 20, 2020
9c544ef
remove cloned cards after removing the platform card
andrewpbrett Nov 20, 2020
103366e
add request limits to credit card cloner
andrewpbrett Nov 20, 2020
f1d4398
use a named argument for `offline` param
andrewpbrett Dec 3, 2020
3a82030
refactor to remove boolean flag param
andrewpbrett Dec 4, 2020
eddf8da
update subscription spec with new method name
andrewpbrett Dec 5, 2020
d55343d
only return gateway payment info if set on customer
andrewpbrett Dec 5, 2020
50e87a0
rename method to `validate!` since it can raise an error
andrewpbrett Dec 10, 2020
13b95f4
use built-in `auto_paging_each` with stripe
andrewpbrett Dec 10, 2020
0ac248f
refactor offline payment methods
andrewpbrett Dec 10, 2020
4c25edd
refactor find_cloned_card to separate class
andrewpbrett Dec 10, 2020
8c747e4
refactor destroy_clones to separate class
andrewpbrett Dec 10, 2020
a1f6fe5
remove unused method now that we use autopaging
andrewpbrett Dec 10, 2020
f50577b
refactor cloner to use ivars
andrewpbrett Dec 10, 2020
655512a
add missing `require` statements
andrewpbrett Dec 10, 2020
3daccb6
update specs with new method signature
andrewpbrett Dec 10, 2020
3b7313f
add spec for deleting the default card
andrewpbrett Dec 10, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ Darkswarm.factory 'CreditCards', ($http, $filter, savedCreditCards, Messages, Cu
othercard.is_default = false
$http.put("/credit_cards/#{card.id}", is_default: true).then (data) ->
Messages.success(t('js.default_card_updated'))
for customer in Customers.index()
customer.allow_charges = false
Customers.clearAllAllowCharges()
, (response) ->
Messages.flash(response.data.flash)

Expand Down
17 changes: 15 additions & 2 deletions app/assets/javascripts/darkswarm/services/customer.js.coffee
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
angular.module("Darkswarm").factory 'Customer', ($resource, Messages) ->
angular.module("Darkswarm").factory 'Customer', ($resource, $injector, Messages) ->
andrewpbrett marked this conversation as resolved.
Show resolved Hide resolved
Customer = $resource('/api/customers/:id/:action.json', {}, {
'index':
method: 'GET'
Expand All @@ -12,8 +12,21 @@ angular.module("Darkswarm").factory 'Customer', ($resource, Messages) ->
})

Customer.prototype.update = ->
if @allow_charges
Messages.loading(t('js.authorising'))
@$update().then (response) =>
Messages.success(t('js.changes_saved'))
if response.gateway_recurring_payment_client_secret && $injector.has('stripePublishableKey')
andrewpbrett marked this conversation as resolved.
Show resolved Hide resolved
Messages.clear()
stripe = Stripe($injector.get('stripePublishableKey'), { stripeAccount: response.gateway_shop_id })
andrewpbrett marked this conversation as resolved.
Show resolved Hide resolved
stripe.confirmCardSetup(response.gateway_recurring_payment_client_secret).then (result) =>
andrewpbrett marked this conversation as resolved.
Show resolved Hide resolved
if result.error
@allow_charges = false
@$update(allow_charges: false)
Messages.error(result.error.message)
else
Messages.success(t('js.changes_saved'))
else
Messages.success(t('js.changes_saved'))
sauloperez marked this conversation as resolved.
Show resolved Hide resolved
, (response) =>
Messages.error(response.data.error)

Expand Down
4 changes: 4 additions & 0 deletions app/assets/javascripts/darkswarm/services/customers.js.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ angular.module("Darkswarm").factory 'Customers', (Customer) ->
for customer in customers
@all.push customer
@byID[customer.id] = customer

clearAllAllowCharges: () ->
for customer in @index()
customer.allow_charges = false
12 changes: 12 additions & 0 deletions app/controllers/api/customers_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,25 @@ def update
@customer = Customer.find(params[:id])
authorize! :update, @customer

client_secret = RecurringPayments.setup_for(@customer) if params[:customer][:allow_charges]

if @customer.update(customer_params)
add_recurring_payment_info(client_secret)
render json: @customer, serializer: CustomerSerializer, status: :ok
else
invalid_resource!(@customer)
end
end

private

def add_recurring_payment_info(client_secret)
return unless client_secret

@customer.gateway_recurring_payment_client_secret = client_secret
@customer.gateway_shop_id = @customer.enterprise.stripe_account&.stripe_user_id
end

def customer_params
params.require(:customer).permit(:code, :email, :enterprise_id, :allow_charges)
end
Expand Down
15 changes: 6 additions & 9 deletions app/controllers/spree/credit_cards_controller.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
require 'stripe/credit_card_clone_destroyer'
andrewpbrett marked this conversation as resolved.
Show resolved Hide resolved

module Spree
class CreditCardsController < BaseController
def new_from_token
Expand Down Expand Up @@ -45,6 +47,7 @@ def destroy

# Using try because we may not have a card here
if @credit_card.try(:destroy)
remove_shop_authorizations if @credit_card.is_default
flash[:success] = I18n.t(:card_has_been_removed, number: "x-#{@credit_card.last_digits}")
else
flash[:error] = I18n.t(:card_could_not_be_removed)
Expand All @@ -63,16 +66,10 @@ def remove_shop_authorizations

# It destroys the whole customer object
def destroy_at_stripe
stripe_customer = Stripe::Customer.retrieve(@credit_card.gateway_customer_profile_id, {})
andrewpbrett marked this conversation as resolved.
Show resolved Hide resolved
Stripe::CreditCardCloneDestroyer.new.destroy_clones(@credit_card)

stripe_customer&.delete
end

def stripe_account_id
StripeAccount.
find_by(enterprise_id: @credit_card.payment_method.preferred_enterprise_id).
andand.
stripe_user_id
stripe_customer = Stripe::Customer.retrieve(@credit_card.gateway_customer_profile_id, {})
stripe_customer&.delete unless stripe_customer.deleted?
andrewpbrett marked this conversation as resolved.
Show resolved Hide resolved
end

def create_customer(token)
Expand Down
2 changes: 1 addition & 1 deletion app/jobs/subscription_confirm_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def process_payment!(order)
return unless order.payment_required?

prepare_for_payment!(order)
order.process_payments!
order.process_payments_offline!
raise if order.errors.any?
end

Expand Down
3 changes: 3 additions & 0 deletions app/models/customer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ class Customer < ActiveRecord::Base

before_create :associate_user

attr_accessor :gateway_recurring_payment_client_secret
attr_accessor :gateway_shop_id

private

def downcase_email
Expand Down
20 changes: 15 additions & 5 deletions app/models/spree/gateway/stripe_sca.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,21 @@ def purchase(money, creditcard, gateway_options)
failed_activemerchant_billing_response(e.message)
end

# NOTE: the name of this method is determined by Spree::Payment::Processing
def charge_offline(money, creditcard, gateway_options)
customer, payment_method =
Stripe::CreditCardCloner.new(creditcard, stripe_account_id).find_or_clone

options = basic_options(gateway_options).merge(customer: customer, off_session: true)
provider.purchase(money, payment_method, options)
rescue Stripe::StripeError => e
failed_activemerchant_billing_response(e.message)
end

# NOTE: the name of this method is determined by Spree::Payment::Processing
def authorize(money, creditcard, gateway_options)
authorize_response = provider.authorize(*options_for_authorize(money,
creditcard,
gateway_options))
authorize_response =
provider.authorize(*options_for_authorize(money, creditcard, gateway_options))
Stripe::AuthorizeResponsePatcher.new(authorize_response).call!
rescue Stripe::StripeError => e
failed_activemerchant_billing_response(e.message)
Expand Down Expand Up @@ -97,8 +107,8 @@ def options_for_authorize(money, creditcard, gateway_options)
options = basic_options(gateway_options)
options[:return_url] = full_checkout_path

customer_id, payment_method_id = Stripe::CreditCardCloner.new.clone(creditcard,
stripe_account_id)
customer_id, payment_method_id =
Stripe::CreditCardCloner.new(creditcard, stripe_account_id).find_or_clone
options[:customer] = customer_id
[money, payment_method_id, options]
end
Expand Down
33 changes: 22 additions & 11 deletions app/models/spree/order.rb
Original file line number Diff line number Diff line change
Expand Up @@ -500,22 +500,19 @@ def pending_payments
# :allow_checkout_on_gateway_error is set to false
#
def process_payments!
raise Core::GatewayError, Spree.t(:no_pending_payments) if pending_payments.empty?

pending_payments.each do |payment|
break if payment_total >= total

payment.process!

if payment.completed?
self.payment_total += payment.amount
end
end
process_each_payment(&:process!)
rescue Core::GatewayError => e
result = !!Spree::Config[:allow_checkout_on_gateway_error]
errors.add(:base, e.message) && (return result)
end

def process_payments_offline!
process_each_payment(&:process_offline!)
rescue Core::GatewayError => e
errors.add(:base, e.message)
false
end

andrewpbrett marked this conversation as resolved.
Show resolved Hide resolved
def billing_firstname
bill_address.try(:firstname)
end
Expand Down Expand Up @@ -778,6 +775,20 @@ def update_attributes_without_callbacks(attributes)

private

def process_each_payment
raise Core::GatewayError, Spree.t(:no_pending_payments) if pending_payments.empty?

pending_payments.each do |payment|
break if payment_total >= total

yield payment

if payment.completed?
self.payment_total += payment.amount
end
end
end

def link_by_email
self.email = user.email if user
end
Expand Down
38 changes: 29 additions & 9 deletions app/models/spree/payment/processing.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,20 @@ module Spree
class Payment < ActiveRecord::Base
module Processing
def process!
return unless payment_method&.source_required?
return unless validate!

raise Core::GatewayError, Spree.t(:payment_processing_failed) unless source

return if processing?

unless payment_method.supports?(source)
invalidate!
raise Core::GatewayError, Spree.t(:payment_method_not_supported)
if payment_method.auto_capture?
purchase!
else
authorize!
end
end

def process_offline!
return unless validate!

if payment_method.auto_capture?
purchase!
charge_offline!
andrewpbrett marked this conversation as resolved.
Show resolved Hide resolved
else
authorize!
end
Expand All @@ -32,6 +33,11 @@ def purchase!
gateway_action(source, :purchase, :complete)
end

def charge_offline!
started_processing!
gateway_action(source, :charge_offline, :complete)
end

def capture!
return true if completed?

Expand Down Expand Up @@ -193,6 +199,20 @@ def gateway_options

private

def validate!
return false unless payment_method&.source_required?

raise Core::GatewayError, Spree.t(:payment_processing_failed) unless source

return false if processing?

unless payment_method.supports?(source)
invalidate!
raise Core::GatewayError, Spree.t(:payment_method_not_supported)
end
true
end

def calculate_refund_amount(refund_amount = nil)
refund_amount ||= if credit_allowed >= order.outstanding_balance.abs
order.outstanding_balance.abs
Expand Down
9 changes: 9 additions & 0 deletions app/serializers/api/customer_serializer.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
module Api
class CustomerSerializer < ActiveModel::Serializer
attributes :id, :enterprise_id, :name, :code, :email, :allow_charges

def attributes
hash = super
if secret = object.gateway_recurring_payment_client_secret
hash.merge!(gateway_recurring_payment_client_secret: secret)
andrewpbrett marked this conversation as resolved.
Show resolved Hide resolved
end
hash.merge!(gateway_shop_id: object.gateway_shop_id) if object.gateway_shop_id
andrewpbrett marked this conversation as resolved.
Show resolved Hide resolved
hash
end
andrewpbrett marked this conversation as resolved.
Show resolved Hide resolved
end
end
16 changes: 16 additions & 0 deletions app/services/recurring_payments.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true

class RecurringPayments
def self.setup_for(customer)
return unless card = customer.user.default_card
return unless stripe_account = customer.enterprise.stripe_account&.stripe_user_id

customer_id, payment_method_id =
Stripe::CreditCardCloner.new(card, stripe_account).find_or_clone
setup_intent = Stripe::SetupIntent.create(
{ payment_method: payment_method_id, customer: customer_id },
stripe_account: stripe_account
)
setup_intent.client_secret
end
end
1 change: 1 addition & 0 deletions app/views/spree/users/show.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- if Stripe.publishable_key
:javascript
angular.module('Darkswarm').value("stripeObject", Stripe("#{Stripe.publishable_key}"))
angular.module('Darkswarm').value("stripePublishableKey", "#{Stripe.publishable_key}")
andrewpbrett marked this conversation as resolved.
Show resolved Hide resolved

.darkswarm
.row.pad-top
Expand Down
1 change: 1 addition & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2510,6 +2510,7 @@ See the %{link} to find out more about %{sitename}'s features and to start using
js:
saving: 'Saving...'
changes_saved: 'Changes saved.'
authorising: "Authorising..."
save_changes_first: Save changes first.
all_changes_saved: All changes saved
unsaved_changes: You have unsaved changes
Expand Down
24 changes: 24 additions & 0 deletions lib/stripe/credit_card_clone_destroyer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

# Here we destroy (on Stripe) any clones that we have created for a platform card.
# See CreditCardCloner for details.

# This is useful when the platform card is deleted (and needs to happen before the
# platform card is deleted on Stripe).

module Stripe
class CreditCardCloneDestroyer
def destroy_clones(card)
card.user.customers.each do |customer|
next unless stripe_account = customer.enterprise.stripe_account&.stripe_user_id

customer_id, _payment_method_id =
Stripe::CreditCardCloneFinder.new(card, stripe_account).find_cloned_card
next unless customer_id

customer = Stripe::Customer.retrieve(customer_id, stripe_account: stripe_account)
customer&.delete unless customer.deleted?
end
end
end
end
35 changes: 35 additions & 0 deletions lib/stripe/credit_card_clone_finder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# frozen_string_literal: true

module Stripe
class CreditCardCloneFinder
def initialize(card, stripe_account)
@card = card
@stripe_account = stripe_account
end

def find_cloned_card
return nil unless fingerprint = fingerprint_for_card(@card) && email = @card.user&.email

customers = Stripe::Customer.list({ email: email, limit: 100 }, stripe_account: @stripe_account)
andrewpbrett marked this conversation as resolved.
Show resolved Hide resolved

customers.auto_paging_each do |customer|
options = { customer: customer.id, type: 'card', limit: 100 }
payment_methods = Stripe::PaymentMethod.list(options, stripe_account: @stripe_account)
payment_methods.auto_paging_each do |payment_method|
return [customer.id, payment_method.id] if clone?(payment_method, fingerprint)
end
end
nil
end

private

def clone?(payment_method, fingerprint)
payment_method.card.fingerprint == fingerprint && payment_method.metadata["ofn-clone"]
end

def fingerprint_for_card(card)
Stripe::PaymentMethod.retrieve(card.gateway_payment_profile_id).card.fingerprint
end
end
end
Loading