Skip to content
Merged
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
85 changes: 84 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<!-- Explain how to use your extension once it's been installed. -->
### 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

Expand Down
27 changes: 27 additions & 0 deletions app/controllers/solidus_stripe/webhooks_controller.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@

SolidusStripe::Engine.routes.draw do
resources :payment_intents, only: :create
resources :webhooks, only: :create, format: false
Comment thread
kennyadsl marked this conversation as resolved.
end
Original file line number Diff line number Diff line change
@@ -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']
Expand Down
20 changes: 17 additions & 3 deletions lib/solidus_stripe/configuration.rb
Original file line number Diff line number Diff line change
@@ -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<Symbol>] 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
Comment thread
kennyadsl marked this conversation as resolved.

# @!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
Expand Down
10 changes: 10 additions & 0 deletions lib/solidus_stripe/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions lib/solidus_stripe/webhook/event.rb
Original file line number Diff line number Diff line change
@@ -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<String, Object>]
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
15 changes: 15 additions & 0 deletions spec/lib/solidus_stripe/configuration_spec.rb
Original file line number Diff line number Diff line change
@@ -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
107 changes: 107 additions & 0 deletions spec/lib/solidus_stripe/webhook/event_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading