zazu-ruby 0.2.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 71bc6633249ecc429eb365ac5421ec4877d05f51f0b9b96bbcb1e45d8b65eaae
4
- data.tar.gz: eea60136a9acaea0d9cbade450c6a8e08893c30c4258605d7242b396d4b6d06f
3
+ metadata.gz: 78fd31e6e0f84fd07c1e55e9af4b5828d232d39d1d040829a6d8518c33e176d2
4
+ data.tar.gz: 9760600e49b71b6bed3383677d7e108874740ca665c2e601cc6b7a87c1c8210b
5
5
  SHA512:
6
- metadata.gz: b62cd7f7eba2cd18e82d6b3d464a7aa9abf86d9047d9bca26627788eef33714d6defd64cba0842a75cdae9d3290e25cad9b3f9a89adbb8a8d08daa727d78d1d3
7
- data.tar.gz: e5aa06c535d57287791b49e0a22f9c9a9f9b9df146ed9e53784bf2637950f00dc742b4d386fe5b42c991a7c77b0921ada67a3f18710f8ef1337660419adffb3c
6
+ metadata.gz: 283a136fd5ca23c3db9d38f5007758bbc021eeeff6336cc117eb8342585d8b29ac09d8349a4db823c9f9743bdf1842c564ef4c2cffbe59aef005402dd64c1971
7
+ data.tar.gz: 5542a59251e3f5608c03ef92c4eadc2147ade849576662af02e67bd98b4c3e78673474ef0f82d929ec12db7e6ea6079b4e186254fe1f41298a17de7b2bad9b05
data/CHANGELOG.md CHANGED
@@ -7,6 +7,50 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Added
11
+
12
+ - `Zazu::ConflictError` (409), the 10th error class. A duplicate
13
+ `client_reference` on a transfer draft raises it with `#payment_id`
14
+ naming the existing draft. 400 now maps to `Zazu::ValidationError`
15
+ (lists return 400 for a malformed `limit`/`cursor`).
16
+ - `TransferDrafts#authorize(id, authorization_id:, signature:)` and
17
+ `#decline(id, authorization_id:, reason: nil)` for machine-authorized
18
+ transfers. A blank signature raises `Zazu::ArgumentError` locally —
19
+ the API would count it as a failed attempt.
20
+ - `TransferDrafts#create` documents the new optional `client_reference`;
21
+ responses carry `client_reference` and `authorization`.
22
+ - `Zazu::TransferAuthorization` — `signature_input`, `sign` and
23
+ `payee_for`, the HMAC-SHA256 signer for authorization challenges, with
24
+ a fixed test vector shared across SDKs
25
+ (`spec/zazu/transfer_authorization_spec.rb`).
26
+ - `Beneficiaries#create`, `#list_external_accounts`,
27
+ `#get_external_account` and `#create_external_account`.
28
+ - `Zazu::Resources::PayeeTrustRequests` (`zazu.payee_trust_requests`) —
29
+ `create(external_account_ids:)` and `get(id)`.
30
+ - Docs for new pass-through fields: checkout session `customer_name`,
31
+ `collect_billing_address`, `billing_address`, `settled_at`,
32
+ `transaction` and the `clearing` status; payment link billing fields;
33
+ customer `registration_number` / `vat_number` (and MA-only `tax_id` /
34
+ `ice_number`).
35
+
36
+ ### Changed
37
+
38
+ - Default base URL is now `https://ma.manza.finance` (Morocco production;
39
+ South Africa is `https://za.manza.finance`). Cassettes are recorded
40
+ against staging at `https://ma.manza.dev`. The old `zazu.ma` hosts are
41
+ still served.
42
+ - Cassettes scrub `Manza-Version`, `account_number`, `bank_identifier`
43
+ and the authorize request `signature`. The three authorize cassettes
44
+ match the request body minus `signature` (`body_without_signature`),
45
+ which replay cannot reproduce; other SDKs should do the same.
46
+ - `rake fixtures:seed` needs a second, authorizer API key, the
47
+ authorizer endpoint's signing secret and a tunnel to a local webhook
48
+ receiver (see the one-time setup in `lib/tasks/fixtures.rake`).
49
+ Re-recording now executes a real 10.00 MAD transfer (the API minimum).
50
+ - Every cassette is re-recorded against `ma.manza.dev`, including the
51
+ full machine-authorization path (authorize 200, decline 200, bad
52
+ signature 422, same key 403, duplicate client_reference 409).
53
+
10
54
  ## [0.2.1]
11
55
 
12
56
  ### Added
data/README.md CHANGED
@@ -1,6 +1,6 @@
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
+ Ruby SDK for the [Manza API](https://ma.manza.finance). Faraday + HTTPX adapter for HTTP/2 + persistent connections.
4
4
 
5
5
  ```ruby
6
6
  gem "zazu-ruby"
@@ -14,8 +14,8 @@ The gem is published as `zazu-ruby` on RubyGems but loaded as `zazu` in code (th
14
14
  require "zazu"
15
15
 
16
16
  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")
17
+ # Or with explicit base URL (defaults to https://ma.manza.finance, Morocco):
18
+ zazu = Zazu.new(api_key: ENV["ZAZU_API_KEY"], base_url: "https://za.manza.finance")
19
19
 
20
20
  entity = zazu.entity.get
21
21
  # => #<Zazu::Response status=200 ...>
@@ -79,6 +79,24 @@ zazu.checkout_sessions.create(
79
79
  )
80
80
  zazu.checkout_sessions.get("cs_...")
81
81
 
82
+ zazu.beneficiaries.list
83
+ zazu.beneficiaries.create(beneficiary_type: "business", company_name: "Acme Supplies", email: "ap@acme.com")
84
+ zazu.beneficiaries.list_external_accounts("01a0...")
85
+ zazu.beneficiaries.get_external_account("01a0...", "01a1...")
86
+ zazu.beneficiaries.create_external_account("01a0...", account_number: "007780...", name: "Main account")
87
+
88
+ zazu.payee_trust_requests.create(external_account_ids: ["01a1..."])
89
+ zazu.payee_trust_requests.get("01a2...")
90
+
91
+ zazu.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 Zazu::ConflictError
96
+ )
97
+ zazu.transfer_drafts.get("01a3...")
98
+ zazu.transfer_drafts.decline("01a3...", authorization_id: "01a4...", reason: "Not ours")
99
+
82
100
  zazu.webhook_endpoints.list
83
101
  zazu.webhook_endpoints.create(
84
102
  url: "https://example.com/webhooks/zazu",
@@ -90,6 +108,28 @@ zazu.webhook_endpoints.enable("01a0...")
90
108
  zazu.webhook_endpoints.disable("01a0...")
91
109
  ```
92
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 = Zazu::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: Zazu::TransferAuthorization.payee_for(external_account_id: draft["external_account_id"]),
123
+ client_reference: draft["client_reference"]
124
+ )
125
+ signature = Zazu::TransferAuthorization.sign(secret: signing_secret, signature_input: input)
126
+
127
+ authorizer = Zazu.new(api_key: ENV["ZAZU_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 `Zazu::ValidationError` (`type` `invalid_signature`). Five on one challenge send the draft to your in-app approvers; five in a row suspend the authorizer.
132
+
93
133
  ## Pagination
94
134
 
95
135
  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 +157,9 @@ Every non-2xx response raises a subclass of `Zazu::Error`:
117
157
  |---|---|
118
158
  | 401 | `Zazu::AuthenticationError` |
119
159
  | 403 | `Zazu::ForbiddenError` |
160
+ | 400 | `Zazu::ValidationError` (malformed request, e.g. bad `limit`/`cursor`) |
120
161
  | 404 | `Zazu::NotFoundError` |
162
+ | 409 | `Zazu::ConflictError` (carries `#payment_id` for a duplicate `client_reference`) |
121
163
  | 422 | `Zazu::ValidationError` |
122
164
  | 429 | `Zazu::RateLimitError` (carries `#retry_after`) |
123
165
  | 5xx | `Zazu::ServerError` |
@@ -141,7 +183,7 @@ end
141
183
  zazu = Zazu.new(api_key: "...", api_version: "2026-03-27")
142
184
  ```
143
185
 
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`.
186
+ 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
187
 
146
188
  ## Development
147
189
 
@@ -157,10 +199,45 @@ To re-record cassettes against staging:
157
199
 
158
200
  ```bash
159
201
  cp .env.example .env
160
- # fill in ZAZU_STAGING_API_KEY and the ZAZU_FIXTURE_*_ID values
202
+ # fill in the keys, ZAZU_FIXTURE_ACCOUNT_ID and ZAZU_FIXTURE_BENEFICIARY_ID
203
+ cloudflared tunnel --config ~/.cloudflared/zazu-sdk-authorizer.yml run zazu-sdk-authorizer # separate terminal
161
204
  bundle exec rake fixtures:record
162
205
  ```
163
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:${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).
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 `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.
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
+
164
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.
165
242
 
166
243
  ## 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.0"
5
5
  end
data/lib/zazu.rb CHANGED
@@ -20,6 +20,7 @@ require_relative "zazu/version"
20
20
  require_relative "zazu/errors"
21
21
  require_relative "zazu/response"
22
22
  require_relative "zazu/page"
23
+ require_relative "zazu/transfer_authorization"
23
24
  require_relative "zazu/resources/base"
24
25
  require_relative "zazu/resources/accounts"
25
26
  require_relative "zazu/resources/beneficiaries"
@@ -27,6 +28,7 @@ require_relative "zazu/resources/checkout_sessions"
27
28
  require_relative "zazu/resources/customers"
28
29
  require_relative "zazu/resources/entity"
29
30
  require_relative "zazu/resources/invoices"
31
+ require_relative "zazu/resources/payee_trust_requests"
30
32
  require_relative "zazu/resources/payment_links"
31
33
  require_relative "zazu/resources/transfer_drafts"
32
34
  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.0
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: