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.
@@ -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
@@ -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: []