From 977979864a2716dd0d933adf89106faa7c9a9db1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Busqu=C3=A9?= Date: Wed, 18 Jan 2023 16:34:42 +0100 Subject: [PATCH] Add support for Stripe webhooks We set up the backbone to support Stripe webhook [1] events. There're two different use cases: - Users want to listen to certain webhook events. They'll need to add their names in a `webhook_events` setting. - This extension code makes internal use of some of them. That's something programmed, and when the time comes, we'll need to add the event names in the `SolidusStripe::Webhook::Event::CORE_EVENTS` constant. A new `POST /solidus_stripe/webhooks` endpoint exists to support the integration. Users must also configure a `solidus_stripe.webhook_endpoint_secret` Rails credential to validate the incoming events [2]. There's also a `webhook_signature_tolerance` setting to change the default number of seconds while a signature is valid [3]. When an incoming webhook arrives, a matching `Spree::Bus` event prepended with `stripe.` is published. Therefore, all the usual techniques in Solidus's pub/sub system [4] are allowed. [1] - https://stripe.com/docs/webhooks [2] - https://stripe.com/docs/webhooks/signatures [3] - https://stripe.com/docs/webhooks/signatures#replay-attacks [4] - https://guides.solidus.io/customization/subscribing-to-events --- README.md | 85 +++++++++++- .../solidus_stripe/webhooks_controller.rb | 27 ++++ config/routes.rb | 1 + .../config/initializers/solidus_stripe.rb | 12 +- lib/solidus_stripe/configuration.rb | 20 ++- lib/solidus_stripe/engine.rb | 10 ++ lib/solidus_stripe/webhook/event.rb | 77 +++++++++++ spec/lib/solidus_stripe/configuration_spec.rb | 15 +++ spec/lib/solidus_stripe/webhook/event_spec.rb | 107 +++++++++++++++ .../webhooks_controller_spec.rb | 54 ++++++++ spec/support/solidus_stripe/fixtures.rb | 123 ++++++++++++++++++ 11 files changed, 525 insertions(+), 6 deletions(-) create mode 100644 app/controllers/solidus_stripe/webhooks_controller.rb create mode 100644 lib/solidus_stripe/webhook/event.rb create mode 100644 spec/lib/solidus_stripe/configuration_spec.rb create mode 100644 spec/lib/solidus_stripe/webhook/event_spec.rb create mode 100644 spec/requests/solidus_stripe/webhooks_controller_spec.rb create mode 100644 spec/support/solidus_stripe/fixtures.rb diff --git a/README.md b/README.md index 85b9c50c..5dfb1d1c 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,92 @@ Bundle your dependencies and run the installation generator: bin/rails generate solidus_stripe:install ``` +### Webhooks + +This library makes use of some [Stripe +webhooks](https://stripe.com/docs/webhooks). + +On development, you can [test them by using Stripe CLI](https://stripe.com/docs/webhooks/test). + +Before going to production, you'll need to [register the +`/solidus_stripe/webhooks` endpoint with +Stripe](https://stripe.com/docs/webhooks/go-live), and make sure to subscribe +to the following events: + +[TBD] + +In both environments, you'll need to create a +`solidus_stripe.webhook_endpoint_secret` credential with [the webhook signing +secret](https://stripe.com/docs/webhooks/signatures): + +```bash +# For development, add `--environment development` +bin/rails credentials:edit +``` + +```yaml +# config/credentials.yml.enc +solidus_stripe: + webhook_endpoint_secret: "whsec_..." +``` + ## Usage - +### Custom webhooks + +You can also use [Stripe webhooks](https://stripe.com/docs/webhooks) to trigger +custom actions in your application. + +First, you need to register the event you want to listen to, both [in +Stripe](https://stripe.com/docs/webhooks/go-live) and in your application: + +```ruby +# config/initializers/solidus_stripe.rb +SolidusStripe.configure do |config| + config.webhook_events = %i[charge.succeeded] +end +``` + +That will register a new `:"stripe.charge.succeeded"` event in the [Solidus +bus](https://guides.solidus.io/customization/subscribing-to-events). The +Solidus event will be published whenever a matching incoming webhook event is +received. You can subscribe to it as regular: + +```ruby +# app/subscribers/update_account_balance_subscriber.rb +class UpdateAccountBalanceSubscriber + include Omnes::Subscriber + + handle :"stripe.charge.succeeded", with: :call + + def call(event) + # ... + end +end + +# config/initializers/solidus_stripe.rb +# ... +Rails.application.config.to_prepare do + UpdateAccountBalanceSubscriber.new.subscribe_to(Spree::Bus) +end +``` + +The passed event object is a thin wrapper around the [Stripe +event](https://www.rubydoc.info/gems/stripe/Stripe/Event) and will +delegate all methods to it. It can also be used in async [ +adapters](https://github.com/nebulab/omnes#adapters), which is recommended as +otherwise the response to Stripe will be delayed until subscribers are done. + +You can also configure the signature verification tolerance in seconds (it +defaults to the [same value as Stripe +default](https://stripe.com/docs/webhooks/signatures#replay-attacks)): + +```ruby +# config/initializers/solidus_stripe.rb +SolidusStripe.configure do |config| + config.webhook_signature_tolerance = 150 +end +``` ## Development diff --git a/app/controllers/solidus_stripe/webhooks_controller.rb b/app/controllers/solidus_stripe/webhooks_controller.rb new file mode 100644 index 00000000..703b30f1 --- /dev/null +++ b/app/controllers/solidus_stripe/webhooks_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "solidus_stripe/webhook/event" +require "stripe" + +module SolidusStripe + class WebhooksController < Spree::BaseController + SIGNATURE_HEADER = "HTTP_STRIPE_SIGNATURE" + + skip_before_action :verify_authenticity_token, only: :create + + respond_to :json + + def create + event = Webhook::Event.from_request(payload: request.body.read, signature_header: signature_header) + return head(:bad_request) unless event + + Spree::Bus.publish(event) && head(:ok) + end + + private + + def signature_header + request.headers[SIGNATURE_HEADER] + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 6774d06d..cf7f815d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,4 +2,5 @@ SolidusStripe::Engine.routes.draw do resources :payment_intents, only: :create + resources :webhooks, only: :create, format: false end diff --git a/lib/generators/solidus_stripe/install/templates/config/initializers/solidus_stripe.rb b/lib/generators/solidus_stripe/install/templates/config/initializers/solidus_stripe.rb index a2598c99..169c1f38 100644 --- a/lib/generators/solidus_stripe/install/templates/config/initializers/solidus_stripe.rb +++ b/lib/generators/solidus_stripe/install/templates/config/initializers/solidus_stripe.rb @@ -1,8 +1,16 @@ # frozen_string_literal: true SolidusStripe.configure do |config| - # TODO: Remember to change this with the actual preferences you have implemented! - # config.sample_preference = 'sample_value' + # List of webhook events you want to handle. + # For instance, if you want to handle the `payment_intent.succeeded` event, + # you should add it to the list below. A corresponding + # `:"stripe.payment_intent.succeeded"` event will be published in `Spree::Bus` + # whenever a `payment_intent.succeeded` event is received from Stripe. + # config.webhook_events = %i[payment_intent.succeeded] + # + # Number of seconds while a webhook event is valid after its creation. + # Defaults to the same value as Stripe's default. + # config.webhook_signature_tolerance = 150 end if ENV['SOLIDUS_STRIPE_API_KEY'] diff --git a/lib/solidus_stripe/configuration.rb b/lib/solidus_stripe/configuration.rb index 86b12421..e6e2fee3 100644 --- a/lib/solidus_stripe/configuration.rb +++ b/lib/solidus_stripe/configuration.rb @@ -1,10 +1,24 @@ # frozen_string_literal: true +require "stripe/webhook" + module SolidusStripe class Configuration - # Define here the settings for this extension, e.g.: - # - # attr_accessor :my_setting + # @!attribute [rw] webhook_events + # @return [Array] stripe events to handle. You also need to + # register them in the Stripe dashboard. For an event `:foo`, a matching + # `:"stripe.foo"` event will be registered in {Spree::Bus}. + attr_accessor :webhook_events + + # @!attribute [rw] webhook_signature_tolerance + # @return [Integer] number of seconds while a webhook event is valid after + # its creation. Defaults to {Stripe::Webhook::DEFAULT_TOLERANCE}. + attr_accessor :webhook_signature_tolerance + + def initialize + @webhook_events = [] + @webhook_signature_tolerance = Stripe::Webhook::DEFAULT_TOLERANCE + end end class << self diff --git a/lib/solidus_stripe/engine.rb b/lib/solidus_stripe/engine.rb index 47bc7639..b49405b4 100644 --- a/lib/solidus_stripe/engine.rb +++ b/lib/solidus_stripe/engine.rb @@ -14,6 +14,16 @@ class Engine < Rails::Engine app.config.spree.payment_methods << 'SolidusStripe::PaymentMethod' end + initializer "solidus_stripe.pub_sub", after: "spree.core.pub_sub" do |app| + require "solidus_stripe/webhook/event" + app.reloader.to_prepare do + SolidusStripe::Webhook::Event.register( + user_events: SolidusStripe.configuration.webhook_events, + bus: Spree::Bus + ) + end + end + # use rspec for tests config.generators do |g| g.test_framework :rspec diff --git a/lib/solidus_stripe/webhook/event.rb b/lib/solidus_stripe/webhook/event.rb new file mode 100644 index 00000000..07bf2fd8 --- /dev/null +++ b/lib/solidus_stripe/webhook/event.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module SolidusStripe + module Webhook + # Omnes event wrapping a Stripe event. + # + # All unknown methods are delegated to the wrapped Stripe event. + # + # @see Stripe::Event + class Event + include Omnes::Event + + PREFIX = "stripe." + private_constant :PREFIX + + # TBD + CORE_EVENTS = Set[*%i[]].freeze + private_constant :CORE_EVENTS + + # @api private + class << self + def from_request(payload:, signature_header:, secret: default_secret, tolerance: default_tolerance) + stripe_event = Stripe::Webhook.construct_event(payload, signature_header, secret, tolerance: tolerance) + new(stripe_event: stripe_event) + rescue Stripe::SignatureVerificationError, JSON::ParserError + nil + end + + def register(user_events:, bus:, core_events: CORE_EVENTS) + (core_events + user_events).each do |event| + bus.register(:"stripe.#{event}") + end + end + + private + + def default_tolerance + SolidusStripe.configuration.webhook_signature_tolerance + end + + def default_secret + Rails.application.credentials.solidus_stripe[:webhook_endpoint_secret] + end + end + + # @api private + attr_reader :omnes_event_name + + # @api private + def initialize(stripe_event:) + @stripe_event = stripe_event + @omnes_event_name = :"#{PREFIX}#{stripe_event.type}" + end + + # Serializable representation of the event. + # + # Ready to be consumed by async Omnes adapters, like + # {Omnes::Subscriber::Adapter::ActiveJob} or + # {Omnes::Subscriber::Adapter::Sidekiq}. + # + # @return [Hash] + def payload + @stripe_event.as_json + end + + private + + def method_missing(method_name, ...) + @stripe_event.send(method_name, ...) + end + + def respond_to_missing?(...) + @stripe_event.respond_to?(...) + end + end + end +end diff --git a/spec/lib/solidus_stripe/configuration_spec.rb b/spec/lib/solidus_stripe/configuration_spec.rb new file mode 100644 index 00000000..7746a095 --- /dev/null +++ b/spec/lib/solidus_stripe/configuration_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "solidus_stripe_spec_helper" + +RSpec.describe SolidusStripe::Configuration do + describe "#initialize" do + it "defaults webhook events to empty" do + expect(described_class.new.webhook_events).to eq([]) + end + + it "defaults webhook signature tolerance to stripe's default" do + expect(described_class.new.webhook_signature_tolerance).to eq(Stripe::Webhook::DEFAULT_TOLERANCE) + end + end +end diff --git a/spec/lib/solidus_stripe/webhook/event_spec.rb b/spec/lib/solidus_stripe/webhook/event_spec.rb new file mode 100644 index 00000000..16332338 --- /dev/null +++ b/spec/lib/solidus_stripe/webhook/event_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "solidus_stripe_spec_helper" +require "solidus_stripe/webhook/event" +require "omnes/bus" + +RSpec.describe SolidusStripe::Webhook::Event do + describe ".register" do + it "registers core events prepending with 'stripe'" do + bus = Omnes::Bus.new + + described_class.register(bus: bus, core_events: %i[foo], user_events: []) + + expect(bus.registry.registered?(:"stripe.foo")).to be(true) + end + + it "registers user events prepending with 'stripe'" do + bus = Omnes::Bus.new + + described_class.register(bus: bus, user_events: %i[foo]) + + expect(bus.registry.registered?(:"stripe.foo")).to be(true) + end + end + + describe ".from_request" do + context "with a valid event" do + it "returns an event" do + payload = JSON.generate(SolidusStripe::WebhookFixtures.charge_succeeded) + fixture = SolidusStripe::WebhookFixtures.new(payload: payload) + + event = described_class.from_request( + payload: payload, signature_header: fixture.signature_header, secret: fixture.secret + ) + + expect(event).to be_a(described_class) + end + end + + context "when the signature is invalid" do + it "returns nil" do + payload = JSON.generate(SolidusStripe::WebhookFixtures.charge_succeeded) + fixture = SolidusStripe::WebhookFixtures.new(payload: payload) + signature_header = "t=1,v1=1" + + event = described_class.from_request( + payload: payload, signature_header: signature_header, secret: fixture.secret + ) + + expect(event).to be(nil) + end + end + + context "when the payload is malformed" do + it "returns nil" do + payload = "invalid" + fixture = SolidusStripe::WebhookFixtures.new(payload: payload) + + event = described_class.from_request( + payload: payload, signature_header: fixture.signature_header, secret: fixture.secret + ) + + expect(event).to be(nil) + end + end + end + + describe "#initialize" do + let(:charge_suceeded_event) do + Stripe::Event.construct_from( + SolidusStripe::WebhookFixtures.charge_succeeded + ) + end + + it "sets the omnes_event_name from the type field" do + event = described_class.new(stripe_event: charge_suceeded_event) + + expect(event.omnes_event_name).to be(:"stripe.charge.succeeded") + end + + it "delegates all other methods to the stripe event" do + event = described_class.new(stripe_event: charge_suceeded_event) + + expect(event.type).to eq("charge.succeeded") + end + end + + describe "#payload" do + let(:charge_suceeded_event) do + Stripe::Event.construct_from( + SolidusStripe::WebhookFixtures.charge_succeeded + ) + end + + it "returns Hash representation" do + event = described_class.new(stripe_event: charge_suceeded_event) + + expect(event.payload).to be_a(Hash) + end + + it "uses strings for the hash keys" do + event = described_class.new(stripe_event: charge_suceeded_event) + + expect(event.payload.keys).to include("type") + end + end +end diff --git a/spec/requests/solidus_stripe/webhooks_controller_spec.rb b/spec/requests/solidus_stripe/webhooks_controller_spec.rb new file mode 100644 index 00000000..667d24f1 --- /dev/null +++ b/spec/requests/solidus_stripe/webhooks_controller_spec.rb @@ -0,0 +1,54 @@ +require "solidus_stripe_spec_helper" + +RSpec.describe SolidusStripe::WebhooksController, type: :request do + describe "POST /create" do + let(:signature_header_key) { described_class::SIGNATURE_HEADER } + let(:fixture) { SolidusStripe::WebhookFixtures.new(payload: payload) } + + before do + allow(Rails.application.credentials).to receive(:solidus_stripe) + .and_return({ webhook_endpoint_secret: fixture.secret }) + end + + context "when the request is valid" do + let(:payload) { JSON.generate(SolidusStripe::WebhookFixtures.charge_succeeded) } + + around do |example| + if Spree::Bus.registry.registered?(:"stripe.charge.succeeded") + example.run + else + Spree::Bus.register(:"stripe.charge.succeeded") + example.run + Spree::Bus.registry.unregister(:"stripe.charge.succeeded") + end + end + + it "triggers a matching event on Spree::Bus" do + event_type = nil + subscription = Spree::Bus.subscribe(:"stripe.charge.succeeded") { |event| event_type = event.type } + + post "/solidus_stripe/webhooks", params: payload, headers: { signature_header_key => fixture.signature_header } + + expect(event_type).to eq("charge.succeeded") + ensure + Spree::Bus.unsubscribe(subscription) + end + + it "returns a 200 status code" do + post "/solidus_stripe/webhooks", params: payload, headers: { signature_header_key => fixture.signature_header } + + expect(response).to have_http_status(:ok) + end + end + + context "when the event can't be generated" do + let(:payload) { "invalid" } + + it "returns a 400 status code" do + post "/solidus_stripe/webhooks", params: payload, headers: { signature_header_key => fixture.signature_header } + + expect(response).to have_http_status(:bad_request) + end + end + end +end diff --git a/spec/support/solidus_stripe/fixtures.rb b/spec/support/solidus_stripe/fixtures.rb new file mode 100644 index 00000000..b90e2c64 --- /dev/null +++ b/spec/support/solidus_stripe/fixtures.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +module SolidusStripe + class WebhookFixtures + attr_reader :payload, :timestamp, :secret + + def initialize(payload:, timestamp: Time.zone.now, secret: "whsec_123") + @payload = payload + @timestamp = timestamp + @secret = secret + end + + def signature + @signature ||= Stripe::Webhook::Signature.compute_signature(timestamp, payload, secret) + end + + def signature_header + @signature_header ||= Stripe::Webhook::Signature.generate_header(timestamp, signature) + end + + class << self + def charge_succeeded + charge_succeeded_base.merge("webhook" => charge_succeeded_base) + end + + private + + def charge_succeeded_base + { + "id" => "evt_3MRUo1JvEPu9yc7w091rP2XV", + "type" => "charge.succeeded", + "object" => "event", + "api_version" => "2022-11-15", + "created" => 1_674_022_050, + "data" => { + "object" => { + "id" => "ch_3MRUo1JvEPu9yc7w0BglRG7L", + "object" => "charge", + "amount" => 2000, + "amount_captured" => 2000, + "amount_refunded" => 0, + "application" => nil, + "application_fee" => nil, + "application_fee_amount" => nil, + "balance_transaction" => "txn_3MRUo1JvEPu9yc7w0aaUukiY", + "billing_details" => { + "address" => { "city" => nil, "country" => nil, "line1" => nil, "line2" => nil, "postal_code" => nil, + "state" => nil }, + "email" => nil, + "name" => nil, + "phone" => nil + }, + "calculated_statement_descriptor" => "NEBULAB SRL", + "captured" => true, + "created" => 1_674_022_049, + "currency" => "usd", + "customer" => nil, + "description" => "(created by Stripe CLI)", + "destination" => nil, + "dispute" => nil, + "disputed" => false, + "failure_balance_transaction" => nil, + "failure_code" => nil, + "failure_message" => nil, + "fraud_details" => {}, + "invoice" => nil, + "livemode" => false, + "metadata" => {}, + "on_behalf_of" => nil, + "order" => nil, + "outcome" => { "network_status" => "approved_by_network", "reason" => nil, "risk_level" => "normal", + "risk_score" => 1, "seller_message" => "Payment complete.", "type" => "authorized" }, + "paid" => true, + "payment_intent" => "pi_3MRUo1JvEPu9yc7w0ldPcSpn", + "payment_method" => "pm_1MRUo0JvEPu9yc7wVFMdYurf", + "payment_method_details" => { + "card" => { "brand" => "visa", + "checks" => { "address_line1_check" => nil, "address_postal_code_check" => nil, + "cvc_check" => nil }, + "country" => "US", + "exp_month" => 1, + "exp_year" => 2024, + "fingerprint" => "pUfqdtmzdaOnI2SE", + "funding" => "credit", + "installments" => nil, + "last4" => "4242", + "mandate" => nil, + "network" => "visa", + "three_d_secure" => nil, + "wallet" => nil }, + "type" => "card" + }, + "receipt_email" => nil, + "receipt_number" => nil, + "receipt_url" => "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xN21MdGJKdkVQdTl5Yzd3KKKZnp4GMgahzL5hXx86LBYPolrU9mdbq37sokiWbp-wT-NGJrXXxmipTFzS_AG1Wp1Rg6HWN1F2-9ek", + "refunded" => false, + "refunds" => { "object" => "list", "data" => [], "has_more" => false, "total_count" => 0, + "url" => "/v1/charges/ch_3MRUo1JvEPu9yc7w0BglRG7L/refunds" }, + "review" => nil, + "shipping" => { + "address" => { "city" => "San Francisco", "country" => "US", + "line1" => "510 Townsend St", "line2" => nil, + "postal_code" => "94103", "state" => "CA" }, + "carrier" => nil, "name" => "Jenny Rosen", "phone" => nil, + "tracking_number" => nil + }, + "source" => nil, + "source_transfer" => nil, + "statement_descriptor" => nil, + "statement_descriptor_suffix" => nil, + "status" => "succeeded", + "transfer_data" => nil, + "transfer_group" => nil + } + }, + "livemode" => false, + "pending_webhooks" => 3, + "request" => "req_CAHWxuQdLQukn0" + } + end + end + end +end