diff --git a/app/decorators/models/spree/credit_card_decorator.rb b/app/decorators/models/spree/credit_card_decorator.rb new file mode 100644 index 00000000..99090250 --- /dev/null +++ b/app/decorators/models/spree/credit_card_decorator.rb @@ -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 diff --git a/app/decorators/models/spree/order_decorator.rb b/app/decorators/models/spree/order_decorator.rb new file mode 100644 index 00000000..c21a1e91 --- /dev/null +++ b/app/decorators/models/spree/order_decorator.rb @@ -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 diff --git a/app/decorators/models/spree/user_decorator.rb b/app/decorators/models/spree/user_decorator.rb new file mode 100644 index 00000000..e81614ac --- /dev/null +++ b/app/decorators/models/spree/user_decorator.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Spree + module UserDecorator + include StripeApiMethods + + def self.prepended(base) + base.after_create :create_stripe_customer + base.after_update :update_stripe_customer + 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) + 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 diff --git a/app/models/concerns/stripe_api_methods.rb b/app/models/concerns/stripe_api_methods.rb new file mode 100644 index 00000000..0a0aa71a --- /dev/null +++ b/app/models/concerns/stripe_api_methods.rb @@ -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 diff --git a/app/models/spree/payment_method/stripe_credit_card.rb b/app/models/spree/payment_method/stripe_credit_card.rb index 7e219d76..16a0df31 100644 --- a/app/models/spree/payment_method/stripe_credit_card.rb +++ b/app/models/spree/payment_method/stripe_credit_card.rb @@ -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) @@ -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 @@ -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 diff --git a/config/initializers/solidus_stripe.rb b/config/initializers/solidus_stripe.rb new file mode 100644 index 00000000..de701e15 --- /dev/null +++ b/config/initializers/solidus_stripe.rb @@ -0,0 +1 @@ +Stripe.api_key = Spree::PaymentMethod::StripeCreditCard.last&.preferences&.dig(:secret_key) diff --git a/db/migrate/20200726022620_add_stripe_customer_id_to_users.rb b/db/migrate/20200726022620_add_stripe_customer_id_to_users.rb new file mode 100644 index 00000000..849fe479 --- /dev/null +++ b/db/migrate/20200726022620_add_stripe_customer_id_to_users.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class AddStripeCustomerIdToUsers < SolidusSupport::Migration[5.1] + def up + add_column :spree_users, :stripe_customer_id, :string, unique: true + + Spree::User.includes(bill_address: :country, ship_address: :country).find_each do |u| + user_stripe_payment_sources = user&.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_users, :stripe_customer_id + end +end diff --git a/lib/solidus_stripe.rb b/lib/solidus_stripe.rb index b3c76b30..5da741c9 100644 --- a/lib/solidus_stripe.rb +++ b/lib/solidus_stripe.rb @@ -5,3 +5,4 @@ require "solidus_support" require "solidus_stripe/engine" require "solidus_stripe/version" +require "stripe" diff --git a/solidus_stripe.gemspec b/solidus_stripe.gemspec index 151a6c3b..eff231e2 100644 --- a/solidus_stripe.gemspec +++ b/solidus_stripe.gemspec @@ -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 diff --git a/spec/features/stripe_checkout_spec.rb b/spec/features/stripe_checkout_spec.rb index f0df05b9..d550427f 100644 --- a/spec/features/stripe_checkout_spec.rb +++ b/spec/features/stripe_checkout_spec.rb @@ -124,31 +124,31 @@ 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 @@ -156,31 +156,31 @@ 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 @@ -308,7 +308,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." @@ -317,7 +317,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 @@ -414,7 +414,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)}" @@ -481,7 +481,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 diff --git a/spec/features/stripe_customer_spec.rb b/spec/features/stripe_customer_spec.rb new file mode 100644 index 00000000..3e4b61ac --- /dev/null +++ b/spec/features/stripe_customer_spec.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe "Stripe checkout", type: :feature do + let(:zone) { FactoryBot.create(:zone) } + let(:country) { FactoryBot.create(:country) } + + before do + FactoryBot.create(:store) + zone.members << Spree::ZoneMember.create!(zoneable: country) + FactoryBot.create(:free_shipping_method) + + Spree::PaymentMethod::StripeCreditCard.create!( + name: "Stripe", + preferred_secret_key: "sk_test_VCZnDv3GLU15TRvn8i2EsaAN", + preferred_publishable_key: "pk_test_Cuf0PNtiAkkMpTVC2gwYDMIg", + preferred_v3_elements: preferred_v3_elements, + preferred_v3_intents: preferred_v3_intents + ) + + FactoryBot.create(:product, name: "DL-44") + + visit spree.root_path + click_link "DL-44" + click_button "Add To Cart" + + expect(page).to have_current_path("/cart") + click_button "Checkout" + + expect(page).to have_current_path("/checkout/registration") + click_link "Create a new account" + within("#new_spree_user") do + fill_in "Email", with: "mary@example.com" + fill_in "Password", with: "superStrongPassword" + fill_in "Password Confirmation", with: "superStrongPassword" + end + click_button "Create" + + # Address + expect(page).to have_current_path("/checkout/address") + + # Creating a new Solidus user should create a corresponding Stripe customer + u = Spree::User.find_by_email("mary@example.com") + expect(u.stripe_customer_id).to start_with 'cus_' + expect(u.stripe_customer).to be_a Stripe::Customer + + within("#billing") do + fill_in_name + fill_in "Street Address", with: "YT-1300" + fill_in "City", with: "Mos Eisley" + select "United States of America", from: "Country" + select country.states.first.name, from: "order_bill_address_attributes_state_id" + fill_in "Zip", with: "12010" + fill_in "Phone", with: "(555) 555-5555" + end + click_on "Save and Continue" + + # Delivery + expect(page).to have_current_path("/checkout/delivery") + expect(page).to have_content("UPS Ground") + click_on "Save and Continue" + + # Payment + expect(page).to have_current_path("/checkout/payment") + fill_in_card + click_button "Save and Continue" + + # Confirmation + expect(page).to have_current_path("/checkout/confirm") + click_button "Place Order" + expect(page).to have_content("Your order has been processed successfully") + + # Begin Second Purchase + visit spree.root_path + click_link "DL-44" + click_button "Add To Cart" + + expect(page).to have_current_path("/cart") + click_button "Checkout" + + # Address + expect(page).to have_current_path("/checkout/address") + + within("#billing") do + fill_in_name + fill_in "Street Address", with: "YT-1300" + fill_in "City", with: "Mos Eisley" + select "United States of America", from: "Country" + select country.states.first.name, from: "order_bill_address_attributes_state_id" + fill_in "Zip", with: "12010" + fill_in "Phone", with: "(555) 555-5555" + end + click_on "Save and Continue" + + # Delivery + expect(page).to have_current_path("/checkout/delivery") + expect(page).to have_content("UPS Ground") + click_on "Save and Continue" + + # Payment + expect(page).to have_current_path("/checkout/payment") + end + + shared_examples "Maintain Consistent Stripe Customer Across Purchases" do + it "can re-use saved cards and maintain the same Stripe payment ID and customer ID", js: true do + choose "Use an existing card on file" + click_button "Save and Continue" + + # Confirm + expect(page).to have_current_path("/checkout/confirm") + click_button "Place Order" + expect(page).to have_content("Your order has been processed successfully") + + user = Spree::User.find_by(email: "mary@example.com") + user_sources = user.wallet.wallet_payment_sources + expect(user_sources.size).to eq(1) + + user_card = user_sources.first.payment_source + expect(user_card.gateway_customer_profile_id).to start_with 'cus_' + expect(user_card.gateway_payment_profile_id).to start_with (preferred_v3_intents ? 'pm_' : 'card_') + + stripe_customer = Stripe::Customer.retrieve(user_card.gateway_customer_profile_id) + expect(stripe_customer[:email]).to eq(user.email) + stripe_customer_cards = Stripe::PaymentMethod.list(customer: stripe_customer, type: 'card') + expect(stripe_customer_cards.count).to eq(1) + expect(stripe_customer_cards.first.customer).to eq(user_card.gateway_customer_profile_id) + expect(stripe_customer_cards.first.id).to eq(user_card.gateway_payment_profile_id) + + expect(user.orders.map { |o| o.payments.valid.first.source.gateway_payment_profile_id }.uniq.size).to eq(1) + expect(user.orders.map { |o| o.payments.valid.first.source.gateway_customer_profile_id }.uniq.size).to eq(1) + end + + it "can use a new card and maintain the same Stripe customer ID", js: true do + choose "Use a new card / payment method" + fill_in_card(number: '5555 5555 5555 4444') + click_button "Save and Continue" + + # Confirm + expect(page).to have_current_path("/checkout/confirm") + + user = Spree::User.find_by(email: "mary@example.com") + user_cards = user.credit_cards + expect(user_cards.size).to eq(2) + expect(user_cards.pluck(:gateway_customer_profile_id)).to all( start_with 'cus_' ) + expect(user_cards.pluck(:gateway_payment_profile_id)).to all( start_with (preferred_v3_intents ? 'pm_' : 'card_')) + expect(user_cards.last.gateway_customer_profile_id).to eq(user_cards.first.gateway_customer_profile_id) + expect(user_cards.pluck(:gateway_customer_profile_id).uniq.size).to eq(1) + + click_button "Place Order" + expect(page).to have_content("Your order has been processed successfully") + + expect(user.wallet.wallet_payment_sources.size).to eq(2) + expect(user.orders.map { |o| o.payments.valid.first.source.gateway_payment_profile_id }.uniq.size).to eq(2) + expect(user.orders.map { |o| o.payments.valid.first.source.gateway_customer_profile_id }.uniq.size).to eq(1) + + stripe_customer = Stripe::Customer.retrieve(user_cards.last.gateway_customer_profile_id) + stripe_customer_cards = Stripe::PaymentMethod.list(customer: stripe_customer, type: 'card') + expect(stripe_customer_cards.count).to eq(2) + expect(stripe_customer_cards.data.map(&:id)).to match_array(user.orders.map { |o| o.payments.valid.first.source.gateway_payment_profile_id }.uniq) + expect(stripe_customer_cards.data.map(&:id)).to match_array(user_cards.pluck(:gateway_payment_profile_id)) + end + end + + context 'when using Stripe V2 API library' do + let(:preferred_v3_elements) { false } + let(:preferred_v3_intents) { false } + + it_behaves_like "Maintain Consistent Stripe Customer Across Purchases" + end + + context 'when using Stripe V3 API library with Elements' do + let(:preferred_v3_elements) { true } + let(:preferred_v3_intents) { false } + + it_behaves_like "Maintain Consistent Stripe Customer Across Purchases" + end + + context 'when using Stripe V3 API library with Intents' do + let(:preferred_v3_elements) { false } + let(:preferred_v3_intents) { true } + + it_behaves_like "Maintain Consistent Stripe Customer Across Purchases" + end +end diff --git a/spec/models/spree/payment_method/stripe_credit_card_spec.rb b/spec/models/spree/payment_method/stripe_credit_card_spec.rb index 61b8430e..f85f372d 100644 --- a/spec/models/spree/payment_method/stripe_credit_card_spec.rb +++ b/spec/models/spree/payment_method/stripe_credit_card_spec.rb @@ -8,11 +8,13 @@ let(:source) { Spree::CreditCard.new } let(:bill_address) { nil } + let(:ship_address) { nil } let(:order) { - double('Spree::Order', + build(:order, email: email, bill_address: bill_address, + ship_address: ship_address, currency: 'USD', number: 'NUMBER', total: 10.99 @@ -84,29 +86,43 @@ context 'with an order that has a bill address' do let(:bill_address) { - double('Spree::Address', + build(:address, address1: '123 Happy Road', address2: 'Apt 303', city: 'Suzarac', zipcode: '95671', - state: double('Spree::State', name: 'Oregon'), - country: double('Spree::Country', name: 'United States')) + state: build(:state, name: 'Oregon', abbr: 'OR'), + country: build(:country, name: 'United States', iso: 'US'), + full_name: 'John Smith', + phone: '555-555-5555') } it 'stores the bill address with the gateway' do - expect(subject.gateway).to receive(:store).with(payment.source, { - email: email, - login: secret_key, - + expect(Stripe::Customer).to receive(:create).with( address: { - address1: '123 Happy Road', - address2: 'Apt 303', + line1: '123 Happy Road', + line2: 'Apt 303', city: 'Suzarac', - zip: '95671', - state: 'Oregon', - country: 'United States' + postal_code: '95671', + state: 'OR', + country: 'US' + }, + email: email, + name: 'John Smith', + phone: '555-555-5555', + shipping: { + address: { + line1: nil, + line2: nil, + city: nil, + postal_code: nil, + state: nil, + country: nil, + }, + name: nil, + phone: nil } - }).and_return double.as_null_object + ).and_return double.as_null_object subject.create_profile payment end @@ -114,10 +130,31 @@ context 'with an order that does not have a bill address' do it 'does not store a bill address with the gateway' do - expect(subject.gateway).to receive(:store).with(payment.source, { + expect(Stripe::Customer).to receive(:create).with( + address: { + line1: nil, + line2: nil, + city: nil, + postal_code: nil, + state: nil, + country: nil, + }, email: email, - login: secret_key, - }).and_return double.as_null_object + name: nil, + phone: nil, + shipping: { + address: { + line1: nil, + line2: nil, + city: nil, + postal_code: nil, + state: nil, + country: nil, + }, + name: nil, + phone: nil + } + ).and_return double.as_null_object subject.create_profile payment end @@ -154,7 +191,7 @@ let(:bill_address) { nil } it 'stores the profile_id as a card' do - expect(subject.gateway).to receive(:store).with(source.gateway_payment_profile_id, anything).and_return double.as_null_object + expect(Stripe::Customer).to receive(:create_source).with(anything, source: source.gateway_payment_profile_id).and_return double.as_null_object subject.create_profile payment end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 46a595ee..44a10801 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -21,6 +21,9 @@ # Requires card input helper defined in lib/solidus_stripe/testing_support/card_input_helper.rb require 'solidus_stripe/testing_support/card_input_helper' +# Stripe config +Stripe.api_key = 'sk_test_VCZnDv3GLU15TRvn8i2EsaAN' + RSpec.configure do |config| config.infer_spec_type_from_file_location! FactoryBot.find_definitions