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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8afad05465e8552625852dc7e1f0b51622c5f22c60363a3c634a12e9d0ec9eca
|
|
4
|
+
data.tar.gz: e02ec17de6bae9637866fd224308ebe01c76b039b2c9f71cfb5397f030074257
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 730e98dad92f45ba053b64c09fb9d9ad46423cad73e70b556cedb96401bf75b2ffb51693c7cf026bcc62847aba7a1bffbcd604ae4dd2c3e2bd2acc7d41c076b8
|
|
7
|
+
data.tar.gz: 694293710c28f718bd958864f3b62d045b95cb3e4fe1f8e58a68db59e2076f641e389471bb924248d6b3bb5377907147a30be8228c7c92dad308c7b244d78b4c
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
|
+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0]
|
|
10
|
+
|
|
11
|
+
Initial release — the official Ruby SDK for the NombaOne subscription-billing
|
|
12
|
+
API.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- `Nombaone::Client` covering the full API surface: customers, plans (with
|
|
17
|
+
nested prices), prices, subscriptions (with schedules and dunning), invoices,
|
|
18
|
+
coupons, payment methods, mandates, settlements, webhook endpoints (with
|
|
19
|
+
deliveries), events, organization (with billing policy), metrics, and the
|
|
20
|
+
sandbox toolkit.
|
|
21
|
+
- Automatic, money-safe idempotency: a UUID `Idempotency-Key` is generated once
|
|
22
|
+
per POST and reused across every automatic retry, so a network blip can never
|
|
23
|
+
double-charge.
|
|
24
|
+
- Automatic retries with full-jitter exponential backoff, honoring `Retry-After`,
|
|
25
|
+
for transport failures, timeouts, 408/429/5xx, and in-flight idempotency
|
|
26
|
+
conflicts.
|
|
27
|
+
- Typed error hierarchy (`Nombaone::APIError` and subclasses) carrying the wire
|
|
28
|
+
`code`, `hint`, `doc_url`, per-field validation errors, and `request_id`.
|
|
29
|
+
- Cursor pagination with idiomatic auto-iteration (`Enumerable`, `each`,
|
|
30
|
+
`auto_paging_each`) plus manual cursor control.
|
|
31
|
+
- A keyless webhook helper (`Nombaone.webhooks`) implementing signed-delivery
|
|
32
|
+
verification, event construction, and test-header generation.
|
|
33
|
+
- Zero runtime dependencies; RBS type signatures shipped in `sig/`.
|
|
34
|
+
|
|
35
|
+
[Unreleased]: https://github.com/nombaone/nomba-ruby/compare/v0.1.0...HEAD
|
|
36
|
+
[0.1.0]: https://github.com/nombaone/nomba-ruby/releases/tag/v0.1.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nomba One
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# nombaone
|
|
2
|
+
|
|
3
|
+
The official Ruby SDK for the [Nomba One](https://nombaone.xyz) subscription-billing API — recurring billing for Nigeria over card, direct debit, bank transfer, and more, with dunning that recovers and a ledger that never loses a kobo.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
gem install nombaone
|
|
7
|
+
# or add to your Gemfile:
|
|
8
|
+
gem "nombaone"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Ruby 3.1+. Zero runtime dependencies (built on the standard library). Ships RBS type signatures.
|
|
12
|
+
|
|
13
|
+
## Quickstart
|
|
14
|
+
|
|
15
|
+
Grab a sandbox key (`nbo_sandbox_…`) from the [dashboard](https://app.nombaone.xyz), set it as `NOMBAONE_API_KEY`, and you are three objects away from a live subscription:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
require "nombaone"
|
|
19
|
+
|
|
20
|
+
nombaone = Nombaone.new(ENV["NOMBAONE_API_KEY"])
|
|
21
|
+
|
|
22
|
+
plan = nombaone.plans.create(name: "Pro")
|
|
23
|
+
price = nombaone.plans.prices.create(plan.id, unit_amount_in_kobo: 250_000, interval: "month") # ₦2,500/mo
|
|
24
|
+
customer = nombaone.customers.create(email: "ada@example.com", name: "Ada Lovelace")
|
|
25
|
+
|
|
26
|
+
# Sandbox: mint a deterministic test card, then subscribe.
|
|
27
|
+
method = nombaone.sandbox.create_payment_method(customer_id: customer.id)
|
|
28
|
+
subscription = nombaone.subscriptions.create(
|
|
29
|
+
customer_id: customer.id,
|
|
30
|
+
price_id: price.id,
|
|
31
|
+
payment_method_id: method.id,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
subscription.status # => "active"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The client derives the host from your key prefix — `nbo_sandbox_…` talks to `https://sandbox.api.nombaone.xyz`, `nbo_live_…` to `https://api.nombaone.xyz`. Server-side only; there is no publishable key to leak.
|
|
38
|
+
|
|
39
|
+
## Sandbox first
|
|
40
|
+
|
|
41
|
+
The sandbox runs the real billing engine. `nombaone.sandbox.*` gives you the levers to make a month happen in a second:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
# A card that declines like a thin balance does — "not yet", not "no".
|
|
45
|
+
nombaone.sandbox.create_payment_method(
|
|
46
|
+
customer_id: customer.id,
|
|
47
|
+
behavior: "decline_insufficient_funds", # or success | requires_otp | decline_expired_card | decline_do_not_honor
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# The test clock: force the next billing cycle through the real engine.
|
|
51
|
+
cycle = nombaone.sandbox.advance_cycle(subscription.id)
|
|
52
|
+
cycle.outcome # => "paid" | "past_due" | …
|
|
53
|
+
|
|
54
|
+
# Fire a real, signed webhook at your registered endpoints.
|
|
55
|
+
nombaone.sandbox.simulate_webhook(type: "invoice.payment_failed")
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
These methods raise locally (before any network call) if used with a live key.
|
|
59
|
+
|
|
60
|
+
## Money is integer kobo
|
|
61
|
+
|
|
62
|
+
Every amount in the API is an **integer in kobo**: `₦1.00 = 100`. `250_000` is ₦2,500 — not ₦250,000. No floats, no decimal strings, `currency` is always `"NGN"`. Multiply naira by 100 exactly once, at the edge of your system; every money field is suffixed `_in_kobo` so a mixup is hard to type.
|
|
63
|
+
|
|
64
|
+
## Response objects
|
|
65
|
+
|
|
66
|
+
Every result is a lightweight object with snake_case readers derived from the wire's camelCase, plus bracket access and a `to_h` escape hatch:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
sub = nombaone.subscriptions.retrieve(id)
|
|
70
|
+
sub.status # => "active"
|
|
71
|
+
sub.current_period_end # from "currentPeriodEnd"
|
|
72
|
+
sub.items.first.price_id
|
|
73
|
+
sub[:latestInvoiceId] # bracket access (snake or wire, String or Symbol)
|
|
74
|
+
sub.to_h # the raw wire Hash
|
|
75
|
+
sub.request_id # the meta.requestId for this call
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Pagination
|
|
79
|
+
|
|
80
|
+
Every `list` works three ways:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
# One page.
|
|
84
|
+
page = nombaone.invoices.list(status: "open", limit: 50)
|
|
85
|
+
page.data # the items on this page
|
|
86
|
+
page.has_more?
|
|
87
|
+
page.next_cursor
|
|
88
|
+
|
|
89
|
+
# Manual paging.
|
|
90
|
+
page = page.next_page if page.next_page?
|
|
91
|
+
|
|
92
|
+
# Or let the SDK thread the cursors — a Page is Enumerable, and iteration is
|
|
93
|
+
# lazy (first(3) only fetches what it needs):
|
|
94
|
+
nombaone.invoices.list(status: "open").each do |invoice|
|
|
95
|
+
# every item across every page
|
|
96
|
+
end
|
|
97
|
+
recent = nombaone.customers.list.first(3)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Errors are a feature
|
|
101
|
+
|
|
102
|
+
Failures raise typed errors carrying everything the API said — the stable `code` to branch on, a `hint` telling you exactly what to do next, a `doc_url` into the error reference, per-field details on validation failures, and the `request_id` to quote to support:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
begin
|
|
106
|
+
nombaone.subscriptions.create(customer_id: customer_id, price_id: price_id)
|
|
107
|
+
rescue Nombaone::ValidationError => e
|
|
108
|
+
e.fields # { "paymentMethodId" => [...] }
|
|
109
|
+
rescue Nombaone::RateLimitError => e
|
|
110
|
+
e.retry_after # seconds
|
|
111
|
+
rescue Nombaone::NotFoundError => e
|
|
112
|
+
e.code # "CUSTOMER_NOT_FOUND"
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
| Status | Class | Notes |
|
|
117
|
+
| ------ | ------------------------ | ----------------------------------------- |
|
|
118
|
+
| 400 | `BadRequestError` | malformed request |
|
|
119
|
+
| 401 | `AuthenticationError` | missing/invalid/wrong-environment key |
|
|
120
|
+
| 403 | `PermissionDeniedError` | missing scope, foreign resource |
|
|
121
|
+
| 404 | `NotFoundError` | wrong id or wrong environment |
|
|
122
|
+
| 409 | `ConflictError` | state conflicts, idempotency reuse |
|
|
123
|
+
| 422 | `ValidationError` | `error.fields` has the per-field messages |
|
|
124
|
+
| 429 | `RateLimitError` | `retry_after`, `limit`, `remaining` |
|
|
125
|
+
| 5xx | `ServerError` | safe to retry (the SDK already did) |
|
|
126
|
+
| — | `ConnectionError` / `TimeoutError` | transport-level |
|
|
127
|
+
|
|
128
|
+
All of them descend from `Nombaone::Error`, and webhook failures raise `Nombaone::WebhookVerificationError`. Branch on `e.code` (stable) or the class; the error codes are available as constants (`Nombaone::ErrorCode::CUSTOMER_NOT_FOUND`), and unknown future codes still parse.
|
|
129
|
+
|
|
130
|
+
## Idempotency & retries
|
|
131
|
+
|
|
132
|
+
The SDK auto-generates an `Idempotency-Key` for every POST and **reuses it across its automatic retries** (network failures, timeouts, 408/429/5xx — 2 retries by default, honoring `Retry-After`), so a blip can never double-charge. Pass your own key when the operation must stay idempotent across _process_ restarts:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
nombaone.settlements.create_payout(
|
|
136
|
+
amount_in_kobo: 5_000_000, bank_code: "058", account_number: "0123456789",
|
|
137
|
+
request_options: { idempotency_key: "payout-#{my_payout.id}" }, # ⚠ doubles as the payout's durable merchantTxRef
|
|
138
|
+
)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Every method also accepts `request_options:` with `:idempotency_key`, `:headers`, `:timeout`, `:max_retries`, and `:cancel_when` (a callable checked before each attempt — a caller cancel is never retried).
|
|
142
|
+
|
|
143
|
+
## Webhooks
|
|
144
|
+
|
|
145
|
+
Verify before you parse, and dedupe on the event id — delivery is at-least-once, never exactly-once. Verification needs only the signing secret, never an API key, so `Nombaone.webhooks` works in a receiver that never builds a client.
|
|
146
|
+
|
|
147
|
+
**Feed it the raw request body** — parsing and re-serializing JSON changes bytes and breaks the signature.
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
# Rails
|
|
151
|
+
class WebhooksController < ApplicationController
|
|
152
|
+
skip_before_action :verify_authenticity_token
|
|
153
|
+
|
|
154
|
+
def create
|
|
155
|
+
event = Nombaone.webhooks.construct_event(
|
|
156
|
+
request.raw_post, # the RAW body — never re-serialize
|
|
157
|
+
request.headers["X-Nombaone-Signature"],
|
|
158
|
+
ENV.fetch("NOMBAONE_WEBHOOK_SECRET"), # shown once when you created the endpoint
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return head(:ok) if already_processed?(event.event.id) # at-least-once ⇒ dedupe on event.event.id
|
|
162
|
+
|
|
163
|
+
case event.type
|
|
164
|
+
when Nombaone::WebhookEventType::INVOICE_PAID then unlock(event.data.reference)
|
|
165
|
+
when Nombaone::WebhookEventType::INVOICE_ACTION_REQUIRED then email(event.data.checkout_link)
|
|
166
|
+
when Nombaone::WebhookEventType::INVOICE_PAYMENT_FAILED then note(event.data.reason)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
head :ok # respond 2xx fast; do heavy work async
|
|
170
|
+
rescue Nombaone::WebhookVerificationError
|
|
171
|
+
head :bad_request
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
In **Sinatra/Rack**, the raw body is `request.body.read` and the header is `request.env["HTTP_X_NOMBAONE_SIGNATURE"]`. `construct_event` checks the `X-Nombaone-Signature` (`t=<unix>,v1=<hex>`, HMAC-SHA256 over `"#{t}.#{body}"`) in constant time, rejects stale timestamps (300s tolerance, configurable), and returns a typed event. `Nombaone.webhooks.generate_test_header(payload:, secret:)` lets you unit-test your handler. Manage endpoints via `nombaone.webhook_endpoints` (create/rotate return the secret **exactly once**).
|
|
177
|
+
|
|
178
|
+
## The full surface
|
|
179
|
+
|
|
180
|
+
`customers` (+credit, discount) · `plans` (+nested `prices`) · `prices` · `subscriptions` (pause/resume/cancel/resubscribe/change, `schedule`, `dunning`, upcoming invoice, events) · `invoices` · `coupons` · `payment_methods` (hosted-checkout cards, virtual accounts) · `mandates` (NIBSS direct debit) · `settlements` (escrow, refunds, payouts) · `webhook_endpoints` (+deliveries, replay) · `events` (+catalog) · `organization` (+billing policy) · `metrics` · `sandbox` — every operation in the [API reference](https://docs.nombaone.xyz), 1:1.
|
|
181
|
+
|
|
182
|
+
Worth knowing:
|
|
183
|
+
|
|
184
|
+
- **Mandates are asynchronous.** They start `consent_pending` and activate when the customer's bank confirms — listen for `payment_method.updated`, don't poll, don't charge early.
|
|
185
|
+
- **Bank transfer is a push rail.** `payment_methods.create_virtual_account` issues a NUBAN; collection completes when the transfer arrives and reconciles.
|
|
186
|
+
- **`past_due` is not canceled.** Read `nombaone.subscriptions.dunning.retrieve(id)` and honor `grace_access_until` before cutting anyone off. Involuntary churn is `status: "canceled"` with `cancellation_reason: "involuntary"` (and a `subscription.churned` event).
|
|
187
|
+
- **Prices are immutable; plans archive, never delete.**
|
|
188
|
+
|
|
189
|
+
## Configuration
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
Nombaone.new(
|
|
193
|
+
api_key, # defaults to ENV["NOMBAONE_API_KEY"]
|
|
194
|
+
base_url: nil, # override the derived host
|
|
195
|
+
timeout: 30, # per-attempt seconds
|
|
196
|
+
max_retries: 2, # automatic retry budget
|
|
197
|
+
default_headers: {}, # sent on every request
|
|
198
|
+
http: nil, # inject a connection (tests, proxies)
|
|
199
|
+
)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Read-only `nombaone.mode` (`"sandbox"`/`"live"`, derived from the key) and `nombaone.base_url` are exposed.
|
|
203
|
+
|
|
204
|
+
## Examples & development
|
|
205
|
+
|
|
206
|
+
Runnable scripts live in [`examples/`](examples) — quickstart, pagination, the subscription lifecycle, a webhook receiver, and a dunning rehearsal with the test clock:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
NOMBAONE_API_KEY=nbo_sandbox_… ruby -Ilib examples/01_quickstart.rb
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
To develop the SDK itself: `bundle install && rake` (RuboCop + RBS validate + specs). The live suite is opt-in:
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
NOMBAONE_INTEGRATION=1 NOMBAONE_API_KEY=nbo_sandbox_… \
|
|
216
|
+
NOMBAONE_BASE_URL=https://sandbox.api.nombaone.xyz \
|
|
217
|
+
bundle exec rspec spec/integration
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
To prove the whole surface against the sandbox — every method called, with a plain per-method result and a `DEFECTS 0` verdict:
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
NOMBAONE_API_KEY=nbo_sandbox_… ruby -Ilib scripts/verify.rb
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Releases are automated and merge-triggered — see [`PUBLISHING.md`](PUBLISHING.md).
|
|
227
|
+
|
|
228
|
+
## Requirements & versioning
|
|
229
|
+
|
|
230
|
+
Ruby ≥ 3.1. Semantic versioning; the API itself is versioned at `/v1` and additive changes never break you. MIT licensed.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nombaone
|
|
4
|
+
# The NombaOne API client. Construct one with your secret key; it derives the
|
|
5
|
+
# host from the key's prefix and exposes every resource as a namespace.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# nombaone = Nombaone.new(ENV["NOMBAONE_API_KEY"])
|
|
9
|
+
#
|
|
10
|
+
# plan = nombaone.plans.create(name: "Pro")
|
|
11
|
+
# price = nombaone.plans.prices.create(plan.id, unit_amount_in_kobo: 250_000, interval: "month")
|
|
12
|
+
# customer = nombaone.customers.create(email: "ada@example.com", name: "Ada Lovelace")
|
|
13
|
+
# method = nombaone.sandbox.create_payment_method(customer_id: customer.id)
|
|
14
|
+
# subscription = nombaone.subscriptions.create(
|
|
15
|
+
# customer_id: customer.id, price_id: price.id, payment_method_id: method.id
|
|
16
|
+
# )
|
|
17
|
+
# subscription.status # => "active"
|
|
18
|
+
class Client
|
|
19
|
+
# Default host per environment. Overridable with the `base_url:` option.
|
|
20
|
+
BASE_URLS = {
|
|
21
|
+
"sandbox" => "https://sandbox.api.nombaone.xyz",
|
|
22
|
+
"live" => "https://api.nombaone.xyz",
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
# Per-attempt request timeout, in seconds.
|
|
26
|
+
DEFAULT_TIMEOUT = 30
|
|
27
|
+
# Automatic retries (3 attempts total). Retried POSTs reuse one idempotency key.
|
|
28
|
+
DEFAULT_MAX_RETRIES = 2
|
|
29
|
+
|
|
30
|
+
# @return [String] the environment this client talks to ("sandbox"/"live"),
|
|
31
|
+
# derived from the key prefix.
|
|
32
|
+
attr_reader :mode
|
|
33
|
+
# @return [String] the API origin in use (no `/v1`).
|
|
34
|
+
attr_reader :base_url
|
|
35
|
+
|
|
36
|
+
# @param api_key [String, nil] your secret key (`nbo_sandbox_…` / `nbo_live_…`).
|
|
37
|
+
# Defaults to `ENV["NOMBAONE_API_KEY"]`. Server-side only — never ship it
|
|
38
|
+
# to a browser or mobile app.
|
|
39
|
+
# @param base_url [String, nil] override the API origin (no `/v1`). Defaults
|
|
40
|
+
# to the host matching the key's environment; required if the key prefix
|
|
41
|
+
# is unrecognized.
|
|
42
|
+
# @param timeout [Numeric] per-attempt timeout in seconds (default 30).
|
|
43
|
+
# @param max_retries [Integer] automatic retry budget (default 2).
|
|
44
|
+
# @param http [#execute, nil] an injectable connection for tests/proxies;
|
|
45
|
+
# defaults to a `Net::HTTP` transport.
|
|
46
|
+
# @param default_headers [Hash{String => String}, nil] extra headers on
|
|
47
|
+
# every request.
|
|
48
|
+
# @raise [Nombaone::Error] if the key is missing or its prefix is
|
|
49
|
+
# unrecognized and no `base_url` was given.
|
|
50
|
+
def initialize(api_key = nil, base_url: nil, timeout: DEFAULT_TIMEOUT,
|
|
51
|
+
max_retries: DEFAULT_MAX_RETRIES, http: nil, default_headers: nil, sleeper: nil)
|
|
52
|
+
resolved_key = api_key || ENV.fetch("NOMBAONE_API_KEY", nil)
|
|
53
|
+
if resolved_key.nil? || resolved_key.empty?
|
|
54
|
+
raise Error,
|
|
55
|
+
"Missing API key — set the NOMBAONE_API_KEY environment variable, or pass one: " \
|
|
56
|
+
'Nombaone.new("nbo_sandbox_…"). Create keys in the dashboard under API keys.'
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
derived = derive_mode(resolved_key)
|
|
60
|
+
if derived.nil? && base_url.nil?
|
|
61
|
+
raise Error,
|
|
62
|
+
'Unrecognized API key format — expected a key starting with "nbo_sandbox_" or ' \
|
|
63
|
+
'"nbo_live_". Copy the key exactly as shown in the dashboard, or pass an explicit ' \
|
|
64
|
+
"base_url: if you are targeting a custom host."
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
@mode = derived || "sandbox"
|
|
68
|
+
@base_url = (base_url || BASE_URLS.fetch(@mode)).sub(%r{/+\z}, "")
|
|
69
|
+
@http = Internal::HTTPClient.new(
|
|
70
|
+
api_key: resolved_key,
|
|
71
|
+
base_url: @base_url,
|
|
72
|
+
timeout: timeout,
|
|
73
|
+
max_retries: max_retries,
|
|
74
|
+
connection: http || Internal::NetHTTPConnection.new,
|
|
75
|
+
default_headers: default_headers,
|
|
76
|
+
sleeper: sleeper,
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [Resources::Customers] the people and businesses you bill.
|
|
81
|
+
def customers = @customers ||= Resources::Customers.new(self)
|
|
82
|
+
# @return [Resources::Plans] your catalog (prices nest under `plans.prices`).
|
|
83
|
+
def plans = @plans ||= Resources::Plans.new(self)
|
|
84
|
+
# @return [Resources::Prices] immutable amounts and cadences.
|
|
85
|
+
def prices = @prices ||= Resources::Prices.new(self)
|
|
86
|
+
# @return [Resources::Subscriptions] the core billing object.
|
|
87
|
+
def subscriptions = @subscriptions ||= Resources::Subscriptions.new(self)
|
|
88
|
+
# @return [Resources::Invoices] what billing cycles produced (read + void).
|
|
89
|
+
def invoices = @invoices ||= Resources::Invoices.new(self)
|
|
90
|
+
# @return [Resources::Coupons] reusable discount rules.
|
|
91
|
+
def coupons = @coupons ||= Resources::Coupons.new(self)
|
|
92
|
+
# @return [Resources::PaymentMethods] cards, mandates, virtual accounts.
|
|
93
|
+
def payment_methods = @payment_methods ||= Resources::PaymentMethods.new(self)
|
|
94
|
+
# @return [Resources::Mandates] direct-debit mandates (async NIBSS consent).
|
|
95
|
+
def mandates = @mandates ||= Resources::Mandates.new(self)
|
|
96
|
+
# @return [Resources::Settlements] settlements, refunds, payouts, escrow.
|
|
97
|
+
def settlements = @settlements ||= Resources::Settlements.new(self)
|
|
98
|
+
# @return [Resources::WebhookEndpoints] webhook endpoint management (REST).
|
|
99
|
+
def webhook_endpoints = @webhook_endpoints ||= Resources::WebhookEndpoints.new(self)
|
|
100
|
+
# @return [Resources::Events] the domain-event log — your reconciliation backstop.
|
|
101
|
+
def events = @events ||= Resources::Events.new(self)
|
|
102
|
+
# @return [Resources::Organization] org settings + billing/dunning policy.
|
|
103
|
+
def organization = @organization ||= Resources::Organization.new(self)
|
|
104
|
+
# @return [Resources::Metrics] billing KPIs computed from the ledger.
|
|
105
|
+
def metrics = @metrics ||= Resources::Metrics.new(self)
|
|
106
|
+
# @return [Resources::Sandbox] sandbox-only simulation instruments.
|
|
107
|
+
def sandbox = @sandbox ||= Resources::Sandbox.new(self)
|
|
108
|
+
|
|
109
|
+
# A {Webhooks} helper bound to this client. Verification needs only the
|
|
110
|
+
# signing secret, so {Nombaone.webhooks} works without a client too.
|
|
111
|
+
#
|
|
112
|
+
# @return [Nombaone::Webhooks]
|
|
113
|
+
def webhooks = @webhooks ||= Webhooks.new
|
|
114
|
+
|
|
115
|
+
# Execute a single-object request and return the wrapped {NombaObject}.
|
|
116
|
+
# @api private
|
|
117
|
+
def request(method:, path:, query: nil, body: nil, options: nil)
|
|
118
|
+
result = perform(method: method, path: path, query: query, body: body, options: options)
|
|
119
|
+
wrap_data(result)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Execute a list request and return a {Page} that auto-paginates.
|
|
123
|
+
# @api private
|
|
124
|
+
def request_page(method:, path:, query: nil, options: nil)
|
|
125
|
+
raw_query = query || {}
|
|
126
|
+
fetcher = lambda do |query_for_page|
|
|
127
|
+
perform(method: method, path: path, query: query_for_page, options: options)
|
|
128
|
+
end
|
|
129
|
+
Page.new(fetcher: fetcher, query: raw_query, result: fetcher.call(raw_query))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def perform(method:, path:, query: nil, body: nil, options: nil)
|
|
135
|
+
@http.request(
|
|
136
|
+
method: method,
|
|
137
|
+
path: path,
|
|
138
|
+
query: query.nil? ? nil : Internal::Util.serialize_query(query),
|
|
139
|
+
body: body.nil? ? nil : Internal::Util.serialize_body(body),
|
|
140
|
+
options: options,
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def wrap_data(result)
|
|
145
|
+
data = result.data
|
|
146
|
+
return data unless data.is_a?(Hash)
|
|
147
|
+
|
|
148
|
+
NombaObject.new(data, request_id: result.request_id, response: result.response)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def derive_mode(api_key)
|
|
152
|
+
return "sandbox" if api_key.start_with?("nbo_sandbox_")
|
|
153
|
+
return "live" if api_key.start_with?("nbo_live_")
|
|
154
|
+
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|