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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +36 -0
  3. data/LICENSE +21 -0
  4. data/README.md +230 -0
  5. data/lib/nombaone/client.rb +158 -0
  6. data/lib/nombaone/errors.rb +286 -0
  7. data/lib/nombaone/internal/http_client.rb +230 -0
  8. data/lib/nombaone/internal/util.rb +155 -0
  9. data/lib/nombaone/object.rb +152 -0
  10. data/lib/nombaone/pagination.rb +116 -0
  11. data/lib/nombaone/resources/base_resource.rb +40 -0
  12. data/lib/nombaone/resources/coupons.rb +84 -0
  13. data/lib/nombaone/resources/customers.rb +160 -0
  14. data/lib/nombaone/resources/events.rb +46 -0
  15. data/lib/nombaone/resources/invoices.rb +61 -0
  16. data/lib/nombaone/resources/mandates.rb +71 -0
  17. data/lib/nombaone/resources/metrics.rb +24 -0
  18. data/lib/nombaone/resources/organization.rb +91 -0
  19. data/lib/nombaone/resources/payment_methods.rb +102 -0
  20. data/lib/nombaone/resources/plans.rb +129 -0
  21. data/lib/nombaone/resources/prices.rb +53 -0
  22. data/lib/nombaone/resources/sandbox.rb +81 -0
  23. data/lib/nombaone/resources/settlements.rb +91 -0
  24. data/lib/nombaone/resources/subscriptions.rb +310 -0
  25. data/lib/nombaone/resources/webhook_endpoints.rb +140 -0
  26. data/lib/nombaone/version.rb +7 -0
  27. data/lib/nombaone/webhook_event.rb +64 -0
  28. data/lib/nombaone/webhooks.rb +167 -0
  29. data/lib/nombaone.rb +58 -0
  30. data/sig/nombaone/client.rbs +36 -0
  31. data/sig/nombaone/errors.rbs +68 -0
  32. data/sig/nombaone/internal.rbs +51 -0
  33. data/sig/nombaone/object.rbs +22 -0
  34. data/sig/nombaone/pagination.rbs +23 -0
  35. data/sig/nombaone/resources.rbs +243 -0
  36. data/sig/nombaone/webhooks.rbs +18 -0
  37. data/sig/nombaone.rbs +24 -0
  38. 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nombaone
4
+ # The installed SDK version. Sent on every request as part of the
5
+ # `User-Agent` (`nombaone-ruby/<version>`) and used for release tooling.
6
+ VERSION = "0.1.0"
7
+ 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