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

Sync Solidus Users and Stripe Customers #82

Open
wants to merge 1 commit into
base: v4
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
35 changes: 35 additions & 0 deletions app/decorators/models/spree/credit_card_decorator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# frozen_string_literal: true

module Spree
module CreditCardDecorator
def cc_type=(type)
# See https://stripe.com/docs/api/cards/object#card_object-brand,
# active_merchant/lib/active_merchant/billing/credit_card.rb,
# and active_merchant/lib/active_merchant/billing/credit_card_methods.rb
# (And see also the Solidus docs at core/app/models/spree/credit_card.rb,
# which indicate that Solidus uses ActiveMerchant conventions by default.)
self[:cc_type] = case type
when 'American Express'
'american_express'
when 'Diners Club'
'diners_club'
when 'Discover'
'discover'
when 'JCB'
'jcb'
when 'MasterCard'
'master'
when 'UnionPay'
'unionpay'
when 'Visa'
'visa'
when 'Unknown'
super('')
else
super(type)
end
end

::Spree::CreditCard.prepend(self)
end
end
13 changes: 13 additions & 0 deletions app/decorators/models/spree/order_decorator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

module Spree
module OrderDecorator
include StripeApiMethods

def stripe_customer_params
stripe_customer_params_from_addresses(bill_address, ship_address, email)
end

::Spree::Order.prepend(self)
end
end
40 changes: 40 additions & 0 deletions app/decorators/models/spree/user_decorator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# frozen_string_literal: true

module Spree
module UserDecorator
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about having a dedicated class to deal with the StripeCustomer management(creation, update, delete, etc)? I don't think the User class should have knowledge about the Stripe integration, even through a decorator.

include StripeApiMethods

def self.prepended(base)
base.after_create :create_stripe_customer
base.after_update :update_stripe_customer
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could create/update the stripe_customer only during a payment done through stripe. Also, with those callbacks, every change in the 'User' will trigger an update for the stripe_customer which in some cases won't be necessary.

end

def stripe_customer
Stripe::Customer.retrieve(stripe_customer_id) if stripe_customer_id.present?
end

def create_stripe_customer
stripe_customer = Stripe::Customer.create(self.stripe_params)
update_column(:stripe_customer_id, stripe_customer.id)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason for using update_column instead of the normal update? I don't think we should skip validations and callbacks here.

stripe_customer
end

def update_stripe_customer
Stripe::Customer.update(stripe_customer_id, self.stripe_params)
end

def delete_stripe_customer
if stripe_customer_id.present?
deleted_user = Stripe::Customer.delete(stripe_customer_id)
update_column(:stripe_customer_id, nil)
deleted_user
end
end

def stripe_params
stripe_customer_params_from_addresses(bill_address, ship_address, email)
end

::Spree.user_class.prepend(self)
end
end
28 changes: 28 additions & 0 deletions app/models/concerns/stripe_api_methods.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
module StripeApiMethods
extend ActiveSupport::Concern

def stripe_customer_params_from_addresses(bill_address, ship_address, email)
{
address: stripe_address_hash(bill_address),
email: email,
name: bill_address.try(:name) || bill_address&.full_name,
phone: bill_address&.phone,
shipping: {
address: stripe_address_hash(ship_address),
name: ship_address.try(:name) || ship_address&.full_name,
phone: ship_address&.phone
}
}
end

def stripe_address_hash(address)
{
city: address&.city,
country: address&.country&.iso,
line1: address&.address1,
line2: address&.address2,
postal_code: address&.zipcode,
state: address&.state_text
}
end
end
88 changes: 23 additions & 65 deletions app/models/spree/payment_method/stripe_credit_card.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,6 @@ class StripeCreditCard < Spree::PaymentMethod::CreditCard
preference :v3_elements, :boolean
preference :v3_intents, :boolean

CARD_TYPE_MAPPING = {
'American Express' => 'american_express',
'Diners Club' => 'diners_club',
'Visa' => 'visa'
}

delegate :create_intent, :update_intent, :confirm_intent, :show_intent, to: :gateway

def stripe_config(order)
Expand Down Expand Up @@ -103,39 +97,29 @@ def cancel(response_code)
def create_profile(payment)
return unless payment.source.gateway_customer_profile_id.nil?

options = {
email: payment.order.email,
login: preferred_secret_key,
}.merge! address_for(payment)

source = update_source!(payment.source)
if source.number.blank? && source.gateway_payment_profile_id.present?
if v3_intents?
creditcard = ActiveMerchant::Billing::StripeGateway::StripePaymentToken.new('id' => source.gateway_payment_profile_id)
else
creditcard = source.gateway_payment_profile_id
end
else
creditcard = source
end

response = gateway.store(creditcard, options)
if response.success?
if v3_intents?
payment.source.update!(
cc_type: payment.source.cc_type,
gateway_customer_profile_id: response.params['customer'],
gateway_payment_profile_id: response.params['id']
)
else
payment.source.update!(
cc_type: payment.source.cc_type,
gateway_customer_profile_id: response.params['id'],
gateway_payment_profile_id: response.params['default_source'] || response.params['default_card']
)
end
else
payment.send(:gateway_error, response.message)
source = payment.source
order = payment.order
user = source.user || order.user

# Find or create Stripe customer
stripe_customer = user&.stripe_customer || Stripe::Customer.create(order.stripe_customer_params)

# Create new Stripe card / payment method and attach to
# (new or existing) Stripe customer
if source.gateway_payment_profile_id&.starts_with?('pm_')
stripe_payment_method = Stripe::PaymentMethod.attach(source.gateway_payment_profile_id, customer: stripe_customer)
source.update!(
cc_type: stripe_payment_method.card.brand,
gateway_customer_profile_id: stripe_customer.id,
gateway_payment_profile_id: stripe_payment_method.id
)
elsif source.gateway_payment_profile_id&.starts_with?('tok_')
stripe_card = Stripe::Customer.create_source(stripe_customer.id, source: source.gateway_payment_profile_id)
source.update!(
cc_type: stripe_card.brand,
gateway_customer_profile_id: stripe_customer.id,
gateway_payment_profile_id: stripe_card.id
)
end
end

Expand Down Expand Up @@ -164,32 +148,6 @@ def options_for_purchase_or_auth(money, creditcard, transaction_options)
end
[money, creditcard, options]
end

def address_for(payment)
{}.tap do |options|
if address = payment.order.bill_address
options[:address] = {
address1: address.address1,
address2: address.address2,
city: address.city,
zip: address.zipcode
}

if country = address.country
options[:address][:country] = country.name
end

if state = address.state
options[:address].merge!(state: state.name)
end
end
end
end

def update_source!(source)
source.cc_type = CARD_TYPE_MAPPING[source.cc_type] if CARD_TYPE_MAPPING.include?(source.cc_type)
source
end
end
end
end
1 change: 1 addition & 0 deletions config/initializers/solidus_stripe.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Stripe.api_key = Spree::PaymentMethod::StripeCreditCard.last&.preferences&.dig(:secret_key)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Stripe secret_key can also be defined in the database, which means it could be changed in the runtime. In that case, this would still have the old key. It would be better to define this during the runtime. This is also a good indication that we might need a class to handle the integration.

25 changes: 25 additions & 0 deletions db/migrate/20200726022620_add_stripe_customer_id_to_users.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

class AddStripeCustomerIdToUsers < SolidusSupport::Migration[5.1]
def up
add_column Spree.user_class.table_name.to_sym, :stripe_customer_id, :string, unique: true

Spree::User.includes(bill_address: :country, ship_address: :country).find_each do |u|
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part could be in a different migration. I believe with the custom user class situation, some users would want to deal with it themselves, and would be easier to skip a migration instead of comment this one.

user_stripe_payment_sources = u&.wallet&.wallet_payment_sources&.select do |wps|
wps.payment_source.payment_method.type == 'Spree::PaymentMethod::StripeCreditCard'
end
payment_customer_id = user_stripe_payment_sources&.map { |ps| ps&.payment_source&.gateway_customer_profile_id }.compact.last

if payment_customer_id.present?
u.update_column(:stripe_customer_id, payment_customer_id)
u.update_stripe_customer
else
u.create_stripe_customer
end
end
end

def down
remove_column Spree.user_class.table_name.to_sym, :stripe_customer_id
end
end
1 change: 1 addition & 0 deletions lib/solidus_stripe.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
require "solidus_support"
require "solidus_stripe/engine"
require "solidus_stripe/version"
require "stripe"
1 change: 1 addition & 0 deletions solidus_stripe.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Gem::Specification.new do |spec|
spec.add_dependency 'solidus_core', ['>= 2.3', '< 3']
spec.add_dependency 'solidus_support', '~> 0.5'
spec.add_dependency 'activemerchant', '>= 1.100'
spec.add_dependency 'stripe'

spec.add_development_dependency 'solidus_dev_support'
end
28 changes: 14 additions & 14 deletions spec/features/stripe_checkout_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -123,63 +123,63 @@
end

it "shows an error with a missing credit card number", js: true do
fill_in_card({ number: "", code: "" })
fill_in_card(number: "", code: "")
click_button "Save and Continue"
expect(page).to have_content("Could not find payment information")
end

it "shows an error with a missing expiration date", js: true do
fill_in_card({ exp_month: "", exp_year: "" })
fill_in_card(exp_month: "", exp_year: "")
click_button "Save and Continue"
expect(page).to have_content("Your card's expiration year is invalid.")
end

it "shows an error with an invalid credit card number", js: true do
fill_in_card({ number: "1111 1111 1111 1111" })
fill_in_card(number: "1111 1111 1111 1111")
click_button "Save and Continue"
expect(page).to have_content("Your card number is incorrect.")
end

it "shows an error with invalid security fields", js: true do
fill_in_card({ code: "12" })
fill_in_card(code: "12")
click_button "Save and Continue"
expect(page).to have_content("Your card's security code is invalid.")
end

it "shows an error with invalid expiry fields", js: true do
fill_in_card({ exp_month: "00" })
fill_in_card(exp_month: "00")
click_button "Save and Continue"
expect(page).to have_content("Your card's expiration month is invalid.")
end
end

shared_examples "Stripe Elements invalid payments" do
it "shows an error with a missing credit card number" do
fill_in_card({ number: "" })
fill_in_card(number: "")
click_button "Save and Continue"
expect(page).to have_content("Your card number is incomplete.")
end

it "shows an error with a missing expiration date" do
fill_in_card({ exp_month: "", exp_year: "" })
fill_in_card(exp_month: "", exp_year: "")
click_button "Save and Continue"
expect(page).to have_content("Your card's expiration date is incomplete.")
end

it "shows an error with an invalid credit card number" do
fill_in_card({ number: "1111 1111 1111 1111" })
fill_in_card(number: "1111 1111 1111 1111")
click_button "Save and Continue"
expect(page).to have_content("Your card number is invalid.")
end

it "shows an error with invalid security fields" do
fill_in_card({ code: "12" })
fill_in_card(code: "12")
click_button "Save and Continue"
expect(page).to have_content("Your card's security code is incomplete.")
end

it "shows an error with invalid expiry fields" do
fill_in_card({ exp_month: "01", exp_year: "3" })
fill_in_card(exp_month: "01", exp_year: "3")
click_button "Save and Continue"
expect(page).to have_content("Your card's expiration date is incomplete.")
end
Expand Down Expand Up @@ -299,7 +299,7 @@

context "when using a card without enough money" do
it "fails the payment" do
fill_in_card({ number: "4000 0000 0000 9995" })
fill_in_card(number: "4000 0000 0000 9995")
click_button "Save and Continue"

expect(page).to have_content "Your card has insufficient funds."
Expand All @@ -308,7 +308,7 @@

context "when entering the wrong 3D verification code" do
it "fails the payment" do
fill_in_card({ number: "4000 0084 0000 1629" })
fill_in_card(number: "4000 0084 0000 1629")
click_button "Save and Continue"

within_3d_secure_modal do
Expand Down Expand Up @@ -407,7 +407,7 @@
let(:regular_card) { "4242 4242 4242 4242" }

it "voids the first stripe payment and successfully pays with 3DS card" do
fill_in_card({ number: regular_card })
fill_in_card(number: regular_card)
click_button "Save and Continue"

expect(page).to have_content "Ending in #{regular_card.last(4)}"
Expand Down Expand Up @@ -474,7 +474,7 @@ def within_3d_secure_modal
end

def authenticate_3d_secure_card(card_number)
fill_in_card({ number: card_number })
fill_in_card(number: card_number)
click_button "Save and Continue"

within_3d_secure_modal do
Expand Down
Loading