nombaone 0.1.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 +36 -0
- data/LICENSE +21 -0
- data/README.md +230 -0
- data/lib/nombaone/client.rb +158 -0
- data/lib/nombaone/errors.rb +286 -0
- data/lib/nombaone/internal/http_client.rb +230 -0
- data/lib/nombaone/internal/util.rb +155 -0
- data/lib/nombaone/object.rb +152 -0
- data/lib/nombaone/pagination.rb +116 -0
- data/lib/nombaone/resources/base_resource.rb +40 -0
- data/lib/nombaone/resources/coupons.rb +84 -0
- data/lib/nombaone/resources/customers.rb +160 -0
- data/lib/nombaone/resources/events.rb +46 -0
- data/lib/nombaone/resources/invoices.rb +61 -0
- data/lib/nombaone/resources/mandates.rb +71 -0
- data/lib/nombaone/resources/metrics.rb +24 -0
- data/lib/nombaone/resources/organization.rb +91 -0
- data/lib/nombaone/resources/payment_methods.rb +102 -0
- data/lib/nombaone/resources/plans.rb +129 -0
- data/lib/nombaone/resources/prices.rb +53 -0
- data/lib/nombaone/resources/sandbox.rb +81 -0
- data/lib/nombaone/resources/settlements.rb +91 -0
- data/lib/nombaone/resources/subscriptions.rb +310 -0
- data/lib/nombaone/resources/webhook_endpoints.rb +140 -0
- data/lib/nombaone/version.rb +7 -0
- data/lib/nombaone/webhook_event.rb +64 -0
- data/lib/nombaone/webhooks.rb +167 -0
- data/lib/nombaone.rb +58 -0
- data/sig/nombaone/client.rbs +36 -0
- data/sig/nombaone/errors.rbs +68 -0
- data/sig/nombaone/internal.rbs +51 -0
- data/sig/nombaone/object.rbs +22 -0
- data/sig/nombaone/pagination.rbs +23 -0
- data/sig/nombaone/resources.rbs +243 -0
- data/sig/nombaone/webhooks.rbs +18 -0
- data/sig/nombaone.rbs +24 -0
- metadata +87 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nombaone
|
|
4
|
+
module Resources
|
|
5
|
+
# Scheduled (next-cycle) changes queued against a subscription
|
|
6
|
+
# (`nombaone.subscriptions.schedule`).
|
|
7
|
+
class SubscriptionSchedules < BaseResource
|
|
8
|
+
# Queue a change for the next cycle boundary — the safe way to switch
|
|
9
|
+
# billing intervals (mid-cycle interval proration is unsupported).
|
|
10
|
+
#
|
|
11
|
+
# @param subscription_id [String] `nbo…sub`
|
|
12
|
+
# @param price_id [String] the price to switch to at the boundary.
|
|
13
|
+
# @param quantity [Integer]
|
|
14
|
+
# @param effective_at [String] defaults to `"next_cycle"` server-side.
|
|
15
|
+
# @param request_options [Hash]
|
|
16
|
+
# @return [NombaObject]
|
|
17
|
+
# @raise [Nombaone::ConflictError] 409 `SUBSCRIPTION_SCHEDULE_CONFLICT`
|
|
18
|
+
def create(subscription_id, price_id:, quantity: OMIT, effective_at: OMIT,
|
|
19
|
+
request_options: {})
|
|
20
|
+
request(:post, "/subscriptions/#{encode(subscription_id)}/schedule",
|
|
21
|
+
body: { price_id: price_id, quantity: quantity, effective_at: effective_at },
|
|
22
|
+
options: request_options)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Retrieve the subscription's schedule.
|
|
26
|
+
#
|
|
27
|
+
# @param subscription_id [String] `nbo…sub`
|
|
28
|
+
# @param request_options [Hash]
|
|
29
|
+
# @return [NombaObject]
|
|
30
|
+
# @raise [Nombaone::NotFoundError] 404 `SUBSCRIPTION_SCHEDULE_NOT_FOUND`
|
|
31
|
+
def retrieve(subscription_id, request_options: {})
|
|
32
|
+
request(:get, "/subscriptions/#{encode(subscription_id)}/schedule",
|
|
33
|
+
options: request_options)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Cancel the pending schedule before it applies.
|
|
37
|
+
#
|
|
38
|
+
# @param subscription_id [String] `nbo…sub`
|
|
39
|
+
# @param request_options [Hash]
|
|
40
|
+
# @return [NombaObject]
|
|
41
|
+
def release(subscription_id, request_options: {})
|
|
42
|
+
request(:delete, "/subscriptions/#{encode(subscription_id)}/schedule",
|
|
43
|
+
options: request_options)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Read-only view into a subscription's recovery state
|
|
48
|
+
# (`nombaone.subscriptions.dunning`).
|
|
49
|
+
class SubscriptionDunning < BaseResource
|
|
50
|
+
# Where the subscription stands in dunning. Check `grace_access_until`
|
|
51
|
+
# before cutting access — `past_due` usually means "not yet", not "no".
|
|
52
|
+
#
|
|
53
|
+
# @param subscription_id [String] `nbo…sub`
|
|
54
|
+
# @param request_options [Hash]
|
|
55
|
+
# @return [NombaObject]
|
|
56
|
+
def retrieve(subscription_id, request_options: {})
|
|
57
|
+
request(:get, "/subscriptions/#{encode(subscription_id)}/dunning", options: request_options)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# List every recovery attempt, newest first.
|
|
61
|
+
#
|
|
62
|
+
# @param subscription_id [String] `nbo…sub`
|
|
63
|
+
# @param limit [Integer] page size, 1–100 (API default 20).
|
|
64
|
+
# @param cursor [String] opaque cursor from a previous page.
|
|
65
|
+
# @param request_options [Hash]
|
|
66
|
+
# @return [Page<NombaObject>]
|
|
67
|
+
def list_attempts(subscription_id, limit: OMIT, cursor: OMIT, request_options: {})
|
|
68
|
+
request_page("/subscriptions/#{encode(subscription_id)}/dunning/attempts",
|
|
69
|
+
query: { limit: limit, cursor: cursor }, options: request_options)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Subscriptions — the core object. Create one against a customer and a
|
|
74
|
+
# price; the engine handles cycles, invoices, retries, and recovery.
|
|
75
|
+
#
|
|
76
|
+
# Involuntary churn is `status: "canceled"` with
|
|
77
|
+
# `cancellation_reason: "involuntary"` (there is no separate `churned`
|
|
78
|
+
# status; there IS a `subscription.churned` event). `past_due` is **not**
|
|
79
|
+
# canceled — read {#dunning} and honor `grace_access_until`.
|
|
80
|
+
#
|
|
81
|
+
# @example
|
|
82
|
+
# subscription = nombaone.subscriptions.create(
|
|
83
|
+
# customer_id: customer.id, price_id: price.id, payment_method_id: method.id
|
|
84
|
+
# )
|
|
85
|
+
# subscription.status # => "active"
|
|
86
|
+
class Subscriptions < BaseResource
|
|
87
|
+
# Scheduled (next-cycle) changes.
|
|
88
|
+
# @return [SubscriptionSchedules]
|
|
89
|
+
def schedule
|
|
90
|
+
@schedule ||= SubscriptionSchedules.new(@client)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Recovery/dunning state (read-only).
|
|
94
|
+
# @return [SubscriptionDunning]
|
|
95
|
+
def dunning
|
|
96
|
+
@dunning ||= SubscriptionDunning.new(@client)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Create a subscription. This can move money (the first charge), so the
|
|
100
|
+
# API requires an `Idempotency-Key`; the SDK sends one automatically and
|
|
101
|
+
# reuses it across its own retries.
|
|
102
|
+
#
|
|
103
|
+
# @param customer_id [String] `nbo…cus`
|
|
104
|
+
# @param price_id [String] `nbo…prc` — subscriptions reference a price, not a plan.
|
|
105
|
+
# @param payment_method_id [String] required for `charge_automatically`
|
|
106
|
+
# unless `trial_days > 0` (the first charge is deferred to trial end).
|
|
107
|
+
# @param collection_method [String] `"charge_automatically"` (default) or
|
|
108
|
+
# `"send_invoice"`.
|
|
109
|
+
# @param trial_days [Integer]
|
|
110
|
+
# @param quantity [Integer] defaults to 1 server-side.
|
|
111
|
+
# @param metadata [Hash]
|
|
112
|
+
# @param request_options [Hash]
|
|
113
|
+
# @return [NombaObject]
|
|
114
|
+
# @raise [Nombaone::ValidationError] 422 — e.g. a missing payment method without a trial.
|
|
115
|
+
# @raise [Nombaone::ConflictError] 409 `SUBSCRIPTION_PAYMENT_METHOD_REQUIRED`
|
|
116
|
+
def create(customer_id:, price_id:, payment_method_id: OMIT, collection_method: OMIT,
|
|
117
|
+
trial_days: OMIT, quantity: OMIT, metadata: OMIT, request_options: {})
|
|
118
|
+
request(:post, "/subscriptions",
|
|
119
|
+
body: {
|
|
120
|
+
customer_id: customer_id,
|
|
121
|
+
price_id: price_id,
|
|
122
|
+
payment_method_id: payment_method_id,
|
|
123
|
+
collection_method: collection_method,
|
|
124
|
+
trial_days: trial_days,
|
|
125
|
+
quantity: quantity,
|
|
126
|
+
metadata: metadata,
|
|
127
|
+
},
|
|
128
|
+
options: request_options)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Retrieve a subscription by id.
|
|
132
|
+
#
|
|
133
|
+
# @param id [String] `nbo…sub`
|
|
134
|
+
# @param request_options [Hash]
|
|
135
|
+
# @return [NombaObject]
|
|
136
|
+
# @raise [Nombaone::NotFoundError] 404 `SUBSCRIPTION_NOT_FOUND`
|
|
137
|
+
def retrieve(id, request_options: {})
|
|
138
|
+
request(:get, "/subscriptions/#{encode(id)}", options: request_options)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Edit metadata or the default payment method. For a price, quantity, or
|
|
142
|
+
# interval change (which prorates), use {#change}. At least one field is required.
|
|
143
|
+
#
|
|
144
|
+
# @param id [String] `nbo…sub`
|
|
145
|
+
# @param default_payment_method_id [String]
|
|
146
|
+
# @param metadata [Hash]
|
|
147
|
+
# @param request_options [Hash]
|
|
148
|
+
# @return [NombaObject]
|
|
149
|
+
def update(id, default_payment_method_id: OMIT, metadata: OMIT, request_options: {})
|
|
150
|
+
request(:patch, "/subscriptions/#{encode(id)}",
|
|
151
|
+
body: { default_payment_method_id: default_payment_method_id, metadata: metadata },
|
|
152
|
+
options: request_options)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# List subscriptions, newest first.
|
|
156
|
+
#
|
|
157
|
+
# @param customer_id [String] filter to one customer (`nbo…cus`).
|
|
158
|
+
# @param status [String] filter by subscription status.
|
|
159
|
+
# @param limit [Integer] page size, 1–100 (API default 20).
|
|
160
|
+
# @param cursor [String] opaque cursor from a previous page.
|
|
161
|
+
# @param request_options [Hash]
|
|
162
|
+
# @return [Page<NombaObject>]
|
|
163
|
+
def list(customer_id: OMIT, status: OMIT, limit: OMIT, cursor: OMIT, request_options: {})
|
|
164
|
+
request_page("/subscriptions",
|
|
165
|
+
query: { customer_id: customer_id, status: status, limit: limit,
|
|
166
|
+
cursor: cursor },
|
|
167
|
+
options: request_options)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The subscription's audit trail of domain events, newest first.
|
|
171
|
+
#
|
|
172
|
+
# @param id [String] `nbo…sub`
|
|
173
|
+
# @param limit [Integer] page size, 1–100 (API default 20).
|
|
174
|
+
# @param cursor [String] opaque cursor from a previous page.
|
|
175
|
+
# @param request_options [Hash]
|
|
176
|
+
# @return [Page<NombaObject>]
|
|
177
|
+
def list_events(id, limit: OMIT, cursor: OMIT, request_options: {})
|
|
178
|
+
request_page("/subscriptions/#{encode(id)}/events",
|
|
179
|
+
query: { limit: limit, cursor: cursor }, options: request_options)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Pause billing. The subscription keeps its place in the cycle and resumes cleanly.
|
|
183
|
+
#
|
|
184
|
+
# @param id [String] `nbo…sub`
|
|
185
|
+
# @param max_days [Integer] auto-resume after this many days.
|
|
186
|
+
# @param request_options [Hash]
|
|
187
|
+
# @return [NombaObject]
|
|
188
|
+
# @raise [Nombaone::ConflictError] 409 `SUBSCRIPTION_ILLEGAL_TRANSITION`
|
|
189
|
+
def pause(id, max_days: OMIT, request_options: {})
|
|
190
|
+
request(:post, "/subscriptions/#{encode(id)}/pause",
|
|
191
|
+
body: { max_days: max_days }, options: request_options)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Resume a paused subscription.
|
|
195
|
+
#
|
|
196
|
+
# @param id [String] `nbo…sub`
|
|
197
|
+
# @param request_options [Hash]
|
|
198
|
+
# @return [NombaObject]
|
|
199
|
+
def resume(id, request_options: {})
|
|
200
|
+
request(:post, "/subscriptions/#{encode(id)}/resume", body: {}, options: request_options)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Cancel a subscription — immediately (default) or at period end.
|
|
204
|
+
#
|
|
205
|
+
# @param id [String] `nbo…sub`
|
|
206
|
+
# @param mode [String] `"now"` (default) or `"at_period_end"` (keeps
|
|
207
|
+
# access until the cycle closes).
|
|
208
|
+
# @param comment [String]
|
|
209
|
+
# @param request_options [Hash]
|
|
210
|
+
# @return [NombaObject]
|
|
211
|
+
#
|
|
212
|
+
# @example
|
|
213
|
+
# nombaone.subscriptions.cancel(subscription.id, mode: "at_period_end")
|
|
214
|
+
def cancel(id, mode: OMIT, comment: OMIT, request_options: {})
|
|
215
|
+
request(:post, "/subscriptions/#{encode(id)}/cancel",
|
|
216
|
+
body: { mode: mode, comment: comment }, options: request_options)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Start a fresh subscription for a canceled one's customer, reusing the
|
|
220
|
+
# old price/payment method unless overridden. The subscription must be in
|
|
221
|
+
# a terminal state.
|
|
222
|
+
#
|
|
223
|
+
# @param id [String] `nbo…sub`
|
|
224
|
+
# @param price_id [String] defaults to the previous price.
|
|
225
|
+
# @param payment_method_id [String] defaults to the previous payment method.
|
|
226
|
+
# @param request_options [Hash]
|
|
227
|
+
# @return [NombaObject]
|
|
228
|
+
# @raise [Nombaone::ConflictError] 409 `SUBSCRIPTION_NOT_TERMINAL`
|
|
229
|
+
def resubscribe(id, price_id: OMIT, payment_method_id: OMIT, request_options: {})
|
|
230
|
+
request(:post, "/subscriptions/#{encode(id)}/resubscribe",
|
|
231
|
+
body: { price_id: price_id, payment_method_id: payment_method_id },
|
|
232
|
+
options: request_options)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Change price or quantity mid-cycle, prorating by default. Switching the
|
|
236
|
+
# billing interval mid-cycle is unsupported
|
|
237
|
+
# (`PRORATION_INTERVAL_SWITCH_UNSUPPORTED`) — queue it with {#schedule} instead.
|
|
238
|
+
#
|
|
239
|
+
# @param id [String] `nbo…sub`
|
|
240
|
+
# @param price_id [String]
|
|
241
|
+
# @param quantity [Integer]
|
|
242
|
+
# @param interval_switch [Boolean]
|
|
243
|
+
# @param proration_behavior [String] `"create_prorations"` (default) or `"none"`.
|
|
244
|
+
# @param request_options [Hash]
|
|
245
|
+
# @return [NombaObject]
|
|
246
|
+
#
|
|
247
|
+
# @example
|
|
248
|
+
# nombaone.subscriptions.change(subscription.id, price_id: bigger_price.id)
|
|
249
|
+
def change(id, price_id: OMIT, quantity: OMIT, interval_switch: OMIT,
|
|
250
|
+
proration_behavior: OMIT, request_options: {})
|
|
251
|
+
request(:post, "/subscriptions/#{encode(id)}/change",
|
|
252
|
+
body: {
|
|
253
|
+
price_id: price_id,
|
|
254
|
+
quantity: quantity,
|
|
255
|
+
interval_switch: interval_switch,
|
|
256
|
+
proration_behavior: proration_behavior,
|
|
257
|
+
},
|
|
258
|
+
options: request_options)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Swap the payment method that bills this subscription — the card-update
|
|
262
|
+
# path during dunning. Provide exactly one of `payment_method_reference`
|
|
263
|
+
# or `checkout_token`.
|
|
264
|
+
#
|
|
265
|
+
# @param id [String] `nbo…sub`
|
|
266
|
+
# @param payment_method_reference [String] an already-captured method (`nbo…pmt`).
|
|
267
|
+
# @param checkout_token [String] a fresh hosted-checkout token — attaches and swaps atomically.
|
|
268
|
+
# @param request_options [Hash]
|
|
269
|
+
# @return [NombaObject]
|
|
270
|
+
def update_payment_method(id, payment_method_reference: OMIT, checkout_token: OMIT,
|
|
271
|
+
request_options: {})
|
|
272
|
+
request(:post, "/subscriptions/#{encode(id)}/payment-method",
|
|
273
|
+
body: {
|
|
274
|
+
payment_method_reference: payment_method_reference,
|
|
275
|
+
checkout_token: checkout_token,
|
|
276
|
+
},
|
|
277
|
+
options: request_options)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Preview the next invoice without charging or storing anything.
|
|
281
|
+
#
|
|
282
|
+
# @param id [String] `nbo…sub`
|
|
283
|
+
# @param request_options [Hash]
|
|
284
|
+
# @return [NombaObject]
|
|
285
|
+
def retrieve_upcoming_invoice(id, request_options: {})
|
|
286
|
+
request(:get, "/subscriptions/#{encode(id)}/upcoming-invoice", options: request_options)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Apply a coupon to this subscription only.
|
|
290
|
+
#
|
|
291
|
+
# @param id [String] `nbo…sub`
|
|
292
|
+
# @param coupon [String] a coupon id (`nbo…cpn`) or its code.
|
|
293
|
+
# @param request_options [Hash]
|
|
294
|
+
# @return [NombaObject]
|
|
295
|
+
def apply_discount(id, coupon:, request_options: {})
|
|
296
|
+
request(:post, "/subscriptions/#{encode(id)}/discount",
|
|
297
|
+
body: { coupon: coupon }, options: request_options)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Remove the subscription's active discount. Returns the ended discount.
|
|
301
|
+
#
|
|
302
|
+
# @param id [String] `nbo…sub`
|
|
303
|
+
# @param request_options [Hash]
|
|
304
|
+
# @return [NombaObject]
|
|
305
|
+
def remove_discount(id, request_options: {})
|
|
306
|
+
request(:delete, "/subscriptions/#{encode(id)}/discount", options: request_options)
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nombaone
|
|
4
|
+
module Resources
|
|
5
|
+
# Deliveries under a webhook endpoint: inspect and replay
|
|
6
|
+
# (`nombaone.webhook_endpoints.deliveries`).
|
|
7
|
+
class WebhookEndpointDeliveries < BaseResource
|
|
8
|
+
# List an endpoint's deliveries, newest first.
|
|
9
|
+
#
|
|
10
|
+
# @param endpoint_id [String] `nbo…whk`
|
|
11
|
+
# @param status [String] `"pending"`, `"succeeded"`, `"failed"`, `"dead"`.
|
|
12
|
+
# @param event_type [String] filter by catalog event type.
|
|
13
|
+
# @param endpoint [String] filter by endpoint reference.
|
|
14
|
+
# @param limit [Integer] page size, 1–100 (API default 20).
|
|
15
|
+
# @param cursor [String] opaque cursor from a previous page.
|
|
16
|
+
# @param request_options [Hash]
|
|
17
|
+
# @return [Page<NombaObject>]
|
|
18
|
+
def list(endpoint_id, status: OMIT, event_type: OMIT, endpoint: OMIT, limit: OMIT,
|
|
19
|
+
cursor: OMIT, request_options: {})
|
|
20
|
+
request_page("/webhooks/#{encode(endpoint_id)}/deliveries",
|
|
21
|
+
query: {
|
|
22
|
+
status: status,
|
|
23
|
+
event_type: event_type,
|
|
24
|
+
endpoint: endpoint,
|
|
25
|
+
limit: limit,
|
|
26
|
+
cursor: cursor,
|
|
27
|
+
},
|
|
28
|
+
options: request_options)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Retrieve one delivery.
|
|
32
|
+
#
|
|
33
|
+
# @param endpoint_id [String] `nbo…whk`
|
|
34
|
+
# @param delivery_id [String] `nbo…whd`
|
|
35
|
+
# @param request_options [Hash]
|
|
36
|
+
# @return [NombaObject]
|
|
37
|
+
def retrieve(endpoint_id, delivery_id, request_options: {})
|
|
38
|
+
request(:get, "/webhooks/#{encode(endpoint_id)}/deliveries/#{encode(delivery_id)}",
|
|
39
|
+
options: request_options)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Redeliver a past delivery. The **original event id is kept**, so a
|
|
43
|
+
# receiver that dedupes on `event.event.id` correctly treats it as
|
|
44
|
+
# already-seen.
|
|
45
|
+
#
|
|
46
|
+
# @param endpoint_id [String] `nbo…whk`
|
|
47
|
+
# @param delivery_id [String] `nbo…whd`
|
|
48
|
+
# @param request_options [Hash]
|
|
49
|
+
# @return [NombaObject]
|
|
50
|
+
def replay(endpoint_id, delivery_id, request_options: {})
|
|
51
|
+
request(:post, "/webhooks/#{encode(endpoint_id)}/deliveries/#{encode(delivery_id)}/replay",
|
|
52
|
+
body: {}, options: request_options)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Webhook endpoints — register and manage the URLs that receive signed
|
|
57
|
+
# events. To *verify* incoming deliveries in your handler, use
|
|
58
|
+
# {Nombaone.webhooks} / {Nombaone::Webhooks#construct_event} — the crypto
|
|
59
|
+
# helper, not this REST resource.
|
|
60
|
+
class WebhookEndpoints < BaseResource
|
|
61
|
+
# Deliveries under an endpoint.
|
|
62
|
+
# @return [WebhookEndpointDeliveries]
|
|
63
|
+
def deliveries
|
|
64
|
+
@deliveries ||= WebhookEndpointDeliveries.new(@client)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Register an endpoint. The response includes the full `signing_secret`
|
|
68
|
+
# **exactly once** — store it in your secret manager immediately.
|
|
69
|
+
#
|
|
70
|
+
# @param url [String]
|
|
71
|
+
# @param enabled_events [Array<String>] event types to fan out; defaults to
|
|
72
|
+
# `["*"]` (all events) server-side.
|
|
73
|
+
# @param request_options [Hash]
|
|
74
|
+
# @return [NombaObject] the endpoint plus its one-time `signing_secret`.
|
|
75
|
+
#
|
|
76
|
+
# @example
|
|
77
|
+
# endpoint = nombaone.webhook_endpoints.create(
|
|
78
|
+
# url: "https://example.com/nombaone/webhooks",
|
|
79
|
+
# enabled_events: ["invoice.paid", "invoice.payment_failed"]
|
|
80
|
+
# )
|
|
81
|
+
# store_secret(endpoint.signing_secret)
|
|
82
|
+
def create(url:, enabled_events: OMIT, request_options: {})
|
|
83
|
+
request(:post, "/webhooks",
|
|
84
|
+
body: { url: url, enabled_events: enabled_events }, options: request_options)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Retrieve an endpoint by id.
|
|
88
|
+
#
|
|
89
|
+
# @param id [String] `nbo…whk`
|
|
90
|
+
# @param request_options [Hash]
|
|
91
|
+
# @return [NombaObject]
|
|
92
|
+
# @raise [Nombaone::NotFoundError] 404 `WEBHOOK_ENDPOINT_NOT_FOUND`
|
|
93
|
+
def retrieve(id, request_options: {})
|
|
94
|
+
request(:get, "/webhooks/#{encode(id)}", options: request_options)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Update url, event subscription, or enabled state.
|
|
98
|
+
#
|
|
99
|
+
# @param id [String] `nbo…whk`
|
|
100
|
+
# @param url [String]
|
|
101
|
+
# @param enabled_events [Array<String>]
|
|
102
|
+
# @param disabled [Boolean] `true` pauses deliveries; `false` re-enables.
|
|
103
|
+
# @param request_options [Hash]
|
|
104
|
+
# @return [NombaObject]
|
|
105
|
+
def update(id, url: OMIT, enabled_events: OMIT, disabled: OMIT, request_options: {})
|
|
106
|
+
request(:patch, "/webhooks/#{encode(id)}",
|
|
107
|
+
body: { url: url, enabled_events: enabled_events, disabled: disabled },
|
|
108
|
+
options: request_options)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# List your endpoints.
|
|
112
|
+
#
|
|
113
|
+
# @param request_options [Hash]
|
|
114
|
+
# @return [Page<NombaObject>]
|
|
115
|
+
def list(request_options: {})
|
|
116
|
+
request_page("/webhooks", options: request_options)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Delete an endpoint. Pending deliveries to it are retired.
|
|
120
|
+
#
|
|
121
|
+
# @param id [String] `nbo…whk`
|
|
122
|
+
# @param request_options [Hash]
|
|
123
|
+
# @return [NombaObject]
|
|
124
|
+
def delete(id, request_options: {})
|
|
125
|
+
request(:delete, "/webhooks/#{encode(id)}", options: request_options)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Rotate the signing secret. The new secret is returned **exactly once**;
|
|
129
|
+
# the old one is briefly honored so you can roll without dropping in-flight
|
|
130
|
+
# deliveries.
|
|
131
|
+
#
|
|
132
|
+
# @param id [String] `nbo…whk`
|
|
133
|
+
# @param request_options [Hash]
|
|
134
|
+
# @return [NombaObject] with the new one-time `signing_secret`.
|
|
135
|
+
def rotate_secret(id, request_options: {})
|
|
136
|
+
request(:post, "/webhooks/#{encode(id)}/rotate-secret", body: {}, options: request_options)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nombaone
|
|
4
|
+
# Every event type the platform can deliver, as `name = value` constants
|
|
5
|
+
# (`Nombaone::WebhookEventType::INVOICE_PAID == "invoice.paid"`), plus the
|
|
6
|
+
# frozen {ALL} list. Branch on {WebhookEvent#type} against these.
|
|
7
|
+
#
|
|
8
|
+
# The catalog is **shipped open**: an event type the API adds tomorrow is
|
|
9
|
+
# still delivered and parsed — it just will not have a named constant here
|
|
10
|
+
# yet, and never breaks your handler.
|
|
11
|
+
module WebhookEventType
|
|
12
|
+
# The public event catalog (scaffold `example.*` events are excluded).
|
|
13
|
+
ALL = %w[
|
|
14
|
+
customer.created
|
|
15
|
+
customer.updated
|
|
16
|
+
coupon.created
|
|
17
|
+
discount.created
|
|
18
|
+
discount.removed
|
|
19
|
+
plan.created
|
|
20
|
+
plan.updated
|
|
21
|
+
plan.archived
|
|
22
|
+
price.created
|
|
23
|
+
price.deactivated
|
|
24
|
+
subscription.created
|
|
25
|
+
subscription.updated
|
|
26
|
+
subscription.trial_will_end
|
|
27
|
+
subscription.activated
|
|
28
|
+
subscription.paused
|
|
29
|
+
subscription.resumed
|
|
30
|
+
subscription.canceled
|
|
31
|
+
subscription.churned
|
|
32
|
+
invoice.created
|
|
33
|
+
invoice.finalized
|
|
34
|
+
invoice.paid
|
|
35
|
+
invoice.payment_failed
|
|
36
|
+
invoice.payment_partially_collected
|
|
37
|
+
invoice.payment_recovered
|
|
38
|
+
invoice.action_required
|
|
39
|
+
invoice.voided
|
|
40
|
+
payment_method.attached
|
|
41
|
+
payment_method.updated
|
|
42
|
+
payment_method.expiring
|
|
43
|
+
settlement.created
|
|
44
|
+
settlement.refunded
|
|
45
|
+
settlement.payout_created
|
|
46
|
+
].freeze
|
|
47
|
+
|
|
48
|
+
ALL.each { |type| const_set(type.upcase.tr(".", "_"), type) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A verified webhook delivery. It is a {NombaObject}, so every field reads
|
|
52
|
+
# idiomatically:
|
|
53
|
+
#
|
|
54
|
+
# event.type # => "invoice.payment_failed"
|
|
55
|
+
# event.event.id # => "nbo…evt" — DEDUPE on this
|
|
56
|
+
# event.data.reason # => "insufficient_funds" (typed payloads narrow with the type)
|
|
57
|
+
#
|
|
58
|
+
# Delivery is **at-least-once** — after verification, dedupe on `event.event.id`
|
|
59
|
+
# before acting. The `event` block is always present: if a delivery arrives in
|
|
60
|
+
# the older flat shape (top-level `id`/`type`/`created_at`), it is synthesized
|
|
61
|
+
# so dedupe still works.
|
|
62
|
+
class WebhookEvent < NombaObject
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Nombaone
|
|
7
|
+
# Verify and parse incoming NombaOne webhook deliveries.
|
|
8
|
+
#
|
|
9
|
+
# Available as `nombaone.webhooks` on a client, or standalone via
|
|
10
|
+
# {Nombaone.webhooks} — verification needs only the endpoint's signing secret,
|
|
11
|
+
# never an API key, so it works in a receiver that never builds a client.
|
|
12
|
+
#
|
|
13
|
+
# **Feed it the raw request body.** Parsing and re-serializing JSON can
|
|
14
|
+
# reorder keys and change bytes, which breaks the signature. Capture the body
|
|
15
|
+
# before any middleware parses it (`request.raw_post` in Rack/Rails).
|
|
16
|
+
#
|
|
17
|
+
# @example Rails controller
|
|
18
|
+
# def receive
|
|
19
|
+
# event = Nombaone.webhooks.construct_event(
|
|
20
|
+
# request.raw_post,
|
|
21
|
+
# request.headers["X-Nombaone-Signature"],
|
|
22
|
+
# ENV.fetch("NOMBAONE_WEBHOOK_SECRET"),
|
|
23
|
+
# )
|
|
24
|
+
# return head(:ok) if already_processed?(event.event.id) # at-least-once ⇒ dedupe
|
|
25
|
+
#
|
|
26
|
+
# unlock(event.data.reference) if event.type == Nombaone::WebhookEventType::INVOICE_PAID
|
|
27
|
+
# head :ok # respond 2xx fast; do heavy work async
|
|
28
|
+
# rescue Nombaone::WebhookVerificationError
|
|
29
|
+
# head :bad_request
|
|
30
|
+
# end
|
|
31
|
+
class Webhooks
|
|
32
|
+
# Maximum allowed age (seconds) between the delivery's `t` timestamp and now.
|
|
33
|
+
DEFAULT_TOLERANCE_SECONDS = 300
|
|
34
|
+
|
|
35
|
+
# Verify a delivery's signature and timestamp, then parse and return the
|
|
36
|
+
# typed event. This is the one call your handler needs.
|
|
37
|
+
#
|
|
38
|
+
# @param payload [String] the exact raw request body.
|
|
39
|
+
# @param signature_header [String] the `X-Nombaone-Signature` header value.
|
|
40
|
+
# @param secret [String] the endpoint's signing secret (shown once at creation).
|
|
41
|
+
# @param tolerance [Numeric] max timestamp age in seconds (default 300).
|
|
42
|
+
# @return [WebhookEvent]
|
|
43
|
+
# @raise [WebhookVerificationError] on a missing/malformed header, a stale
|
|
44
|
+
# timestamp, an invalid signature, or a non-JSON body.
|
|
45
|
+
def construct_event(payload, signature_header, secret, tolerance: DEFAULT_TOLERANCE_SECONDS)
|
|
46
|
+
verify_signature(payload, signature_header, secret, tolerance: tolerance)
|
|
47
|
+
|
|
48
|
+
begin
|
|
49
|
+
parsed = JSON.parse(payload.to_s)
|
|
50
|
+
rescue JSON::ParserError
|
|
51
|
+
raise WebhookVerificationError, "Webhook payload was not valid JSON."
|
|
52
|
+
end
|
|
53
|
+
unless parsed.is_a?(Hash)
|
|
54
|
+
raise WebhookVerificationError, "Webhook payload was not a JSON object."
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
WebhookEvent.new(ensure_event_block(parsed))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Verify a delivery's signature and timestamp only (no parse). Returns true
|
|
61
|
+
# on success; raises with a distinct message per failure mode.
|
|
62
|
+
#
|
|
63
|
+
# @param payload [String] the exact raw request body.
|
|
64
|
+
# @param signature_header [String] the `X-Nombaone-Signature` header value.
|
|
65
|
+
# @param secret [String] the endpoint's signing secret.
|
|
66
|
+
# @param tolerance [Numeric] max timestamp age in seconds (default 300).
|
|
67
|
+
# @return [true]
|
|
68
|
+
# @raise [WebhookVerificationError]
|
|
69
|
+
def verify_signature(payload, signature_header, secret, tolerance: DEFAULT_TOLERANCE_SECONDS)
|
|
70
|
+
if signature_header.nil? || signature_header.empty?
|
|
71
|
+
raise WebhookVerificationError,
|
|
72
|
+
"Missing X-Nombaone-Signature header — is this request really from NombaOne?"
|
|
73
|
+
end
|
|
74
|
+
if secret.nil? || secret.empty?
|
|
75
|
+
raise WebhookVerificationError,
|
|
76
|
+
"Missing signing secret — pass the secret shown when the endpoint was created."
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
timestamp, signatures = parse_signature_header(signature_header)
|
|
80
|
+
assert_within_tolerance(timestamp, tolerance)
|
|
81
|
+
|
|
82
|
+
expected = compute_signature(secret, timestamp, payload.to_s)
|
|
83
|
+
return true if signatures.any? { |candidate| secure_compare(candidate, expected) }
|
|
84
|
+
|
|
85
|
+
raise WebhookVerificationError,
|
|
86
|
+
"Webhook signature verification failed — check you are using this endpoint's " \
|
|
87
|
+
"current signing secret and the exact raw request body (no re-serialization)."
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Build a valid `X-Nombaone-Signature` header for a payload — for testing
|
|
91
|
+
# your own handler without waiting on a real delivery.
|
|
92
|
+
#
|
|
93
|
+
# @param payload [String] the raw body you will pass to {#construct_event}.
|
|
94
|
+
# @param secret [String] the signing secret.
|
|
95
|
+
# @param timestamp [Integer, nil] unix seconds (defaults to now).
|
|
96
|
+
# @return [String] a `t=<unix>,v1=<hex>` header value.
|
|
97
|
+
#
|
|
98
|
+
# @example
|
|
99
|
+
# header = Nombaone.webhooks.generate_test_header(payload: body, secret: secret)
|
|
100
|
+
# event = Nombaone.webhooks.construct_event(body, header, secret)
|
|
101
|
+
def generate_test_header(payload:, secret:, timestamp: nil)
|
|
102
|
+
timestamp = (timestamp || Time.now.to_i).to_s
|
|
103
|
+
"t=#{timestamp},v1=#{compute_signature(secret, timestamp, payload.to_s)}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def compute_signature(secret, timestamp, raw_body)
|
|
109
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def parse_signature_header(header)
|
|
113
|
+
timestamp = nil
|
|
114
|
+
signatures = []
|
|
115
|
+
header.split(",").each do |pair|
|
|
116
|
+
eq = pair.index("=")
|
|
117
|
+
next if eq.nil?
|
|
118
|
+
|
|
119
|
+
key = pair[0...eq].strip
|
|
120
|
+
value = pair[(eq + 1)..].strip
|
|
121
|
+
timestamp = value if key == "t"
|
|
122
|
+
# Multiple `v1` entries are legal during secret rotation — any match passes.
|
|
123
|
+
signatures << value if key == "v1" && !value.empty?
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
if timestamp.nil? || signatures.empty?
|
|
127
|
+
raise WebhookVerificationError,
|
|
128
|
+
'Malformed X-Nombaone-Signature header — expected "t=<unix>,v1=<hex>".'
|
|
129
|
+
end
|
|
130
|
+
[timestamp, signatures]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def assert_within_tolerance(timestamp, tolerance)
|
|
134
|
+
seconds = Integer(timestamp, exception: false)
|
|
135
|
+
if seconds.nil?
|
|
136
|
+
raise WebhookVerificationError,
|
|
137
|
+
"Malformed X-Nombaone-Signature header — `t` is not a unix timestamp."
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
age = (Time.now.to_i - seconds).abs
|
|
141
|
+
return if age <= tolerance
|
|
142
|
+
|
|
143
|
+
raise WebhookVerificationError,
|
|
144
|
+
"Webhook timestamp is outside the allowed tolerance (#{age}s old, limit " \
|
|
145
|
+
"#{tolerance}s) — possible replay, or severe clock skew."
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def secure_compare(candidate, expected)
|
|
149
|
+
candidate.bytesize == expected.bytesize &&
|
|
150
|
+
OpenSSL.fixed_length_secure_compare(candidate, expected)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Guarantee a dedupe-able `event.id` even if a delivery body arrives flat
|
|
154
|
+
# (older shape) — fall back to top-level fields.
|
|
155
|
+
def ensure_event_block(body)
|
|
156
|
+
return body if body["event"].is_a?(Hash)
|
|
157
|
+
|
|
158
|
+
body.merge(
|
|
159
|
+
"event" => {
|
|
160
|
+
"id" => body["id"].is_a?(String) ? body["id"] : "",
|
|
161
|
+
"type" => body["type"].is_a?(String) ? body["type"] : "",
|
|
162
|
+
"createdAt" => body["createdAt"].is_a?(String) ? body["createdAt"] : "",
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|