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 +7 -0
- data/CHANGELOG.md +129 -0
- data/LICENSE +21 -0
- data/README.md +265 -0
- data/lib/manza/client.rb +206 -0
- data/lib/manza/errors.rb +93 -0
- data/lib/manza/page.rb +67 -0
- data/lib/manza/resources/accounts.rb +61 -0
- data/lib/manza/resources/base.rb +136 -0
- data/lib/manza/resources/beneficiaries.rb +49 -0
- data/lib/manza/resources/checkout_sessions.rb +27 -0
- data/lib/manza/resources/customers.rb +42 -0
- data/lib/manza/resources/entity.rb +14 -0
- data/lib/manza/resources/invoices.rb +69 -0
- data/lib/manza/resources/payee_trust_requests.rb +23 -0
- data/lib/manza/resources/payment_links.rb +37 -0
- data/lib/manza/resources/transfer_drafts.rb +62 -0
- data/lib/manza/resources/webhook_endpoints.rb +67 -0
- data/lib/manza/response.rb +64 -0
- data/lib/manza/transfer_authorization.rb +55 -0
- data/lib/manza/version.rb +5 -0
- data/lib/manza.rb +35 -0
- metadata +110 -0
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
|
data/lib/manza/client.rb
ADDED
|
@@ -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
|