manza 0.3.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 80076371c65341ec2d0505fee01860048e6198a878249315cda749dd66a848e1
4
+ data.tar.gz: 489435e29915da758541243d8a02af6f94c8ee6cf5c0322341495e89b84eb9b7
5
+ SHA512:
6
+ metadata.gz: af686049cde39a906d1991c3bdf76b1165963c7a759573dc1f60b60ec1f309c822bb44821eedb4691cdca0fe6db0414662387aec7342f07ab45ddf1c2695bb40
7
+ data.tar.gz: 720050593b2e7399068b20345d99355cd4ee11547f5e335199e28e6c4b5f03039f9472cbe73b1160b8f5e51ba7ceaf7d584dc89fa41d63e75656a13b4bbac5f7
data/CHANGELOG.md ADDED
@@ -0,0 +1,129 @@
1
+ # Changelog
2
+
3
+ All notable changes to `manza` (formerly `zazu-ruby`) are documented here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ The SDK is renamed from Zazu to Manza and released as `manza` 1.0.0.
11
+ The API surface is otherwise unchanged.
12
+
13
+ ### Migration from `zazu-ruby` 0.x
14
+
15
+ | Was | Now |
16
+ |---|---|
17
+ | `gem "zazu-ruby"` | `gem "manza"` |
18
+ | `require "zazu"` | `require "manza"` |
19
+ | `Zazu.new`, `Zazu::Client`, `Zazu::Error`, … | `Manza.new`, `Manza::Client`, `Manza::Error`, … |
20
+ | `ZAZU_API_KEY` | `MANZA_API_KEY` |
21
+ | `ZAZU_BASE_URL` | `MANZA_BASE_URL` |
22
+ | `ZAZU_API_VERSION` | `MANZA_API_VERSION` |
23
+ | `ZAZU_TIMEOUT` | `MANZA_TIMEOUT` |
24
+ | `Zazu-Version` request header | `Manza-Version` |
25
+ | User-Agent `zazu-ruby/x` | `manza-ruby/x` |
26
+
27
+ The `ZAZU_*` environment variables keep working for all of 1.x: when
28
+ the `MANZA_*` one is unset, the client reads the old name and prints a
29
+ one-time deprecation warning per variable.
30
+
31
+ ### Changed
32
+
33
+ - Gem `zazu-ruby` → `manza`, namespace `Zazu` → `Manza`, repository
34
+ `getmanza/zazu-ruby` → `getmanza/manza-ruby`.
35
+ - Requests send `Manza-Version` and User-Agent `manza-ruby/<version>`.
36
+ - `Response#api_version` reads `Manza-Version`, falling back to
37
+ `Zazu-Version`.
38
+ - Cassettes use the placeholders `<MANZA_API_KEY>` and `<MANZA_VERSION>`
39
+ (were `<ZAZU_API_KEY>`, `<ZAZU_VERSION>`). Other SDKs replaying them
40
+ must rename their placeholders in lockstep.
41
+ - Fixture and staging env vars for recording are `MANZA_FIXTURE_*` and
42
+ `MANZA_STAGING_*`, with no fallback. Rename them in your `.env`.
43
+
44
+ ## [0.3.0]
45
+
46
+ ### Added
47
+
48
+ - `Zazu::ConflictError` (409), the 10th error class. A duplicate
49
+ `client_reference` on a transfer draft raises it with `#payment_id`
50
+ naming the existing draft. 400 now maps to `Zazu::ValidationError`
51
+ (lists return 400 for a malformed `limit`/`cursor`).
52
+ - `TransferDrafts#authorize(id, authorization_id:, signature:)` and
53
+ `#decline(id, authorization_id:, reason: nil)` for machine-authorized
54
+ transfers. A blank signature raises `Zazu::ArgumentError` locally —
55
+ the API would count it as a failed attempt.
56
+ - `TransferDrafts#create` documents the new optional `client_reference`;
57
+ responses carry `client_reference` and `authorization`.
58
+ - `Zazu::TransferAuthorization` — `signature_input`, `sign` and
59
+ `payee_for`, the HMAC-SHA256 signer for authorization challenges, with
60
+ a fixed test vector shared across SDKs
61
+ (`spec/zazu/transfer_authorization_spec.rb`).
62
+ - `Beneficiaries#create`, `#list_external_accounts`,
63
+ `#get_external_account` and `#create_external_account`.
64
+ - `Zazu::Resources::PayeeTrustRequests` (`zazu.payee_trust_requests`) —
65
+ `create(external_account_ids:)` and `get(id)`.
66
+ - Docs for new pass-through fields: checkout session `customer_name`,
67
+ `collect_billing_address`, `billing_address`, `settled_at`,
68
+ `transaction` and the `clearing` status; payment link billing fields;
69
+ customer `registration_number` / `vat_number` (and MA-only `tax_id` /
70
+ `ice_number`).
71
+
72
+ ### Changed
73
+
74
+ - Default base URL is now `https://ma.manza.finance` (Morocco production;
75
+ South Africa is `https://za.manza.finance`). Cassettes are recorded
76
+ against staging at `https://ma.manza.dev`. The old `zazu.ma` hosts are
77
+ still served.
78
+ - Cassettes scrub `Manza-Version`, `account_number`, `bank_identifier`
79
+ and the authorize request `signature`. The three authorize cassettes
80
+ match the request body minus `signature` (`body_without_signature`),
81
+ which replay cannot reproduce; other SDKs should do the same.
82
+ - `rake fixtures:seed` needs a second, authorizer API key, the
83
+ authorizer endpoint's signing secret and a tunnel to a local webhook
84
+ receiver (see the one-time setup in `lib/tasks/fixtures.rake`).
85
+ Re-recording now executes a real 10.00 MAD transfer (the API minimum).
86
+ - Every cassette is re-recorded against `ma.manza.dev`, including the
87
+ full machine-authorization path (authorize 200, decline 200, bad
88
+ signature 422, same key 403, duplicate client_reference 409).
89
+
90
+ ## [0.2.1]
91
+
92
+ ### Added
93
+
94
+ - `Zazu::Resources::TransferDrafts` — `create` and `get`. Creating a
95
+ draft routes it into the workspace's in-app approval flow; the API
96
+ never executes a transfer itself. Lifecycle: `requested` →
97
+ `processing` → `completed` / `failed`.
98
+ - `Zazu::Resources::Beneficiaries` — `list` and `get`, the read-only
99
+ recipient directory (each beneficiary embeds its bank accounts;
100
+ the `default` one is used when a transfer names only the
101
+ beneficiary_id).
102
+ - Fixture seeding: `discover_beneficiary_id!` + `seed_transfer_draft!`.
103
+ The transfer_drafts/beneficiaries cassettes in this release are
104
+ hand-authored against the documented contract; re-record via
105
+ `rake fixtures:record` once the endpoints are live on staging.
106
+
107
+ ## [0.2.0]
108
+
109
+ ### Added
110
+
111
+ - `Zazu::Resources::CheckoutSessions` — `create` and `get` for one-off
112
+ hosted checkout sessions. Status enum: `open`, `processing`,
113
+ `complete`, `expired` (read-only — no API to mutate). No list, no
114
+ update, no delete; sessions are addressed by their `cs_…` id.
115
+
116
+ ## [0.1.0]
117
+
118
+ Initial release.
119
+
120
+ ### Added
121
+
122
+ - `Zazu::Client` — Faraday + HTTPX adapter, JSON request/response, retry middleware.
123
+ - Resource modules: `Accounts`, `Customers`, `Entity`, `Invoices`, `PaymentLinks`, `WebhookEndpoints`.
124
+ - Cursor-based pagination via `Zazu::Page` (max 100 records per page; no auto-pagination).
125
+ - Error hierarchy: `AuthenticationError`, `ForbiddenError`, `NotFoundError`,
126
+ `ValidationError`, `RateLimitError`, `ServerError`, `ConnectionError`,
127
+ `ConfigurationError`, `ArgumentError` — all under `Zazu::Error`.
128
+ - VCR-backed RSpec suite covering every public method.
129
+ - Cassette tarball published as a release asset for cross-language SDK reuse.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Manza
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,265 @@
1
+ # Manza Ruby SDK
2
+
3
+ Ruby SDK for the [Manza API](https://ma.manza.finance). Faraday + HTTPX adapter for HTTP/2 + persistent connections.
4
+
5
+ ```ruby
6
+ gem "manza"
7
+ ```
8
+
9
+ Upgrading from `zazu-ruby` 0.x? See the migration guide in [CHANGELOG.md](CHANGELOG.md).
10
+
11
+ ## Quick start
12
+
13
+ ```ruby
14
+ require "manza"
15
+
16
+ manza = Manza.new(api_key: ENV["MANZA_API_KEY"])
17
+ # Or with explicit base URL (defaults to https://ma.manza.finance, Morocco):
18
+ manza = Manza.new(api_key: ENV["MANZA_API_KEY"], base_url: "https://za.manza.finance")
19
+
20
+ entity = manza.entity.get
21
+ # => #<Manza::Response status=200 ...>
22
+ entity.body["name"]
23
+ # => "Acme Corp"
24
+ ```
25
+
26
+ Environment variables `MANZA_API_KEY`, `MANZA_BASE_URL`, `MANZA_API_VERSION`, and `MANZA_TIMEOUT` are read by default. The pre-1.0 `ZAZU_*` names still work for all of 1.x, with a one-time deprecation warning per variable.
27
+
28
+ ## Resources
29
+
30
+ ```ruby
31
+ manza.entity.get
32
+
33
+ manza.accounts.list(currency_code: "MAD", limit: 50)
34
+ manza.accounts.get("019dde7d-...")
35
+ manza.accounts.list_transactions("019dde7d-...", operation: "credit")
36
+ manza.accounts.get_transaction("019dde7d-...", "01a0e1...")
37
+
38
+ manza.customers.list(q: "acme")
39
+ manza.customers.get("01a0...")
40
+ manza.customers.create(
41
+ customer_type: "business",
42
+ company_name: "Acme Corp",
43
+ email: "billing@acme.com",
44
+ ice_number: "000000000000000"
45
+ )
46
+ manza.customers.update("01a0...", email: "new@example.com")
47
+ manza.customers.delete("01a0...")
48
+
49
+ manza.invoices.list(status: "sent", limit: 50)
50
+ manza.invoices.create(
51
+ customer_id: "01a0...",
52
+ currency_code: "MAD",
53
+ issue_date: "2026-05-03",
54
+ due_date: "2026-06-03",
55
+ items: [{ description: "Consulting", quantity: 10, unit_price: "150.00" }]
56
+ )
57
+ manza.invoices.send_invoice("01a0...")
58
+ manza.invoices.mark_as_paid("01a0...")
59
+ manza.invoices.cancel("01a0...")
60
+ manza.invoices.credit_note("01a0...")
61
+ manza.invoices.create_payment_link("01a0...", account_id: "019dde7d-...")
62
+
63
+ manza.payment_links.list(status: "active")
64
+ manza.payment_links.create(
65
+ account_id: "019dde7d-...",
66
+ amount: "1500.00",
67
+ description: "March consulting",
68
+ link_type: "single"
69
+ )
70
+ manza.payment_links.cancel("01a0...")
71
+
72
+ manza.checkout_sessions.create(
73
+ account_id: "019dde7d-...",
74
+ amount: "1500.00",
75
+ success_url: "https://merchant.example.com/success?session_id={CHECKOUT_SESSION_ID}",
76
+ cancel_url: "https://merchant.example.com/cancel",
77
+ customer_email: "buyer@example.com",
78
+ metadata: { order_id: "ORD-123" }
79
+ )
80
+ manza.checkout_sessions.get("cs_...")
81
+
82
+ manza.beneficiaries.list
83
+ manza.beneficiaries.create(beneficiary_type: "business", company_name: "Acme Supplies", email: "ap@acme.com")
84
+ manza.beneficiaries.list_external_accounts("01a0...")
85
+ manza.beneficiaries.get_external_account("01a0...", "01a1...")
86
+ manza.beneficiaries.create_external_account("01a0...", account_number: "007780...", name: "Main account")
87
+
88
+ manza.payee_trust_requests.create(external_account_ids: ["01a1..."])
89
+ manza.payee_trust_requests.get("01a2...")
90
+
91
+ manza.transfer_drafts.create(
92
+ account_id: "019dde7d-...",
93
+ beneficiary_id: "01a0...",
94
+ amount: "2500.00",
95
+ client_reference: "po_1042" # unique per entity; a duplicate raises Manza::ConflictError
96
+ )
97
+ manza.transfer_drafts.get("01a3...")
98
+ manza.transfer_drafts.decline("01a3...", authorization_id: "01a4...", reason: "Not ours")
99
+
100
+ manza.webhook_endpoints.list
101
+ manza.webhook_endpoints.create(
102
+ url: "https://example.com/webhooks/manza",
103
+ events: ["invoice.sent", "payment_link.paid"]
104
+ )
105
+ manza.webhook_endpoints.test_endpoint("01a0...")
106
+ manza.webhook_endpoints.regenerate_secret("01a0...")
107
+ manza.webhook_endpoints.enable("01a0...")
108
+ manza.webhook_endpoints.disable("01a0...")
109
+ ```
110
+
111
+ ## Machine-authorized transfers
112
+
113
+ A draft inside your entity's authorization envelope (trusted payee, within limits) is sent to your enrolled authorizer endpoint as a `payment.authorization_requested` webhook carrying an `authorization.id` and a one-time `nonce`. Sign the draft from **your own record** of it with the endpoint's signing secret, and authorize it with a **different API key** from the one that created it (the creating key gets 403 `same_key_forbidden`):
114
+
115
+ ```ruby
116
+ input = Manza::TransferAuthorization.signature_input(
117
+ payment_id: draft["id"],
118
+ nonce: webhook["data"]["authorization"]["nonce"],
119
+ amount: draft["amount"], # the API's decimal string, e.g. "2500.0"
120
+ currency_code: draft["currency_code"],
121
+ account_id: draft["account_id"],
122
+ payee: Manza::TransferAuthorization.payee_for(external_account_id: draft["external_account_id"]),
123
+ client_reference: draft["client_reference"]
124
+ )
125
+ signature = Manza::TransferAuthorization.sign(secret: signing_secret, signature_input: input)
126
+
127
+ authorizer = Manza.new(api_key: ENV["MANZA_AUTHORIZER_API_KEY"])
128
+ authorizer.transfer_drafts.authorize(draft["id"], authorization_id: webhook["data"]["authorization"]["id"], signature: signature)
129
+ ```
130
+
131
+ A wrong signature raises `Manza::ValidationError` (`type` `invalid_signature`). Five on one challenge send the draft to your in-app approvers; five in a row suspend the authorizer.
132
+
133
+ ## Pagination
134
+
135
+ Every list endpoint returns a `Manza::Page`. The SDK enforces a hard cap of **100 records per page** — there is no auto-pagination across pages.
136
+
137
+ ```ruby
138
+ page = manza.invoices.list(limit: 100)
139
+ page.data # => Array of invoice hashes
140
+ page.has_more # => true / false
141
+ page.next_cursor # => string or nil
142
+
143
+ # Walk pages explicitly:
144
+ while page
145
+ page.data.each { |inv| process(inv) }
146
+ page = page.next # returns nil when has_more is false
147
+ end
148
+ ```
149
+
150
+ For capped iteration, use the underlying `each_page_record` helper on a resource (private; access via `send` if you need it). The deliberate restriction is a guardrail — accidentally pulling 50,000 records in a single SDK call should be impossible without explicit per-page consent.
151
+
152
+ ## Errors
153
+
154
+ Every non-2xx response raises a subclass of `Manza::Error`:
155
+
156
+ | Status | Class |
157
+ |---|---|
158
+ | 401 | `Manza::AuthenticationError` |
159
+ | 403 | `Manza::ForbiddenError` |
160
+ | 400 | `Manza::ValidationError` (malformed request, e.g. bad `limit`/`cursor`) |
161
+ | 404 | `Manza::NotFoundError` |
162
+ | 409 | `Manza::ConflictError` (carries `#payment_id` for a duplicate `client_reference`) |
163
+ | 422 | `Manza::ValidationError` |
164
+ | 429 | `Manza::RateLimitError` (carries `#retry_after`) |
165
+ | 5xx | `Manza::ServerError` |
166
+ | network | `Manza::ConnectionError` |
167
+
168
+ Each error exposes `#status`, `#request_id`, `#type`, `#param`, and the raw `#body`.
169
+
170
+ ```ruby
171
+ begin
172
+ manza.invoices.get("does-not-exist")
173
+ rescue Manza::NotFoundError => e
174
+ e.status # => 404
175
+ e.request_id # => "req_..."
176
+ e.type # => "not_found_error"
177
+ end
178
+ ```
179
+
180
+ ## Versioning the API contract
181
+
182
+ ```ruby
183
+ manza = Manza.new(api_key: "...", api_version: "2026-03-27")
184
+ ```
185
+
186
+ Or via env: `MANZA_API_VERSION=2026-03-27`. The header is sent on every request; the API echoes it back in `Manza-Version` (and the legacy `Zazu-Version`); `response.api_version` reads it.
187
+
188
+ ## Development
189
+
190
+ ```bash
191
+ bundle install
192
+ bundle exec rspec
193
+ bundle exec rubocop
194
+ ```
195
+
196
+ The spec suite is VCR-backed — cassettes live in `spec/fixtures/cassettes/` and are committed to the repo.
197
+
198
+ To re-record cassettes against staging:
199
+
200
+ ```bash
201
+ cp .env.example .env
202
+ # fill in the keys, MANZA_FIXTURE_ACCOUNT_ID and MANZA_FIXTURE_BENEFICIARY_ID
203
+ cloudflared tunnel --config ~/.cloudflared/zazu-sdk-authorizer.yml run zazu-sdk-authorizer # separate terminal
204
+ bundle exec rake fixtures:record
205
+ ```
206
+
207
+ Recording executes a real 10.00 MAD transfer on staging (the authorize cassette). The one-time staging setup (keys, authorizer enrolment, trusted payee) is listed at the top of `lib/tasks/fixtures.rake`.
208
+
209
+ ### The authorizer tunnel
210
+
211
+ The machine-authorization cassettes need the `payment.authorization_requested` webhook, which staging sends to the webhook endpoint enrolled as transfer authorizer. During `rake fixtures:record` the seeder listens for it on `127.0.0.1:${MANZA_STAGING_AUTHORIZER_PORT:-4599}`, so a tunnel must forward the endpoint's public URL to that port. The endpoint URL cannot change once enrolled, so the tunnel needs a **stable hostname** (a throwaway `trycloudflare.com` URL won't do).
212
+
213
+ The existing setup uses a named Cloudflare tunnel `zazu-sdk-authorizer` → `https://sdk-authorizer.manza.dev/`. To run it on a new machine:
214
+
215
+ ```bash
216
+ brew install cloudflared
217
+ cloudflared tunnel login # pick the manza.dev zone
218
+ cloudflared tunnel token --cred-file ~/.cloudflared/zazu-sdk-authorizer.json zazu-sdk-authorizer
219
+ cat > ~/.cloudflared/zazu-sdk-authorizer.yml <<YML
220
+ tunnel: zazu-sdk-authorizer
221
+ credentials-file: $HOME/.cloudflared/zazu-sdk-authorizer.json
222
+ ingress:
223
+ - hostname: sdk-authorizer.manza.dev
224
+ service: http://127.0.0.1:4599
225
+ - service: http_status:404
226
+ YML
227
+ cloudflared tunnel --config ~/.cloudflared/zazu-sdk-authorizer.yml run zazu-sdk-authorizer
228
+ ```
229
+
230
+ To create one from scratch instead (then point a new webhook endpoint at it and enrol that one as authorizer):
231
+
232
+ ```bash
233
+ cloudflared tunnel create zazu-sdk-authorizer
234
+ cloudflared tunnel route dns zazu-sdk-authorizer sdk-authorizer.manza.dev
235
+ ```
236
+
237
+ The tunnel's `service` port must match `MANZA_STAGING_AUTHORIZER_PORT`: if you change one, change the other, or deliveries never reach the seeder and `fixtures:record` times out waiting for them.
238
+
239
+ Check it end to end: with the tunnel running and nothing on port 4599, `curl -X POST https://sdk-authorizer.manza.dev/` returns 502. During a record run the seeder answers unsigned requests with 401.
240
+
241
+ Cassettes are scrubbed before write — bearer tokens and request IDs are rewritten to placeholders. Even if a real key is in `.env`, the committed cassette never contains it.
242
+
243
+ ## Cassettes for other-language SDKs
244
+
245
+ Each release of `manza-ruby` publishes the cassette directory as a tarball release asset:
246
+
247
+ ```
248
+ https://github.com/getmanza/manza-ruby/releases/download/v0.1.0/cassettes-v0.1.0.tar.gz
249
+ ```
250
+
251
+ `manza-go`, `manza-python`, etc. pin a specific tag in their cassette fetch script and download the tarball during CI. This guarantees every SDK is tested against the same recorded API interactions, surfacing cross-SDK inconsistencies immediately.
252
+
253
+ VCR's YAML format is supported natively by:
254
+
255
+ - Ruby — VCR (this gem)
256
+ - Go — go-vcr
257
+ - Python — VCR.py
258
+ - PHP — PHP-VCR
259
+ - Crystal — vcr-crystal / hi8.cr
260
+ - Rust — http_replayer (or a small custom YAML reader)
261
+ - JavaScript / TypeScript — Talkback or polly.js (slight format adapter needed)
262
+
263
+ ## License
264
+
265
+ MIT
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+ require "httpx/adapters/faraday"
6
+ require "json"
7
+ require "securerandom"
8
+
9
+ module Manza
10
+ # The main SDK entry point.
11
+ #
12
+ # manza = Manza::Client.new(api_key: "sk_live_...")
13
+ # manza.entity.get
14
+ # manza.accounts.list(limit: 50)
15
+ #
16
+ # All public state is set at construction time. The client is
17
+ # thread-safe in the sense that the underlying Faraday connection
18
+ # uses a connection pool via the HTTPX adapter — multiple threads
19
+ # can share one client.
20
+ class Client
21
+ # Morocco production. South Africa: https://za.manza.finance.
22
+ DEFAULT_BASE_URL = "https://ma.manza.finance"
23
+ DEFAULT_TIMEOUT = 30
24
+ USER_AGENT = "manza-ruby/#{VERSION}".freeze
25
+
26
+ attr_reader :api_key, :base_url, :api_version, :timeout, :logger
27
+
28
+ # Reads MANZA_<name>, falling back to the pre-1.0 ZAZU_<name> with a
29
+ # one-time deprecation warning per variable. The fallback stays for
30
+ # all of 1.x.
31
+ #
32
+ # @api private
33
+ def self.env(name)
34
+ value = ENV.fetch("MANZA_#{name}", nil)
35
+ return value unless value.nil?
36
+
37
+ legacy = ENV.fetch("ZAZU_#{name}", nil)
38
+ return if legacy.nil?
39
+
40
+ @warned_legacy_env ||= Set.new
41
+ warn("[manza] ZAZU_#{name} is deprecated; set MANZA_#{name} instead.") if @warned_legacy_env.add?(name)
42
+ legacy
43
+ end
44
+
45
+ def initialize(
46
+ api_key: Client.env("API_KEY"),
47
+ base_url: Client.env("BASE_URL") || DEFAULT_BASE_URL,
48
+ api_version: Client.env("API_VERSION"),
49
+ timeout: Integer(Client.env("TIMEOUT") || DEFAULT_TIMEOUT),
50
+ logger: nil
51
+ )
52
+ raise ConfigurationError, "Missing api_key. Pass api_key: or set MANZA_API_KEY." if api_key.to_s.empty?
53
+
54
+ @api_key = api_key
55
+ @base_url = base_url.to_s.chomp("/")
56
+ @api_version = api_version
57
+ @timeout = timeout
58
+ @logger = logger
59
+ end
60
+
61
+ # Resource accessors — each returns a memoized resource module.
62
+ def accounts
63
+ @accounts ||= Resources::Accounts.new(self)
64
+ end
65
+
66
+ def beneficiaries
67
+ @beneficiaries ||= Resources::Beneficiaries.new(self)
68
+ end
69
+
70
+ def checkout_sessions
71
+ @checkout_sessions ||= Resources::CheckoutSessions.new(self)
72
+ end
73
+
74
+ def customers
75
+ @customers ||= Resources::Customers.new(self)
76
+ end
77
+
78
+ def entity
79
+ @entity ||= Resources::Entity.new(self)
80
+ end
81
+
82
+ def invoices
83
+ @invoices ||= Resources::Invoices.new(self)
84
+ end
85
+
86
+ def payment_links
87
+ @payment_links ||= Resources::PaymentLinks.new(self)
88
+ end
89
+
90
+ def payee_trust_requests
91
+ @payee_trust_requests ||= Resources::PayeeTrustRequests.new(self)
92
+ end
93
+
94
+ def transfer_drafts
95
+ @transfer_drafts ||= Resources::TransferDrafts.new(self)
96
+ end
97
+
98
+ def webhook_endpoints
99
+ @webhook_endpoints ||= Resources::WebhookEndpoints.new(self)
100
+ end
101
+
102
+ # Performs an HTTP request and returns a {Manza::Response} on
103
+ # success. Translates non-2xx responses into the matching
104
+ # {Manza::Error} subclass.
105
+ def request(method, path, params: nil, body: nil, headers: {})
106
+ raw = connection.send(method) do |req|
107
+ req.url(path)
108
+ req.params.update(params) if params
109
+ req.body = body unless body.nil?
110
+ headers.each { |k, v| req.headers[k] = v }
111
+ end
112
+
113
+ response = Response.new(raw)
114
+ return response if response.success?
115
+
116
+ raise build_error(response)
117
+ rescue Faraday::TimeoutError => e
118
+ raise ConnectionError, "Request timed out after #{timeout}s: #{e.message}"
119
+ rescue Faraday::ConnectionFailed => e
120
+ raise ConnectionError, "Connection failed: #{e.message}"
121
+ end
122
+
123
+ private
124
+
125
+ def connection
126
+ @connection ||= Faraday.new(url: base_url) do |f|
127
+ f.headers["Authorization"] = "Bearer #{api_key}"
128
+ f.headers["User-Agent"] = USER_AGENT
129
+ f.headers["Accept"] = "application/json"
130
+ f.headers["Manza-Version"] = api_version if api_version
131
+ f.request :json
132
+ f.response :json, content_type: /\bjson$/
133
+ f.options.timeout = timeout
134
+ f.options.open_timeout = [timeout, 10].min
135
+ f.response :logger, logger if logger
136
+ f.adapter(*adapter_args)
137
+ end
138
+ end
139
+
140
+ # The HTTPX adapter ships its own WebMock plugin that wraps every
141
+ # connection. When VCR's WebMock library hook is also active and
142
+ # net-connect is allowed (recording mode), the two interceptors
143
+ # layer in a way that deadlocks on the first real request. For
144
+ # cassette recording we drop down to Net::HTTP, which has rock-
145
+ # solid WebMock + VCR integration. Cassettes are adapter-agnostic
146
+ # so replay continues to use the production HTTPX adapter.
147
+ def adapter_args
148
+ ENV["VCR_RECORD"] ? [:net_http] : [:httpx]
149
+ end
150
+
151
+ # Lookup table for status → (error class, default message). 5xx
152
+ # is matched separately because Range keys don't work in Hash
153
+ # lookup the way exact integers do.
154
+ ERROR_BY_STATUS = {
155
+ 400 => [ValidationError, "Bad request"],
156
+ 401 => [AuthenticationError, "Authentication failed"],
157
+ 403 => [ForbiddenError, "Forbidden"],
158
+ 404 => [NotFoundError, "Not found"],
159
+ 422 => [ValidationError, "Validation failed"]
160
+ }.freeze
161
+ private_constant :ERROR_BY_STATUS
162
+
163
+ def build_error(response)
164
+ payload = error_payload(response.body)
165
+ message = payload["message"]
166
+ kwargs = error_kwargs(response, payload)
167
+
168
+ if (mapping = ERROR_BY_STATUS[response.status])
169
+ klass, default_message = mapping
170
+ return klass.new(message || default_message, **kwargs)
171
+ end
172
+
173
+ build_special_error(response, payload, message, kwargs)
174
+ end
175
+
176
+ def error_payload(body)
177
+ return {} unless body.is_a?(Hash) && body["error"].is_a?(Hash)
178
+
179
+ body["error"]
180
+ end
181
+
182
+ def error_kwargs(response, payload)
183
+ {
184
+ status: response.status,
185
+ request_id: response.request_id,
186
+ type: payload["type"],
187
+ param: payload["param"],
188
+ body: response.body
189
+ }
190
+ end
191
+
192
+ def build_special_error(response, payload, message, kwargs)
193
+ case response.status
194
+ when 409
195
+ ConflictError.new(message || "Conflict", payment_id: payload["payment_id"], **kwargs)
196
+ when 429
197
+ retry_after = response.headers["retry-after"]&.to_i
198
+ RateLimitError.new(message || "Rate limited", retry_after: retry_after, **kwargs)
199
+ when 500..599
200
+ ServerError.new(message || "Server error (#{response.status})", **kwargs)
201
+ else
202
+ Error.new(message || "Unexpected status #{response.status}", **kwargs)
203
+ end
204
+ end
205
+ end
206
+ end