zazu-ruby 0.2.1 → 0.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 71bc6633249ecc429eb365ac5421ec4877d05f51f0b9b96bbcb1e45d8b65eaae
4
- data.tar.gz: eea60136a9acaea0d9cbade450c6a8e08893c30c4258605d7242b396d4b6d06f
3
+ metadata.gz: 0a07ad0431dd522117f55a018e21182f6cbb492d1830e1c26db1d3c283b9b16f
4
+ data.tar.gz: 40b5c2a385cde96b5fd541b599e2ca97f2d25438b76cd0f062dfc953bc8679e1
5
5
  SHA512:
6
- metadata.gz: b62cd7f7eba2cd18e82d6b3d464a7aa9abf86d9047d9bca26627788eef33714d6defd64cba0842a75cdae9d3290e25cad9b3f9a89adbb8a8d08daa727d78d1d3
7
- data.tar.gz: e5aa06c535d57287791b49e0a22f9c9a9f9b9df146ed9e53784bf2637950f00dc742b4d386fe5b42c991a7c77b0921ada67a3f18710f8ef1337660419adffb3c
6
+ metadata.gz: 7f75208b1808504c0fc664dbe4903197c7befcf63361ed75c22ca54ad3a2d666a3299f83ba543b336eb441dea5bd2ff245a324cbc423d7b9a6dc8e249a5cf021
7
+ data.tar.gz: 39fa7349bba2bec0f1f4f88aac90c2e0c76df1dc2969437a225f6bdae0769ead1b29552b21623bbc29e536c2c9af45d8c3be53447fe7c15a5e17abd0f3865edd
data/CHANGELOG.md CHANGED
@@ -5,7 +5,60 @@ All notable changes to `zazu-ruby` are documented here.
5
5
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
  This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.3.1]
9
+
10
+ ### Deprecated
11
+
12
+ - `zazu-ruby` is renamed to `manza` (1.0.0). `require "zazu"` now prints a
13
+ deprecation warning and the gem shows a post-install message pointing to
14
+ `gem "manza"`. No further releases under this name; see the
15
+ [migration guide](https://github.com/getmanza/manza-ruby/blob/main/CHANGELOG.md).
16
+
17
+ ## [0.3.0]
18
+
19
+ ### Added
20
+
21
+ - `Zazu::ConflictError` (409), the 10th error class. A duplicate
22
+ `client_reference` on a transfer draft raises it with `#payment_id`
23
+ naming the existing draft. 400 now maps to `Zazu::ValidationError`
24
+ (lists return 400 for a malformed `limit`/`cursor`).
25
+ - `TransferDrafts#authorize(id, authorization_id:, signature:)` and
26
+ `#decline(id, authorization_id:, reason: nil)` for machine-authorized
27
+ transfers. A blank signature raises `Zazu::ArgumentError` locally —
28
+ the API would count it as a failed attempt.
29
+ - `TransferDrafts#create` documents the new optional `client_reference`;
30
+ responses carry `client_reference` and `authorization`.
31
+ - `Zazu::TransferAuthorization` — `signature_input`, `sign` and
32
+ `payee_for`, the HMAC-SHA256 signer for authorization challenges, with
33
+ a fixed test vector shared across SDKs
34
+ (`spec/zazu/transfer_authorization_spec.rb`).
35
+ - `Beneficiaries#create`, `#list_external_accounts`,
36
+ `#get_external_account` and `#create_external_account`.
37
+ - `Zazu::Resources::PayeeTrustRequests` (`zazu.payee_trust_requests`) —
38
+ `create(external_account_ids:)` and `get(id)`.
39
+ - Docs for new pass-through fields: checkout session `customer_name`,
40
+ `collect_billing_address`, `billing_address`, `settled_at`,
41
+ `transaction` and the `clearing` status; payment link billing fields;
42
+ customer `registration_number` / `vat_number` (and MA-only `tax_id` /
43
+ `ice_number`).
44
+
45
+ ### Changed
46
+
47
+ - Default base URL is now `https://ma.manza.finance` (Morocco production;
48
+ South Africa is `https://za.manza.finance`). Cassettes are recorded
49
+ against staging at `https://ma.manza.dev`. The old `zazu.ma` hosts are
50
+ still served.
51
+ - Cassettes scrub `Manza-Version`, `account_number`, `bank_identifier`
52
+ and the authorize request `signature`. The three authorize cassettes
53
+ match the request body minus `signature` (`body_without_signature`),
54
+ which replay cannot reproduce; other SDKs should do the same.
55
+ - `rake fixtures:seed` needs a second, authorizer API key, the
56
+ authorizer endpoint's signing secret and a tunnel to a local webhook
57
+ receiver (see the one-time setup in `lib/tasks/fixtures.rake`).
58
+ Re-recording now executes a real 10.00 MAD transfer (the API minimum).
59
+ - Every cassette is re-recorded against `ma.manza.dev`, including the
60
+ full machine-authorization path (authorize 200, decline 200, bad
61
+ signature 422, same key 403, duplicate client_reference 409).
9
62
 
10
63
  ## [0.2.1]
11
64
 
data/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # Zazu Ruby SDK
2
2
 
3
- Ruby SDK for the [Zazu API](https://zazu.ma). Faraday + HTTPX adapter for HTTP/2 + persistent connections.
3
+ > **Deprecated.** `zazu-ruby` is now [`manza`](https://rubygems.org/gems/manza): `gem "manza"`, `require "manza"`, `Manza::Client`. See the [migration guide](https://github.com/getmanza/manza-ruby/blob/main/CHANGELOG.md). This gem gets no further updates.
4
+
5
+ Ruby SDK for the [Manza API](https://ma.manza.finance). Faraday + HTTPX adapter for HTTP/2 + persistent connections.
4
6
 
5
7
  ```ruby
6
8
  gem "zazu-ruby"
@@ -14,8 +16,8 @@ The gem is published as `zazu-ruby` on RubyGems but loaded as `zazu` in code (th
14
16
  require "zazu"
15
17
 
16
18
  zazu = Zazu.new(api_key: ENV["ZAZU_API_KEY"])
17
- # Or with explicit base URL (defaults to https://zazu.ma):
18
- zazu = Zazu.new(api_key: ENV["ZAZU_API_KEY"], base_url: "https://zazu.africa")
19
+ # Or with explicit base URL (defaults to https://ma.manza.finance, Morocco):
20
+ zazu = Zazu.new(api_key: ENV["ZAZU_API_KEY"], base_url: "https://za.manza.finance")
19
21
 
20
22
  entity = zazu.entity.get
21
23
  # => #<Zazu::Response status=200 ...>
@@ -79,6 +81,24 @@ zazu.checkout_sessions.create(
79
81
  )
80
82
  zazu.checkout_sessions.get("cs_...")
81
83
 
84
+ zazu.beneficiaries.list
85
+ zazu.beneficiaries.create(beneficiary_type: "business", company_name: "Acme Supplies", email: "ap@acme.com")
86
+ zazu.beneficiaries.list_external_accounts("01a0...")
87
+ zazu.beneficiaries.get_external_account("01a0...", "01a1...")
88
+ zazu.beneficiaries.create_external_account("01a0...", account_number: "007780...", name: "Main account")
89
+
90
+ zazu.payee_trust_requests.create(external_account_ids: ["01a1..."])
91
+ zazu.payee_trust_requests.get("01a2...")
92
+
93
+ zazu.transfer_drafts.create(
94
+ account_id: "019dde7d-...",
95
+ beneficiary_id: "01a0...",
96
+ amount: "2500.00",
97
+ client_reference: "po_1042" # unique per entity; a duplicate raises Zazu::ConflictError
98
+ )
99
+ zazu.transfer_drafts.get("01a3...")
100
+ zazu.transfer_drafts.decline("01a3...", authorization_id: "01a4...", reason: "Not ours")
101
+
82
102
  zazu.webhook_endpoints.list
83
103
  zazu.webhook_endpoints.create(
84
104
  url: "https://example.com/webhooks/zazu",
@@ -90,6 +110,28 @@ zazu.webhook_endpoints.enable("01a0...")
90
110
  zazu.webhook_endpoints.disable("01a0...")
91
111
  ```
92
112
 
113
+ ## Machine-authorized transfers
114
+
115
+ 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`):
116
+
117
+ ```ruby
118
+ input = Zazu::TransferAuthorization.signature_input(
119
+ payment_id: draft["id"],
120
+ nonce: webhook["data"]["authorization"]["nonce"],
121
+ amount: draft["amount"], # the API's decimal string, e.g. "2500.0"
122
+ currency_code: draft["currency_code"],
123
+ account_id: draft["account_id"],
124
+ payee: Zazu::TransferAuthorization.payee_for(external_account_id: draft["external_account_id"]),
125
+ client_reference: draft["client_reference"]
126
+ )
127
+ signature = Zazu::TransferAuthorization.sign(secret: signing_secret, signature_input: input)
128
+
129
+ authorizer = Zazu.new(api_key: ENV["ZAZU_AUTHORIZER_API_KEY"])
130
+ authorizer.transfer_drafts.authorize(draft["id"], authorization_id: webhook["data"]["authorization"]["id"], signature: signature)
131
+ ```
132
+
133
+ A wrong signature raises `Zazu::ValidationError` (`type` `invalid_signature`). Five on one challenge send the draft to your in-app approvers; five in a row suspend the authorizer.
134
+
93
135
  ## Pagination
94
136
 
95
137
  Every list endpoint returns a `Zazu::Page`. The SDK enforces a hard cap of **100 records per page** — there is no auto-pagination across pages.
@@ -117,7 +159,9 @@ Every non-2xx response raises a subclass of `Zazu::Error`:
117
159
  |---|---|
118
160
  | 401 | `Zazu::AuthenticationError` |
119
161
  | 403 | `Zazu::ForbiddenError` |
162
+ | 400 | `Zazu::ValidationError` (malformed request, e.g. bad `limit`/`cursor`) |
120
163
  | 404 | `Zazu::NotFoundError` |
164
+ | 409 | `Zazu::ConflictError` (carries `#payment_id` for a duplicate `client_reference`) |
121
165
  | 422 | `Zazu::ValidationError` |
122
166
  | 429 | `Zazu::RateLimitError` (carries `#retry_after`) |
123
167
  | 5xx | `Zazu::ServerError` |
@@ -141,7 +185,7 @@ end
141
185
  zazu = Zazu.new(api_key: "...", api_version: "2026-03-27")
142
186
  ```
143
187
 
144
- Or via env: `ZAZU_API_VERSION=2026-03-27`. The header is sent on every request; the API echoes it back in `Zazu-Version`.
188
+ Or via env: `ZAZU_API_VERSION=2026-03-27`. The header is sent on every request; the API echoes it back in both `Zazu-Version` and `Manza-Version`.
145
189
 
146
190
  ## Development
147
191
 
@@ -157,10 +201,45 @@ To re-record cassettes against staging:
157
201
 
158
202
  ```bash
159
203
  cp .env.example .env
160
- # fill in ZAZU_STAGING_API_KEY and the ZAZU_FIXTURE_*_ID values
204
+ # fill in the keys, ZAZU_FIXTURE_ACCOUNT_ID and ZAZU_FIXTURE_BENEFICIARY_ID
205
+ cloudflared tunnel --config ~/.cloudflared/zazu-sdk-authorizer.yml run zazu-sdk-authorizer # separate terminal
161
206
  bundle exec rake fixtures:record
162
207
  ```
163
208
 
209
+ 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`.
210
+
211
+ ### The authorizer tunnel
212
+
213
+ 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:${ZAZU_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).
214
+
215
+ The existing setup uses a named Cloudflare tunnel `zazu-sdk-authorizer` → `https://sdk-authorizer.manza.dev/`. To run it on a new machine:
216
+
217
+ ```bash
218
+ brew install cloudflared
219
+ cloudflared tunnel login # pick the manza.dev zone
220
+ cloudflared tunnel token --cred-file ~/.cloudflared/zazu-sdk-authorizer.json zazu-sdk-authorizer
221
+ cat > ~/.cloudflared/zazu-sdk-authorizer.yml <<YML
222
+ tunnel: zazu-sdk-authorizer
223
+ credentials-file: $HOME/.cloudflared/zazu-sdk-authorizer.json
224
+ ingress:
225
+ - hostname: sdk-authorizer.manza.dev
226
+ service: http://127.0.0.1:4599
227
+ - service: http_status:404
228
+ YML
229
+ cloudflared tunnel --config ~/.cloudflared/zazu-sdk-authorizer.yml run zazu-sdk-authorizer
230
+ ```
231
+
232
+ To create one from scratch instead (then point a new webhook endpoint at it and enrol that one as authorizer):
233
+
234
+ ```bash
235
+ cloudflared tunnel create zazu-sdk-authorizer
236
+ cloudflared tunnel route dns zazu-sdk-authorizer sdk-authorizer.manza.dev
237
+ ```
238
+
239
+ The tunnel's `service` port must match `ZAZU_STAGING_AUTHORIZER_PORT`: if you change one, change the other, or deliveries never reach the seeder and `fixtures:record` times out waiting for them.
240
+
241
+ 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.
242
+
164
243
  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.
165
244
 
166
245
  ## Cassettes for other-language SDKs
data/lib/zazu/client.rb CHANGED
@@ -18,7 +18,8 @@ module Zazu
18
18
  # uses a connection pool via the HTTPX adapter — multiple threads
19
19
  # can share one client.
20
20
  class Client
21
- DEFAULT_BASE_URL = "https://zazu.ma"
21
+ # Morocco production. South Africa: https://za.manza.finance.
22
+ DEFAULT_BASE_URL = "https://ma.manza.finance"
22
23
  DEFAULT_TIMEOUT = 30
23
24
  USER_AGENT = "zazu-ruby/#{VERSION}".freeze
24
25
 
@@ -69,6 +70,10 @@ module Zazu
69
70
  @payment_links ||= Resources::PaymentLinks.new(self)
70
71
  end
71
72
 
73
+ def payee_trust_requests
74
+ @payee_trust_requests ||= Resources::PayeeTrustRequests.new(self)
75
+ end
76
+
72
77
  def transfer_drafts
73
78
  @transfer_drafts ||= Resources::TransferDrafts.new(self)
74
79
  end
@@ -130,6 +135,7 @@ module Zazu
130
135
  # is matched separately because Range keys don't work in Hash
131
136
  # lookup the way exact integers do.
132
137
  ERROR_BY_STATUS = {
138
+ 400 => [ValidationError, "Bad request"],
133
139
  401 => [AuthenticationError, "Authentication failed"],
134
140
  403 => [ForbiddenError, "Forbidden"],
135
141
  404 => [NotFoundError, "Not found"],
@@ -147,7 +153,7 @@ module Zazu
147
153
  return klass.new(message || default_message, **kwargs)
148
154
  end
149
155
 
150
- build_special_error(response, message, kwargs)
156
+ build_special_error(response, payload, message, kwargs)
151
157
  end
152
158
 
153
159
  def error_payload(body)
@@ -166,8 +172,10 @@ module Zazu
166
172
  }
167
173
  end
168
174
 
169
- def build_special_error(response, message, kwargs)
175
+ def build_special_error(response, payload, message, kwargs)
170
176
  case response.status
177
+ when 409
178
+ ConflictError.new(message || "Conflict", payment_id: payload["payment_id"], **kwargs)
171
179
  when 429
172
180
  retry_after = response.headers["retry-after"]&.to_i
173
181
  RateLimitError.new(message || "Rate limited", retry_after: retry_after, **kwargs)
data/lib/zazu/errors.rb CHANGED
@@ -42,10 +42,29 @@ module Zazu
42
42
  # see it).
43
43
  class NotFoundError < Error; end
44
44
 
45
- # 422 — request body or query params failed validation. `#param`
46
- # carries the offending field name when the API supplies it.
45
+ # 400 / 422 — request body or query params failed validation (400 for
46
+ # a malformed request such as a bad `limit`/`cursor`, 422 for a
47
+ # well-formed one the API rejects). `#param` carries the offending
48
+ # field name when the API supplies it.
47
49
  class ValidationError < Error; end
48
50
 
51
+ # 409 — the request conflicts with an existing resource. For a
52
+ # duplicate `client_reference` on a transfer draft (`type`
53
+ # "duplicate_client_reference"), `#payment_id` names the draft that
54
+ # already holds it.
55
+ class ConflictError < Error
56
+ attr_reader :payment_id
57
+
58
+ def initialize(message = nil, payment_id: nil, **)
59
+ super(message, **)
60
+ @payment_id = payment_id
61
+ end
62
+
63
+ def to_h
64
+ super.merge(payment_id:).compact
65
+ end
66
+ end
67
+
49
68
  # 429 — rate limited. Retry after the `Retry-After` header (seconds).
50
69
  class RateLimitError < Error
51
70
  attr_reader :retry_after
@@ -2,10 +2,9 @@
2
2
 
3
3
  module Zazu
4
4
  module Resources
5
- # Read-only directory of saved transfer recipients. Each
6
- # beneficiary embeds its bank accounts; the one flagged `default`
7
- # is used when a transfer names only the beneficiary_id.
8
- # Beneficiaries are created and managed in the Zazu dashboard.
5
+ # Saved transfer recipients. Each beneficiary embeds its bank
6
+ # accounts; the one flagged `default` is used when a transfer names
7
+ # only the beneficiary_id. There is no update or delete via the API.
9
8
  class Beneficiaries < Base
10
9
  # GET /api/beneficiaries
11
10
  def list(limit: MAX_PER_PAGE, cursor: nil)
@@ -16,6 +15,35 @@ module Zazu
16
15
  def get(id)
17
16
  http_get(encode_path("api/beneficiaries", id))
18
17
  end
18
+
19
+ # POST /api/beneficiaries
20
+ #
21
+ # Keys: beneficiary_type ("individual" | "business"; inferred from
22
+ # person_name / company_name when omitted), person_name, company_name, email,
23
+ # phone_number. Values must be strings. Shares a 10/minute limit
24
+ # with {#create_external_account}.
25
+ def create(**attributes)
26
+ http_post("api/beneficiaries", body: attributes)
27
+ end
28
+
29
+ # GET /api/beneficiaries/:beneficiary_id/external_accounts
30
+ def list_external_accounts(beneficiary_id, limit: MAX_PER_PAGE, cursor: nil)
31
+ list_page(encode_path("api/beneficiaries", beneficiary_id, "external_accounts"), limit: limit, cursor: cursor)
32
+ end
33
+
34
+ # GET /api/beneficiaries/:beneficiary_id/external_accounts/:id
35
+ def get_external_account(beneficiary_id, id)
36
+ http_get(encode_path("api/beneficiaries", beneficiary_id, "external_accounts", id))
37
+ end
38
+
39
+ # POST /api/beneficiaries/:beneficiary_id/external_accounts
40
+ #
41
+ # Required: account_number. Optional: name, country_code,
42
+ # currency_code, account_type ("bank" only), bank_identifier
43
+ # (required in ZA, rejected in MA, where it is derived from the RIB).
44
+ def create_external_account(beneficiary_id, **attributes)
45
+ http_post(encode_path("api/beneficiaries", beneficiary_id, "external_accounts"), body: attributes)
46
+ end
19
47
  end
20
48
  end
21
49
  end
@@ -4,8 +4,9 @@ module Zazu
4
4
  module Resources
5
5
  # One-off hosted checkout sessions. Pre-API there's no list,
6
6
  # update, or delete — sessions are created and inspected by id.
7
- # State (`open`, `processing`, `complete`, `expired`) transitions
8
- # are read-only from the SDK's perspective.
7
+ # State (`open`, `processing`, `clearing`, `complete`, `expired`)
8
+ # transitions are read-only from the SDK's perspective. Responses
9
+ # carry `settled_at` and the paying `transaction`.
9
10
  class CheckoutSessions < Base
10
11
  # GET /api/checkout_sessions/:id
11
12
  def get(id)
@@ -16,7 +17,8 @@ module Zazu
16
17
  #
17
18
  # @param attributes [Hash] checkout-session attributes — see API docs.
18
19
  # Required: account_id, amount, success_url.
19
- # Optional: metadata, customer_email, cancel_url, description, expires_at.
20
+ # Optional: metadata, customer_email, customer_name, cancel_url,
21
+ # description, expires_at, collect_billing_address, billing_address.
20
22
  def create(**attributes)
21
23
  http_post("api/checkout_sessions", body: attributes)
22
24
  end
@@ -20,8 +20,10 @@ module Zazu
20
20
  #
21
21
  # @param attributes [Hash] customer attributes — see API docs.
22
22
  # Common keys: customer_type ("individual"|"business"),
23
- # person_name, company_name, email, phone, tax_id, ice_number,
24
- # billing_address (Hash with street/city/postal_code/country/country_code).
23
+ # person_name, company_name, email, phone, registration_number,
24
+ # vat_number, billing_address (Hash with street/city/postal_code/
25
+ # country/country_code). Morocco only: tax_id, ice_number — these
26
+ # keys are absent from responses in other markets.
25
27
  def create(**attributes)
26
28
  http_post("api/customers", body: attributes)
27
29
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zazu
4
+ module Resources
5
+ # Requests to trust payees for machine-authorized transfers. The API
6
+ # key can only ask: a member holding payment-authorize permission
7
+ # approves the request in the Zazu app. Status: pending → approved /
8
+ # declined / cancelled. There is no list, update, or delete.
9
+ class PayeeTrustRequests < Base
10
+ # POST /api/payee_trust_requests
11
+ #
12
+ # @param external_account_ids [Array<String>] at most 100 bank accounts.
13
+ def create(external_account_ids:)
14
+ http_post("api/payee_trust_requests", body: { external_account_ids: external_account_ids })
15
+ end
16
+
17
+ # GET /api/payee_trust_requests/:id
18
+ def get(id)
19
+ http_get(encode_path("api/payee_trust_requests", id))
20
+ end
21
+ end
22
+ end
23
+ end
@@ -21,6 +21,9 @@ module Zazu
21
21
  end
22
22
 
23
23
  # POST /api/payment_links
24
+ #
25
+ # Optional billing keys: collect_billing_address, billing_address.
26
+ # Responses carry `settled_at`; status includes `clearing`.
24
27
  def create(**attributes)
25
28
  http_post("api/payment_links", body: attributes)
26
29
  end
@@ -2,19 +2,25 @@
2
2
 
3
3
  module Zazu
4
4
  module Resources
5
- # API-initiated transfers. Creating a draft routes it into the
6
- # workspace's in-app approval flow — the API never executes a
7
- # transfer itself. A manager or legal representative approves in
8
- # the Zazu app; poll {#get} (status: requested → processing →
9
- # completed / failed) or subscribe to the `transfer.executed`
10
- # webhook to follow execution.
5
+ # API-initiated transfers. Creating a draft never executes a
6
+ # transfer by itself. A draft inside the entity's machine-
7
+ # authorization envelope (trusted payee, within limits) is sent to
8
+ # the enrolled transfer authorizer as a `payment.authorization_requested`
9
+ # webhook; answer it with {#authorize} or {#decline}, using an API
10
+ # key other than the one that created the draft. Every other draft
11
+ # goes to the in-app approval flow, where a manager or legal
12
+ # representative approves it. Poll {#get} (status: requested →
13
+ # processing → completed / failed) or subscribe to the
14
+ # `transfer.executed` webhook to follow execution.
11
15
  class TransferDrafts < Base
12
16
  # POST /api/transfer_drafts
13
17
  #
14
18
  # Required: account_id, amount, and exactly one of beneficiary_id
15
19
  # (external transfer) or destination_account_id (own-account move).
16
20
  # Optional: external_account_id, currency_code, payment_reference,
17
- # internal_notes.
21
+ # internal_notes, client_reference (unique per entity, at most 128
22
+ # characters; a duplicate raises {Zazu::ConflictError} whose
23
+ # `payment_id` names the existing draft).
18
24
  def create(**attributes)
19
25
  http_post("api/transfer_drafts", body: attributes)
20
26
  end
@@ -23,6 +29,34 @@ module Zazu
23
29
  def get(id)
24
30
  http_get(encode_path("api/transfer_drafts", id))
25
31
  end
32
+
33
+ # POST /api/transfer_drafts/:id/authorize
34
+ #
35
+ # Executes the draft. `authorization_id` comes from the
36
+ # `payment.authorization_requested` webhook; build `signature` with
37
+ # {Zazu::TransferAuthorization}. Requires the `transfers:authorize`
38
+ # scope on a key other than the draft's creator (otherwise 403
39
+ # `same_key_forbidden`). A blank signature is refused locally: the
40
+ # API counts it as a failed attempt, and five fail the challenge.
41
+ def authorize(id, authorization_id:, signature:)
42
+ raise Zazu::ArgumentError, "signature cannot be blank" if signature.to_s.strip.empty?
43
+
44
+ http_post(
45
+ encode_path("api/transfer_drafts", id, "authorize"),
46
+ body: { authorization_id: authorization_id, signature: signature }
47
+ )
48
+ end
49
+
50
+ # POST /api/transfer_drafts/:id/decline
51
+ #
52
+ # Declines the challenge and deletes the draft. Returns the
53
+ # authorization (`status: "declined"`).
54
+ def decline(id, authorization_id:, reason: nil)
55
+ http_post(
56
+ encode_path("api/transfer_drafts", id, "decline"),
57
+ body: { authorization_id: authorization_id, reason: reason }.compact
58
+ )
59
+ end
26
60
  end
27
61
  end
28
62
  end
@@ -27,11 +27,16 @@ module Zazu
27
27
  end
28
28
 
29
29
  # PATCH /api/webhook_endpoints/:id
30
+ #
31
+ # Changing `url` on the endpoint enrolled as transfer authorizer
32
+ # raises {Zazu::ValidationError}.
30
33
  def update(id, **attributes)
31
34
  http_patch(encode_path("api/webhook_endpoints", id), body: attributes)
32
35
  end
33
36
 
34
37
  # DELETE /api/webhook_endpoints/:id
38
+ #
39
+ # A soft delete (still 204).
35
40
  def delete(id)
36
41
  http_delete(encode_path("api/webhook_endpoints", id))
37
42
  end
@@ -42,6 +47,8 @@ module Zazu
42
47
  end
43
48
 
44
49
  # POST /api/webhook_endpoints/:id/regenerate_secret
50
+ #
51
+ # Raises {Zazu::ValidationError} on the enrolled transfer authorizer.
45
52
  def regenerate_secret(id)
46
53
  http_post(encode_path("api/webhook_endpoints", id, "regenerate_secret"))
47
54
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Zazu
6
+ # Signs a machine-authorization challenge for an API-created transfer
7
+ # draft. Pure functions — no HTTP.
8
+ #
9
+ # The `payment.authorization_requested` webhook delivers the
10
+ # authorization id and a one-time nonce. Build the signature input
11
+ # from your *own* record of the transfer (not the webhook's
12
+ # `signature_input`, which is there only to compare against), sign
13
+ # it with the authorizer endpoint's signing secret, and pass the
14
+ # result to {Resources::TransferDrafts#authorize}:
15
+ #
16
+ # input = Zazu::TransferAuthorization.signature_input(
17
+ # payment_id: draft["id"], nonce: nonce, amount: draft["amount"],
18
+ # currency_code: draft["currency_code"], account_id: draft["account_id"],
19
+ # payee: Zazu::TransferAuthorization.payee_for(external_account_id: draft["external_account_id"]),
20
+ # client_reference: draft["client_reference"]
21
+ # )
22
+ # signature = Zazu::TransferAuthorization.sign(secret: signing_secret, signature_input: input)
23
+ # zazu.transfer_drafts.authorize(draft["id"], authorization_id: authorization_id, signature: signature)
24
+ module TransferAuthorization
25
+ SIGNATURE_VERSION = "manza.transfer-authorization.v1"
26
+
27
+ module_function
28
+
29
+ # `amount` must be the API's decimal string verbatim (e.g. "2500.0").
30
+ # `client_reference` is empty when the transfer has none.
31
+ def signature_input(payment_id:, nonce:, amount:, currency_code:, account_id:, payee:, client_reference: nil)
32
+ raise Zazu::ArgumentError, "amount must be the API's decimal string (got #{amount.inspect})" unless amount.is_a?(String)
33
+
34
+ [
35
+ SIGNATURE_VERSION, payment_id, nonce, amount, currency_code, account_id, payee, client_reference.to_s
36
+ ].join("|")
37
+ end
38
+
39
+ # Hex HMAC-SHA256 of the signature input under the authorizer
40
+ # endpoint's signing secret.
41
+ def sign(secret:, signature_input:)
42
+ OpenSSL::HMAC.hexdigest("SHA256", secret, signature_input)
43
+ end
44
+
45
+ # The payee token: `ext:<id>` for a beneficiary's bank account,
46
+ # `own:<id>` for one of the entity's own accounts. Pass exactly one.
47
+ def payee_for(external_account_id: nil, destination_account_id: nil)
48
+ unless external_account_id.nil? ^ destination_account_id.nil?
49
+ raise Zazu::ArgumentError, "pass exactly one of external_account_id or destination_account_id"
50
+ end
51
+
52
+ destination_account_id ? "own:#{destination_account_id}" : "ext:#{external_account_id}"
53
+ end
54
+ end
55
+ end
data/lib/zazu/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zazu
4
- VERSION = "0.2.1"
4
+ VERSION = "0.3.1"
5
5
  end
data/lib/zazu.rb CHANGED
@@ -9,6 +9,12 @@
9
9
  # zazu.accounts.list(limit: 50)
10
10
  #
11
11
  # See README.md for full documentation.
12
+ #
13
+ # Deprecated: this gem was renamed. Use `gem "manza"` / `require "manza"`.
14
+ warn("[zazu-ruby] zazu-ruby is deprecated and gets no further updates. " \
15
+ 'Switch to gem "manza" (require "manza", Manza::Client). ' \
16
+ "Migration guide: https://github.com/getmanza/manza-ruby/blob/main/CHANGELOG.md")
17
+
12
18
  module Zazu
13
19
  # Module-level shortcut. Equivalent to Zazu::Client.new(...).
14
20
  def self.new(**)
@@ -20,6 +26,7 @@ require_relative "zazu/version"
20
26
  require_relative "zazu/errors"
21
27
  require_relative "zazu/response"
22
28
  require_relative "zazu/page"
29
+ require_relative "zazu/transfer_authorization"
23
30
  require_relative "zazu/resources/base"
24
31
  require_relative "zazu/resources/accounts"
25
32
  require_relative "zazu/resources/beneficiaries"
@@ -27,6 +34,7 @@ require_relative "zazu/resources/checkout_sessions"
27
34
  require_relative "zazu/resources/customers"
28
35
  require_relative "zazu/resources/entity"
29
36
  require_relative "zazu/resources/invoices"
37
+ require_relative "zazu/resources/payee_trust_requests"
30
38
  require_relative "zazu/resources/payment_links"
31
39
  require_relative "zazu/resources/transfer_drafts"
32
40
  require_relative "zazu/resources/webhook_endpoints"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zazu-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zazu
@@ -74,10 +74,12 @@ files:
74
74
  - lib/zazu/resources/customers.rb
75
75
  - lib/zazu/resources/entity.rb
76
76
  - lib/zazu/resources/invoices.rb
77
+ - lib/zazu/resources/payee_trust_requests.rb
77
78
  - lib/zazu/resources/payment_links.rb
78
79
  - lib/zazu/resources/transfer_drafts.rb
79
80
  - lib/zazu/resources/webhook_endpoints.rb
80
81
  - lib/zazu/response.rb
82
+ - lib/zazu/transfer_authorization.rb
81
83
  - lib/zazu/version.rb
82
84
  homepage: https://github.com/getzazu/zazu-ruby
83
85
  licenses:
@@ -88,6 +90,10 @@ metadata:
88
90
  bug_tracker_uri: https://github.com/getzazu/zazu-ruby/issues
89
91
  documentation_uri: https://github.com/getzazu/zazu-ruby#readme
90
92
  rubygems_mfa_required: 'true'
93
+ post_install_message: |
94
+ zazu-ruby is deprecated and gets no further updates. It is now published
95
+ as gem "manza" (require "manza", Manza::Client). Migration guide:
96
+ https://github.com/getmanza/manza-ruby/blob/main/CHANGELOG.md
91
97
  rdoc_options: []
92
98
  require_paths:
93
99
  - lib