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/README.md ADDED
@@ -0,0 +1,1008 @@
1
+ # PaymentKit
2
+
3
+ Ruby HTTP client for the [PaymentKit](https://docs.paymentkit.com) REST API.
4
+
5
+ This gem is a thin SDK: it authenticates requests, encodes JSON, follows redirects,
6
+ retries transient failures, maps API errors, and exposes resource methods that
7
+ return parsed JSON hashes.
8
+
9
+ ## Requirements
10
+
11
+ - Ruby `>= 3.2`
12
+ - `activesupport >= 6.1` (powers the webhook event bus)
13
+ - Transport uses only stdlib (`net/http`, `json`, `openssl`); Rails is optional
14
+ and needed only for the mountable webhook engine
15
+
16
+ ## Installation
17
+
18
+ Add the gem to your Gemfile:
19
+
20
+ ```ruby
21
+ gem "payment_kit"
22
+ ```
23
+
24
+ Then run:
25
+
26
+ ```bash
27
+ bundle install
28
+ ```
29
+
30
+ Or install it directly:
31
+
32
+ ```bash
33
+ gem install payment_kit
34
+ ```
35
+
36
+ ## Quick start
37
+
38
+ ```ruby
39
+ require "payment_kit"
40
+
41
+ PaymentKit.configure do |config|
42
+ config.secret_key = ENV.fetch("PAYMENT_KIT_SECRET_KEY") # st_prod_...
43
+ config.account_id = ENV.fetch("PAYMENT_KIT_ACCOUNT_ID") # acc_prod_...
44
+ end
45
+
46
+ client = PaymentKit::Client.new
47
+
48
+ customer = client.create_customer(
49
+ email: "customer@example.com",
50
+ first_name: "Jane",
51
+ last_name: "Smith",
52
+ business_name: "Acme Inc"
53
+ )
54
+
55
+ puts customer["id"] # => "cus_prod_..."
56
+ ```
57
+
58
+ ## Configuration
59
+
60
+ ### Global configuration
61
+
62
+ ```ruby
63
+ PaymentKit.configure do |config|
64
+ config.secret_key = ENV.fetch("PAYMENT_KIT_SECRET_KEY")
65
+ config.account_id = ENV.fetch("PAYMENT_KIT_ACCOUNT_ID")
66
+ config.signing_secret = ENV["PAYMENT_KIT_SIGNING_SECRET"] # optional, for webhooks
67
+ config.api_host = "https://app.paymentkit.com/api" # default
68
+ config.open_timeout = 10 # seconds, default
69
+ config.read_timeout = 30 # seconds, default
70
+ config.max_retries = 2 # default
71
+ end
72
+ ```
73
+
74
+ | Option | Required | Default | Description |
75
+ |--------|----------|---------|-------------|
76
+ | `secret_key` | Yes* | — | Server secret token (`st_prod_...`). Never expose in browsers. |
77
+ | `account_id` | Yes* | — | Account external id (`acc_prod_...`) used in the API path. |
78
+ | `api_host` | No | `https://app.paymentkit.com/api` | API host root (without account id). |
79
+ | `base_url` | No | — | Full base URL override (`https://host/api/{account_id}`). Skips `account_id` when set. |
80
+ | `open_timeout` | No | `10` | TCP open timeout (seconds). |
81
+ | `read_timeout` | No | `30` | Response read timeout (seconds). |
82
+ | `max_retries` | No | `2` | Retries for transient HTTP statuses. |
83
+ | `signing_secret` | No | — | Webhook signing secret (`whsec_...`). |
84
+
85
+ \*Required unless you pass them (or `base_url`) when constructing `Client`.
86
+
87
+ Reset configuration in tests:
88
+
89
+ ```ruby
90
+ PaymentKit.reset_configuration!
91
+ ```
92
+
93
+ ### Base URL resolution
94
+
95
+ Requests are sent to:
96
+
97
+ ```text
98
+ {base_url}{path}
99
+ ```
100
+
101
+ Where `base_url` is resolved as:
102
+
103
+ 1. Explicit `base_url` if set (trailing slash removed), or
104
+ 2. `{api_host}/{account_id}`
105
+
106
+ Examples:
107
+
108
+ ```ruby
109
+ # Production-style
110
+ PaymentKit.configure do |c|
111
+ c.secret_key = "st_prod_..."
112
+ c.account_id = "acc_prod_abc"
113
+ end
114
+ # => https://app.paymentkit.com/api/acc_prod_abc
115
+
116
+ # Custom host
117
+ PaymentKit.configure do |c|
118
+ c.secret_key = "st_prod_..."
119
+ c.account_id = "acc_prod_abc"
120
+ c.api_host = "https://app.paymentkit.com/api"
121
+ end
122
+
123
+ # Full override (useful in tests)
124
+ client = PaymentKit::Client.new(
125
+ secret_key: "st_test",
126
+ base_url: "https://api.test/acc"
127
+ )
128
+ ```
129
+
130
+ ### Per-client configuration (recommended for multi-account)
131
+
132
+ Prefer explicit credentials on the client when serving multiple accounts:
133
+
134
+ ```ruby
135
+ client = PaymentKit::Client.new(
136
+ secret_key: ENV.fetch("PAYMENT_KIT_SECRET_KEY"),
137
+ account_id: ENV.fetch("PAYMENT_KIT_ACCOUNT_ID"),
138
+ open_timeout: 5,
139
+ read_timeout: 20,
140
+ max_retries: 3
141
+ )
142
+
143
+ client.base_url
144
+ # => "https://app.paymentkit.com/api/acc_prod_..."
145
+ ```
146
+
147
+ Keyword arguments override the global `PaymentKit.configuration` for that instance only.
148
+
149
+ ### Rails initializer example
150
+
151
+ ```ruby
152
+ # config/initializers/payment_kit.rb
153
+ PaymentKit.configure do |config|
154
+ config.secret_key = Rails.application.credentials.dig(:payment_kit, :secret_key)
155
+ config.account_id = Rails.application.credentials.dig(:payment_kit, :account_id)
156
+ config.signing_secret = Rails.application.credentials.dig(:payment_kit, :signing_secret)
157
+ end
158
+ ```
159
+
160
+ ## Client overview
161
+
162
+ `PaymentKit::Client` is the only network entry point.
163
+
164
+ - Auth: `Authorization: Bearer {secret_key}`
165
+ - Bodies: JSON (`Content-Type: application/json`)
166
+ - Responses: parsed JSON `Hash` (or `Array` for auto-paginated lists)
167
+ - Writes: `POST`, `PUT` and `PATCH` send an automatic `Idempotency-Key` (UUID)
168
+ unless you pass one
169
+ - Redirects: follows HTTP `307` / `308` (PaymentKit path canonicalization)
170
+ - Retries: `408`, `429` and `5xx` with exponential backoff; `409` only when
171
+ PaymentKit marks it retryable
172
+ - Escape hatch: `raw_request` for endpoints the gem does not wrap
173
+
174
+ ```ruby
175
+ client = PaymentKit::Client.new
176
+ ```
177
+
178
+ ## Resource API
179
+
180
+ All methods return parsed JSON. Pass request bodies and query params as hashes
181
+ (symbol or string keys are fine). Every write method also accepts an
182
+ `idempotency_key:` — see [Idempotency](#idempotency).
183
+
184
+ Paths below are relative to the account base URL
185
+ (`{api_host}/{account_id}`), matching the
186
+ [PaymentKit API reference](https://docs.paymentkit.com/api-reference/introduction).
187
+
188
+ ### Method index
189
+
190
+ | Method | HTTP | Path |
191
+ |--------|------|------|
192
+ | `create_customer` | `POST` | `/customers/` |
193
+ | `retrieve_customer` | `GET` | `/customers/{id}` |
194
+ | `update_customer` | `PUT` | `/customers/{id}` |
195
+ | `list_customers` | `GET` | `/customers/` |
196
+ | `set_credit_balance` | `PATCH` | `/customers/{id}/credit-balance` |
197
+ | `create_balance_transaction` *(deprecated)* | `PATCH` | `/customers/{id}/credit-balance` |
198
+ | `create_credit_note` | `POST` | `/customers/{id}/credit-notes` |
199
+ | `list_credit_notes` | `GET` | `/customers/{id}/credit-notes` |
200
+ | `create_subscription` | `POST` | `/subscriptions` |
201
+ | `retrieve_subscription` | `GET` | `/subscriptions/{id}` |
202
+ | `list_subscriptions` | `GET` | `/subscriptions` |
203
+ | `update_subscription` | `PATCH` | `/subscriptions/{id}` |
204
+ | `update_subscription_items` | `PATCH` | `/subscriptions/{id}/items` |
205
+ | `cancel_subscription` | `POST` | `/subscriptions/{id}/cancel` |
206
+ | `schedule_cancellation` | `POST` | `/subscriptions/{id}/schedule-cancellation` |
207
+ | `cancel_scheduled_cancellation` | `DELETE` | `/subscriptions/{id}/scheduled-cancellation` |
208
+ | `pause_subscription` | `POST` | `/subscriptions/{id}/pause` |
209
+ | `cancel_scheduled_pause` | `DELETE` | `/subscriptions/{id}/scheduled-pause` |
210
+ | `resume_subscription` | `POST` | `/subscriptions/{id}/resume` |
211
+ | `reschedule_billing` | `POST` | `/subscriptions/{id}/reschedule-billing` |
212
+ | `renew_subscription` | `POST` | `/subscriptions/{id}/renew` |
213
+ | `cancel_pending_change` | `DELETE` | `/subscriptions/{id}/pending-change` |
214
+ | `change_plan` *(deprecated)* | `POST` | `/subscriptions/{id}/change-plan` |
215
+ | `create_change_request` | `POST` | `/subscriptions/{id}/change-requests` |
216
+ | `retrieve_change_request` | `GET` | `/subscriptions/{id}/change-requests/{request_id}` |
217
+ | `active_change_request` | `GET` | `/subscriptions/{id}/change-requests/active` |
218
+ | `add_change_request_changes` | `PATCH` | `/subscriptions/{id}/change-requests/{request_id}` |
219
+ | `preview_change_request` | `POST` | `/subscriptions/{id}/change-requests/{request_id}/preview` |
220
+ | `apply_change_request` | `POST` | `/subscriptions/{id}/change-requests/{request_id}/apply` |
221
+ | `cancel_change_request` | `DELETE` | `/subscriptions/{id}/change-requests/{request_id}` |
222
+ | `apply_subscription_changes` | `POST` | `/subscriptions/{id}/change-requests/apply` |
223
+ | `create_invoice` | `POST` | `/invoices/` |
224
+ | `retrieve_invoice` | `GET` | `/invoices/{id}` |
225
+ | `list_invoices` | `GET` | `/invoices/` |
226
+ | `retrieve_invoice_pdf` | `GET` | `/invoices/{id}/pdf` |
227
+ | `finalize_invoice` | `POST` | `/invoices/{id}/finalize` |
228
+ | `pay_invoice` | `POST` | `/invoices/{id}/collect` |
229
+ | `void_invoice` | `POST` | `/invoices/{id}/void` |
230
+ | `mark_invoice_uncollectible` | `POST` | `/invoices/{id}/mark-uncollectible` |
231
+ | `bill_pending_items` | `POST` | `/invoices/bill-pending-items` |
232
+ | `create_invoice_item` | `POST` | `/invoice-items/` |
233
+ | `retrieve_invoice_item` | `GET` | `/invoice-items/{id}` |
234
+ | `list_invoice_items` | `GET` | `/invoice-items/` |
235
+ | `update_invoice_item` | `PATCH` | `/invoice-items/{id}` |
236
+ | `create_payment_intent` | `POST` | `/payments/intents/` |
237
+ | `retrieve_payment_intent` | `GET` | `/payments/intents/{id}` |
238
+ | `list_payment_intents` | `GET` | `/payments/intents/` |
239
+ | `list_refunds_by_intent` | `GET` | `/payments/refunds/by_intent/{id}` |
240
+ | `list_attempts_by_intent` | `GET` | `/payments/processor_attempts/by_intent/{id}` |
241
+ | `create_payment_method` | `POST` | `/payments/payment_methods/` |
242
+ | `retrieve_payment_method` | `GET` | `/payments/payment_methods/{id}` |
243
+ | `update_payment_method` | `PUT` | `/payments/payment_methods/{id}` |
244
+ | `deactivate_payment_method` | `PUT` | `/payments/payment_methods/{id}` |
245
+ | `detach_payment_method` | `DELETE` | `/payments/payment_methods/{id}` |
246
+ | `create_checkout_session` | `POST` | `/checkout-sessions` |
247
+ | `retrieve_checkout_session` | `GET` | `/checkout-sessions/{id}` |
248
+ | `list_products` | `GET` | `/products/` |
249
+ | `retrieve_product` | `GET` | `/products/{id}` |
250
+ | `create_product` | `POST` | `/products/` |
251
+ | `update_product` | `PATCH` | `/products/{id}` |
252
+ | `list_product_prices` | `GET` | `/products/{id}/prices` |
253
+ | `list_prices` | `GET` | `/prices/` |
254
+ | `retrieve_price` | `GET` | `/prices/{id}` |
255
+ | `create_price` | `POST` | `/prices/` |
256
+
257
+ ### Customers
258
+
259
+ ```ruby
260
+ customer = client.create_customer(
261
+ email: "customer@example.com",
262
+ first_name: "Jane",
263
+ last_name: "Smith",
264
+ business_name: "Acme Inc",
265
+ phone: "+15550100",
266
+ billing_email: "ap@acme.test",
267
+ currency: "USD",
268
+ language: "en",
269
+ address: { line1: "1 Market St", city: "San Francisco", country: "US", postal_code: "94105" },
270
+ tax_ids: ["EU372009832"],
271
+ metadata: { plan_tier: "enterprise" }
272
+ )
273
+
274
+ customer = client.retrieve_customer("cus_123")
275
+
276
+ # PUT — only the fields you send are changed
277
+ customer = client.update_customer("cus_123",
278
+ email: "new@example.com",
279
+ metadata: { plan_tier: "pro" }
280
+ )
281
+
282
+ customers = client.list_customers(limit: 50) # auto-paginated Array
283
+ ```
284
+
285
+ ### Customer credit
286
+
287
+ `set_credit_balance` sets an **absolute target** balance for one currency —
288
+ PaymentKit issues or voids credit to reach it. It is not a delta. Balances are
289
+ tracked independently per currency, and `amount_atom` is in the smallest
290
+ currency unit.
291
+
292
+ ```ruby
293
+ # Make the customer's USD balance exactly 5000 atoms ($50.00)
294
+ balance = client.set_credit_balance("cus_123", amount_atom: 5000, currency: "USD")
295
+ # => { "amount_atom" => 5000, "currency" => "USD" }
296
+ ```
297
+
298
+ To *add* credit incrementally, append a credit note. `reason` is required and
299
+ must be one of `proration_excess`, `manual_adjustment`, `auto_apply` or
300
+ `debit_settlement`. Always pass a stable idempotency key: a double submit issues
301
+ two notes.
302
+
303
+ ```ruby
304
+ note = client.create_credit_note(
305
+ "cus_123",
306
+ {
307
+ amount_atom: 2500,
308
+ currency: "USD",
309
+ reason: "manual_adjustment",
310
+ memo: "Goodwill credit for billing error",
311
+ invoice_id: "inv_123" # optional; otherwise a paid companion invoice is created
312
+ },
313
+ idempotency_key: "credit-overcharge-918"
314
+ )
315
+
316
+ notes = client.list_credit_notes("cus_123", currency: "USD")
317
+ ```
318
+
319
+ PaymentKit draws credit down automatically when an invoice is collected; you do
320
+ not apply it manually.
321
+
322
+ `create_balance_transaction` is deprecated. It hits the same endpoint, forwards
323
+ to `set_credit_balance` and warns — the old name implied delta semantics, but the
324
+ endpoint sets an absolute target balance:
325
+
326
+ ```ruby
327
+ # Deprecated; identical to set_credit_balance("cus_123", ...)
328
+ client.create_balance_transaction("cus_123", amount_atom: 5000, currency: "USD")
329
+ ```
330
+
331
+ ### Subscriptions
332
+
333
+ `customer_id`, `currency`, `billing_interval`, `billing_interval_count`,
334
+ `period_start` and `collection_method` are required. Each entry in `items` is a
335
+ `price_id` plus `quantity`.
336
+
337
+ ```ruby
338
+ subscription = client.create_subscription(
339
+ customer_id: "cus_123",
340
+ currency: "USD",
341
+ billing_interval: "month", # day | week | month | year
342
+ billing_interval_count: 1,
343
+ period_start: "2026-02-01T00:00:00Z",
344
+ collection_method: "charge_automatically", # or send_invoice
345
+ processor_id: "proc_live_abc", # falls back to the account default
346
+ items: [
347
+ { price_id: "price_monthly_pro", quantity: 1 },
348
+ { price_id: "price_addon_seats", quantity: 5 }
349
+ ],
350
+ trial_start: "2026-02-01T00:00:00Z",
351
+ trial_end: "2026-02-15T00:00:00Z",
352
+ net_d: 30, # payment terms in days
353
+ coupon_id: "coup_welcome20",
354
+ total_billing_cycles: 12 # auto-cancel after N cycles
355
+ )
356
+
357
+ subscription = client.retrieve_subscription("sub_123")
358
+ subscriptions = client.list_subscriptions(customer_id: "cus_123")
359
+
360
+ # Cancel at period end (and undo it) via the base update endpoint
361
+ client.update_subscription("sub_123", cancel_at_period_end: true)
362
+ client.update_subscription("sub_123", cancel_at_period_end: false)
363
+ ```
364
+
365
+ Line-item changes go through the dedicated items endpoint, where
366
+ `proration_behavior` is **required** (`always_invoice`, `create_prorations` or
367
+ `none`). This is charge-first, so pass a stable idempotency key.
368
+
369
+ ```ruby
370
+ client.update_subscription_items("sub_123",
371
+ {
372
+ proration_behavior: "always_invoice",
373
+ items: [
374
+ { id: "si_plan", price_id: "price_monthly_pro", quantity: 2 }
375
+ ]
376
+ },
377
+ idempotency_key: "seat-change-42-v3"
378
+ )
379
+ ```
380
+
381
+ #### Cancelling, pausing and rescheduling
382
+
383
+ ```ruby
384
+ # Immediate cancel. refund_option: none | full | prorated | cancel_unpaid
385
+ client.cancel_subscription("sub_123", refund_option: "prorated")
386
+
387
+ # Calculate the refund without executing
388
+ preview = client.cancel_subscription("sub_123", refund_option: "prorated", is_preview: true)
389
+
390
+ # Cancel on a specific date instead, then undo it
391
+ client.schedule_cancellation("sub_123", cancel_at: "2026-06-01T00:00:00Z", refund_option: "none")
392
+ client.cancel_scheduled_cancellation("sub_123")
393
+
394
+ # Pause now, or at period end. pause_for_cycles and resumption_date are
395
+ # mutually exclusive ways to schedule the auto-resume.
396
+ client.pause_subscription("sub_123", pause_behavior: "pause_immediately", pause_for_cycles: 2)
397
+ client.pause_subscription("sub_123", pause_behavior: "pause_at_end")
398
+ client.cancel_scheduled_pause("sub_123") # removes a pending pause_at_end
399
+
400
+ client.resume_subscription("sub_123")
401
+ client.renew_subscription("sub_123")
402
+
403
+ client.reschedule_billing("sub_123",
404
+ next_billing_date: "2026-03-15T00:00:00Z",
405
+ create_proration: true,
406
+ is_preview: false
407
+ )
408
+
409
+ # Cancels a scheduled (period-end) plan change before it executes
410
+ client.cancel_pending_change("sub_123")
411
+ ```
412
+
413
+ #### Change requests (recommended for plan changes)
414
+
415
+ A create → add changes → preview → apply workflow that shows exact proration
416
+ before committing, and collects payment before modifying the subscription. Only
417
+ one active (`draft`/`ready`) request may exist per subscription; creating a
418
+ second raises `PaymentKit::ConflictError`.
419
+
420
+ ```ruby
421
+ request = client.create_change_request("sub_123",
422
+ reason: "Upgrade to annual plan",
423
+ expires_in_hours: 24
424
+ )
425
+
426
+ # Append item, coupon and balance changes. Callable repeatedly; each call
427
+ # appends. Doing this on a `ready` request reverts it to `draft`.
428
+ client.add_change_request_changes("sub_123", request["id"],
429
+ item_changes: [
430
+ { action: "update", item_id: "si_monthly_plan", price_id: "price_annual_plan" },
431
+ { action: "add", price_id: "price_addon_support", quantity: 1, apply_at_end: false },
432
+ { action: "drop", item_id: "si_legacy_addon" }
433
+ ],
434
+ coupon_changes: [
435
+ { action: "add", coupon_id: "coup_welcome20" }
436
+ ],
437
+ trial_behavior: "preserve" # or end_now
438
+ )
439
+
440
+ # Pure computation: returns proration amounts and the execution plan, → ready
441
+ preview = client.preview_change_request("sub_123", request["id"])
442
+ preview["preview"]["invoice_total_atom"]
443
+
444
+ # Charge-first execution. A decline raises PaymentKit::CardError (402).
445
+ client.apply_change_request("sub_123", request["id"],
446
+ idempotency_key: "cr-#{request["id"]}"
447
+ )
448
+
449
+ request = client.retrieve_change_request("sub_123", request["id"])
450
+ active = client.active_change_request("sub_123") # nil when none is pending
451
+ client.cancel_change_request("sub_123", request["id"])
452
+ ```
453
+
454
+ One-step shortcut — create, add changes, preview and apply in a single call:
455
+
456
+ ```ruby
457
+ client.apply_subscription_changes("sub_123",
458
+ {
459
+ item_changes: [
460
+ { action: "update", item_id: "si_monthly_plan", price_id: "price_annual_plan" }
461
+ ],
462
+ reason: "Upgrade to annual plan",
463
+ payment_method_id: "pm_123"
464
+ },
465
+ idempotency_key: "upgrade-#{user_id}-v1"
466
+ )
467
+ ```
468
+
469
+ #### Legacy plan change (deprecated)
470
+
471
+ PaymentKit documents `change-plan` as the legacy single-call endpoint, planned
472
+ for deprecation. Prefer the change-request workflow above, or
473
+ `apply_subscription_changes`. Kept for existing integrations:
474
+
475
+ ```ruby
476
+ client.change_plan("sub_123",
477
+ reason: "Billing interval change to year",
478
+ proration_behavior: "always_invoice",
479
+ effective_at: "immediate",
480
+ items: [
481
+ { action: "update", subscription_item_id: "si_plan",
482
+ new_price_id: "price_annual_plan", quantity: 1 }
483
+ ]
484
+ )
485
+ ```
486
+
487
+ ### Invoices
488
+
489
+ Invoices are created in `draft`. Each line item takes either an `amount`, a
490
+ `unit_amount` × `quantity`, or a catalog `price_id`.
491
+
492
+ ```ruby
493
+ invoice = client.create_invoice(
494
+ customer_id: "cus_123",
495
+ currency: "USD",
496
+ issued_at: "2026-02-01T00:00:00Z",
497
+ description: "Setup fee — Enterprise onboarding",
498
+ collection_method: "charge_automatically",
499
+ items: [
500
+ { description: "Enterprise onboarding", quantity: 1, amount: 500.00 },
501
+ { description: "Custom integration", quantity: 4, unit_amount: 150.00 },
502
+ { price_id: "price_support_plan", quantity: 1 }
503
+ ]
504
+ )
505
+
506
+ # Draft → Open: locks amounts, optionally generates the PDF and emails it
507
+ invoice = client.finalize_invoice(invoice["id"])
508
+
509
+ # Attempts collection; finalizes the invoice first when still draft.
510
+ # A decline raises PaymentKit::CardError and leaves the invoice payable.
511
+ result = client.pay_invoice(invoice["id"], payment_method_id: "pm_123")
512
+ result["invoice_status"] # => "paid"
513
+
514
+ invoice = client.retrieve_invoice("inv_123", expand: "custom_fields")
515
+ invoices = client.list_invoices(customer_id: "cus_123", status: "open")
516
+
517
+ # Poll while status is "generating"
518
+ pdf = client.retrieve_invoice_pdf("inv_123")
519
+ pdf["pdf_url"] if pdf["status"] == "available"
520
+
521
+ client.void_invoice("inv_123")
522
+
523
+ # Only permitted from OPEN or PAST_DUE; other states return 422
524
+ client.mark_invoice_uncollectible("inv_123")
525
+ ```
526
+
527
+ Sweep floating (pending) items into standalone invoices — one per currency —
528
+ finalize them and attempt collection immediately, without waiting for renewal.
529
+ Duplicate requests create duplicate invoices, so a stable key is essential.
530
+
531
+ ```ruby
532
+ result = client.bill_pending_items(
533
+ {
534
+ customer_id: "cus_123", # required
535
+ subscription_id: "sub_123", # optional narrowing
536
+ currency: "USD",
537
+ collection_method: "charge_automatically",
538
+ description: "July usage",
539
+ tax_amount_atom: 0
540
+ },
541
+ idempotency_key: "bill-cus_123-2026-07"
542
+ )
543
+
544
+ result["invoices"].each { |inv| puts "#{inv["currency"]}: #{inv["items_swept"]} items" }
545
+ ```
546
+
547
+ A renewal running concurrently raises `PaymentKit::ConflictError` (409).
548
+
549
+ ### Invoice items
550
+
551
+ Floating items attach to a subscription and are collected at the next renewal
552
+ (or on demand via `bill_pending_items`). The subscription must be `active` or
553
+ `trialing`, and the `customer_id` must match it.
554
+
555
+ ```ruby
556
+ # With a catalog price
557
+ item = client.create_invoice_item(
558
+ customer_id: "cus_123",
559
+ subscription_id: "sub_123",
560
+ price_id: "price_sms_usage",
561
+ quantity: 150,
562
+ description: "SMS charges — July 2026 (150 messages)"
563
+ )
564
+
565
+ # With a custom amount
566
+ item = client.create_invoice_item(
567
+ customer_id: "cus_123",
568
+ subscription_id: "sub_123",
569
+ amount: 75.00,
570
+ description: "Custom setup fee"
571
+ )
572
+
573
+ item = client.retrieve_invoice_item(item["id"])
574
+ item = client.update_invoice_item(item["id"], quantity: 200, description: "SMS charges — revised")
575
+ items = client.list_invoice_items(subscription_id: "sub_123", status: "floating")
576
+ ```
577
+
578
+ ### Payment intents
579
+
580
+ Low-level charge control. Amounts are in atomic units.
581
+
582
+ ```ruby
583
+ intent = client.create_payment_intent(
584
+ customer_id: "cus_123",
585
+ amount_atom: 2500,
586
+ currency: "USD",
587
+ payment_method_id: "pm_123",
588
+ processor_id: "proc_live_abc",
589
+ metadata: { order_id: "ord_9" }
590
+ )
591
+
592
+ intent = client.retrieve_payment_intent("pi_123", expand: "checkout_attempt")
593
+ intents = client.list_payment_intents(customer_id: "cus_123", limit: 50)
594
+
595
+ refunds = client.list_refunds_by_intent("pi_123")
596
+ attempts = client.list_attempts_by_intent("pi_123")
597
+ ```
598
+
599
+ ### Payment methods
600
+
601
+ ```ruby
602
+ pm = client.create_payment_method(customer_id: "cus_123", provider_type: "card")
603
+ pm = client.retrieve_payment_method("pm_123")
604
+ pm = client.update_payment_method("pm_123", metadata: { label: "primary" })
605
+
606
+ # Documented way to take a card out of use (PUT is_active: false)
607
+ client.deactivate_payment_method("pm_123")
608
+
609
+ # Outright delete. PaymentKit documents deletion only on the customer-portal
610
+ # surface, so if your account returns 404/405 use deactivate_payment_method.
611
+ client.detach_payment_method("pm_123")
612
+ ```
613
+
614
+ ### Checkout sessions
615
+
616
+ Hosted collection. The response carries the `secure_token` used to initialise
617
+ PaymentKit.js or redirect to the hosted page.
618
+
619
+ ```ruby
620
+ session = client.create_checkout_session(
621
+ customer_id: "cus_123", # optional; pre-fills the customer
622
+ line_items: [{ price_id: "price_123", quantity: 1 }],
623
+ success_url: "https://example.com/success",
624
+ return_url: "https://example.com/cancel",
625
+ expires_in_hours: 24,
626
+ promotion_code: "LAUNCH20",
627
+ custom_fields: { internal_ref: "ord_9" }
628
+ )
629
+
630
+ session["secure_token"]
631
+
632
+ session = client.retrieve_checkout_session(session["id"])
633
+ ```
634
+
635
+ ### Catalog
636
+
637
+ ```ruby
638
+ product = client.create_product(
639
+ name: "Pro plan",
640
+ description: "Everything in Starter, plus priority support",
641
+ is_active: true,
642
+ metadata: { tier: "pro" }
643
+ )
644
+
645
+ product = client.retrieve_product("prod_123")
646
+ product = client.update_product("prod_123", is_active: false, default_price_id: "price_123")
647
+ products = client.list_products(limit: 50)
648
+ prices = client.list_product_prices("prod_123")
649
+ ```
650
+
651
+ ```ruby
652
+ price = client.create_price(
653
+ product_id: "prod_123",
654
+ currency: "USD",
655
+ unit_amount_atom: 2500,
656
+ pricing_type: "recurring",
657
+ billing_scheme: "per_unit",
658
+ recurring_interval: "month",
659
+ recurring_interval_count: 1,
660
+ trial_days: 14
661
+ )
662
+
663
+ price = client.retrieve_price("price_123")
664
+ prices = client.list_prices(limit: 50)
665
+ ```
666
+
667
+ ## List pagination
668
+
669
+ Every `list_*` helper auto-paginates PaymentKit’s offset/limit envelope:
670
+
671
+ ```json
672
+ { "items": [...], "total": 150, "has_more": true }
673
+ ```
674
+
675
+ and return a flat Ruby `Array` of item hashes. PaymentKit defaults `limit` to 50
676
+ and caps it at 100; the helpers keep requesting pages until `has_more` is false.
677
+
678
+ ```ruby
679
+ products = client.list_products(limit: 50)
680
+ products.each { |product| puts product["id"] }
681
+
682
+ # Filters are forwarded as query params
683
+ invoices = client.list_invoices(customer_id: "cus_123", status: "open")
684
+ ```
685
+
686
+ Use `raw_request` when you need a single page rather than the whole collection.
687
+
688
+ ## Webhooks and event bus
689
+
690
+ Inbound webhooks are verified, then fanned out synchronously via
691
+ `ActiveSupport::Notifications`. The gem does **not** persist events, enqueue jobs,
692
+ or retry deliveries — those remain host responsibilities. Deduplication is a host
693
+ responsibility too, but the gem provides the `event_retriever` hook for it
694
+ (see [Deduplicating redeliveries](#deduplicating-redeliveries)).
695
+
696
+ Multiple signing secrets are supported and tried in order, which is what
697
+ PaymentKit's `roll-secret` grace period requires: during rotation both the old
698
+ and new secret are live.
699
+
700
+ ### Configure signing secrets
701
+
702
+ ```ruby
703
+ PaymentKit.configure do |config|
704
+ config.signing_secret = ENV.fetch("PAYMENT_KIT_SIGNING_SECRET") # whsec_...
705
+ # or multiple secrets (tried in order):
706
+ # config.signing_secrets = [ENV["PAYMENT_KIT_SIGNING_SECRET"], ENV["PAYMENT_KIT_SIGNING_SECRET_OLD"]]
707
+ end
708
+
709
+ # Module-level accessors also work:
710
+ PaymentKit.signing_secret = ENV.fetch("PAYMENT_KIT_SIGNING_SECRET")
711
+ PaymentKit.signing_secrets = [ENV["PAYMENT_KIT_SIGNING_SECRET"], ENV["PAYMENT_KIT_SIGNING_SECRET_OLD"]]
712
+ ```
713
+
714
+ ### Subscribe to events
715
+
716
+ ```ruby
717
+ PaymentKit.subscribe "invoice.paid" do |event|
718
+ # event is a Hash, e.g. { "id" => "evt_...", "type" => "invoice.paid", ... }
719
+ # Prefer enqueueing work here; slow handlers delay the webhook HTTP response.
720
+ end
721
+
722
+ PaymentKit.subscribe "invoice.", InvoiceHandler.new # prefix match; #call(event)
723
+ PaymentKit.all { |event| Rails.logger.info(event["type"]) }
724
+
725
+ PaymentKit.event_filter = lambda do |event|
726
+ # return event to dispatch, or nil to ignore (still a successful verify path)
727
+ event["type"] == "ping" ? nil : event
728
+ end
729
+ ```
730
+
731
+ `configure` accepts two block shapes. A block that takes an argument receives the
732
+ configuration object; a block that takes none is evaluated against the module,
733
+ which reads better for registering subscribers in an initializer:
734
+
735
+ ```ruby
736
+ PaymentKit.configure do |config| # settings
737
+ config.secret_key = ENV.fetch("PAYMENT_KIT_SECRET_KEY")
738
+ end
739
+
740
+ PaymentKit.configure do # subscriber DSL
741
+ subscribe("invoice.paid") { |event| InvoicePaidJob.perform_later(event["id"]) }
742
+ all { |event| Rails.logger.info(event["type"]) }
743
+ end
744
+ ```
745
+
746
+ ### Process a webhook (non-Rails or custom controller)
747
+
748
+ ```ruby
749
+ payload = request.body.read
750
+ signature = request.headers["X-Webhook-Signature"]
751
+
752
+ begin
753
+ event = PaymentKit.process_webhook(payload, signature)
754
+ # verified + instrumented; subscribers already ran
755
+ rescue PaymentKit::SignatureVerificationError
756
+ head :unauthorized # signature missing or invalid
757
+ rescue PaymentKit::InvalidRequestError
758
+ head :bad_request # verified, but the body is not JSON
759
+ end
760
+ ```
761
+
762
+ Low-level verify without dispatch (existing Client API, preserved):
763
+
764
+ ```ruby
765
+ event = client.verify_webhook(payload, signature)
766
+ # or:
767
+ event = PaymentKit::Webhook.construct_event(payload, signature, PaymentKit.signing_secrets)
768
+ PaymentKit.instrument(event)
769
+ ```
770
+
771
+ ### Rails Engine (optional)
772
+
773
+ When Rails is loaded, mount the engine to get `POST /` → verify → instrument → `200`:
774
+
775
+ ```ruby
776
+ # config/routes.rb
777
+ mount PaymentKit::Engine, at: "/payment_kit"
778
+ ```
779
+
780
+ Point PaymentKit’s webhook URL at `https://your.app/payment_kit`.
781
+
782
+ PaymentKit treats `4xx` as a permanent failure and retries `5xx`/timeouts five
783
+ times over roughly 27 hours, so the controller maps failures deliberately:
784
+
785
+ | Outcome | Status | Retried by PaymentKit |
786
+ |---------|--------|-----------------------|
787
+ | Bad or missing signature | `401` | No |
788
+ | Verified but unparseable body | `400` | No |
789
+ | Subscriber raised, no `error_handler` | `500` | Yes |
790
+ | Subscriber raised, `error_handler` set | `200` | No |
791
+
792
+ Endpoints must respond within 30 seconds, so subscribers should enqueue work
793
+ rather than perform it inline.
794
+
795
+ ### Deduplicating redeliveries
796
+
797
+ `event_retriever` runs after verification and before dispatch. Return the event
798
+ to continue, or `nil` to drop it. PaymentKit redelivers on retry, so dedupe here
799
+ on the event id — the same value it sends in the `X-Webhook-Event-Id` header:
800
+
801
+ ```ruby
802
+ PaymentKit.event_retriever = lambda do |event|
803
+ key = "payment_kit:webhook:#{event["id"]}"
804
+ Sidekiq.redis { |r| r.set(key, "1", nx: true, ex: 3.days.to_i) } ? event : nil
805
+ end
806
+ ```
807
+
808
+ `process_webhook` returns `nil` when the retriever drops a delivery.
809
+
810
+ ### Reporting subscriber failures
811
+
812
+ Without an `error_handler`, a raising subscriber returns `500` and PaymentKit
813
+ retries. Set one to report the exception and answer `200` instead, which is the
814
+ right choice when subscribers only enqueue background work:
815
+
816
+ ```ruby
817
+ PaymentKit.error_handler = ->(exception, _request) { Sentry.capture_exception(exception) }
818
+ ```
819
+
820
+ `error_handler` is request-scoped: it fires once, after the fan-out has already
821
+ failed. For per-subscriber isolation use `subscriber_error_handler`, which wraps
822
+ each subscriber individually:
823
+
824
+ ```ruby
825
+ PaymentKit.subscriber_error_handler = lambda do |exception, event|
826
+ Sentry.capture_exception(exception, extra: { event_id: event["id"] })
827
+ end
828
+ ```
829
+
830
+ This matters because `ActiveSupport::Notifications` runs the remaining
831
+ subscribers when one raises but still re-raises afterwards (aggregating into
832
+ `ActiveSupport::Notifications::InstrumentationSubscriberError` when several
833
+ fail). Without a handler the delivery fails and PaymentKit redelivers the whole
834
+ event, re-running the subscribers that already succeeded. With one set, the
835
+ failure is reported and the delivery is acknowledged.
836
+
837
+ ## Error handling
838
+
839
+ All errors inherit from `PaymentKit::Error` and may expose:
840
+
841
+ - `status` — HTTP status
842
+ - `body` — raw response body
843
+ - `request_id` — from the RFC 7807 payload, falling back to the `Request-Id` header
844
+ - `error_code` / `retryable?` — set on transient failures such as `invoice_locked`
845
+ - `problem` and `error["..."]` — any RFC 7807 extension member (`invoice_id`,
846
+ `subscription_id`, …)
847
+
848
+ | Exception | Typical cause |
849
+ |-----------|----------------|
850
+ | `PaymentKit::AuthenticationError` | Missing/invalid key, HTTP 401 |
851
+ | `PaymentKit::PermissionError` | HTTP 403 — valid key, but not allowed on this resource |
852
+ | `PaymentKit::SignatureVerificationError` | Webhook signature missing or invalid |
853
+ | `PaymentKit::InvalidRequestError` | Missing `account_id`, HTTP 400/404/422, unparseable webhook body |
854
+ | `PaymentKit::CardError` | HTTP 402 — payment declined; the invoice stays payable |
855
+ | `PaymentKit::ConflictError` | HTTP 409 — clashes with current resource state |
856
+ | `PaymentKit::RateLimitError` | HTTP 429 |
857
+ | `PaymentKit::APIError` | Other HTTP errors (including exhausted 5xx retries) |
858
+ | `PaymentKit::APIConnectionError` | Timeouts, connection refused/reset, DNS failures |
859
+
860
+ ```ruby
861
+ begin
862
+ client.create_subscription(params)
863
+ rescue PaymentKit::ConflictError => e
864
+ retry if e.retryable? # e.g. error_code == "invoice_locked"
865
+ warn "Conflict on invoice #{e.invoice_id}: #{e.message}"
866
+ rescue PaymentKit::CardError => e
867
+ warn "Declined: #{e.message}"
868
+ rescue PaymentKit::InvalidRequestError => e
869
+ warn "Bad request (#{e.status}): #{e.message} request_id=#{e.request_id}"
870
+ rescue PaymentKit::APIConnectionError => e
871
+ warn "Network problem: #{e.message}"
872
+ rescue PaymentKit::Error => e
873
+ warn "PaymentKit error: #{e.message}"
874
+ end
875
+ ```
876
+
877
+ `PermissionError` and `SignatureVerificationError` both subclass
878
+ `AuthenticationError`, so existing `rescue PaymentKit::AuthenticationError`
879
+ blocks keep catching them.
880
+
881
+ API errors follow [RFC 7807](https://datatracker.ietf.org/doc/html/rfc7807) problem
882
+ details (`title`, `detail`, `request_id`). Validation `detail` arrays are flattened
883
+ into readable field messages.
884
+
885
+ For compatibility with applications written against a nested error namespace,
886
+ `PaymentKit::Client::AuthenticationError` and friends resolve to the same classes.
887
+
888
+ ## Idempotency
889
+
890
+ - `POST`, `PUT` and `PATCH` automatically send an `Idempotency-Key` header
891
+ (random UUID), and the same key is reused across the gem's internal retries so
892
+ a retried write cannot double-charge.
893
+ - A random key only protects a single call. For operations that must not
894
+ double-bill across *process-level* retries — `bill_pending_items`,
895
+ `create_credit_note`, charge-first `update_subscription_items` and
896
+ `apply_change_request` — pass your own stable key:
897
+
898
+ ```ruby
899
+ client.bill_pending_items(params, idempotency_key: "dispatch-#{user_id}-#{timestamp}")
900
+ client.update_subscription_items("sub_1", { items: [...], proration_behavior: "always_invoice" },
901
+ idempotency_key: "plan-change-#{user_id}-#{version}")
902
+ ```
903
+
904
+ Every write method accepts the body positionally or as keywords, with an optional
905
+ `idempotency_key:` alongside:
906
+
907
+ ```ruby
908
+ client.create_customer(email: "a@b.com")
909
+ client.create_customer({ email: "a@b.com" }, idempotency_key: "signup-42")
910
+ client.create_customer(email: "a@b.com", idempotency_key: "signup-42")
911
+ ```
912
+
913
+ ### Retries
914
+
915
+ `408`, `429` and `5xx` are always retried with exponential backoff. `409` is
916
+ retried **only** when PaymentKit flags it (`retryable: true`, or a known
917
+ side-effect-free `error_code` such as `invoice_locked`) — other conflicts, like
918
+ creating a second active change request, fail immediately.
919
+
920
+ A numeric `Retry-After` response header takes precedence over the backoff curve,
921
+ capped at `PaymentKit::Client::MAX_RETRY_DELAY` (32s) so a bad header cannot park
922
+ a request indefinitely.
923
+
924
+ ## Instrumentation
925
+
926
+ `request_begin` and `request_end` hooks wrap every outbound API call. They are
927
+ deliberately separate from the webhook event bus, so API traffic is never
928
+ delivered to `PaymentKit.all` subscribers:
929
+
930
+ ```ruby
931
+ PaymentKit::Instrumentation.subscribe(:request_end) do |event|
932
+ StatsD.timing(
933
+ "payment_kit.request",
934
+ event.duration * 1000,
935
+ tags: ["method:#{event.method}", "path:#{event.path}", "status:#{event.status}"]
936
+ )
937
+ Rails.logger.warn("PaymentKit retried #{event.path} #{event.num_retries}x") if event.num_retries.positive?
938
+ end
939
+ ```
940
+
941
+ `request_end` fires once per logical call — after retries, on both the success
942
+ and failure paths — and carries `method`, `path`, `status`, `duration`,
943
+ `num_retries` and `request_id`. A raising subscriber warns on stderr and never
944
+ breaks the API call. `subscribe` returns a name you can pass to `unsubscribe`.
945
+
946
+ ## Calling unwrapped endpoints
947
+
948
+ `raw_request` reaches any PaymentKit endpoint this gem does not wrap yet, reusing
949
+ the same auth, retry, idempotency and error mapping:
950
+
951
+ ```ruby
952
+ client.raw_request(:get, "/payment-links", params: { limit: 10 })
953
+ client.raw_request(:post, "/webhook-endpoints/we_1/roll-secret",
954
+ params: { ttl_seconds: 3600 }, idempotency_key: "roll-1")
955
+ ```
956
+
957
+ Paths are account-scoped by default. Surfaces that sit outside the account
958
+ prefix — such as the customer portal — opt out:
959
+
960
+ ```ruby
961
+ client.raw_request(:get, "/billing-portal/token/#{token}/payment-methods",
962
+ account_scoped: false)
963
+ ```
964
+
965
+ It returns the parsed JSON body as a `Hash`; unlike `list_*` helpers it does not
966
+ auto-paginate.
967
+
968
+ ## Testing
969
+
970
+ Point the client at a stub base URL and stub `transport` (or use WebMock against
971
+ the resolved host):
972
+
973
+ ```ruby
974
+ client = PaymentKit::Client.new(
975
+ secret_key: "st_test",
976
+ base_url: "https://api.test/acc"
977
+ )
978
+
979
+ # Example: inject responses by stubbing the private transport seam in unit tests
980
+ allow(client).to receive(:transport).and_return(fake_response)
981
+ ```
982
+
983
+ In RSpec suites, call `PaymentKit.reset_configuration!` between examples that
984
+ mutate global config.
985
+
986
+ ## Development
987
+
988
+ ```bash
989
+ bin/setup
990
+ bundle exec rspec
991
+ bundle exec rubocop
992
+ bundle exec rake # spec + rubocop
993
+ ```
994
+
995
+ Interactive console:
996
+
997
+ ```bash
998
+ bin/console
999
+ ```
1000
+
1001
+ ## Documentation
1002
+
1003
+ - API & guides: https://docs.paymentkit.com
1004
+ - Changelog: [CHANGELOG.md](CHANGELOG.md)
1005
+
1006
+ ## License
1007
+
1008
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).