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
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