payment_kit 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module PaymentKit
6
+ # Base error for all PaymentKit failures.
7
+ #
8
+ # PaymentKit returns RFC 7807 problem documents. Standard members (+title+,
9
+ # +detail+, +status+, +instance+, +request_id+) plus any extension members are
10
+ # kept in +problem+ so callers can branch on them without re-parsing the body.
11
+ #
12
+ # Transient failures carry a top-level +error_code+ and +retryable: true+
13
+ # (for example +invoice_locked+ on HTTP 409).
14
+ class Error < StandardError
15
+ # HTTP status of the failing response, or +nil+ for client-side failures.
16
+ attr_reader :status
17
+
18
+ # Raw response body as received.
19
+ attr_reader :body
20
+
21
+ # PaymentKit request id, from the problem document or the +Request-Id+ header.
22
+ attr_reader :request_id
23
+
24
+ # Machine-readable code such as +invoice_locked+, when PaymentKit sends one.
25
+ attr_reader :error_code
26
+
27
+ # Parsed RFC 7807 problem document as a Hash; empty when the body was not JSON.
28
+ attr_reader :problem
29
+
30
+ # Any attribute not passed explicitly is read from the RFC 7807 +body+.
31
+ def initialize(message = nil, status: nil, body: nil, request_id: nil,
32
+ error_code: nil, retryable: nil)
33
+ @status = status
34
+ @body = body
35
+ @problem = parse_problem(body)
36
+ @request_id = request_id || @problem["request_id"]
37
+ @error_code = error_code || @problem["error_code"]
38
+ @retryable = retryable.nil? ? @problem["retryable"] : retryable
39
+ super(message)
40
+ end
41
+
42
+ # Whether PaymentKit marked this failure as safe to retry unchanged.
43
+ def retryable?
44
+ @retryable == true
45
+ end
46
+
47
+ # Reads an RFC 7807 extension member, e.g. +error["invoice_id"]+.
48
+ def [](key)
49
+ @problem[key.to_s]
50
+ end
51
+
52
+ # Convenience reader for the +invoice_id+ extension member, when present.
53
+ def invoice_id
54
+ self["invoice_id"]
55
+ end
56
+
57
+ # Convenience reader for the +subscription_id+ extension member, when present.
58
+ def subscription_id
59
+ self["subscription_id"]
60
+ end
61
+
62
+ private
63
+
64
+ def parse_problem(raw)
65
+ return {} if raw.nil? || raw.to_s.empty?
66
+
67
+ parsed = JSON.parse(raw.to_s)
68
+ parsed.is_a?(Hash) ? parsed : {}
69
+ rescue JSON::ParserError
70
+ {}
71
+ end
72
+ end
73
+
74
+ # Raised when credentials are missing/invalid or the API returns 401.
75
+ class AuthenticationError < Error; end
76
+
77
+ # Raised on HTTP 403: the credential is valid but is not allowed to touch this
78
+ # resource. Subclasses AuthenticationError so existing rescues still match.
79
+ class PermissionError < AuthenticationError; end
80
+
81
+ # Raised when an inbound webhook signature is missing or does not verify.
82
+ # Subclasses AuthenticationError so existing rescues still match.
83
+ class SignatureVerificationError < AuthenticationError; end
84
+
85
+ # Raised for client-side invalid parameters or API 400/404/422 responses.
86
+ class InvalidRequestError < Error; end
87
+
88
+ # Raised when a request conflicts with the current resource state (HTTP 409).
89
+ class ConflictError < Error; end
90
+
91
+ # Raised when a payment attempt is declined (HTTP 402). The invoice or intent
92
+ # is left in a retry-safe state, so this is a decline rather than a bug.
93
+ class CardError < Error; end
94
+
95
+ # Raised when the API rate-limits the client (HTTP 429).
96
+ class RateLimitError < Error; end
97
+
98
+ # Raised for unexpected API responses and other HTTP error statuses.
99
+ class APIError < Error; end
100
+
101
+ # Raised when the HTTP connection fails (timeouts, refused, reset, etc.).
102
+ class APIConnectionError < Error; end
103
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module PaymentKit
6
+ # Optional hooks around outbound HTTP requests.
7
+ #
8
+ # Deliberately separate from the webhook event bus: instrumenting API calls
9
+ # through +ActiveSupport::Notifications+ under the +payment_kit.+ namespace
10
+ # would deliver them to +PaymentKit.all+ webhook subscribers.
11
+ #
12
+ # PaymentKit::Instrumentation.subscribe(:request_end) do |event|
13
+ # StatsD.timing("payment_kit.request", event.duration * 1000,
14
+ # tags: ["path:#{event.path}", "status:#{event.status}"])
15
+ # end
16
+ #
17
+ # Subscriber exceptions are never allowed to break the API call.
18
+ module Instrumentation
19
+ # Emitted before the request leaves the process.
20
+ class RequestBeginEvent
21
+ # HTTP verb as a Symbol, e.g. +:post+.
22
+ attr_reader :method
23
+
24
+ # Request path, relative to the account base URL.
25
+ attr_reader :path
26
+
27
+ def initialize(method:, path:) # :nodoc:
28
+ @method = method
29
+ @path = path
30
+ end
31
+ end
32
+
33
+ # Emitted once a request has finished, including after retries.
34
+ class RequestEvent
35
+ # HTTP verb as a Symbol, e.g. +:post+.
36
+ attr_reader :method
37
+
38
+ # Request path, relative to the account base URL.
39
+ attr_reader :path
40
+
41
+ # HTTP status of the final attempt, or +nil+ if no response arrived.
42
+ attr_reader :status
43
+
44
+ # Wall-clock seconds for the whole call, retries included.
45
+ attr_reader :duration
46
+
47
+ # Number of retries performed; +0+ when the first attempt settled it.
48
+ attr_reader :num_retries
49
+
50
+ # PaymentKit request id from the response headers, when present.
51
+ attr_reader :request_id
52
+
53
+ def initialize(method:, path:, status:, duration:, num_retries:, request_id: nil) # :nodoc:
54
+ @method = method
55
+ @path = path
56
+ @status = status
57
+ @duration = duration
58
+ @num_retries = num_retries
59
+ @request_id = request_id
60
+ end
61
+ end
62
+
63
+ # Topics that may be subscribed to.
64
+ TOPICS = %i[request_begin request_end].freeze
65
+
66
+ class << self
67
+ # Registered blocks, keyed by topic and then by subscriber name.
68
+ def subscribers
69
+ @subscribers ||= Hash.new { |hash, key| hash[key] = {} }
70
+ end
71
+
72
+ # Registers +block+ for +topic+, which must be +:request_begin+ or
73
+ # +:request_end+. Returns the subscriber name, for use with #unsubscribe.
74
+ def subscribe(topic, name = SecureRandom.uuid, &block)
75
+ raise ArgumentError, "unknown topic: #{topic}" unless TOPICS.include?(topic)
76
+ raise ArgumentError, "subscriber block required" if block.nil?
77
+
78
+ subscribers[topic][name] = block
79
+ name
80
+ end
81
+
82
+ # Removes the subscriber registered under +name+ for +topic+.
83
+ def unsubscribe(topic, name)
84
+ subscribers[topic].delete(name)
85
+ end
86
+
87
+ # Whether +topic+ has any subscribers. The client checks this before
88
+ # building event objects, so an unsubscribed topic costs nothing.
89
+ def subscribers?(topic)
90
+ !subscribers[topic].empty?
91
+ end
92
+
93
+ # Notifies subscribers. A raising subscriber must never break the API call,
94
+ # so failures are swallowed after being reported to +$stderr+.
95
+ def notify(topic, event)
96
+ subscribers[topic].each_value do |subscriber|
97
+ subscriber.call(event)
98
+ rescue StandardError => e
99
+ warn("[PaymentKit::Instrumentation] #{topic} subscriber raised: #{e.message}")
100
+ end
101
+ end
102
+
103
+ # Drops every subscriber. Intended for test suites.
104
+ def reset!
105
+ @subscribers = nil
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaymentKit
4
+ # Prefixes notification names and builds subscription regexes.
5
+ #
6
+ # Events instrument as +payment_kit.<type>+.
7
+ class Namespace
8
+ # +prefix+ is prepended to every event type, and should end with a dot.
9
+ def initialize(prefix)
10
+ @prefix = prefix.to_s
11
+ end
12
+
13
+ # The configured prefix, e.g. <tt>"payment_kit."</tt>.
14
+ attr_reader :prefix
15
+
16
+ # Returns the fully namespaced notification name for an event type.
17
+ def call(name)
18
+ "#{@prefix}#{name}"
19
+ end
20
+
21
+ # +nil+/empty name matches the entire namespace (used by +PaymentKit.all+).
22
+ def to_regexp(name = nil)
23
+ pattern = name.nil? || name.to_s.empty? ? @prefix : "#{@prefix}#{name}"
24
+ /\A#{Regexp.escape(pattern)}/
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaymentKit
4
+ # Adapts ActiveSupport::Notifications' multi-argument callback to +#call(event)+.
5
+ class NotificationAdapter
6
+ # Wraps +subscriber+ so the notification backend can invoke it.
7
+ def self.call(subscriber)
8
+ new(subscriber)
9
+ end
10
+
11
+ def initialize(subscriber) # :nodoc:
12
+ @subscriber = subscriber
13
+ end
14
+
15
+ # Receives the backend's callback arguments and forwards only the payload.
16
+ def call(*args)
17
+ event = args.last
18
+ @subscriber.call(event)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaymentKit
4
+ module Resources
5
+ # Products and prices.
6
+ module Catalog
7
+ # GET /products/ — auto-paginated Array of products.
8
+ def list_products(params = {}) = list("/products/", params)
9
+
10
+ # GET /products/{id}
11
+ def retrieve_product(id) = get("/products/#{id}")
12
+
13
+ # POST /products/ — takes +name+, +description+, +is_active+,
14
+ # +default_price_id+, +metadata+, +custom_fields+.
15
+ def create_product(params = nil, **) = post("/products/", params, **)
16
+
17
+ # PATCH /products/{id} — only the fields you send are changed.
18
+ def update_product(id, params = nil, **) = patch("/products/#{id}", params, **)
19
+
20
+ # GET /products/{id}/prices — auto-paginated Array of the product's prices.
21
+ def list_product_prices(product_id, params = {})
22
+ list("/products/#{product_id}/prices", params)
23
+ end
24
+
25
+ # GET /prices/ — auto-paginated Array of prices.
26
+ def list_prices(params = {}) = list("/prices/", params)
27
+
28
+ # GET /prices/{id}
29
+ def retrieve_price(id, params = {}) = get("/prices/#{id}", params)
30
+
31
+ # POST /prices/ — takes +product_id+, +currency+, +unit_amount_atom+,
32
+ # +pricing_type+, +billing_scheme+, +recurring_interval+ and friends.
33
+ def create_price(params = nil, **) = post("/prices/", params, **)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaymentKit
4
+ # Endpoint methods, grouped by resource and mixed into PaymentKit::Client.
5
+ #
6
+ # Each module assumes the private request helpers the client provides, so the
7
+ # modules are not useful on their own.
8
+ module Resources
9
+ # Customers, credit balance and credit notes.
10
+ #
11
+ # Write methods accept the body positionally or as keywords, and take an
12
+ # optional +idempotency_key:+:
13
+ #
14
+ # client.create_customer(email: "a@b.com")
15
+ # client.create_customer({ email: "a@b.com" }, idempotency_key: "signup-42")
16
+ module Customers
17
+ # POST /customers/ — takes +email+, +first_name+, +last_name+,
18
+ # +business_name+, +address+, +currency+, +metadata+ and friends.
19
+ def create_customer(params = nil, **) = post("/customers/", params, **)
20
+
21
+ # GET /customers/{id}
22
+ def retrieve_customer(id) = get("/customers/#{id}")
23
+
24
+ # PUT /customers/{id} — only the fields you send are changed.
25
+ def update_customer(id, params = nil, **) = put("/customers/#{id}", params, **)
26
+
27
+ # GET /customers/ — auto-paginated Array of customers.
28
+ def list_customers(params = {}) = list("/customers/", params)
29
+
30
+ # Sets the customer's credit balance to an ABSOLUTE target for the given
31
+ # currency — PaymentKit issues or voids credit to reach it. This is not a
32
+ # delta; use #create_credit_note to add credit incrementally.
33
+ #
34
+ # client.set_credit_balance("cus_1", amount_atom: 5000, currency: "USD")
35
+ def set_credit_balance(customer_id, params = nil, **)
36
+ patch("/customers/#{customer_id}/credit-balance", params, **)
37
+ end
38
+
39
+ # *Deprecated.* Use #set_credit_balance. The name implied delta semantics,
40
+ # but the endpoint sets an absolute target balance.
41
+ def create_balance_transaction(customer_id, params = nil, **)
42
+ warn("[PaymentKit] create_balance_transaction is deprecated; use set_credit_balance. " \
43
+ "The endpoint sets an absolute target balance, not a delta.")
44
+ set_credit_balance(customer_id, params, **)
45
+ end
46
+
47
+ # Appends a credit note (an additive adjustment) to the customer balance.
48
+ # Always pass a stable +idempotency_key+: a double submit issues two notes.
49
+ #
50
+ # +reason+ is required and must be one of +proration_excess+,
51
+ # +manual_adjustment+, +auto_apply+ or +debit_settlement+.
52
+ def create_credit_note(customer_id, params = nil, **)
53
+ post("/customers/#{customer_id}/credit-notes", params, **)
54
+ end
55
+
56
+ # GET /customers/{id}/credit-notes — newest first; filter with +currency+.
57
+ def list_credit_notes(customer_id, params = {})
58
+ list("/customers/#{customer_id}/credit-notes", params)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaymentKit
4
+ module Resources
5
+ # Invoices and floating (pending) invoice items.
6
+ module Invoices
7
+ # POST /invoices/ — creates a one-off invoice in +draft+. Each entry in
8
+ # +items+ takes an +amount+, a +unit_amount+ with +quantity+, or a
9
+ # catalog +price_id+.
10
+ def create_invoice(params = nil, **) = post("/invoices/", params, **)
11
+
12
+ # Supports +expand+, e.g. <tt>retrieve_invoice("in_1", expand: "custom_fields")</tt>.
13
+ def retrieve_invoice(id, params = {}) = get("/invoices/#{id}", params)
14
+
15
+ # GET /invoices/ — auto-paginated Array of invoices.
16
+ def list_invoices(params = {}) = list("/invoices/", params)
17
+
18
+ # Returns <tt>{ "pdf_url" => ..., "status" => "available" | "generating" }</tt>;
19
+ # poll again while +status+ is +generating+.
20
+ def retrieve_invoice_pdf(id) = get("/invoices/#{id}/pdf")
21
+
22
+ # Attempts collection on an open invoice (finalizing it first if draft).
23
+ # A decline raises PaymentKit::CardError and leaves the invoice payable.
24
+ def pay_invoice(id, params = nil, **) = post("/invoices/#{id}/collect", params, **)
25
+
26
+ # Cancels the invoice so no payment is expected.
27
+ def void_invoice(id, **) = post("/invoices/#{id}/void", {}, **)
28
+
29
+ # Moves a draft invoice to +open+ and locks its amounts.
30
+ def finalize_invoice(id, **) = post("/invoices/#{id}/finalize", {}, **)
31
+
32
+ # Only permitted from +OPEN+ or +PAST_DUE+; other states return 422.
33
+ def mark_invoice_uncollectible(id, **)
34
+ post("/invoices/#{id}/mark-uncollectible", {}, **)
35
+ end
36
+
37
+ # Sweeps floating items into standalone invoices (one per currency),
38
+ # finalizes them and attempts collection. Always pass a stable
39
+ # +idempotency_key+ or duplicate requests create duplicate invoices.
40
+ def bill_pending_items(params = nil, **)
41
+ post("/invoices/bill-pending-items", params, **)
42
+ end
43
+
44
+ # --- Invoice items ------------------------------------------------------
45
+
46
+ # POST /invoice-items/ — creates a floating (pending) item, collected at
47
+ # the next renewal or on demand via #bill_pending_items. The subscription
48
+ # must be +active+ or +trialing+ and belong to the same customer.
49
+ def create_invoice_item(params = nil, **) = post("/invoice-items/", params, **)
50
+
51
+ # GET /invoice-items/{id}
52
+ def retrieve_invoice_item(id) = get("/invoice-items/#{id}")
53
+
54
+ # GET /invoice-items/ — auto-paginated; filter with +subscription_id+ and
55
+ # <tt>status: "floating"</tt> to list pending items.
56
+ def list_invoice_items(params = {}) = list("/invoice-items/", params)
57
+
58
+ # PATCH /invoice-items/{id}
59
+ def update_invoice_item(id, params = nil, **)
60
+ patch("/invoice-items/#{id}", params, **)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaymentKit
4
+ module Resources
5
+ # Payment intents, payment methods, refunds and checkout sessions.
6
+ module Payments
7
+ # --- Payment intents ----------------------------------------------------
8
+
9
+ # POST /payments/intents/ — takes +customer_id+, +amount_atom+,
10
+ # +currency+, +payment_method_id+, +processor_id+ and +metadata+.
11
+ def create_payment_intent(params = nil, **)
12
+ post("/payments/intents/", params, **)
13
+ end
14
+
15
+ # Supports <tt>expand: "checkout_attempt"</tt>.
16
+ def retrieve_payment_intent(id, params = {}) = get("/payments/intents/#{id}", params)
17
+
18
+ # GET /payments/intents/ — auto-paginated Array of payment intents.
19
+ def list_payment_intents(params = {}) = list("/payments/intents/", params)
20
+
21
+ # Auto-paginated Array of refunds recorded against a payment intent.
22
+ def list_refunds_by_intent(payment_intent_id, params = {})
23
+ list("/payments/refunds/by_intent/#{payment_intent_id}", params)
24
+ end
25
+
26
+ # Auto-paginated Array of processor attempts for a payment intent.
27
+ def list_attempts_by_intent(payment_intent_id, params = {})
28
+ list("/payments/processor_attempts/by_intent/#{payment_intent_id}", params)
29
+ end
30
+
31
+ # --- Payment methods ----------------------------------------------------
32
+
33
+ # POST /payments/payment_methods/
34
+ def create_payment_method(params = nil, **)
35
+ post("/payments/payment_methods/", params, **)
36
+ end
37
+
38
+ # GET /payments/payment_methods/{id}
39
+ def retrieve_payment_method(id) = get("/payments/payment_methods/#{id}")
40
+
41
+ # PUT /payments/payment_methods/{id}
42
+ def update_payment_method(id, params = nil, **)
43
+ put("/payments/payment_methods/#{id}", params, **)
44
+ end
45
+
46
+ # Documented way to take a card out of use: flips +is_active+ to false.
47
+ # Prefer this over #detach_payment_method.
48
+ def deactivate_payment_method(id) = update_payment_method(id, is_active: false)
49
+
50
+ # Removes the payment method outright.
51
+ #
52
+ # NOTE: PaymentKit documents deletion only on the customer-portal surface
53
+ # (<tt>DELETE /billing-portal/token/{token}/payment-methods/{id}</tt>). This
54
+ # account-scoped delete is kept for backwards compatibility; if your account
55
+ # returns 404/405, use #deactivate_payment_method instead.
56
+ def detach_payment_method(id) = delete("/payments/payment_methods/#{id}")
57
+
58
+ # --- Checkout sessions --------------------------------------------------
59
+
60
+ # Returns a session carrying the +secure_token+ used to initialise
61
+ # PaymentKit.js or redirect to hosted checkout. +line_items+,
62
+ # +success_url+ and +return_url+ are required.
63
+ def create_checkout_session(params = nil, **)
64
+ post("/checkout-sessions", params, **)
65
+ end
66
+
67
+ # GET /checkout-sessions/{id}
68
+ def retrieve_checkout_session(id) = get("/checkout-sessions/#{id}")
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaymentKit
4
+ module Resources
5
+ # Subscription lifecycle, scheduling and change requests.
6
+ module Subscriptions
7
+ # POST /subscriptions — +customer_id+, +currency+, +billing_interval+,
8
+ # +billing_interval_count+, +period_start+ and +collection_method+ are
9
+ # required; +items+ is an Array of +price_id+/+quantity+ pairs.
10
+ def create_subscription(params = nil, **) = post("/subscriptions", params, **)
11
+
12
+ # GET /subscriptions/{id}
13
+ def retrieve_subscription(id) = get("/subscriptions/#{id}")
14
+
15
+ # GET /subscriptions — auto-paginated Array of subscriptions.
16
+ def list_subscriptions(params = {}) = list("/subscriptions", params)
17
+
18
+ # PATCH /subscriptions/{id} — subscription-level fields such as
19
+ # +cancel_at_period_end+ and +metadata+. Line items are not accepted here;
20
+ # use #update_subscription_items.
21
+ def update_subscription(id, params = nil, **)
22
+ patch("/subscriptions/#{id}", params, **)
23
+ end
24
+
25
+ # Add/update/remove items within the current billing interval. Charge-first:
26
+ # payment is collected before the change applies, so pass a stable
27
+ # +idempotency_key+. +proration_behavior+ is required by the API.
28
+ def update_subscription_items(id, params = nil, **)
29
+ patch("/subscriptions/#{id}/items", params, **)
30
+ end
31
+
32
+ # Immediate, irreversible cancellation. Accepts +refund_option+
33
+ # (+none+, +full+, +prorated+, +cancel_unpaid+) and +is_preview+.
34
+ def cancel_subscription(id, params = nil, **)
35
+ post("/subscriptions/#{id}/cancel", params, **)
36
+ end
37
+
38
+ # Cancel on a specific date instead of immediately.
39
+ def schedule_cancellation(id, params = nil, **)
40
+ post("/subscriptions/#{id}/schedule-cancellation", params, **)
41
+ end
42
+
43
+ # Clears a cancellation previously scheduled by #schedule_cancellation.
44
+ def cancel_scheduled_cancellation(id) = delete("/subscriptions/#{id}/scheduled-cancellation")
45
+
46
+ # +pause_behavior+ (+pause_immediately+/+pause_at_end+) is required.
47
+ def pause_subscription(id, params = nil, **)
48
+ post("/subscriptions/#{id}/pause", params, **)
49
+ end
50
+
51
+ # Removes a pending +pause_at_end+ before it activates.
52
+ def cancel_scheduled_pause(id) = delete("/subscriptions/#{id}/scheduled-pause")
53
+
54
+ # Resumes a paused subscription. Whether billing re-anchors or catches up
55
+ # is governed by the account's +resume_billing_behavior+ setting.
56
+ def resume_subscription(id, params = nil, **)
57
+ post("/subscriptions/#{id}/resume", params, **)
58
+ end
59
+
60
+ # Moves the next invoice date. Accepts +next_billing_date+,
61
+ # +create_proration+ and +is_preview+.
62
+ def reschedule_billing(id, params = nil, **)
63
+ post("/subscriptions/#{id}/reschedule-billing", params, **)
64
+ end
65
+
66
+ # Forces a renewal cycle immediately.
67
+ def renew_subscription(id, **) = post("/subscriptions/#{id}/renew", {}, **)
68
+
69
+ # *Deprecated.* PaymentKit documents +change-plan+ as the legacy
70
+ # single-call endpoint, planned for deprecation. Prefer the change-request
71
+ # workflow (#create_change_request, #add_change_request_changes,
72
+ # #preview_change_request, #apply_change_request) or the one-step
73
+ # #apply_subscription_changes.
74
+ def change_plan(subscription_id, params = nil, **)
75
+ post("/subscriptions/#{subscription_id}/change-plan", params, **)
76
+ end
77
+
78
+ # Cancels a scheduled (period-end) plan change before it executes.
79
+ def cancel_pending_change(id) = delete("/subscriptions/#{id}/pending-change")
80
+
81
+ # --- Change requests ----------------------------------------------------
82
+
83
+ # Opens a draft change request. Accepts +reason+ and +expires_in_hours+.
84
+ #
85
+ # Only one active (draft/ready) change request may exist per subscription;
86
+ # creating a second raises ConflictError (HTTP 409).
87
+ def create_change_request(subscription_id, params = nil, **)
88
+ post("/subscriptions/#{subscription_id}/change-requests", params, **)
89
+ end
90
+
91
+ # GET /subscriptions/{id}/change-requests/{request_id}
92
+ def retrieve_change_request(subscription_id, request_id)
93
+ get("/subscriptions/#{subscription_id}/change-requests/#{request_id}")
94
+ end
95
+
96
+ # Returns +null+ when the subscription has no active change request.
97
+ def active_change_request(subscription_id)
98
+ get("/subscriptions/#{subscription_id}/change-requests/active")
99
+ end
100
+
101
+ # Appends +item_changes+, +coupon_changes+, +balance_changes+ and
102
+ # +trial_behavior+ to a draft. Callable repeatedly; each call appends.
103
+ # Adding changes to a +ready+ request reverts it to +draft+.
104
+ def add_change_request_changes(subscription_id, request_id, params = nil, **)
105
+ patch("/subscriptions/#{subscription_id}/change-requests/#{request_id}", params, **)
106
+ end
107
+
108
+ # Computes proration and moves the request to +ready+.
109
+ def preview_change_request(subscription_id, request_id)
110
+ post("/subscriptions/#{subscription_id}/change-requests/#{request_id}/preview", {})
111
+ end
112
+
113
+ # Charge-first execution: pass a stable +idempotency_key+.
114
+ def apply_change_request(subscription_id, request_id, **)
115
+ post("/subscriptions/#{subscription_id}/change-requests/#{request_id}/apply", {}, **)
116
+ end
117
+
118
+ # Discards a change request without applying it.
119
+ def cancel_change_request(subscription_id, request_id)
120
+ delete("/subscriptions/#{subscription_id}/change-requests/#{request_id}")
121
+ end
122
+
123
+ # One-step shortcut: create, add changes, preview and apply in a single call.
124
+ def apply_subscription_changes(subscription_id, params = nil, **)
125
+ post("/subscriptions/#{subscription_id}/change-requests/apply", params, **)
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaymentKit
4
+ # Gem version.
5
+ VERSION = "1.0.0"
6
+ end