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

WIP: Add support for Stripe Payment Element #409

Open
wants to merge 1 commit into
base: main
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
27 changes: 27 additions & 0 deletions app/models/spree/gateway/stripe_elements_gateway.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
module Spree
class Gateway::StripeElementsGateway < Gateway::StripeGateway
preference :intents, :boolean, default: true
preference :endpoint_secret, :string

def method_type
'stripe_elements'
Expand All @@ -14,6 +15,28 @@ def provider_class
end
end

def source_required?
if get_preference(:intents)
# Source is not present as the payment intent is created prior
# to payment details being entered. Therefore must be set to false.
false
else
true
end
end

def payment_profiles_supported?
# Stripe API does not support adding a customer to a payment method AFTER
# payment intent has being created (as is the case with the 'store' method
# in the current Spree implementation.
# Instead need to create customer at the time the payment intent is created.
if get_preference(:intents)
false
else
true
end
end

def create_profile(payment)
return unless payment.source.gateway_customer_profile_id.nil?

Expand Down Expand Up @@ -46,6 +69,10 @@ def create_profile(payment)
end
end

def create_intent(money, card, options)
provider.create_intent(money, card, options)
end

private

def options_for_purchase_or_auth(money, creditcard, gateway_options)
Expand Down
10 changes: 10 additions & 0 deletions app/models/spree_gateway/credit_card_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ def set_last_digits
self.last_digits ||= number.to_s.length <= 4 ? number : number.to_s.slice(-4..-1)
end

def has_intents?
payment_method.has_preference?(:intents) && payment_method.get_preference(:intents)
end

private

# Card numbers are not required, as source is added via payment_intent.succeeded webhook.
def require_card_numbers?
!encrypted_data.present? && !has_payment_profile? && !has_intents?
end
end
end

Expand Down
13 changes: 13 additions & 0 deletions app/models/spree_gateway/order/payments_decorator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
module SpreeGateway
module Order
module PaymentsDecorator

def unprocessed_payments
payments.select(&:checkout_or_intent?)
end

end
end
end

::Spree::Order::Payments.prepend(::SpreeGateway::Order::PaymentsDecorator)
4 changes: 4 additions & 0 deletions app/models/spree_gateway/order_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ def process_payments!
super
end

def create_payment_intent!
process_payments_with(:create_intent!)
end

def intents?
payments.valid.map { |p| p.payment_method&.has_preference?(:intents) && p.payment_method&.get_preference(:intents) }.any?
end
Expand Down
20 changes: 20 additions & 0 deletions app/models/spree_gateway/payment/processing_decorator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
module SpreeGateway
module Payment
module ProcessingDecorator

def create_intent!
process_create_intent
end

private

def process_create_intent
started_creating_intent!
gateway_action(nil, :create_intent, :intent_created)
end

end
end
end

::Spree::Payment.prepend(::SpreeGateway::Payment::ProcessingDecorator)
56 changes: 56 additions & 0 deletions app/models/spree_gateway/payment_decorator.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,60 @@
module SpreeGateway
module PaymentDecorator
def self.prepended(base)
# Added the 'intent' state to allow payment gateway to handle creation of payment intent.
# Overridden here for now, but assume this would be better sitting in core.
base.state_machine initial: :checkout do
Copy link
Member

Choose a reason for hiding this comment

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

I'm wondering if we need this additional state related to intent, or if we could just save a Payment object for StripeElementsGateway only if the payment was successfully created?

Copy link
Member

Choose a reason for hiding this comment

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

But if that needs to happen, this would probably be better of in core. This will be a breaking change though for other payment gateways.

Copy link
Author

@mdavo6 mdavo6 Sep 2, 2022

Choose a reason for hiding this comment

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

@rafalcymerys Yes, I was unsure if there was a way to avoid adding the extra 'intent' payment state. In the end I felt like it was significant enough to warrant a separate state as it may apply to other payment gateways, and seemed to make sense as a way to call the Stripe Gateway. If you were to remove the 'intent' state, how would you suggest calling the Stripe Gateway to create the payment intent prior to save? Would it be some sort of before_action related to payment? And would it go via the spree_gateway?

event :started_creating_intent do
transition from: [:checkout], to: :creating_intent
end

event :intent_created do
transition from: [:creating_intent], to: :intent
end

# With card payments, happens before purchase or authorization happens
#
# Setting it after creating a profile and authorizing a full amount will
# prevent the payment from being authorized again once Order transitions
# to complete
event :started_processing do
transition from: [:checkout, :intent, :pending, :completed, :processing], to: :processing
end
# When processing during checkout fails
event :failure do
transition from: [:creating_intent, :pending, :processing], to: :failed
end
# With card payments this represents authorizing the payment
event :pend do
transition from: [:checkout, :processing], to: :pending
end
# With card payments this represents completing a purchase or capture transaction
event :complete do
transition from: [:processing, :pending, :checkout], to: :completed
end
event :void do
transition from: [:pending, :processing, :completed, :checkout], to: :void
end
# when the card brand isnt supported
event :invalidate do
transition from: [:checkout], to: :invalid
end

after_transition do |payment, transition|
payment.state_changes.create!(
previous_state: transition.from,
next_state: transition.to,
name: 'payment'
)
end
end
end


def handle_response(response, success_state, failure_state)
if response.success? && response.respond_to?(:params)
self.intent_client_key = response.params['client_secret'] if response.params['client_secret']
self.intent_id = response.params['id'] if response.params['id']
end
super
end
Expand All @@ -11,6 +63,10 @@ def verify!(**options)
process_verification(options)
end

def checkout_or_intent?
checkout? || intent?
end

private

def process_verification(**options)
Expand Down
3 changes: 3 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ en:
please_wait_for_confirmation_popup: Please wait for payment confirmation popup to appear.
payment_successfully_authorized: The payment was successfully authorized.
no_payment_authorization_needed: Payment autorization not needed.
no_payment_intent_created: No payment intent created.
order_state:
payment_confirm: Verify payment
log_entry:
Expand All @@ -35,6 +36,8 @@ en:
cvc_check: CVC Check
address_zip_check: Address ZIP check
stripe:
response:
success: Webhook successfully handled
ach:
account_holder_name: Account Holder Name
account_holder_type: Account Holder Type
Expand Down
4 changes: 4 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@
namespace :storefront do
namespace :intents do
post :payment_confirmation_data
post :create
post :handle_response
end
namespace :webhooks do
post :stripe
end
end
end
end
Expand Down
5 changes: 5 additions & 0 deletions db/migrate/20220719054016_add_intent_id_to_payments.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddIntentIdToPayments < ActiveRecord::Migration[5.2]
def change
add_column :spree_payments, :intent_id, :string
end
end
16 changes: 16 additions & 0 deletions lib/controllers/spree/api/v2/storefront/intents_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ module Storefront
class IntentsController < ::Spree::Api::V2::BaseController
include Spree::Api::V2::Storefront::OrderConcern

def create
spree_authorize! :update, spree_current_order, order_token

spree_current_order.create_payment_intent!
spree_current_order.reload

last_valid_payment = spree_current_order.payments.valid.where.not(intent_client_key: nil).last

if last_valid_payment.present?
client_secret = last_valid_payment.intent_client_key
return render json: { client_secret: client_secret }, status: :ok
end

render_error_payload(I18n.t('spree.no_payment_intent_created'))
end

def payment_confirmation_data
spree_authorize! :update, spree_current_order, order_token

Expand Down
90 changes: 90 additions & 0 deletions lib/controllers/spree/api/v2/storefront/webhooks_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
module Spree
module Api
module V2
module Storefront
class WebhooksController < ::Spree::Api::V2::BaseController

def stripe
require 'stripe'
Stripe.api_key = ::Spree::Gateway::StripeElementsGateway&.active.first.get_preference(:secret_key)
endpoint_secret = ::Spree::Gateway::StripeElementsGateway&.active.first.get_preference(:endpoint_secret)

payload = request.body.read
event = nil

begin
event = Stripe::Event.construct_from(
JSON.parse(payload, symbolize_names: true)
)
rescue JSON::ParserError => e
# Invalid payload
puts "Webhook error while parsing basic request. #{e.message})"
status 400
return
end
# Check if webhook signing is configured.
if endpoint_secret
# Retrieve the event by verifying the signature using the raw body and secret.
signature = request.env['HTTP_STRIPE_SIGNATURE'];
begin
event = Stripe::Webhook.construct_event(
payload, signature, endpoint_secret
)
rescue Stripe::SignatureVerificationError
puts "Webhook signature verification failed. #{err.message})"
status 400
end
end

# Handle the event
case event.type
when 'payment_intent.succeeded'
payment_intent = event.data.object # contains a Stripe::PaymentIntent
puts "Payment for #{payment_intent['amount']} succeeded."

# Find payment details (from stripe payment element) and payment in spree
stripe_payment_method = Stripe::PaymentMethod.retrieve(payment_intent[:payment_method])
payment = Spree::Payment.find_by(intent_id: payment_intent['id'])

if payment
payment_method = payment.payment_method
# Create source using payment details from stripe payment element
if payment.source.blank? && payment_method.try(:payment_source_class)
payment.source = payment_method.payment_source_class.create!({
gateway_payment_profile_id: stripe_payment_method.id,
cc_type: stripe_payment_method.card.brand,
month: stripe_payment_method.card.exp_month,
year: stripe_payment_method.card.exp_year,
last_digits: stripe_payment_method.card.last4,
payment_method: payment_method
})
end

# Update payment to pending if authorised only, and completed if auto capture enabled
if payment_intent['capture_method'] == "manual"
payment.update!(state: "pending")
else
payment.update!(state: "completed")
end

# Update order status to complete
order = payment.order
order.next until cannot_make_transition?(order)
end
else
puts "Unhandled event type: #{event.type}"
end
render json: { message: I18n.t('spree.stripe.response.success') }, status: :ok
end

private

def cannot_make_transition?(order)
order.complete? || order.errors.present?
end

end
end
end
end
end
5 changes: 4 additions & 1 deletion lib/spree_gateway/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ class Engine < Rails::Engine

config.autoload_paths += %W(#{config.root}/lib)

initializer "spree.gateway.payment_methods", :after => "spree.register.payment_methods" do |app|
Copy link
Member

Choose a reason for hiding this comment

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

Yup, good point

config.after_initialize do |app|
app.config.spree.payment_methods << Spree::Gateway::AuthorizeNet
app.config.spree.payment_methods << Spree::Gateway::AuthorizeNetCim
app.config.spree.payment_methods << Spree::Gateway::BalancedGateway
Expand Down Expand Up @@ -44,6 +44,9 @@ def self.activate
Dir.glob(File.join(File.dirname(__FILE__), '../../app/**/spree_gateway/*_decorator*.rb')) do |c|
Rails.application.config.cache_classes ? require(c) : load(c)
end
Dir.glob(File.join(File.dirname(__FILE__), '../../app/**/spree_gateway/**/*_decorator*.rb')) do |c|
Rails.application.config.cache_classes ? require(c) : load(c)
end
Dir.glob(File.join(File.dirname(__FILE__), '../../lib/active_merchant/**/*_decorator*.rb')) do |c|
Rails.application.config.cache_classes ? require(c) : load(c)
end
Expand Down
1 change: 1 addition & 0 deletions spree_gateway.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Gem::Specification.new do |s|

s.add_dependency 'spree_core', '>= 3.7.0'
s.add_dependency 'spree_extension'
s.add_dependency 'stripe'
Copy link
Member

Choose a reason for hiding this comment

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

Interesting, so that's not already included?
I would suggest locking this dependency to a particular version - Gemfile.lock will be created in projects using this gem, and they may then resolve to an incompatible version.

Copy link
Author

@mdavo6 mdavo6 Sep 2, 2022

Choose a reason for hiding this comment

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

Yes, I only added this to make manipulating the object coming back from Stripe via webhook easier. ActiveMerchant handles the rest of the Stripe <--> Spree logic, so was not previously included. Happy to add the current version number once we've got the other points 'locked down' (pun intended).


s.add_development_dependency 'braintree', '~> 3.0.0'
s.add_development_dependency 'rspec-activemodel-mocks'
Expand Down