Skip to content

Commit bc88704

Browse files
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 /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
1 parent d6a7c23 commit bc88704

10 files changed

Lines changed: 465 additions & 4 deletions

File tree

README.md

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,92 @@ Bundle your dependencies and run the installation generator:
3737
bin/rails generate solidus_stripe:install
3838
```
3939

40+
### Webhooks
41+
42+
This library makes use of some [Stripe
43+
webhooks](https://stripe.com/docs/webhooks).
44+
45+
On development, you can [test them by using Stripe CLI](https://stripe.com/docs/webhooks/test).
46+
47+
Before going to production, you'll need to [register the
48+
`/solidus_stripe/webhooks` endpoint with
49+
Stripe](https://stripe.com/docs/webhooks/go-live), and make sure to subscribe
50+
to the following events:
51+
52+
[TBD]
53+
54+
In both environments, you'll need to create a
55+
`solidus_stripe.webhook_endpoint_secret` credential with [the webhook signing
56+
secret](https://stripe.com/docs/webhooks/signatures):
57+
58+
```bash
59+
# For development, add `--environment development`
60+
bin/rails credentials:edit
61+
```
62+
63+
```yaml
64+
# config/credentials.yml.enc
65+
solidus_stripe:
66+
webhook_endpoint_secret: "whsec_..."
67+
```
68+
4069
## Usage
4170
42-
<!-- Explain how to use your extension once it's been installed. -->
71+
### Custom webhooks
72+
73+
You can also use [Stripe webhooks](https://stripe.com/docs/webhooks) to trigger
74+
custom actions in your application.
75+
76+
First, you need to register the event you want to listen to, both [in
77+
Stripe](https://stripe.com/docs/webhooks/go-live) and in your application:
78+
79+
```ruby
80+
# config/initializers/solidus_stripe.rb
81+
SolidusStripe.configure do |config|
82+
config.webhook_events = %i[charge.succeeded]
83+
end
84+
```
85+
86+
That will register a new `:"stripe.charge.succeeded"` event in the [Solidus
87+
bus](https://guides.solidus.io/customization/subscribing-to-events). The
88+
Solidus event will be published whenever a matching incoming webhook event is
89+
received. You can subscribe to it as regular:
90+
91+
```ruby
92+
# app/subscribers/update_account_balance_subscriber.rb
93+
class UpdateAccountBalanceSubscriber
94+
include Omnes::Subscriber
95+
96+
handle :"stripe.charge.succeeded", with: :call
97+
98+
def call(event)
99+
# ...
100+
end
101+
end
102+
103+
# config/initializers/solidus_stripe.rb
104+
# ...
105+
Rails.application.config.to_prepare do
106+
UpdateAccountBalanceSubscriber.new.subscribe_to(Spree::Bus)
107+
end
108+
```
109+
110+
The passed event object is a thin wrapper around the [Stripe
111+
event](https://www.rubydoc.info/gems/stripe/Stripe/Event) and will
112+
delegate all methods to it. It can also be used in async [
113+
adapters](https://github.com/nebulab/omnes#adapters), which is recommended as
114+
otherwise the response to Stripe will be delayed until subscribers are done.
115+
116+
You can also configure the signature verification tolerance in seconds (it
117+
defaults to the [same value as Stripe
118+
default](https://stripe.com/docs/webhooks/signatures#replay-attacks)):
119+
120+
```ruby
121+
# config/initializers/solidus_stripe.rb
122+
SolidusStripe.configure do |config|
123+
config.webhook_signature_tolerance = 150
124+
end
125+
```
43126

44127
## Development
45128

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# frozen_string_literal: true
2+
3+
require "solidus_stripe/webhook/event"
4+
require "stripe"
5+
6+
module SolidusStripe
7+
class WebhooksController < Spree::BaseController
8+
SIGNATURE_HEADER = "HTTP_STRIPE_SIGNATURE"
9+
10+
skip_before_action :verify_authenticity_token, only: :create
11+
12+
respond_to :json
13+
14+
def create
15+
request.body.read
16+
.then { |payload| Stripe::Webhook.construct_event(payload, signature_header, secret, tolerance: tolerance) }
17+
.then { |stripe_event| SolidusStripe::Webhook::Event.new(stripe_event: stripe_event) }
18+
.then { |event| Spree::Bus.publish(event) }
19+
20+
head :ok
21+
rescue Stripe::SignatureVerificationError, JSON::ParserError
22+
head :bad_request
23+
end
24+
25+
private
26+
27+
def signature_header
28+
request.headers[SIGNATURE_HEADER]
29+
end
30+
31+
def secret
32+
Rails.application.credentials.solidus_stripe[:webhook_endpoint_secret]
33+
end
34+
35+
def tolerance
36+
SolidusStripe.configuration.webhook_signature_tolerance
37+
end
38+
end
39+
end

config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22

33
SolidusStripe::Engine.routes.draw do
44
resources :payment_intents, only: :create
5+
resources :webhooks, only: :create, format: false
56
end

lib/solidus_stripe/configuration.rb

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,24 @@
11
# frozen_string_literal: true
22

3+
require "stripe/webhook"
4+
35
module SolidusStripe
46
class Configuration
5-
# Define here the settings for this extension, e.g.:
6-
#
7-
# attr_accessor :my_setting
7+
# @!attribute [rw] webhook_events
8+
# @return [Array<Symbol>] stripe events to handle. You also need to
9+
# register them in the Stripe dashboard. For an event `:foo`, a matching
10+
# `:"stripe.foo"` event will be registered in {Spree::Bus}.
11+
attr_accessor :webhook_events
12+
13+
# @!attribute [rw] webhook_signature_tolerance
14+
# @return [Integer] number of seconds while a webhook event is valid after
15+
# its creation. Defaults to {Stripe::Webhook::DEFAULT_TOLERANCE}.
16+
attr_accessor :webhook_signature_tolerance
17+
18+
def initialize
19+
@webhook_events = []
20+
@webhook_signature_tolerance = Stripe::Webhook::DEFAULT_TOLERANCE
21+
end
822
end
923

1024
class << self

lib/solidus_stripe/engine.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ class Engine < Rails::Engine
1414
app.config.spree.payment_methods << 'SolidusStripe::PaymentMethod'
1515
end
1616

17+
initializer "solidus_stripe.pub_sub", after: "spree.core.pub_sub" do |app|
18+
app.reloader.to_prepare do
19+
require "solidus_stripe/webhook/event"
20+
SolidusStripe::Webhook::Event.register(
21+
user_events: SolidusStripe.configuration.webhook_events,
22+
bus: Spree::Bus
23+
)
24+
end
25+
end
26+
1727
# use rspec for tests
1828
config.generators do |g|
1929
g.test_framework :rspec
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# frozen_string_literal: true
2+
3+
module SolidusStripe
4+
module Webhook
5+
# Omnes event wrapping a Stripe event.
6+
#
7+
# All unknown methods are delegated to the wrapped Stripe event.
8+
#
9+
# @see Stripe::Event
10+
class Event
11+
include Omnes::Event
12+
13+
# TBD
14+
CORE_EVENTS = Set[*%i[]].freeze
15+
private_constant :CORE_EVENTS
16+
17+
# @api private
18+
def self.register(user_events:, bus:, core_events: CORE_EVENTS)
19+
(core_events + user_events).each do |event|
20+
bus.register(:"stripe.#{event}")
21+
end
22+
end
23+
24+
attr_reader :omnes_event_name
25+
26+
# @api private
27+
def initialize(stripe_event:)
28+
@stripe_event = stripe_event
29+
@omnes_event_name = :"stripe.#{stripe_event.type}"
30+
end
31+
32+
# Serializable representation of the event.
33+
#
34+
# Ready to be consumed by async Omnes adapters, like
35+
# {Omnes::Subscriber::Adapter::ActiveJob} or
36+
# {Omnes::Subscriber::Adapter::Sidekiq}.
37+
#
38+
# @return [Hash<String, Object>]
39+
def payload
40+
@stripe_event.as_json
41+
end
42+
43+
private
44+
45+
def method_missing(method_name, ...)
46+
@stripe_event.send(method_name, ...)
47+
end
48+
49+
def respond_to_missing?(...)
50+
@stripe_event.respond_to?(...)
51+
end
52+
end
53+
end
54+
end
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# frozen_string_literal: true
2+
3+
require "solidus_stripe_spec_helper"
4+
5+
RSpec.describe SolidusStripe::Configuration do
6+
describe "#initialize" do
7+
it "defaults webhook events to empty" do
8+
expect(described_class.new.webhook_events).to eq([])
9+
end
10+
11+
it "defaults webhook signature tolerance to stripe's default" do
12+
expect(described_class.new.webhook_signature_tolerance).to eq(Stripe::Webhook::DEFAULT_TOLERANCE)
13+
end
14+
end
15+
end
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# frozen_string_literal: true
2+
3+
require "solidus_stripe_spec_helper"
4+
require "solidus_stripe/webhook/event"
5+
require "omnes/bus"
6+
7+
RSpec.describe SolidusStripe::Webhook::Event do
8+
let(:charge_suceeded_event) do
9+
Stripe::Event.construct_from(
10+
SolidusStripe::WebhookFixtures.charge_succeeded
11+
)
12+
end
13+
14+
describe ".register" do
15+
it "registers core events prepending with 'stripe'" do
16+
bus = Omnes::Bus.new
17+
18+
described_class.register(bus: bus, core_events: %i[foo], user_events: [])
19+
20+
expect(bus.registry.registered?(:"stripe.foo")).to be(true)
21+
end
22+
23+
it "registers user events prepending with 'stripe'" do
24+
bus = Omnes::Bus.new
25+
26+
described_class.register(bus: bus, user_events: %i[foo])
27+
28+
expect(bus.registry.registered?(:"stripe.foo")).to be(true)
29+
end
30+
end
31+
32+
describe "#initialize" do
33+
it "sets the omnes_event_name from the type field" do
34+
event = described_class.new(stripe_event: charge_suceeded_event)
35+
36+
expect(event.omnes_event_name).to be(:"stripe.charge.succeeded")
37+
end
38+
39+
it "delegates all other methods to the stripe event" do
40+
event = described_class.new(stripe_event: charge_suceeded_event)
41+
42+
expect(event.type).to eq("charge.succeeded")
43+
end
44+
end
45+
46+
describe "#payload" do
47+
it "returns Hash representation" do
48+
event = described_class.new(stripe_event: charge_suceeded_event)
49+
50+
expect(event.payload).to be_a(Hash)
51+
end
52+
53+
it "uses strings for the hash keys" do
54+
event = described_class.new(stripe_event: charge_suceeded_event)
55+
56+
expect(event.payload.keys).to include("type")
57+
end
58+
end
59+
end
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
require "solidus_stripe_spec_helper"
2+
3+
RSpec.describe SolidusStripe::WebhooksController, type: :request do
4+
# rubocop:disable RSpec/MultipleMemoizedHelpers
5+
describe "POST /create" do
6+
let(:timestamp) { Time.zone.now }
7+
let(:secret) { "whsec_123" }
8+
let(:signature) { Stripe::Webhook::Signature.compute_signature(timestamp, payload, secret) }
9+
let(:signature_header) { Stripe::Webhook::Signature.generate_header(timestamp, signature) }
10+
let(:signature_header_key) { described_class::SIGNATURE_HEADER }
11+
12+
before do
13+
allow(Rails.application.credentials).to receive(:solidus_stripe).and_return({ webhook_endpoint_secret: secret })
14+
end
15+
16+
context "when the request is valid" do
17+
let(:payload) { JSON.generate(SolidusStripe::WebhookFixtures.charge_succeeded) }
18+
19+
around do |example|
20+
if Spree::Bus.registry.registered?(:"stripe.charge.succeeded")
21+
example.run
22+
else
23+
Spree::Bus.register(:"stripe.charge.succeeded")
24+
example.run
25+
Spree::Bus.registry.unregister(:"stripe.charge.succeeded")
26+
end
27+
end
28+
29+
it "triggers a matching event on Spree::Bus" do
30+
run = false
31+
subscription = Spree::Bus.subscribe(:"stripe.charge.succeeded") { run = true }
32+
33+
post "/solidus_stripe/webhooks", params: payload, headers: { signature_header_key => signature_header }
34+
35+
expect(run).to be(true)
36+
ensure
37+
Spree::Bus.unsubscribe(subscription)
38+
end
39+
40+
it "binds the event to the subscriber" do
41+
event_type = nil
42+
subscription = Spree::Bus.subscribe(:"stripe.charge.succeeded") { |event| event_type = event.type }
43+
44+
post "/solidus_stripe/webhooks", params: payload, headers: { signature_header_key => signature_header }
45+
46+
expect(event_type).to eq("charge.succeeded")
47+
ensure
48+
Spree::Bus.unsubscribe(subscription)
49+
end
50+
51+
it "returns a 200 status code" do
52+
post "/solidus_stripe/webhooks", params: payload, headers: { signature_header_key => signature_header }
53+
54+
expect(response).to have_http_status(:ok)
55+
end
56+
end
57+
58+
context "when the signature is invalid" do
59+
let(:payload) { JSON.generate(SolidusStripe::WebhookFixtures.charge_succeeded) }
60+
61+
it "returns a 400 status code" do
62+
post "/solidus_stripe/webhooks", params: payload, headers: { signature_header_key => "t=1,v1=1" }
63+
64+
expect(response).to have_http_status(:bad_request)
65+
end
66+
end
67+
68+
context "when the payload is malformed" do
69+
let(:payload) { "invalid" }
70+
71+
it "returns a 400 status code" do
72+
post "/solidus_stripe/webhooks", params: payload, headers: { signature_header_key => signature_header }
73+
74+
expect(response).to have_http_status(:bad_request)
75+
end
76+
end
77+
end
78+
# rubocop:enable RSpec/MultipleMemoizedHelpers
79+
end

0 commit comments

Comments
 (0)