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
data/Rakefile
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rspec/core/rake_task"
|
|
5
|
+
|
|
6
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
7
|
+
|
|
8
|
+
require "rubocop/rake_task"
|
|
9
|
+
|
|
10
|
+
RuboCop::RakeTask.new
|
|
11
|
+
|
|
12
|
+
# RDoc stopped being a default gem in Ruby 4, so it is not guaranteed to be
|
|
13
|
+
# present. Guard the require: a bundle without it should still run spec and
|
|
14
|
+
# rubocop rather than failing to load the Rakefile at all.
|
|
15
|
+
begin
|
|
16
|
+
require "rdoc/task"
|
|
17
|
+
require_relative "lib/payment_kit/version"
|
|
18
|
+
|
|
19
|
+
# rake rdoc → doc/, rake rerdoc → rebuild from scratch, rake clobber_rdoc → remove.
|
|
20
|
+
RDoc::Task.new do |rdoc|
|
|
21
|
+
rdoc.rdoc_dir = "doc"
|
|
22
|
+
rdoc.title = "PaymentKit #{PaymentKit::VERSION}"
|
|
23
|
+
rdoc.main = "README.md"
|
|
24
|
+
|
|
25
|
+
# app/ carries the optional Rails webhook controller; the architecture reviews
|
|
26
|
+
# at the repo root are research notes about other libraries, not API docs.
|
|
27
|
+
rdoc.rdoc_files.include("README.md", "CHANGELOG.md", "LICENSE.txt",
|
|
28
|
+
"lib/**/*.rb", "app/**/*.rb")
|
|
29
|
+
|
|
30
|
+
rdoc.options << "--line-numbers"
|
|
31
|
+
rdoc.options << "--hyperlink-all"
|
|
32
|
+
rdoc.options << "--charset=UTF-8"
|
|
33
|
+
end
|
|
34
|
+
rescue LoadError
|
|
35
|
+
desc "Generate the API reference (requires the rdoc gem)"
|
|
36
|
+
task :rdoc do
|
|
37
|
+
abort 'RDoc is not available. Add `gem "rdoc"` to your Gemfile, then run bundle install.'
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
task default: %i[spec rubocop]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaymentKit
|
|
4
|
+
# HTTP entrypoint: verify signature(s) → dedupe → instrument → 200 OK.
|
|
5
|
+
#
|
|
6
|
+
# PaymentKit treats 4xx as a permanent failure (no retry) and retries 5xx or
|
|
7
|
+
# timeouts five times over roughly 27 hours, so failure mapping matters:
|
|
8
|
+
#
|
|
9
|
+
# * bad/missing signature → 401, never retried
|
|
10
|
+
# * verified but unparseable → 400, never retried
|
|
11
|
+
# * subscriber raised → 500 by default (retried), or 200 when
|
|
12
|
+
# +PaymentKit.error_handler+ is configured
|
|
13
|
+
#
|
|
14
|
+
# Endpoints must answer within 30 seconds, so subscribers should enqueue work
|
|
15
|
+
# rather than perform it inline.
|
|
16
|
+
class WebhookController < ActionController::Base
|
|
17
|
+
if respond_to?(:skip_forgery_protection)
|
|
18
|
+
skip_forgery_protection
|
|
19
|
+
elsif respond_to?(:protect_from_forgery)
|
|
20
|
+
skip_before_action :verify_authenticity_token, raise: false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Handles one webhook delivery: verify, deduplicate, dispatch, answer.
|
|
24
|
+
def event
|
|
25
|
+
# raw_post (not body.read) so verification still sees the exact bytes
|
|
26
|
+
# after middleware or param parsing has consumed the request stream.
|
|
27
|
+
PaymentKit.process_webhook(
|
|
28
|
+
request.raw_post,
|
|
29
|
+
request.headers["X-Webhook-Signature"]
|
|
30
|
+
)
|
|
31
|
+
head :ok
|
|
32
|
+
rescue AuthenticationError => e
|
|
33
|
+
logger&.error("[PaymentKit::WebhookController] #{e.message}")
|
|
34
|
+
head :unauthorized
|
|
35
|
+
rescue InvalidRequestError => e
|
|
36
|
+
logger&.error("[PaymentKit::WebhookController] #{e.message}")
|
|
37
|
+
head :bad_request
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
raise if PaymentKit.error_handler.nil?
|
|
40
|
+
|
|
41
|
+
PaymentKit.error_handler.call(e, request)
|
|
42
|
+
head :ok
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
require_relative "resources/customers"
|
|
9
|
+
require_relative "resources/subscriptions"
|
|
10
|
+
require_relative "resources/invoices"
|
|
11
|
+
require_relative "resources/payments"
|
|
12
|
+
require_relative "resources/catalog"
|
|
13
|
+
|
|
14
|
+
module PaymentKit
|
|
15
|
+
# HTTP client for the PaymentKit API.
|
|
16
|
+
#
|
|
17
|
+
# Authenticates with a Bearer secret key, scopes requests to an account base URL,
|
|
18
|
+
# encodes JSON bodies, auto-paginates list endpoints, maps RFC 7807 errors, and
|
|
19
|
+
# retries transient failures with exponential backoff.
|
|
20
|
+
#
|
|
21
|
+
# Endpoint methods live in the +PaymentKit::Resources::*+ modules.
|
|
22
|
+
class Client
|
|
23
|
+
# Backwards-compatible aliases: applications written against a nested error
|
|
24
|
+
# namespace (+PaymentKit::Client::AuthenticationError+) keep working.
|
|
25
|
+
Error = PaymentKit::Error
|
|
26
|
+
AuthenticationError = PaymentKit::AuthenticationError
|
|
27
|
+
PermissionError = PaymentKit::PermissionError
|
|
28
|
+
SignatureVerificationError = PaymentKit::SignatureVerificationError
|
|
29
|
+
InvalidRequestError = PaymentKit::InvalidRequestError
|
|
30
|
+
ConflictError = PaymentKit::ConflictError
|
|
31
|
+
CardError = PaymentKit::CardError
|
|
32
|
+
RateLimitError = PaymentKit::RateLimitError
|
|
33
|
+
APIError = PaymentKit::APIError
|
|
34
|
+
ApiError = PaymentKit::APIError
|
|
35
|
+
APIConnectionError = PaymentKit::APIConnectionError
|
|
36
|
+
ConnectionError = PaymentKit::APIConnectionError
|
|
37
|
+
|
|
38
|
+
# Redirects followed before giving up.
|
|
39
|
+
MAX_REDIRECTS = 3
|
|
40
|
+
# Statuses that are always safe to replay. 409 is deliberately excluded:
|
|
41
|
+
# PaymentKit marks only *some* conflicts retryable via +error_code+.
|
|
42
|
+
RETRYABLE_STATUSES = [408, 429, 500, 502, 503, 504].freeze
|
|
43
|
+
# 409 +error_code+ values documented as retry-safe with no side effects.
|
|
44
|
+
RETRYABLE_ERROR_CODES = %w[invoice_locked].freeze
|
|
45
|
+
# Redirects PaymentKit uses for path canonicalization; both preserve the verb.
|
|
46
|
+
REDIRECT_STATUSES = [307, 308].freeze
|
|
47
|
+
# Methods that receive an auto-generated Idempotency-Key when the caller
|
|
48
|
+
# does not supply one, so internal retries cannot double-charge.
|
|
49
|
+
IDEMPOTENT_METHODS = %i[post put patch].freeze
|
|
50
|
+
# Ceiling for a server-supplied Retry-After, so a hostile or mistaken header
|
|
51
|
+
# cannot park a request for hours.
|
|
52
|
+
MAX_RETRY_DELAY = 32
|
|
53
|
+
# Characters of raw response body appended to a 4xx message for context.
|
|
54
|
+
RAW_BODY_LIMIT = 800
|
|
55
|
+
|
|
56
|
+
include Resources::Customers
|
|
57
|
+
include Resources::Subscriptions
|
|
58
|
+
include Resources::Invoices
|
|
59
|
+
include Resources::Payments
|
|
60
|
+
include Resources::Catalog
|
|
61
|
+
|
|
62
|
+
# Builds a client. Every keyword falls back to PaymentKit.configuration (or
|
|
63
|
+
# to +configuration:+ when given), so a bare <tt>Client.new</tt> uses the
|
|
64
|
+
# global settings.
|
|
65
|
+
#
|
|
66
|
+
# Raises AuthenticationError when +secret_key+ is missing or contains
|
|
67
|
+
# whitespace, and InvalidRequestError when neither +base_url+ nor
|
|
68
|
+
# +account_id+ is configured.
|
|
69
|
+
def initialize(secret_key: nil, account_id: nil, base_url: nil, api_host: nil,
|
|
70
|
+
open_timeout: nil, read_timeout: nil, max_retries: nil,
|
|
71
|
+
signing_secret: nil, configuration: nil)
|
|
72
|
+
base = configuration || PaymentKit.configuration
|
|
73
|
+
@config = base.merge(
|
|
74
|
+
secret_key: secret_key,
|
|
75
|
+
account_id: account_id,
|
|
76
|
+
base_url: base_url,
|
|
77
|
+
api_host: api_host,
|
|
78
|
+
open_timeout: open_timeout,
|
|
79
|
+
read_timeout: read_timeout,
|
|
80
|
+
max_retries: max_retries,
|
|
81
|
+
signing_secret: signing_secret
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
raise AuthenticationError, "PaymentKit secret_key is not configured" if blank?(@config.secret_key)
|
|
85
|
+
if @config.secret_key.to_s.match?(/\s/)
|
|
86
|
+
raise AuthenticationError, "PaymentKit secret_key cannot contain whitespace"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
@base_url = @config.resolved_base_url
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Resolved base URL every account-scoped request is sent to.
|
|
93
|
+
attr_reader :base_url
|
|
94
|
+
|
|
95
|
+
# --- Webhooks -----------------------------------------------------------
|
|
96
|
+
# Verifies HMAC-SHA256 over the raw payload (`X-Webhook-Signature: sha256=<hex>`).
|
|
97
|
+
# Delegates to +PaymentKit::Webhook.construct_event+ (supports one or many secrets).
|
|
98
|
+
def verify_webhook(payload, signature, secret: nil)
|
|
99
|
+
secrets = secret.nil? ? @config.signing_secrets : secret
|
|
100
|
+
Webhook.construct_event(payload, signature, secrets)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# --- Escape hatch -------------------------------------------------------
|
|
104
|
+
# Calls any PaymentKit endpoint, including ones this SDK does not wrap yet
|
|
105
|
+
# (payment links, refunds, webhook endpoint management, …). Goes through the
|
|
106
|
+
# same auth, retry, idempotency and error mapping as the typed methods.
|
|
107
|
+
#
|
|
108
|
+
# client.raw_request(:get, "/payment-links", params: { limit: 10 })
|
|
109
|
+
# client.raw_request(:post, "/webhook-endpoints/we_1/roll-secret",
|
|
110
|
+
# params: { ttl_seconds: 3600 })
|
|
111
|
+
#
|
|
112
|
+
# Endpoints that are not account-scoped (for example the customer portal
|
|
113
|
+
# surface at +/billing-portal/token/...+) opt out of the account prefix:
|
|
114
|
+
#
|
|
115
|
+
# client.raw_request(:get, "/billing-portal/token/#{token}/payment-methods",
|
|
116
|
+
# account_scoped: false)
|
|
117
|
+
def raw_request(method, path, params: nil, idempotency_key: nil, account_scoped: true)
|
|
118
|
+
query = method == :get ? params : nil
|
|
119
|
+
body = method == :get ? nil : params
|
|
120
|
+
request(
|
|
121
|
+
method, path,
|
|
122
|
+
query: query, body: body,
|
|
123
|
+
idempotency_key: idempotency_key, account_scoped: account_scoped
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def get(path, params = {}) = request(:get, path, query: params)
|
|
130
|
+
|
|
131
|
+
# Write helpers accept the body either positionally (+post(path, {a: 1})+) or
|
|
132
|
+
# as loose keywords (+post(path, nil, a: 1)+), so resource methods keep their
|
|
133
|
+
# original brace-less call style while also taking +idempotency_key:+.
|
|
134
|
+
def post(path, params = nil, idempotency_key: nil, **rest)
|
|
135
|
+
request(:post, path, body: params || rest, idempotency_key: idempotency_key)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def put(path, params = nil, idempotency_key: nil, **rest)
|
|
139
|
+
request(:put, path, body: params || rest, idempotency_key: idempotency_key)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def patch(path, params = nil, idempotency_key: nil, **rest)
|
|
143
|
+
request(:patch, path, body: params || rest, idempotency_key: idempotency_key)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def delete(path, idempotency_key: nil)
|
|
147
|
+
request(:delete, path, idempotency_key: idempotency_key)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def list(path, params)
|
|
151
|
+
params = stringify_keys(params || {})
|
|
152
|
+
results = []
|
|
153
|
+
offset = 0
|
|
154
|
+
limit = params["limit"] || 100
|
|
155
|
+
|
|
156
|
+
loop do
|
|
157
|
+
query = params.merge("limit" => limit, "offset" => offset)
|
|
158
|
+
page = request(:get, path, query: query)
|
|
159
|
+
data = Array(page["items"])
|
|
160
|
+
results.concat(data)
|
|
161
|
+
break unless page["has_more"] && !data.empty?
|
|
162
|
+
|
|
163
|
+
offset += data.size
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
results
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Wraps the request/retry loop with instrumentation. +request_end+ fires on
|
|
170
|
+
# both the success and failure paths, once per logical call.
|
|
171
|
+
def request(method, path, query: nil, body: nil, idempotency_key: nil, account_scoped: true)
|
|
172
|
+
stats = { status: nil, retries: 0, request_id: nil }
|
|
173
|
+
started = monotonic_time
|
|
174
|
+
notify_request_begin(method, path)
|
|
175
|
+
|
|
176
|
+
perform(
|
|
177
|
+
method, path,
|
|
178
|
+
query: query, body: body, idempotency_key: idempotency_key,
|
|
179
|
+
account_scoped: account_scoped, stats: stats
|
|
180
|
+
)
|
|
181
|
+
ensure
|
|
182
|
+
notify_request_end(method, path, stats, monotonic_time - started)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def perform(method, path, query:, body:, idempotency_key:, account_scoped:, stats:, attempt: 0)
|
|
186
|
+
uri = build_uri(path, query, account_scoped)
|
|
187
|
+
idempotency_key ||= SecureRandom.uuid if idempotent_write?(method, body)
|
|
188
|
+
response = send_with_redirects(method, uri, body, idempotency_key)
|
|
189
|
+
status = response.code.to_i
|
|
190
|
+
stats[:status] = status
|
|
191
|
+
stats[:request_id] = header_request_id(response)
|
|
192
|
+
return parse_body(response.body) if status.between?(200, 299)
|
|
193
|
+
|
|
194
|
+
if retryable_response?(status, response.body) && attempt < @config.max_retries
|
|
195
|
+
stats[:retries] = attempt + 1
|
|
196
|
+
sleep(retry_delay(attempt, response))
|
|
197
|
+
return perform(
|
|
198
|
+
method, path,
|
|
199
|
+
query: query, body: body, idempotency_key: idempotency_key,
|
|
200
|
+
account_scoped: account_scoped, stats: stats, attempt: attempt + 1
|
|
201
|
+
)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
raise error_for(status, response.body, response)
|
|
205
|
+
rescue Timeout::Error, Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError, IOError => e
|
|
206
|
+
raise APIConnectionError, "PaymentKit connection error: #{e.message}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def build_uri(path, query, account_scoped)
|
|
210
|
+
base = account_scoped ? @base_url : @config.unscoped_base_url
|
|
211
|
+
uri = URI("#{base}#{path}")
|
|
212
|
+
return uri if blank?(query)
|
|
213
|
+
|
|
214
|
+
flat = flatten_params(stringify_keys(query))
|
|
215
|
+
uri.query = URI.encode_www_form(flat) unless flat.empty?
|
|
216
|
+
uri
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def monotonic_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
220
|
+
|
|
221
|
+
def notify_request_begin(method, path)
|
|
222
|
+
return unless Instrumentation.subscribers?(:request_begin)
|
|
223
|
+
|
|
224
|
+
Instrumentation.notify(
|
|
225
|
+
:request_begin,
|
|
226
|
+
Instrumentation::RequestBeginEvent.new(method: method, path: path)
|
|
227
|
+
)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def notify_request_end(method, path, stats, duration)
|
|
231
|
+
return unless Instrumentation.subscribers?(:request_end)
|
|
232
|
+
|
|
233
|
+
Instrumentation.notify(
|
|
234
|
+
:request_end,
|
|
235
|
+
Instrumentation::RequestEvent.new(
|
|
236
|
+
method: method, path: path, status: stats[:status], duration: duration,
|
|
237
|
+
num_retries: stats[:retries], request_id: stats[:request_id]
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# A 409 is only replayed when PaymentKit flags it as retryable (or reports a
|
|
243
|
+
# known side-effect-free +error_code+); other conflicts are terminal.
|
|
244
|
+
def retryable_response?(status, raw)
|
|
245
|
+
return true if RETRYABLE_STATUSES.include?(status)
|
|
246
|
+
return false unless status == 409
|
|
247
|
+
|
|
248
|
+
problem = parse_error_json(raw)
|
|
249
|
+
problem["retryable"] == true || RETRYABLE_ERROR_CODES.include?(problem["error_code"].to_s)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def send_with_redirects(method, uri, body, idempotency_key, redirects = 0)
|
|
253
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
254
|
+
http.use_ssl = uri.scheme == "https"
|
|
255
|
+
http.open_timeout = @config.open_timeout
|
|
256
|
+
http.read_timeout = @config.read_timeout
|
|
257
|
+
|
|
258
|
+
req = build_request(method, uri, body, idempotency_key)
|
|
259
|
+
response = transport(http, req)
|
|
260
|
+
|
|
261
|
+
if REDIRECT_STATUSES.include?(response.code.to_i) &&
|
|
262
|
+
present?(response["location"]) &&
|
|
263
|
+
redirects < MAX_REDIRECTS
|
|
264
|
+
target = URI.join(uri, response["location"])
|
|
265
|
+
target.query = uri.query if target.query.nil?
|
|
266
|
+
return send_with_redirects(method, target, body, idempotency_key, redirects + 1)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
response
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Seam for specs: stub this instead of making live HTTP calls.
|
|
273
|
+
def transport(http, request)
|
|
274
|
+
http.request(request)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def build_request(method, uri, body, idempotency_key)
|
|
278
|
+
klass = {
|
|
279
|
+
get: Net::HTTP::Get,
|
|
280
|
+
post: Net::HTTP::Post,
|
|
281
|
+
put: Net::HTTP::Put,
|
|
282
|
+
patch: Net::HTTP::Patch,
|
|
283
|
+
delete: Net::HTTP::Delete
|
|
284
|
+
}.fetch(method)
|
|
285
|
+
|
|
286
|
+
req = klass.new(uri.request_uri)
|
|
287
|
+
req["Authorization"] = "Bearer #{@config.secret_key}"
|
|
288
|
+
req["Accept"] = "application/json"
|
|
289
|
+
req["Idempotency-Key"] = idempotency_key if idempotency_key
|
|
290
|
+
if body
|
|
291
|
+
req["Content-Type"] = "application/json"
|
|
292
|
+
req.body = JSON.generate(stringify_keys(body))
|
|
293
|
+
end
|
|
294
|
+
req
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def idempotent_write?(method, body)
|
|
298
|
+
IDEMPOTENT_METHODS.include?(method) && !body.nil?
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Honours a numeric +Retry-After+ when the API sends one (typically on 429),
|
|
302
|
+
# otherwise falls back to exponential backoff.
|
|
303
|
+
def retry_delay(attempt, response = nil)
|
|
304
|
+
after = retry_after_seconds(response)
|
|
305
|
+
return [after, MAX_RETRY_DELAY].min if after
|
|
306
|
+
|
|
307
|
+
0.5 * (2**attempt)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def retry_after_seconds(response)
|
|
311
|
+
raw = response && response["retry-after"]
|
|
312
|
+
return if blank?(raw)
|
|
313
|
+
|
|
314
|
+
seconds = Float(raw.to_s, exception: false)
|
|
315
|
+
seconds if seconds&.positive?
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def header_request_id(response)
|
|
319
|
+
response["request-id"] || response["x-request-id"]
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def parse_body(raw)
|
|
323
|
+
return {} if blank?(raw)
|
|
324
|
+
|
|
325
|
+
JSON.parse(raw)
|
|
326
|
+
rescue JSON::ParserError => e
|
|
327
|
+
raise APIError.new(
|
|
328
|
+
"PaymentKit returned a success status with an unparseable body: #{e.message}",
|
|
329
|
+
body: raw
|
|
330
|
+
)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def error_for(status, raw, response = nil)
|
|
334
|
+
parsed = parse_error_json(raw)
|
|
335
|
+
message = error_message(parsed, status, raw)
|
|
336
|
+
attrs = {
|
|
337
|
+
status: status,
|
|
338
|
+
body: raw,
|
|
339
|
+
request_id: parsed["request_id"] || (response && header_request_id(response)),
|
|
340
|
+
error_code: parsed["error_code"],
|
|
341
|
+
retryable: parsed["retryable"]
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
case status
|
|
345
|
+
when 401 then AuthenticationError.new(message, **attrs)
|
|
346
|
+
when 403 then PermissionError.new(message, **attrs)
|
|
347
|
+
when 400, 404, 422 then InvalidRequestError.new(with_raw_body(message, raw), **attrs)
|
|
348
|
+
when 402 then CardError.new(message, **attrs)
|
|
349
|
+
when 409 then ConflictError.new(message, **attrs)
|
|
350
|
+
when 429 then RateLimitError.new(message, **attrs)
|
|
351
|
+
else APIError.new(message, **attrs)
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def error_message(parsed, status, raw)
|
|
356
|
+
detail = parsed["detail"]
|
|
357
|
+
detail = format_validation_detail(detail) if detail.is_a?(Array)
|
|
358
|
+
message = detail
|
|
359
|
+
message = parsed["title"] if blank?(message)
|
|
360
|
+
message = dig_error_message(parsed) if blank?(message)
|
|
361
|
+
message = parsed["message"] if blank?(message)
|
|
362
|
+
message = raw if blank?(message)
|
|
363
|
+
message = "PaymentKit API error (HTTP #{status})" if blank?(message)
|
|
364
|
+
message
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def parse_error_json(raw)
|
|
368
|
+
parsed = JSON.parse(raw.to_s)
|
|
369
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
370
|
+
rescue JSON::ParserError
|
|
371
|
+
{}
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def dig_error_message(parsed)
|
|
375
|
+
error = parsed["error"]
|
|
376
|
+
return unless error.is_a?(Hash)
|
|
377
|
+
|
|
378
|
+
error["message"]
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def with_raw_body(message, raw)
|
|
382
|
+
body = raw.to_s.strip
|
|
383
|
+
return message if body.empty? || message.to_s.include?(body)
|
|
384
|
+
|
|
385
|
+
"#{message} — #{truncate(body, RAW_BODY_LIMIT)}"
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def format_validation_detail(detail)
|
|
389
|
+
Array(detail).filter_map do |entry|
|
|
390
|
+
next entry unless entry.is_a?(Hash)
|
|
391
|
+
|
|
392
|
+
location = Array(entry["loc"]).join(".")
|
|
393
|
+
parts = [location.empty? ? nil : location, entry["msg"]].compact
|
|
394
|
+
text = parts.join(": ")
|
|
395
|
+
text.empty? ? nil : text
|
|
396
|
+
end.join("; ")
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def flatten_params(params, prefix = nil)
|
|
400
|
+
return [] if blank?(params)
|
|
401
|
+
|
|
402
|
+
params.flat_map do |key, value|
|
|
403
|
+
composed = prefix ? "#{prefix}[#{key}]" : key.to_s
|
|
404
|
+
case value
|
|
405
|
+
when Hash
|
|
406
|
+
flatten_params(stringify_keys(value), composed)
|
|
407
|
+
when Array
|
|
408
|
+
value.flat_map do |item|
|
|
409
|
+
if item.is_a?(Hash) || item.is_a?(Array)
|
|
410
|
+
flatten_params(item.is_a?(Hash) ? stringify_keys(item) : item, "#{composed}[]")
|
|
411
|
+
else
|
|
412
|
+
[["#{composed}[]", item]]
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
else
|
|
416
|
+
[[composed, value]]
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def stringify_keys(value)
|
|
422
|
+
case value
|
|
423
|
+
when Hash
|
|
424
|
+
value.each_with_object({}) do |(key, nested), memo|
|
|
425
|
+
memo[key.to_s] = stringify_keys(nested)
|
|
426
|
+
end
|
|
427
|
+
when Array
|
|
428
|
+
value.map { |item| stringify_keys(item) }
|
|
429
|
+
else
|
|
430
|
+
value
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def blank?(value)
|
|
435
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?) ||
|
|
436
|
+
(value.is_a?(String) && value.strip.empty?)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def present?(value)
|
|
440
|
+
!blank?(value)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def truncate(string, limit)
|
|
444
|
+
return string if string.length <= limit
|
|
445
|
+
|
|
446
|
+
"#{string[0, limit]}..."
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaymentKit
|
|
4
|
+
# Settings shared between global configuration and per-client overrides.
|
|
5
|
+
class Configuration
|
|
6
|
+
# API host root, without the account segment.
|
|
7
|
+
DEFAULT_API_HOST = "https://app.paymentkit.com/api"
|
|
8
|
+
# TCP connect timeout, in seconds.
|
|
9
|
+
DEFAULT_OPEN_TIMEOUT = 10
|
|
10
|
+
# Response read timeout, in seconds.
|
|
11
|
+
DEFAULT_READ_TIMEOUT = 30
|
|
12
|
+
# Attempts made after a transient failure before giving up.
|
|
13
|
+
DEFAULT_MAX_RETRIES = 2
|
|
14
|
+
|
|
15
|
+
# Server secret token (+st_prod_...+). Never expose this in a browser.
|
|
16
|
+
attr_accessor :secret_key
|
|
17
|
+
|
|
18
|
+
# Account external id (+acc_prod_...+), used as the API path prefix.
|
|
19
|
+
attr_accessor :account_id
|
|
20
|
+
|
|
21
|
+
# API host root; defaults to DEFAULT_API_HOST.
|
|
22
|
+
attr_accessor :api_host
|
|
23
|
+
|
|
24
|
+
# Full base URL override. When set, +account_id+ is not appended.
|
|
25
|
+
attr_accessor :base_url
|
|
26
|
+
|
|
27
|
+
# TCP connect timeout, in seconds.
|
|
28
|
+
attr_accessor :open_timeout
|
|
29
|
+
|
|
30
|
+
# Response read timeout, in seconds.
|
|
31
|
+
attr_accessor :read_timeout
|
|
32
|
+
|
|
33
|
+
# Attempts made after a transient failure before giving up.
|
|
34
|
+
attr_accessor :max_retries
|
|
35
|
+
|
|
36
|
+
# Webhook signing secrets (+whsec_...+), tried in order during rotation.
|
|
37
|
+
attr_accessor :signing_secrets
|
|
38
|
+
|
|
39
|
+
# Builds a configuration with the DEFAULT_* values and no credentials.
|
|
40
|
+
def initialize
|
|
41
|
+
@api_host = DEFAULT_API_HOST
|
|
42
|
+
@open_timeout = DEFAULT_OPEN_TIMEOUT
|
|
43
|
+
@read_timeout = DEFAULT_READ_TIMEOUT
|
|
44
|
+
@max_retries = DEFAULT_MAX_RETRIES
|
|
45
|
+
@signing_secrets = nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize_copy(other) # :nodoc:
|
|
49
|
+
super
|
|
50
|
+
@secret_key = other.secret_key
|
|
51
|
+
@account_id = other.account_id
|
|
52
|
+
@api_host = other.api_host
|
|
53
|
+
@base_url = other.base_url
|
|
54
|
+
@open_timeout = other.open_timeout
|
|
55
|
+
@read_timeout = other.read_timeout
|
|
56
|
+
@max_retries = other.max_retries
|
|
57
|
+
@signing_secrets = other.signing_secrets&.dup
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Singular accessor for the first signing secret.
|
|
61
|
+
def signing_secret
|
|
62
|
+
Array(signing_secrets).compact.first
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Assigns a single signing secret, replacing any already configured.
|
|
66
|
+
def signing_secret=(value)
|
|
67
|
+
@signing_secrets = value.nil? ? nil : Array(value)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Merge keyword overrides into a duplicate configuration.
|
|
71
|
+
def merge(**overrides)
|
|
72
|
+
dup.tap do |config|
|
|
73
|
+
overrides.each do |key, value|
|
|
74
|
+
next if value.nil?
|
|
75
|
+
|
|
76
|
+
writer = "#{key}="
|
|
77
|
+
raise ArgumentError, "Unknown configuration option: #{key}" unless config.respond_to?(writer)
|
|
78
|
+
|
|
79
|
+
config.public_send(writer, value)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Base URL every account-scoped request is sent to: an explicit +base_url+
|
|
85
|
+
# when set, otherwise <tt>{api_host}/{account_id}</tt>.
|
|
86
|
+
#
|
|
87
|
+
# Raises InvalidRequestError when neither is configured.
|
|
88
|
+
def resolved_base_url
|
|
89
|
+
return base_url.to_s.chomp("/") if present?(base_url)
|
|
90
|
+
|
|
91
|
+
host = (api_host || DEFAULT_API_HOST).to_s.chomp("/")
|
|
92
|
+
raise InvalidRequestError, "PaymentKit account_id is not configured" if blank?(account_id)
|
|
93
|
+
|
|
94
|
+
"#{host}/#{account_id}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Base for endpoints that are not account-scoped (e.g. the customer portal
|
|
98
|
+
# surface). An explicit +base_url+ wins, since the account segment cannot be
|
|
99
|
+
# reliably stripped back off it.
|
|
100
|
+
def unscoped_base_url
|
|
101
|
+
return base_url.to_s.chomp("/") if present?(base_url)
|
|
102
|
+
|
|
103
|
+
(api_host || DEFAULT_API_HOST).to_s.chomp("/")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def blank?(value)
|
|
109
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?) ||
|
|
110
|
+
(value.is_a?(String) && value.strip.empty?)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def present?(value)
|
|
114
|
+
!blank?(value)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|