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.
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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ PaymentKit::Engine.routes.draw do
4
+ root to: "webhook#event", via: :post
5
+ end
@@ -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
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaymentKit
4
+ # Isolated Rails Engine that exposes POST / for webhook delivery.
5
+ #
6
+ # Loaded only when Rails is present (soft coupling).
7
+ class Engine < ::Rails::Engine
8
+ isolate_namespace PaymentKit
9
+ end
10
+ end