gocardless_pro 2.10.0 → 2.11.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +54 -0
- data/lib/gocardless_pro.rb +1 -0
- data/lib/gocardless_pro/client.rb +1 -1
- data/lib/gocardless_pro/resources/creditor_bank_account.rb +4 -0
- data/lib/gocardless_pro/version.rb +1 -1
- data/lib/gocardless_pro/webhook.rb +97 -0
- data/spec/webhook_spec.rb +122 -0
- metadata +5 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e050e363d51abaf3c39a5f492efa1512145e38fd83b6ca89271c5a36912d58fb
|
4
|
+
data.tar.gz: 5570e9de8ac349af06b99a282246e69ff4df075826414f5bf9f2df06b65a52f2
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7187e0274d9cf84dec3562521da846c90fddd75316eecc4fbb69b4b62a124a21096fa1fc929ba39ac42bbe83e8e1aa8918b78dfa31ee69dacb7f4fc3812d416a
|
7
|
+
data.tar.gz: f72fb680bd59ac97db9da8341e46d87bc71be4b65a802d51f5eecb443d075d98e64327c1a2602497c4848074e7997636864523643d6b965ff33fd00bb7b60091
|
data/README.md
CHANGED
@@ -173,6 +173,60 @@ If the client is unable to connect to GoCardless, an appropriate exception will
|
|
173
173
|
|
174
174
|
If an error occurs which is likely to be resolved with a retry (e.g. a timeout or connection error), and the request being made is idempotent, the library will automatically retry the request twice (i.e. it will make up to 3 attempts) before giving up and raising an exception.
|
175
175
|
|
176
|
+
### Handling webhooks
|
177
|
+
|
178
|
+
GoCardless supports webhooks, allowing you to receive real-time notifications when things happen in your account, so you can take automatic actions in response, for example:
|
179
|
+
|
180
|
+
* When a customer cancels their mandate with the bank, suspend their club membership
|
181
|
+
* When a payment fails due to lack of funds, mark their invoice as unpaid
|
182
|
+
* When a customer’s subscription generates a new payment, log it in their “past payments” list
|
183
|
+
|
184
|
+
The client allows you to validate that a webhook you receive is genuinely from GoCardless, and to parse it into `GoCardlessPro::Resources::Event` objects which are easy to work with:
|
185
|
+
|
186
|
+
```ruby
|
187
|
+
class WebhooksController < ApplicationController
|
188
|
+
include ActionController::Live
|
189
|
+
|
190
|
+
protect_from_forgery except: :create
|
191
|
+
|
192
|
+
def create
|
193
|
+
# When you create a webhook endpoint, you can specify a secret. When GoCardless sends
|
194
|
+
# you a webhook, it'll sign the body using that secret. Since only you and GoCardless
|
195
|
+
# know the secret, you can check the signature and ensure that the webhook is truly
|
196
|
+
# from GoCardless.
|
197
|
+
#
|
198
|
+
# We recommend storing your webhook endpoint secret in an environment variable
|
199
|
+
# for security, but you could include it as a string directly in your code
|
200
|
+
webhook_endpoint_secret = ENV['GOCARDLESS_WEBHOOK_ENDPOINT_SECRET']
|
201
|
+
|
202
|
+
begin
|
203
|
+
# This example is for Rails. In a Rack app (e.g. Sinatra), access the POST body with
|
204
|
+
# `request.body.tap(&:rewind).read` and the Webhook-Signature header with
|
205
|
+
# `request.env['HTTP_WEBHOOK_SIGNATURE']`.
|
206
|
+
events = GoCardlessPro::Webhook.parse(
|
207
|
+
request_body: request.raw_post,
|
208
|
+
signature_header: request.headers['Webhook-Signature'],
|
209
|
+
webhook_endpoint_secret: webhook_endpoint_secret
|
210
|
+
)
|
211
|
+
|
212
|
+
events.each do |event|
|
213
|
+
# You can access each event in the webhook.
|
214
|
+
puts event.id
|
215
|
+
end
|
216
|
+
|
217
|
+
render status: 200, nothing: true
|
218
|
+
rescue GoCardlessPro::Webhook::InvalidSignatureError
|
219
|
+
# The webhook doesn't appear to be genuinely from GoCardless, as the signature
|
220
|
+
# included in the `Webhook-Signature` header doesn't match one computed with your
|
221
|
+
# webhook endpoint secret and the body
|
222
|
+
render status: 498, nothing: true
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
```
|
227
|
+
|
228
|
+
For more details on working with webhooks, see our ["Getting started" guide](https://developer.gocardless.com/getting-started/api/introduction/?lang=ruby).
|
229
|
+
|
176
230
|
### Using the OAuth API
|
177
231
|
|
178
232
|
The API includes [OAuth](https://developer.gocardless.com/pro/2015-07-06/#guides-oauth) functionality, which allows you to work with other users' GoCardless accounts. Once a user approves you, you can use the GoCardless API on their behalf and receive their webhooks.
|
data/lib/gocardless_pro.rb
CHANGED
@@ -36,6 +36,7 @@ require_relative 'gocardless_pro/paginator'
|
|
36
36
|
require_relative 'gocardless_pro/request'
|
37
37
|
require_relative 'gocardless_pro/response'
|
38
38
|
require_relative 'gocardless_pro/api_response'
|
39
|
+
require_relative 'gocardless_pro/webhook'
|
39
40
|
|
40
41
|
require_relative 'gocardless_pro/resources/bank_details_lookup'
|
41
42
|
require_relative 'gocardless_pro/services/bank_details_lookups_service'
|
@@ -133,7 +133,7 @@ module GoCardlessPro
|
|
133
133
|
'User-Agent' => user_agent.to_s,
|
134
134
|
'Content-Type' => 'application/json',
|
135
135
|
'GoCardless-Client-Library' => 'gocardless-pro-ruby',
|
136
|
-
'GoCardless-Client-Version' => '2.
|
136
|
+
'GoCardless-Client-Version' => '2.11.0',
|
137
137
|
},
|
138
138
|
}
|
139
139
|
end
|
@@ -21,6 +21,10 @@ module GoCardlessPro
|
|
21
21
|
# account. You may wish to handle this by updating the existing record
|
22
22
|
# instead, the ID of which will be provided as
|
23
23
|
# `links[creditor_bank_account]` in the error response.
|
24
|
+
#
|
25
|
+
# <p class="restricted-notice"><strong>Restricted</strong>: This API is not
|
26
|
+
# available for
|
27
|
+
# partner integrations.</p>
|
24
28
|
class CreditorBankAccount
|
25
29
|
attr_reader :account_holder_name
|
26
30
|
attr_reader :account_number_ending
|
@@ -0,0 +1,97 @@
|
|
1
|
+
require 'openssl'
|
2
|
+
|
3
|
+
module GoCardlessPro
|
4
|
+
class Webhook
|
5
|
+
class InvalidSignatureError < StandardError; end
|
6
|
+
|
7
|
+
class << self
|
8
|
+
# Validates that a webhook was genuinely sent by GoCardless using
|
9
|
+
# `.signature_valid?`, and then parses it into an array of
|
10
|
+
# `GoCardlessPro::Resources::Event` objects representing each event
|
11
|
+
# included in the webhook
|
12
|
+
#
|
13
|
+
# @option options [String] :request_body the request body
|
14
|
+
# @option options [String] :signature_header the signature included in the request,
|
15
|
+
# found in the `Webhook-Signature` header
|
16
|
+
# @option options [String] :webhook_endpoint_secret the webhook endpoint secret for
|
17
|
+
# your webhook endpoint, as configured in your GoCardless Dashboard
|
18
|
+
# @return [Array<GoCardlessPro::Resources::Event>] the events included
|
19
|
+
# in the webhook
|
20
|
+
# @raise [InvalidSignatureError] if the signature header specified does not match
|
21
|
+
# the signature computed using the request body and webhook endpoint secret
|
22
|
+
# @raise [ArgumentError] if a required keyword argument is not provided or is not
|
23
|
+
# of the required type
|
24
|
+
def parse(options = {})
|
25
|
+
validate_options!(options)
|
26
|
+
|
27
|
+
unless signature_valid?(request_body: options[:request_body],
|
28
|
+
signature_header: options[:signature_header],
|
29
|
+
webhook_endpoint_secret: options[:webhook_endpoint_secret])
|
30
|
+
raise InvalidSignatureError, "This webhook doesn't appear to be a genuine " \
|
31
|
+
'webhook from GoCardless, because the signature ' \
|
32
|
+
"header doesn't match the signature computed" \
|
33
|
+
' with your webhook endpoint secret.'
|
34
|
+
end
|
35
|
+
|
36
|
+
events = JSON.parse(options[:request_body])['events']
|
37
|
+
|
38
|
+
events.map { |event| Resources::Event.new(event) }
|
39
|
+
end
|
40
|
+
|
41
|
+
# Validates that a webhook was genuinely sent by GoCardless by computing its
|
42
|
+
# signature using the body and your webhook endpoint secret, and comparing that with
|
43
|
+
# the signature included in the `Webhook-Signature` header
|
44
|
+
#
|
45
|
+
# @option options [String] :request_body the request body
|
46
|
+
# @option options [String] :signature_header the signature included in the request,
|
47
|
+
# found in the `Webhook-Signature` header
|
48
|
+
# @option options [String] :webhook_endpoint_secret the webhook endpoint secret for
|
49
|
+
# your webhook endpoint, as configured in your GoCardless Dashboard
|
50
|
+
# @return [Boolean] whether the webhook's signature is valid
|
51
|
+
# @raise [ArgumentError] if a required keyword argument is not provided or is not
|
52
|
+
# of the required type
|
53
|
+
def signature_valid?(options = {})
|
54
|
+
validate_options!(options)
|
55
|
+
|
56
|
+
computed_signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'),
|
57
|
+
options[:webhook_endpoint_secret],
|
58
|
+
options[:request_body])
|
59
|
+
|
60
|
+
secure_compare(options[:signature_header], computed_signature)
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
# Performs a "constant time" comparison of two strings, safe against timing attacks
|
66
|
+
#
|
67
|
+
# Vendored from Rack's `Rack::Utils.secure_compare`
|
68
|
+
# (https://github.com/rack/rack/blob/eb040cf1bbb1b2dacd496ab0aa549de8408d8a27/lib/rack/utils.rb#L368-L382).
|
69
|
+
# Licensed under The MIT License (MIT). Copyright (C) 2007-2018 Christian
|
70
|
+
# Neukirchen.
|
71
|
+
def secure_compare(a, b)
|
72
|
+
return false unless a.bytesize == b.bytesize
|
73
|
+
l = a.unpack('C*')
|
74
|
+
|
75
|
+
r = 0
|
76
|
+
i = -1
|
77
|
+
b.each_byte { |v| r |= v ^ l[i += 1] }
|
78
|
+
r == 0
|
79
|
+
end
|
80
|
+
|
81
|
+
def validate_options!(options)
|
82
|
+
unless options[:request_body].is_a?(String)
|
83
|
+
raise ArgumentError, 'request_body must be provided and must be a string'
|
84
|
+
end
|
85
|
+
|
86
|
+
unless options[:signature_header].is_a?(String)
|
87
|
+
raise ArgumentError, 'signature_header must be provided and must be a string'
|
88
|
+
end
|
89
|
+
|
90
|
+
unless options[:webhook_endpoint_secret].is_a?(String)
|
91
|
+
raise ArgumentError, 'webhook_endpoint_secret must be provided and must be a ' \
|
92
|
+
'string'
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'pathname'
|
3
|
+
|
4
|
+
describe GoCardlessPro::Webhook do
|
5
|
+
let(:options) do
|
6
|
+
{
|
7
|
+
request_body: request_body,
|
8
|
+
signature_header: signature_header,
|
9
|
+
webhook_endpoint_secret: webhook_endpoint_secret,
|
10
|
+
}
|
11
|
+
end
|
12
|
+
|
13
|
+
let(:request_body) do
|
14
|
+
'{"events":[{"id":"EV00BD05S5VM2T","created_at":"2018-07-05T09:13:51.404Z","resou' \
|
15
|
+
'rce_type":"subscriptions","action":"created","links":{"subscription":"SB0003JJQ2' \
|
16
|
+
'MR06"},"details":{"origin":"api","cause":"subscription_created","description":"S' \
|
17
|
+
'ubscription created via the API."},"metadata":{}},{"id":"EV00BD05TB8K63","create' \
|
18
|
+
'd_at":"2018-07-05T09:13:56.893Z","resource_type":"mandates","action":"created","' \
|
19
|
+
'links":{"mandate":"MD000AMA19XGEC"},"details":{"origin":"api","cause":"mandate_c' \
|
20
|
+
'reated","description":"Mandate created via the API."},"metadata":{}}]}'
|
21
|
+
end
|
22
|
+
|
23
|
+
let(:signature_header) do
|
24
|
+
'2693754819d3e32d7e8fcb13c729631f316c6de8dc1cf634d6527f1c07276e7e'
|
25
|
+
end
|
26
|
+
|
27
|
+
let(:webhook_endpoint_secret) { 'ED7D658C-D8EB-4941-948B-3973214F2D49' }
|
28
|
+
|
29
|
+
describe '.parse' do
|
30
|
+
context 'when the signature in the header matches the computed signature' do
|
31
|
+
it 'returns an array of `GoCardlessPro::Resources::Event`s' do
|
32
|
+
events = described_class.parse(options)
|
33
|
+
|
34
|
+
expect(events.length).to eq(2)
|
35
|
+
|
36
|
+
expect(events.first.id).to eq('EV00BD05S5VM2T')
|
37
|
+
expect(events.first.created_at).to eq('2018-07-05T09:13:51.404Z')
|
38
|
+
expect(events.first.resource_type).to eq('subscriptions')
|
39
|
+
expect(events.first.action).to eq('created')
|
40
|
+
expect(events.first.links.subscription).to eq('SB0003JJQ2MR06')
|
41
|
+
expect(events.first.details['origin']).to eq('api')
|
42
|
+
expect(events.first.details['cause']).to eq('subscription_created')
|
43
|
+
expect(events.first.details['description']).
|
44
|
+
to eq('Subscription created via the API.')
|
45
|
+
expect(events.first.metadata).to eq({})
|
46
|
+
|
47
|
+
expect(events.last.id).to eq('EV00BD05TB8K63')
|
48
|
+
expect(events.last.created_at).to eq('2018-07-05T09:13:56.893Z')
|
49
|
+
expect(events.last.resource_type).to eq('mandates')
|
50
|
+
expect(events.last.action).to eq('created')
|
51
|
+
expect(events.last.links.mandate).to eq('MD000AMA19XGEC')
|
52
|
+
expect(events.last.details['origin']).to eq('api')
|
53
|
+
expect(events.last.details['cause']).to eq('mandate_created')
|
54
|
+
expect(events.last.details['description']).
|
55
|
+
to eq('Mandate created via the API.')
|
56
|
+
expect(events.last.metadata).to eq({})
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
context "when the signature in the header doesn't match the computed signature" do
|
61
|
+
let(:webhook_endpoint_secret) { 'foo' }
|
62
|
+
|
63
|
+
it 'raises an InvalidSignatureError' do
|
64
|
+
expect { described_class.parse(options) }.
|
65
|
+
to raise_error(described_class::InvalidSignatureError,
|
66
|
+
/doesn't appear to be a genuine webhook from GoCardless/)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
context 'with a required argument missing' do
|
71
|
+
before { options.delete(:request_body) }
|
72
|
+
|
73
|
+
it 'raises an ArgumentError' do
|
74
|
+
expect { described_class.signature_valid?(options) }.
|
75
|
+
to raise_error(ArgumentError,
|
76
|
+
'request_body must be provided and must be a string')
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
context 'with an argument of the wrong type' do
|
81
|
+
let(:request_body) { StringIO.new }
|
82
|
+
|
83
|
+
it 'raises an ArgumentError' do
|
84
|
+
expect { described_class.signature_valid?(options) }.
|
85
|
+
to raise_error(ArgumentError,
|
86
|
+
'request_body must be provided and must be a string')
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
describe '.signature_valid?' do
|
92
|
+
context 'when the signature in the header matches the computed signature' do
|
93
|
+
specify { expect(described_class.signature_valid?(options)).to be(true) }
|
94
|
+
end
|
95
|
+
|
96
|
+
context "when the signature in the header doesn't match the computed signature" do
|
97
|
+
let(:webhook_endpoint_secret) { 'foo' }
|
98
|
+
|
99
|
+
specify { expect(described_class.signature_valid?(options)).to be(false) }
|
100
|
+
end
|
101
|
+
|
102
|
+
context 'with a required argument missing' do
|
103
|
+
before { options.delete(:request_body) }
|
104
|
+
|
105
|
+
it 'raises an ArgumentError' do
|
106
|
+
expect { described_class.signature_valid?(options) }.
|
107
|
+
to raise_error(ArgumentError,
|
108
|
+
'request_body must be provided and must be a string')
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
context 'with an argument of the wrong type' do
|
113
|
+
let(:request_body) { StringIO.new }
|
114
|
+
|
115
|
+
it 'raises an ArgumentError' do
|
116
|
+
expect { described_class.signature_valid?(options) }.
|
117
|
+
to raise_error(ArgumentError,
|
118
|
+
'request_body must be provided and must be a string')
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: gocardless_pro
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.
|
4
|
+
version: 2.11.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- GoCardless
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2018-
|
11
|
+
date: 2018-07-06 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rspec
|
@@ -151,6 +151,7 @@ files:
|
|
151
151
|
- lib/gocardless_pro/services/refunds_service.rb
|
152
152
|
- lib/gocardless_pro/services/subscriptions_service.rb
|
153
153
|
- lib/gocardless_pro/version.rb
|
154
|
+
- lib/gocardless_pro/webhook.rb
|
154
155
|
- spec/api_response_spec.rb
|
155
156
|
- spec/api_service_spec.rb
|
156
157
|
- spec/client_spec.rb
|
@@ -190,6 +191,7 @@ files:
|
|
190
191
|
- spec/services/refunds_service_spec.rb
|
191
192
|
- spec/services/subscriptions_service_spec.rb
|
192
193
|
- spec/spec_helper.rb
|
194
|
+
- spec/webhook_spec.rb
|
193
195
|
homepage: https://github.com/gocardless/gocardless-pro-ruby
|
194
196
|
licenses:
|
195
197
|
- MIT
|
@@ -254,3 +256,4 @@ test_files:
|
|
254
256
|
- spec/services/refunds_service_spec.rb
|
255
257
|
- spec/services/subscriptions_service_spec.rb
|
256
258
|
- spec/spec_helper.rb
|
259
|
+
- spec/webhook_spec.rb
|