payment_kit 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +102 -0
- data/LICENSE.txt +21 -0
- data/README.md +1008 -0
- data/Rakefile +41 -0
- data/app/controllers/payment_kit/webhook_controller.rb +45 -0
- data/config/routes.rb +5 -0
- data/lib/payment_kit/client.rb +449 -0
- data/lib/payment_kit/configuration.rb +117 -0
- data/lib/payment_kit/engine.rb +10 -0
- data/lib/payment_kit/errors.rb +103 -0
- data/lib/payment_kit/instrumentation.rb +109 -0
- data/lib/payment_kit/namespace.rb +27 -0
- data/lib/payment_kit/notification_adapter.rb +21 -0
- data/lib/payment_kit/resources/catalog.rb +36 -0
- data/lib/payment_kit/resources/customers.rb +62 -0
- data/lib/payment_kit/resources/invoices.rb +64 -0
- data/lib/payment_kit/resources/payments.rb +71 -0
- data/lib/payment_kit/resources/subscriptions.rb +129 -0
- data/lib/payment_kit/version.rb +6 -0
- data/lib/payment_kit/webhook.rb +64 -0
- data/lib/payment_kit.rb +204 -0
- metadata +90 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module PaymentKit
|
|
7
|
+
# Webhook signature verification and event construction (Rails-free PORO).
|
|
8
|
+
#
|
|
9
|
+
# PaymentKit delivers +X-Webhook-Signature: sha256=<hex>+ over the raw body.
|
|
10
|
+
# Multiple signing secrets are tried in order, which is what the +roll-secret+
|
|
11
|
+
# grace period requires: during rotation both the old and new secret are live.
|
|
12
|
+
module Webhook
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Verifies +payload+ against +signature+ using one or more secrets and returns
|
|
16
|
+
# the parsed JSON event as a Hash.
|
|
17
|
+
#
|
|
18
|
+
# +payload+:: raw request body String, never a re-serialised version
|
|
19
|
+
# +signature+:: +X-Webhook-Signature+ header value
|
|
20
|
+
# +secrets+:: one signing secret String, or an Array of them
|
|
21
|
+
#
|
|
22
|
+
# Raises AuthenticationError when no signing secret is configured,
|
|
23
|
+
# SignatureVerificationError when the signature is missing or does not
|
|
24
|
+
# verify, and InvalidRequestError when the verified payload is not JSON.
|
|
25
|
+
def construct_event(payload, signature, secrets)
|
|
26
|
+
secret_list = Array(secrets).flatten.compact.reject { |value| blank?(value) }
|
|
27
|
+
raise AuthenticationError, "PaymentKit signing_secret is not configured" if secret_list.empty?
|
|
28
|
+
|
|
29
|
+
raise SignatureVerificationError, "PaymentKit webhook signature is missing" if blank?(signature)
|
|
30
|
+
|
|
31
|
+
verified = secret_list.any? { |secret| signature_valid?(payload, signature, secret) }
|
|
32
|
+
raise SignatureVerificationError, "PaymentKit webhook signature verification failed" unless verified
|
|
33
|
+
|
|
34
|
+
parse_payload(payload)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def signature_valid?(payload, signature, secret) # :nodoc:
|
|
38
|
+
received = signature.to_s.sub(/\Asha256=/, "")
|
|
39
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, payload.to_s)
|
|
40
|
+
secure_compare(expected, received)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Parsing happens only after a secret verified the payload, so a malformed
|
|
44
|
+
# body is reported as a bad request rather than a signature failure.
|
|
45
|
+
def parse_payload(payload)
|
|
46
|
+
JSON.parse(payload.to_s)
|
|
47
|
+
rescue JSON::ParserError => e
|
|
48
|
+
raise InvalidRequestError, "PaymentKit webhook payload is not valid JSON: #{e.message}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def blank?(value) # :nodoc:
|
|
52
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?) ||
|
|
53
|
+
(value.is_a?(String) && value.strip.empty?)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def secure_compare(left, right) # :nodoc:
|
|
57
|
+
return false unless left.bytesize == right.bytesize
|
|
58
|
+
|
|
59
|
+
OpenSSL.fixed_length_secure_compare(left, right)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private_class_method :signature_valid?, :parse_payload, :blank?, :secure_compare
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/payment_kit.rb
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_support/notifications"
|
|
5
|
+
|
|
6
|
+
require_relative "payment_kit/version"
|
|
7
|
+
require_relative "payment_kit/errors"
|
|
8
|
+
require_relative "payment_kit/configuration"
|
|
9
|
+
require_relative "payment_kit/instrumentation"
|
|
10
|
+
require_relative "payment_kit/namespace"
|
|
11
|
+
require_relative "payment_kit/notification_adapter"
|
|
12
|
+
require_relative "payment_kit/webhook"
|
|
13
|
+
require_relative "payment_kit/client"
|
|
14
|
+
|
|
15
|
+
# Ruby HTTP client and webhook event bus for the PaymentKit REST API.
|
|
16
|
+
#
|
|
17
|
+
# HTTP API access goes through +PaymentKit::Client+. Inbound webhooks are
|
|
18
|
+
# verified and then fanned out via +ActiveSupport::Notifications+
|
|
19
|
+
# (+subscribe+ / +instrument+ / +all+).
|
|
20
|
+
module PaymentKit
|
|
21
|
+
class << self
|
|
22
|
+
# Wraps each subscriber so it receives a single event argument.
|
|
23
|
+
# Defaults to NotificationAdapter.
|
|
24
|
+
attr_accessor :adapter
|
|
25
|
+
|
|
26
|
+
# Pub/sub backend. Defaults to +ActiveSupport::Notifications+; any object
|
|
27
|
+
# answering +instrument(name, payload)+ and +subscribe(pattern, callable)+ works.
|
|
28
|
+
attr_accessor :backend
|
|
29
|
+
|
|
30
|
+
# Namespace prefixing instrumented event names. Defaults to
|
|
31
|
+
# <tt>Namespace.new("payment_kit.")</tt>.
|
|
32
|
+
attr_accessor :namespace
|
|
33
|
+
|
|
34
|
+
# Callable run before dispatch. Return the event to continue, +nil+ to drop it.
|
|
35
|
+
attr_accessor :event_filter
|
|
36
|
+
|
|
37
|
+
# Callable run after verification, before dispatch — the deduplication hook.
|
|
38
|
+
# Return the event to continue, +nil+ to drop a redelivery.
|
|
39
|
+
attr_accessor :event_retriever
|
|
40
|
+
|
|
41
|
+
# Callable <tt>(exception, request)</tt> used by WebhookController to report a
|
|
42
|
+
# failed delivery and answer +200+ instead of +500+.
|
|
43
|
+
attr_accessor :error_handler
|
|
44
|
+
|
|
45
|
+
# Callable <tt>(exception, event)</tt> that isolates a failing subscriber, so
|
|
46
|
+
# one raising handler does not fail the whole delivery.
|
|
47
|
+
attr_accessor :subscriber_error_handler
|
|
48
|
+
|
|
49
|
+
# Global configuration object, created on first use.
|
|
50
|
+
def configuration
|
|
51
|
+
@configuration ||= Configuration.new
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Two forms, chosen by block arity:
|
|
55
|
+
#
|
|
56
|
+
# PaymentKit.configure { |config| config.secret_key = "st_..." } # settings
|
|
57
|
+
# PaymentKit.configure { subscribe("invoice.paid") { |e| ... } } # subscriber DSL
|
|
58
|
+
#
|
|
59
|
+
# The arity-0 form runs against the module itself, so subscriptions can be
|
|
60
|
+
# registered without repeating the +PaymentKit.+ receiver.
|
|
61
|
+
def configure(&block)
|
|
62
|
+
raise ArgumentError, "PaymentKit.configure requires a block" if block.nil?
|
|
63
|
+
|
|
64
|
+
block.arity.zero? ? instance_eval(&block) : yield(configuration)
|
|
65
|
+
configuration
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Restores configuration and the event-bus hooks to their defaults.
|
|
69
|
+
# Intended for test suites.
|
|
70
|
+
def reset_configuration!
|
|
71
|
+
@configuration = Configuration.new
|
|
72
|
+
self.event_filter = DEFAULT_EVENT_FILTER
|
|
73
|
+
self.event_retriever = DEFAULT_EVENT_RETRIEVER
|
|
74
|
+
self.error_handler = nil
|
|
75
|
+
self.subscriber_error_handler = nil
|
|
76
|
+
configuration
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# --- Signing secrets ----------------------------------------------------
|
|
80
|
+
|
|
81
|
+
# First configured signing secret.
|
|
82
|
+
def signing_secret
|
|
83
|
+
configuration.signing_secret
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Assigns a single signing secret, replacing any already configured.
|
|
87
|
+
def signing_secret=(value)
|
|
88
|
+
configuration.signing_secret = value
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# All configured signing secrets, as an Array. During a +roll-secret+ grace
|
|
92
|
+
# period both the old and new secret are live and are tried in order.
|
|
93
|
+
def signing_secrets
|
|
94
|
+
Array(configuration.signing_secrets).compact
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Assigns the full list of signing secrets.
|
|
98
|
+
def signing_secrets=(value)
|
|
99
|
+
configuration.signing_secrets = value.nil? ? nil : Array(value)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# --- Pub/Sub ------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
# Subscribe to an event type or type prefix (e.g. +"invoice.paid"+, +"invoice."+).
|
|
105
|
+
def subscribe(name, callable = nil, &block)
|
|
106
|
+
handler = callable || block
|
|
107
|
+
raise ArgumentError, "subscriber callable or block required" if handler.nil?
|
|
108
|
+
|
|
109
|
+
backend.subscribe(namespace.to_regexp(name), adapter.call(isolate(handler)))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Subscribe to every namespaced PaymentKit event.
|
|
113
|
+
def all(callable = nil, &)
|
|
114
|
+
subscribe(nil, callable, &)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Run +event_filter+, then notify subscribers under +payment_kit.<type>+.
|
|
118
|
+
# Returns the (possibly filtered) event, or +nil+ when the filter drops it.
|
|
119
|
+
def instrument(event)
|
|
120
|
+
filtered = event_filter.call(event)
|
|
121
|
+
return if filtered.nil?
|
|
122
|
+
|
|
123
|
+
type = event_type(filtered)
|
|
124
|
+
raise InvalidRequestError, "PaymentKit event is missing a type" if type.empty?
|
|
125
|
+
|
|
126
|
+
backend.instrument(namespace.call(type), filtered)
|
|
127
|
+
filtered
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Verify webhook signature(s), run the retriever, instrument the event.
|
|
131
|
+
#
|
|
132
|
+
# Returns the dispatched event, or +nil+ when +event_retriever+ dropped it
|
|
133
|
+
# (for example a duplicate delivery).
|
|
134
|
+
def process_webhook(payload, signature, secrets: nil)
|
|
135
|
+
event = Webhook.construct_event(payload, signature, secrets || signing_secrets)
|
|
136
|
+
event = event_retriever.call(event)
|
|
137
|
+
return if event.nil?
|
|
138
|
+
|
|
139
|
+
instrument(event)
|
|
140
|
+
event
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Whether any subscriber is currently listening for +name+.
|
|
144
|
+
def listening?(name)
|
|
145
|
+
notifier = backend.notifier
|
|
146
|
+
return false unless notifier.respond_to?(:listening?)
|
|
147
|
+
|
|
148
|
+
notifier.listening?(namespace.call(name))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
# Opt-in per-subscriber failure isolation.
|
|
154
|
+
#
|
|
155
|
+
# ActiveSupport runs the remaining subscribers even when one raises, but it
|
|
156
|
+
# then re-raises (or aggregates into +InstrumentationSubscriberError+), so the
|
|
157
|
+
# webhook request fails and PaymentKit redelivers the whole event — re-running
|
|
158
|
+
# the subscribers that already succeeded. Setting a +subscriber_error_handler+
|
|
159
|
+
# reports the failure instead and lets the delivery be acknowledged.
|
|
160
|
+
#
|
|
161
|
+
# Read at dispatch time, so it can be configured after subscribing.
|
|
162
|
+
def isolate(handler)
|
|
163
|
+
lambda do |event|
|
|
164
|
+
handler.call(event)
|
|
165
|
+
rescue StandardError => e
|
|
166
|
+
raise if subscriber_error_handler.nil?
|
|
167
|
+
|
|
168
|
+
subscriber_error_handler.call(e, event)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def event_type(event)
|
|
173
|
+
if event.respond_to?(:type) && !event.is_a?(Hash)
|
|
174
|
+
event.type
|
|
175
|
+
elsif event.is_a?(Hash)
|
|
176
|
+
event["type"] || event[:type]
|
|
177
|
+
end.to_s
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Identity filter: dispatches every event. Replace via +event_filter=+.
|
|
182
|
+
DEFAULT_EVENT_FILTER = ->(event) { event }
|
|
183
|
+
|
|
184
|
+
# Runs after signature verification and before dispatch. Return the event to
|
|
185
|
+
# continue, or +nil+ to drop it. PaymentKit redelivers on retry, so hosts
|
|
186
|
+
# should deduplicate here on the event id — the same value PaymentKit sends in
|
|
187
|
+
# the +X-Webhook-Event-Id+ header:
|
|
188
|
+
#
|
|
189
|
+
# PaymentKit.event_retriever = lambda do |event|
|
|
190
|
+
# key = "payment_kit:webhook:#{event["id"]}"
|
|
191
|
+
# Sidekiq.redis { |r| r.set(key, "1", nx: true, ex: 3 * 24 * 60 * 60) } ? event : nil
|
|
192
|
+
# end
|
|
193
|
+
DEFAULT_EVENT_RETRIEVER = ->(event) { event }
|
|
194
|
+
|
|
195
|
+
self.adapter = NotificationAdapter
|
|
196
|
+
self.backend = ActiveSupport::Notifications
|
|
197
|
+
self.namespace = Namespace.new("payment_kit.")
|
|
198
|
+
self.event_filter = DEFAULT_EVENT_FILTER
|
|
199
|
+
self.event_retriever = DEFAULT_EVENT_RETRIEVER
|
|
200
|
+
self.error_handler = nil
|
|
201
|
+
self.subscriber_error_handler = nil
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
require_relative "payment_kit/engine" if defined?(Rails::Engine)
|
metadata
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: payment_kit
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Muhammad Asim
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activesupport
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '6.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '6.1'
|
|
26
|
+
description: PaymentKit is a Ruby SDK for authenticating to and calling the PaymentKit
|
|
27
|
+
API.
|
|
28
|
+
email:
|
|
29
|
+
- mughalma096@gmail.com
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files:
|
|
33
|
+
- CHANGELOG.md
|
|
34
|
+
- LICENSE.txt
|
|
35
|
+
- README.md
|
|
36
|
+
files:
|
|
37
|
+
- CHANGELOG.md
|
|
38
|
+
- LICENSE.txt
|
|
39
|
+
- README.md
|
|
40
|
+
- Rakefile
|
|
41
|
+
- app/controllers/payment_kit/webhook_controller.rb
|
|
42
|
+
- config/routes.rb
|
|
43
|
+
- lib/payment_kit.rb
|
|
44
|
+
- lib/payment_kit/client.rb
|
|
45
|
+
- lib/payment_kit/configuration.rb
|
|
46
|
+
- lib/payment_kit/engine.rb
|
|
47
|
+
- lib/payment_kit/errors.rb
|
|
48
|
+
- lib/payment_kit/instrumentation.rb
|
|
49
|
+
- lib/payment_kit/namespace.rb
|
|
50
|
+
- lib/payment_kit/notification_adapter.rb
|
|
51
|
+
- lib/payment_kit/resources/catalog.rb
|
|
52
|
+
- lib/payment_kit/resources/customers.rb
|
|
53
|
+
- lib/payment_kit/resources/invoices.rb
|
|
54
|
+
- lib/payment_kit/resources/payments.rb
|
|
55
|
+
- lib/payment_kit/resources/subscriptions.rb
|
|
56
|
+
- lib/payment_kit/version.rb
|
|
57
|
+
- lib/payment_kit/webhook.rb
|
|
58
|
+
homepage: https://docs.paymentkit.com
|
|
59
|
+
licenses:
|
|
60
|
+
- MIT
|
|
61
|
+
metadata:
|
|
62
|
+
homepage_uri: https://docs.paymentkit.com
|
|
63
|
+
source_code_uri: https://github.com/mughalma096/payment_kit
|
|
64
|
+
changelog_uri: https://github.com/mughalma096/payment_kit/blob/master/CHANGELOG.md
|
|
65
|
+
rubygems_mfa_required: 'true'
|
|
66
|
+
rdoc_options:
|
|
67
|
+
- "--main"
|
|
68
|
+
- README.md
|
|
69
|
+
- "--title"
|
|
70
|
+
- PaymentKit 1.0.0
|
|
71
|
+
- "--line-numbers"
|
|
72
|
+
- "--hyperlink-all"
|
|
73
|
+
- "--charset=UTF-8"
|
|
74
|
+
require_paths:
|
|
75
|
+
- lib
|
|
76
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - ">="
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: 3.2.0
|
|
81
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
82
|
+
requirements:
|
|
83
|
+
- - ">="
|
|
84
|
+
- !ruby/object:Gem::Version
|
|
85
|
+
version: '0'
|
|
86
|
+
requirements: []
|
|
87
|
+
rubygems_version: 4.0.17
|
|
88
|
+
specification_version: 4
|
|
89
|
+
summary: Ruby HTTP client for the PaymentKit REST API.
|
|
90
|
+
test_files: []
|