diff --git a/app/models/spree/gateway/stripe_sca.rb b/app/models/spree/gateway/stripe_sca.rb index ade64f157b..a6a25aced4 100644 --- a/app/models/spree/gateway/stripe_sca.rb +++ b/app/models/spree/gateway/stripe_sca.rb @@ -48,8 +48,8 @@ module Spree # NOTE: the name of this method is determined by Spree::Payment::Processing def charge_offline(money, creditcard, gateway_options) options = basic_options(gateway_options) - customer_id, payment_method_id = Stripe::CreditCardCloner.new.clone(creditcard, - stripe_account_id) + customer_id, payment_method_id = + Stripe::CreditCardCloner.new.find_or_clone(creditcard, stripe_account_id) options[:customer] = customer_id options[:off_session] = true @@ -110,8 +110,8 @@ module Spree 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.find_or_clone(creditcard, stripe_account_id) options[:customer] = customer_id [money, payment_method_id, options] end diff --git a/app/services/recurring_payments.rb b/app/services/recurring_payments.rb index 8058ee6674..1c622fd6a8 100644 --- a/app/services/recurring_payments.rb +++ b/app/services/recurring_payments.rb @@ -3,15 +3,13 @@ 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 - stripe_account = customer.enterprise.stripe_account&.stripe_user_id - - customer_id, payment_method_id = Stripe::CreditCardCloner.new.clone(card, - stripe_account) - setup_intent = Stripe::SetupIntent.create({ - payment_method: payment_method_id, customer: customer_id }, { - stripe_account: stripe_account - } + customer_id, payment_method_id = + Stripe::CreditCardCloner.new.find_or_clone(card, stripe_account) + setup_intent = Stripe::SetupIntent.create( + { payment_method: payment_method_id, customer: customer_id }, + { stripe_account: stripe_account } ) setup_intent.client_secret end diff --git a/lib/stripe/credit_card_cloner.rb b/lib/stripe/credit_card_cloner.rb index 64b67b5345..fb5e2d3d98 100644 --- a/lib/stripe/credit_card_cloner.rb +++ b/lib/stripe/credit_card_cloner.rb @@ -1,27 +1,37 @@ # frozen_string_literal: true -# Here we clone -# - a card (card_*) or payment_method (pm_*) stored (in a customer) in a platform account -# into +# Here we clone (or find a clone of) +# - a card (card_*) or payment_method (pm_*) stored (in a customer) in a platform account into # - a payment method (pm_*) (in a new customer) in a connected account # # This is required when using the Stripe Payment Intents API: # - the customer and payment methods are stored in the platform account # so that they can be re-used across multiple sellers -# - when a card needs to be charged, we need to create it in the seller's stripe account +# - when a card needs to be charged, we need to clone (or find the clone) +# in the seller's stripe account # -# We are doing this process every time the card is charged: -# - this means that, if the customer uses the same card on the same seller multiple times, -# the card will be created multiple times on the seller's account -# - to avoid this, we would have to store the IDs of every card on each seller's stripe account -# in our database (this way we only have to store the platform account ID) +# To avoid creating a new clone of the card/customer each time the card is charged or +# authorized (e.g. for SCA), we attach metadata { clone: true } to the card the first time we +# clone it and look for a card with the same fingerprint (hash of the card number) and +# that metadata key to avoid cloning it multiple times. + module Stripe class CreditCardCloner + def find_or_clone(credit_card, connected_account_id) + if card = find_cloned_card(credit_card, connected_account_id) + card + else + clone(credit_card, connected_account_id) + end + end + + private + def clone(credit_card, connected_account_id) new_payment_method = clone_payment_method(credit_card, connected_account_id) # If no customer is given, it will clone the payment method only - return nil, new_payment_method.id if credit_card.gateway_customer_profile_id.blank? + return [nil, new_payment_method.id] if credit_card.gateway_customer_profile_id.blank? new_customer = Stripe::Customer.create({ email: credit_card.user.email }, stripe_account: connected_account_id) @@ -29,10 +39,63 @@ module Stripe new_customer.id, connected_account_id) + add_metadata_to_payment_method(new_payment_method.id, connected_account_id) + [new_customer.id, new_payment_method.id] end - private + def find_cloned_card(card, connected_account_id) + matches = [] + return matches unless fingerprint = fingerprint_for_card(card) + + find_customers(card.user.email, connected_account_id).each do |customer| + find_payment_methods(customer.id, connected_account_id).each do |payment_method| + if payment_method_is_clone?(payment_method, fingerprint) + matches << [customer.id, payment_method.id] + end + end + end + + matches.first + end + + def payment_method_is_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 + + def find_customers(email, connected_account_id) + starting_after = nil + customers = [] + + loop do + response = Stripe::Customer.list({ email: email, starting_after: starting_after }, + { stripe_account: connected_account_id }) + customers += response.data + break unless response.has_more + + starting_after = response.data.last.id + end + customers + end + + def find_payment_methods(customer_id, connected_account_id) + starting_after = nil + payment_methods = [] + + loop do + options = { customer: customer_id, type: 'card', starting_after: starting_after } + response = Stripe::PaymentMethod.list(options, { stripe_account: connected_account_id }) + payment_methods += response.data + break unless response.has_more + + starting_after = response.data.last.id + end + payment_methods + end def clone_payment_method(credit_card, connected_account_id) platform_acct_payment_method_id = credit_card.gateway_payment_profile_id @@ -48,5 +111,11 @@ module Stripe { customer: customer_id }, stripe_account: connected_account_id) end + + def add_metadata_to_payment_method(payment_method_id, connected_account_id) + Stripe::PaymentMethod.update(payment_method_id, + { metadata: { "ofn-clone": true } }, + stripe_account: connected_account_id) + end end end