diff --git a/app/models/spree/gateway/stripe_elements_gateway.rb b/app/models/spree/gateway/stripe_elements_gateway.rb index 5b671115..79d95ddc 100644 --- a/app/models/spree/gateway/stripe_elements_gateway.rb +++ b/app/models/spree/gateway/stripe_elements_gateway.rb @@ -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' @@ -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? @@ -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) diff --git a/app/models/spree_gateway/credit_card_decorator.rb b/app/models/spree_gateway/credit_card_decorator.rb index e19c8feb..fcd28c02 100644 --- a/app/models/spree_gateway/credit_card_decorator.rb +++ b/app/models/spree_gateway/credit_card_decorator.rb @@ -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 diff --git a/app/models/spree_gateway/order/payments_decorator.rb b/app/models/spree_gateway/order/payments_decorator.rb new file mode 100644 index 00000000..ef873d61 --- /dev/null +++ b/app/models/spree_gateway/order/payments_decorator.rb @@ -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) diff --git a/app/models/spree_gateway/order_decorator.rb b/app/models/spree_gateway/order_decorator.rb index a7dfec72..62197ac8 100644 --- a/app/models/spree_gateway/order_decorator.rb +++ b/app/models/spree_gateway/order_decorator.rb @@ -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 diff --git a/app/models/spree_gateway/payment/processing_decorator.rb b/app/models/spree_gateway/payment/processing_decorator.rb new file mode 100644 index 00000000..a0cac66a --- /dev/null +++ b/app/models/spree_gateway/payment/processing_decorator.rb @@ -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) diff --git a/app/models/spree_gateway/payment_decorator.rb b/app/models/spree_gateway/payment_decorator.rb index e53953a2..377f443e 100644 --- a/app/models/spree_gateway/payment_decorator.rb +++ b/app/models/spree_gateway/payment_decorator.rb @@ -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 + 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 @@ -11,6 +63,10 @@ def verify!(**options) process_verification(options) end + def checkout_or_intent? + checkout? || intent? + end + private def process_verification(**options) diff --git a/config/locales/en.yml b/config/locales/en.yml index 6ab786ea..828b6c11 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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: @@ -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 diff --git a/config/routes.rb b/config/routes.rb index f94a96ab..f3c9aab2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20220719054016_add_intent_id_to_payments.rb b/db/migrate/20220719054016_add_intent_id_to_payments.rb new file mode 100644 index 00000000..4f7f89bf --- /dev/null +++ b/db/migrate/20220719054016_add_intent_id_to_payments.rb @@ -0,0 +1,5 @@ +class AddIntentIdToPayments < ActiveRecord::Migration[5.2] + def change + add_column :spree_payments, :intent_id, :string + end +end diff --git a/lib/controllers/spree/api/v2/storefront/intents_controller.rb b/lib/controllers/spree/api/v2/storefront/intents_controller.rb index 50274020..082a4b9e 100644 --- a/lib/controllers/spree/api/v2/storefront/intents_controller.rb +++ b/lib/controllers/spree/api/v2/storefront/intents_controller.rb @@ -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 diff --git a/lib/controllers/spree/api/v2/storefront/webhooks_controller.rb b/lib/controllers/spree/api/v2/storefront/webhooks_controller.rb new file mode 100644 index 00000000..8b5addca --- /dev/null +++ b/lib/controllers/spree/api/v2/storefront/webhooks_controller.rb @@ -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 diff --git a/lib/spree_gateway/engine.rb b/lib/spree_gateway/engine.rb index c56f3994..4a644469 100644 --- a/lib/spree_gateway/engine.rb +++ b/lib/spree_gateway/engine.rb @@ -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| + 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 @@ -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 diff --git a/spree_gateway.gemspec b/spree_gateway.gemspec index e87a726c..7d7361a0 100644 --- a/spree_gateway.gemspec +++ b/spree_gateway.gemspec @@ -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' s.add_development_dependency 'braintree', '~> 3.0.0' s.add_development_dependency 'rspec-activemodel-mocks'