manza 1.0.0 → 1.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: 435c347d3d7f45503a976c5be7c7b185f4c86968d6557e93ec736557e2ca3fec
4
- data.tar.gz: 5f13cc5d2633f528ffbe43521e6e64ae2070415e6b47d34afbe51a583ca57cac
3
+ metadata.gz: 460a7caba9195a45a73b84bbadbd1fdc3476ad4fdf8244e0d554fd451b493b28
4
+ data.tar.gz: ecabf7b8a26eeb01ff6405821305ad54e9192674f77982df77f3b53437aefeaa
5
5
  SHA512:
6
- metadata.gz: 4b5efbf767a72be8822fd86ef8596146410910730a6a575f711aea9da6fb77bf7184ebcd283b4c0671fa3b155ee8d770bb237774bc11e18951c2c547008d91c2
7
- data.tar.gz: ed1f8c2a4233014da1447bdb6ca30ca1e6139c49b8ac325a7ce77c08f162ed92e808f78c77ac7738e3e77f75cd86ea6f1041f5ddb3cf7a9b83a84277c89c58fd
6
+ metadata.gz: b17cb8c504555b47d2c26322880a22c9187396617a1fcfdbda5a4d176e6e4671e5ca4164d528454ba69008ec11841658a42c262a9619219a07b9605dba0e23fc
7
+ data.tar.gz: c69b5d6f786843c19f815a8eb1b201653b833d90fe4128796f30b7eee81f46df59096df33d0b9c6d04e08e7f00d595b3d837c7bb3677d856ab3e2243ff45509d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,99 @@ 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
+ - `manza.payments` (`Manza::Resources::Payments`): `create`, `get`,
13
+ `authorize` and `decline` on the API's canonical paths, `POST
14
+ /api/payments`, `GET /api/payments/:id`, `POST /api/payments/:id/authorize`
15
+ and `POST /api/payments/:id/decline`. The API renamed transfer drafts to
16
+ payments; request and response bodies are unchanged, and so is the
17
+ `Manza::TransferAuthorization` signature input.
18
+
19
+ ### Deprecated
20
+
21
+ - `manza.transfer_drafts` and `Manza::Resources::TransferDrafts`. Both keep
22
+ working for all of 1.x: the accessor returns `manza.payments` and the
23
+ constant names the same class, so the old name now calls `/api/payments`
24
+ too. They warn only when Ruby's deprecation warnings are on
25
+ (`Warning[:deprecated] = true` or `ruby -W:deprecated`). The server keeps
26
+ `/api/transfer_drafts` as a permanent alias.
27
+
28
+ ### Changed (cassettes)
29
+
30
+ The nine payment cassettes moved from `transfer_drafts/` to `payments/`
31
+ and match a new URI. An SDK that replays `cassettes-v1.3.0` sends:
32
+
33
+ | Cassette | Request |
34
+ |---|---|
35
+ | `payments/create` | `POST https://ma.manza.dev/api/payments` |
36
+ | `payments/create_duplicate` | `POST https://ma.manza.dev/api/payments` |
37
+ | `payments/get` | `GET https://ma.manza.dev/api/payments/fixture-transfer-draft-id` |
38
+ | `payments/get_refused` | `GET https://ma.manza.dev/api/payments/fixture-refused-draft-id` |
39
+ | `payments/authorize` | `POST https://ma.manza.dev/api/payments/fixture-authorizable-draft-id/authorize` |
40
+ | `payments/authorize_bad_signature` | `POST https://ma.manza.dev/api/payments/fixture-bad-signature-draft-id/authorize` |
41
+ | `payments/authorize_same_key` | `POST https://ma.manza.dev/api/payments/fixture-authorizable-draft-id/authorize` |
42
+ | `payments/authorize_new_payee` | `POST https://ma.manza.dev/api/payments/fixture-new-payee-draft-id/authorize` |
43
+ | `payments/decline` | `POST https://ma.manza.dev/api/payments/fixture-declinable-draft-id/decline` |
44
+
45
+ Each was `/api/transfer_drafts…` with the same rest of the path, under
46
+ `transfer_drafts/<name>`. The fixture placeholders keep their names
47
+ (`fixture-transfer-draft-id`, …). Every other cassette was re-recorded with
48
+ the same method, URI and request body as in `cassettes-v1.2.0`, and every
49
+ response keeps its status and the same JSON keys.
50
+
51
+ ## [1.2.0]
52
+
53
+ 1.1.0 was never published: its changes (the webhook verifier and the
54
+ example) ship here.
55
+
56
+ ### Added
57
+
58
+ - `manza.transfer_authorizer.get` (`GET /api/transfer_authorizer`): the
59
+ enrolled authorizer's `status`, `suspension_reason`, limits,
60
+ `daily_authorized_total`, `secret_expires_at` and `webhook_endpoint_id`.
61
+ Raises `Manza::NotFoundError` when none is enrolled.
62
+ - Documented the new pass-through fields: a draft's `authorization` carries
63
+ `refusal_reason` (status `refused` when it went to the approvers at
64
+ create time), bank accounts carry `trusted`, and the challenge webhook's
65
+ `payment.payee_trusted` says whether the payee was trusted already.
66
+ - `examples/transfer_authorizer/`: `preflight.rb` checks every setup step
67
+ and prints the fix; `create_transfer.rb` prints the authorization status
68
+ and refusal reason in words; the authorizer logs a new payee, declines it
69
+ with `ALLOW_NEW_PAYEES=false`, and retries 409 `authorization_busy`.
70
+ - README: setup step 6 is "Approve transfers to new payees" (or trust each
71
+ payee), step 7 the preflight.
72
+ - Cassettes re-recorded against `ma.manza.dev`, with new ones for
73
+ `transfer_authorizer/get`, a refused draft, a new-payee authorize and the
74
+ payee trusted afterwards.
75
+ - `Manza::Webhook` verifies incoming webhook deliveries: `verify?` (boolean),
76
+ `construct_event` (returns the parsed event, raises `Manza::ArgumentError`)
77
+ and `sign`. Reads `X-Manza-*` headers in any case, the Rack `HTTP_X_MANZA_*`
78
+ form and the legacy `X-Zazu-*` names; checks the timestamp within 5 minutes
79
+ (`tolerance: nil` skips it, for local replay only). The spec carries a fixed test vector every SDK
80
+ reuses.
81
+ - `examples/transfer_authorizer/`: a runnable two-key transfer authorizer
82
+ (Rack app plus a creator script and a JSON ledger). Not shipped in the gem.
83
+ - README: "Setup, step by step" for machine-authorized transfers.
84
+
85
+ ### Changed (cassettes)
86
+
87
+ Two existing cassettes match a different request body. An SDK that replays
88
+ `cassettes-v1.2.0` sends these exact bodies:
89
+
90
+ - `transfer_drafts/create`: `amount` `"10.00"`, the staging per-transfer
91
+ limit (the old `"150.00"` exceeds it and was refused), and a new `internal_notes`
92
+ `"[zazu-ruby-fixture] create spec - do not approve"`. The response's
93
+ `authorization` is a `pending` challenge.
94
+ - `beneficiaries/create`: `company_name` `"Manza Fixture Beneficiary spec"`
95
+ (staging now accepts only unaccented letters, digits and spaces).
96
+
97
+ Responses are scrubbed further: account `balance` and transaction
98
+ `account_balance_after` read the synthetic decimal `"1000.0"`, and the
99
+ authorizer's `daily_authorized_total` reads `"0.0"`, in every cassette.
100
+
101
+ ## [1.0.0]
102
+
10
103
  The SDK is renamed from Zazu to Manza and released as `manza` 1.0.0.
11
104
  The API surface is otherwise unchanged.
12
105
 
@@ -41,6 +134,16 @@ one-time deprecation warning per variable.
41
134
  - Fixture and staging env vars for recording are `MANZA_FIXTURE_*` and
42
135
  `MANZA_STAGING_*`, with no fallback. Rename them in your `.env`.
43
136
 
137
+ ## [0.3.1]
138
+
139
+ Final release under the old `zazu-ruby` name, published from the
140
+ `zazu-legacy` branch.
141
+
142
+ ### Deprecated
143
+
144
+ - `require "zazu"` prints a deprecation warning, and the gem shows a
145
+ post-install message pointing to `gem "manza"`.
146
+
44
147
  ## [0.3.0]
45
148
 
46
149
  ### Added
data/README.md CHANGED
@@ -82,20 +82,23 @@ manza.checkout_sessions.get("cs_...")
82
82
  manza.beneficiaries.list
83
83
  manza.beneficiaries.create(beneficiary_type: "business", company_name: "Acme Supplies", email: "ap@acme.com")
84
84
  manza.beneficiaries.list_external_accounts("01a0...")
85
- manza.beneficiaries.get_external_account("01a0...", "01a1...")
85
+ manza.beneficiaries.get_external_account("01a0...", "01a1...") # "trusted": true once a trusted payee
86
86
  manza.beneficiaries.create_external_account("01a0...", account_number: "007780...", name: "Main account")
87
87
 
88
88
  manza.payee_trust_requests.create(external_account_ids: ["01a1..."])
89
89
  manza.payee_trust_requests.get("01a2...")
90
90
 
91
- manza.transfer_drafts.create(
91
+ manza.payments.create(
92
92
  account_id: "019dde7d-...",
93
93
  beneficiary_id: "01a0...",
94
94
  amount: "2500.00",
95
95
  client_reference: "po_1042" # unique per entity; a duplicate raises Manza::ConflictError
96
96
  )
97
- manza.transfer_drafts.get("01a3...")
98
- manza.transfer_drafts.decline("01a3...", authorization_id: "01a4...", reason: "Not ours")
97
+ manza.payments.get("01a3...") # authorization: status, refusal_reason
98
+ manza.payments.decline("01a3...", authorization_id: "01a4...", reason: "Not ours")
99
+ # manza.transfer_drafts is a deprecated alias of manza.payments (same methods, /api/payments)
100
+
101
+ manza.transfer_authorizer.get # status, suspension_reason, limits, daily_authorized_total; NotFoundError: none enrolled
99
102
 
100
103
  manza.webhook_endpoints.list
101
104
  manza.webhook_endpoints.create(
@@ -110,7 +113,47 @@ manza.webhook_endpoints.disable("01a0...")
110
113
 
111
114
  ## Machine-authorized transfers
112
115
 
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`):
116
+ A draft inside your entity's authorization envelope (trusted payee, or a new one when you allow it, within limits) is sent to your enrolled authorizer endpoint as a `payment.authorization_requested` webhook carrying an `authorization.id` and a one-time `nonce`. Your system checks it against its own ledger, signs it and authorizes it with a **different API key** from the one that created it. [`examples/transfer_authorizer/`](examples/transfer_authorizer/) runs the whole flow.
117
+
118
+ ### Setup, step by step
119
+
120
+ 1. **Feature flags.** Ask support to enable API transfers and transfer authorization (`release_api_webhook_authorization`) on your workspace.
121
+ 2. **Two API keys.** Key A creates drafts (`transfers:write`; `beneficiaries:request_trust` for step 6; `entity:read`, `accounts:read`, `webhook_endpoints:read` and `beneficiaries:read` for the preflight in step 7). Key B answers the challenges (`transfers:authorize`). The creating key is always refused with 403 `same_key_forbidden`, so keep key B and the signing secret on the system that holds your ledger. Bind key B to an IP allowlist: authorizing from a key without one is refused with 403 `unrestricted_api_key`. Bind key A to one too if you allow new payees (step 6): a draft to a new payee from a key without an allowlist goes to your approvers (`unrestricted_api_key`).
122
+ 3. **A stable public URL.** Your authorizer needs an HTTPS URL that never changes: an enrolled endpoint's URL is locked. Use a reserved ngrok domain (`ngrok http --url=your-name.ngrok.app 4599`) or a named Cloudflare tunnel (see [The authorizer tunnel](#the-authorizer-tunnel)). Never a free ngrok URL: it changes on every restart.
123
+ 4. **Create the webhook endpoint** at that URL:
124
+
125
+ ```ruby
126
+ manza.webhook_endpoints.create(url: "https://your-name.ngrok.app/", events: ["transfer.executed"])
127
+ ```
128
+
129
+ 5. **Enrol it in the app**: Settings → Developer → Transfer approval. Pick the endpoint, the currency and both limits, confirm with your two-factor code, and copy the signing secret: it is shown once.
130
+ 6. **Allow new payees, or trust each payee.** Turn on *Approve transfers to new payees* at enrolment (or later, same page, with your two-factor code) and set the *Limit per transfer to a new payee*. Your authorizer may then approve a transfer to bank details your workspace never trusted, up to that limit, and its signature trusts them: the webhook's `payment.payee_trusted` is `false` for such a transfer. Bank details whose trust was revoked, or that were edited after trusting, still go to your approvers. Without the option, an approver marks the beneficiary's bank account as trusted from the Beneficiaries list, or you ask them to:
131
+
132
+ ```ruby
133
+ manza.payee_trust_requests.create(external_account_ids: ["01a1..."])
134
+ ```
135
+
136
+ `trusted` on `manza.beneficiaries.get_external_account` says which it is. The API does not show whether the option is on.
137
+ 7. **Run the preflight, then the example** (from `examples/transfer_authorizer/`, after `bundle install` and filling in `.env`): `bundle exec ruby preflight.rb` checks what the API shows of steps 1–6 and prints a fix for each that fails. What it cannot see, check by hand: that key B is bound to an IP allowlist, and, for a payee not trusted yet, that *Approve transfers to new payees* is on and key A is bound to an IP allowlist too (it warns, it does not fail). Then `bundle exec rackup -p 4599` in one terminal, `bundle exec ruby create_transfer.rb` in another.
138
+ 8. **Troubleshooting**: a draft that goes to your approvers instead has an `authorization` with status `refused` and a `refusal_reason` (`create_transfer.rb` prints it in words); `manza.transfer_authorizer.get` shows a suspension and its `suspension_reason`. An authorize answered 409 `authorization_busy` (a bank-detail edit, trust or trust revocation on the same payee was in flight) is safe to retry. The reasons, and an API error's `type`, are in the [transfer authorization docs](https://ma.manza.finance/docs/api/transfer-authorization).
139
+
140
+ ### Verifying the webhook
141
+
142
+ Pass the **raw request body**, never re-serialized JSON. `construct_event` reads `X-Manza-Signature` and `X-Manza-Timestamp` (any case, the Rack `HTTP_X_MANZA_*` form, or the legacy `X-Zazu-*` names), checks the signature in constant time and the timestamp within 5 minutes, and returns the parsed event (a Hash). It raises `Manza::ArgumentError` otherwise:
143
+
144
+ ```ruby
145
+ begin
146
+ event = Manza::Webhook.construct_event(payload: request.body.read, headers: request.env, secret: signing_secret)
147
+ rescue Manza::ArgumentError => e
148
+ return [401, {}, [e.message]]
149
+ end
150
+ ```
151
+
152
+ `Manza::Webhook.verify?(payload:, signature:, timestamp:, secret:)` returns a boolean instead. Both accept `tolerance: nil` to skip the timestamp check. Use it only to replay a captured delivery in a local tool or test, never on a live endpoint: without the check, anyone holding a captured delivery can replay it.
153
+
154
+ ### Signing the challenge
155
+
156
+ Sign the draft from **your own record** of it with the endpoint's signing secret:
114
157
 
115
158
  ```ruby
116
159
  input = Manza::TransferAuthorization.signature_input(
@@ -125,7 +168,7 @@ input = Manza::TransferAuthorization.signature_input(
125
168
  signature = Manza::TransferAuthorization.sign(secret: signing_secret, signature_input: input)
126
169
 
127
170
  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)
171
+ authorizer.payments.authorize(draft["id"], authorization_id: webhook["data"]["authorization"]["id"], signature: signature)
129
172
  ```
130
173
 
131
174
  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.
@@ -198,13 +241,14 @@ The spec suite is VCR-backed — cassettes live in `spec/fixtures/cassettes/` an
198
241
  To re-record cassettes against staging:
199
242
 
200
243
  ```bash
201
- cp .env.example .env
244
+ bin/rename-env-vars # upgrading an existing 0.x .env: ZAZU_* → MANZA_* (backup in .env.bak)
245
+ cp .env.example .env # fresh setup only — overwrites an existing .env
202
246
  # fill in the keys, MANZA_FIXTURE_ACCOUNT_ID and MANZA_FIXTURE_BENEFICIARY_ID
203
247
  cloudflared tunnel --config ~/.cloudflared/zazu-sdk-authorizer.yml run zazu-sdk-authorizer # separate terminal
204
248
  bundle exec rake fixtures:record
205
249
  ```
206
250
 
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`.
251
+ Recording executes two real 10.00 MAD transfers on staging: the authorize cassette pays the trusted payee, and authorize_new_payee pays a freshly seeded bank account (and trusts it). The one-time staging setup (keys, authorizer enrolment, trusted payee, the new-payee option) is listed at the top of `lib/tasks/fixtures.rake`; AGENTS.md → Staging fixture state keeps it in place.
208
252
 
209
253
  ### The authorizer tunnel
210
254
 
@@ -240,12 +284,26 @@ Check it end to end: with the tunnel running and nothing on port 4599, `curl -X
240
284
 
241
285
  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
286
 
287
+ ## The SDK family
288
+
289
+ | SDK | Repository | Install |
290
+ |---|---|---|
291
+ | Ruby (reference implementation, records the cassettes) | [getmanza/manza-ruby](https://github.com/getmanza/manza-ruby) (this repo) | `gem "manza"` |
292
+ | TypeScript / JavaScript | [getmanza/manza-ts](https://github.com/getmanza/manza-ts) | `npm install @getmanza/sdk` |
293
+ | Python | [getmanza/manza-python](https://github.com/getmanza/manza-python) | `pip install manza` |
294
+ | Go | [getmanza/manza-go](https://github.com/getmanza/manza-go) | `go get github.com/getmanza/manza-go` |
295
+ | PHP | [getmanza/manza-php](https://github.com/getmanza/manza-php) | `composer require manza/manza-php` |
296
+ | Rust | [getmanza/manza-rust](https://github.com/getmanza/manza-rust) | `cargo add manza` |
297
+ | Crystal | [getmanza/manza-crystal](https://github.com/getmanza/manza-crystal) | shard `manza` (`github: getmanza/manza-crystal`) |
298
+ | Elixir | [getmanza/manza-elixir](https://github.com/getmanza/manza-elixir) | `{:manza, "~> 1.0"}` |
299
+ | CLI | [getmanza/cli](https://github.com/getmanza/cli) | `npm install -g @getzazu/cli` or `brew install getmanza/tap/zazu` |
300
+
243
301
  ## Cassettes for other-language SDKs
244
302
 
245
303
  Each release of `manza-ruby` publishes the cassette directory as a tarball release asset:
246
304
 
247
305
  ```
248
- https://github.com/getmanza/manza-ruby/releases/download/v0.1.0/cassettes-v0.1.0.tar.gz
306
+ https://github.com/getmanza/manza-ruby/releases/download/v1.0.0/cassettes-v1.0.0.tar.gz
249
307
  ```
250
308
 
251
309
  `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.
data/lib/manza/client.rb CHANGED
@@ -87,12 +87,24 @@ module Manza
87
87
  @payment_links ||= Resources::PaymentLinks.new(self)
88
88
  end
89
89
 
90
+ def payments
91
+ @payments ||= Resources::Payments.new(self)
92
+ end
93
+
90
94
  def payee_trust_requests
91
95
  @payee_trust_requests ||= Resources::PayeeTrustRequests.new(self)
92
96
  end
93
97
 
98
+ def transfer_authorizer
99
+ @transfer_authorizer ||= Resources::TransferAuthorizer.new(self)
100
+ end
101
+
102
+ # @deprecated Use {#payments}. Kept for all of 1.x: returns the same
103
+ # resource, which calls `/api/payments`. Warns when Ruby's deprecation
104
+ # warnings are on (`Warning[:deprecated] = true` or `-W:deprecated`).
94
105
  def transfer_drafts
95
- @transfer_drafts ||= Resources::TransferDrafts.new(self)
106
+ warn("[manza] transfer_drafts is deprecated; use payments instead.", category: :deprecated, uplevel: 1)
107
+ payments
96
108
  end
97
109
 
98
110
  def webhook_endpoints
@@ -4,7 +4,10 @@ module Manza
4
4
  module Resources
5
5
  # Saved transfer recipients. Each beneficiary embeds its bank
6
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.
7
+ # only the beneficiary_id. Each bank account carries `trusted`: whether
8
+ # its bank details are a trusted payee, which machine-authorized
9
+ # transfers need (or "Approve transfers to new payees" on for details
10
+ # never trusted). There is no update or delete via the API.
8
11
  class Beneficiaries < Base
9
12
  # GET /api/beneficiaries
10
13
  def list(limit: MAX_PER_PAGE, cursor: nil)
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manza
4
+ module Resources
5
+ # API-initiated transfers, called payments (formerly transfer drafts;
6
+ # `/api/transfer_drafts` stays a permanent server-side alias). Creating
7
+ # a draft never executes a transfer by itself. A draft inside the
8
+ # entity's machine-
9
+ # authorization envelope (trusted payee, within limits) is sent to
10
+ # the enrolled transfer authorizer as a `payment.authorization_requested`
11
+ # webhook; answer it with {#authorize} or {#decline}, using an API
12
+ # key other than the one that created the draft. Every other draft
13
+ # goes to the in-app approval flow, where a manager or legal
14
+ # representative approves it. Poll {#get} (status: requested →
15
+ # processing → completed / failed) or subscribe to the
16
+ # `transfer.executed` webhook to follow execution.
17
+ #
18
+ # A draft's `authorization` says which way it went: `pending` (a
19
+ # challenge was sent), `authorized`, `declined` (the draft is deleted),
20
+ # or one of two outcomes that send the draft to the in-app approvers,
21
+ # each with a `refusal_reason`:
22
+ #
23
+ # - `refused`: it did not qualify, at create time (no webhook is sent)
24
+ # or again when the challenge was answered: `untrusted_payee`,
25
+ # `unrestricted_api_key`, `over_new_payee_limit`,
26
+ # `over_transfer_limit`, `over_daily_limit`, `currency_mismatch`,
27
+ # `suspended` or `secret_expired`.
28
+ # - `expired` / `superseded` / `failed`: a challenge was sent and closed
29
+ # without an authorize: `expired` (no answer within the hour),
30
+ # `stale` (the draft, its payee's bank details or the endpoint changed
31
+ # while it was open) or `signature_failed` (five invalid signatures).
32
+ # One exception: the in-app approval that finalizes the draft while
33
+ # its challenge is open supersedes it (`stale`), and the draft
34
+ # executes or is scheduled; the draft's own `status` shows which.
35
+ #
36
+ # `authorization` is nil when no authorizer was enrolled.
37
+ #
38
+ # With "Approve transfers to new payees" on (enrolment, in the app),
39
+ # a draft to bank details the entity never trusted is challenged too,
40
+ # up to the new-payee limit, when the creating key has an IP
41
+ # allowlist; authorizing it also trusts the payee.
42
+ class Payments < Base
43
+ # POST /api/payments
44
+ #
45
+ # Required: account_id, amount, and exactly one of beneficiary_id
46
+ # (external transfer) or destination_account_id (own-account move).
47
+ # Optional: external_account_id, currency_code, payment_reference,
48
+ # internal_notes, client_reference (unique per entity, at most 128
49
+ # characters; a duplicate raises {Manza::ConflictError} whose
50
+ # `payment_id` names the existing draft).
51
+ def create(**attributes)
52
+ http_post("api/payments", body: attributes)
53
+ end
54
+
55
+ # GET /api/payments/:id
56
+ def get(id)
57
+ http_get(encode_path("api/payments", id))
58
+ end
59
+
60
+ # POST /api/payments/:id/authorize
61
+ #
62
+ # Executes the draft. `authorization_id` comes from the
63
+ # `payment.authorization_requested` webhook; build `signature` with
64
+ # {Manza::TransferAuthorization}. Requires the `transfers:authorize`
65
+ # scope on a key other than the draft's creator (otherwise 403
66
+ # `same_key_forbidden`), bound to an IP allowlist (otherwise 403
67
+ # `unrestricted_api_key`). A 409 {Manza::ConflictError} with `type`
68
+ # `authorization_busy` means a bank-detail edit, trust or trust
69
+ # revocation on the payee was in flight: retry. A blank signature
70
+ # raises {Manza::ArgumentError} and is never sent: the API would count
71
+ # it as a failed attempt. Five invalid signatures fail the challenge.
72
+ def authorize(id, authorization_id:, signature:)
73
+ raise Manza::ArgumentError, "signature cannot be blank" if signature.to_s.strip.empty?
74
+
75
+ http_post(
76
+ encode_path("api/payments", id, "authorize"),
77
+ body: { authorization_id: authorization_id, signature: signature }
78
+ )
79
+ end
80
+
81
+ # POST /api/payments/:id/decline
82
+ #
83
+ # Declines the challenge and deletes the draft. Returns the
84
+ # authorization (`status: "declined"`, `declined_at`, and a nil
85
+ # `refusal_reason`).
86
+ def decline(id, authorization_id:, reason: nil)
87
+ http_post(
88
+ encode_path("api/payments", id, "decline"),
89
+ body: { authorization_id: authorization_id, reason: reason }.compact
90
+ )
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manza
4
+ module Resources
5
+ # The entity's enrolled transfer authorizer, read-only. Enrolment,
6
+ # limits, the "Approve transfers to new payees" option and resume stay
7
+ # in the app behind a second factor. A singular resource: no list, no id.
8
+ #
9
+ # manza.transfer_authorizer.get.body["status"] # => "active"
10
+ class TransferAuthorizer < Base
11
+ # GET /api/transfer_authorizer
12
+ #
13
+ # Any key with `transfers:read`, `transfers:write` or
14
+ # `transfers:authorize`. Returns `status` (`active` / `suspended`),
15
+ # `suspension_reason` (`member`, `signature_failures`,
16
+ # `secret_expired`, `endpoint_disabled`; nil when active),
17
+ # `currency_code`, `per_transfer_limit`, `daily_limit`,
18
+ # `daily_authorized_total` (the last 24 hours), `secret_expires_at`,
19
+ # `webhook_endpoint_id` and `enrolled_at`. It does not show whether
20
+ # new payees are approved. Raises {Manza::NotFoundError} when no
21
+ # authorizer is enrolled.
22
+ def get
23
+ http_get("api/transfer_authorizer")
24
+ end
25
+ end
26
+ end
27
+ end
@@ -2,61 +2,9 @@
2
2
 
3
3
  module Manza
4
4
  module Resources
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.
15
- class TransferDrafts < Base
16
- # POST /api/transfer_drafts
17
- #
18
- # Required: account_id, amount, and exactly one of beneficiary_id
19
- # (external transfer) or destination_account_id (own-account move).
20
- # Optional: external_account_id, currency_code, payment_reference,
21
- # internal_notes, client_reference (unique per entity, at most 128
22
- # characters; a duplicate raises {Manza::ConflictError} whose
23
- # `payment_id` names the existing draft).
24
- def create(**attributes)
25
- http_post("api/transfer_drafts", body: attributes)
26
- end
27
-
28
- # GET /api/transfer_drafts/:id
29
- def get(id)
30
- http_get(encode_path("api/transfer_drafts", id))
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
- # {Manza::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 Manza::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
60
- end
5
+ # @deprecated Use {Payments} (`manza.payments`). Kept for all of 1.x;
6
+ # it is the same class and calls `/api/payments`.
7
+ TransferDrafts = Payments
8
+ deprecate_constant :TransferDrafts
61
9
  end
62
10
  end
@@ -11,7 +11,7 @@ module Manza
11
11
  # from your *own* record of the transfer (not the webhook's
12
12
  # `signature_input`, which is there only to compare against), sign
13
13
  # it with the authorizer endpoint's signing secret, and pass the
14
- # result to {Resources::TransferDrafts#authorize}:
14
+ # result to {Resources::Payments#authorize}:
15
15
  #
16
16
  # input = Manza::TransferAuthorization.signature_input(
17
17
  # payment_id: draft["id"], nonce: nonce, amount: draft["amount"],
@@ -20,7 +20,7 @@ module Manza
20
20
  # client_reference: draft["client_reference"]
21
21
  # )
22
22
  # signature = Manza::TransferAuthorization.sign(secret: signing_secret, signature_input: input)
23
- # manza.transfer_drafts.authorize(draft["id"], authorization_id: authorization_id, signature: signature)
23
+ # manza.payments.authorize(draft["id"], authorization_id: authorization_id, signature: signature)
24
24
  module TransferAuthorization
25
25
  SIGNATURE_VERSION = "manza.transfer-authorization.v1"
26
26
 
data/lib/manza/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Manza
4
- VERSION = "1.0.0"
4
+ VERSION = "1.3.0"
5
5
  end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+
6
+ module Manza
7
+ # Verifies incoming webhook deliveries. Pure functions — no HTTP.
8
+ #
9
+ # Every delivery carries `X-Manza-Signature` (hex HMAC-SHA256 of
10
+ # "<timestamp>.<raw body>" under the endpoint's signing secret) and
11
+ # `X-Manza-Timestamp` (Unix seconds, fresh on every attempt). The
12
+ # legacy `X-Zazu-*` names carry the same values.
13
+ #
14
+ # Pass the **raw request body**, never re-serialized JSON:
15
+ #
16
+ # event = Manza::Webhook.construct_event(
17
+ # payload: request.body.read, headers: request.env, secret: signing_secret
18
+ # )
19
+ # event["event"] # => "payment.authorization_requested"
20
+ module Webhook
21
+ DEFAULT_TOLERANCE = 300 # seconds
22
+
23
+ module_function
24
+
25
+ # Hex HMAC-SHA256 of "<timestamp>.<payload>". For tests and local tools.
26
+ def sign(payload:, timestamp:, secret:)
27
+ OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{payload}")
28
+ end
29
+
30
+ # true when the signature matches (constant time) and the timestamp is
31
+ # within `tolerance` seconds of `now`. `tolerance: nil` skips the
32
+ # timestamp check (for replaying a captured delivery). Never raises on
33
+ # bad input; returns false.
34
+ def verify?(payload:, signature:, timestamp:, secret:, tolerance: DEFAULT_TOLERANCE, now: Time.now)
35
+ signature_matches?(payload, signature, timestamp, secret) && timestamp_fresh?(timestamp, tolerance, now)
36
+ end
37
+
38
+ # Reads X-Manza-Signature / X-Manza-Timestamp from any Hash-like
39
+ # headers (case-insensitive, X-Zazu-* fallback, Rack HTTP_X_MANZA_*
40
+ # form too), verifies them, and returns the parsed JSON Hash. Raises
41
+ # Manza::ArgumentError when a header is missing, the signature does
42
+ # not match, the timestamp is outside tolerance, or the body is not
43
+ # a JSON object.
44
+ def construct_event(payload:, headers:, secret:, tolerance: DEFAULT_TOLERANCE, now: Time.now)
45
+ normalized = normalize_headers(headers)
46
+ signature = header(normalized, "signature")
47
+ timestamp = header(normalized, "timestamp")
48
+
49
+ raise Manza::ArgumentError, "invalid webhook signature" unless signature_matches?(payload, signature, timestamp, secret)
50
+ raise Manza::ArgumentError, "webhook timestamp outside tolerance" unless timestamp_fresh?(timestamp, tolerance, now)
51
+
52
+ event =
53
+ begin
54
+ JSON.parse(payload)
55
+ rescue JSON::ParserError
56
+ raise Manza::ArgumentError, "webhook payload is not JSON"
57
+ end
58
+ raise Manza::ArgumentError, "webhook payload is not a JSON object" unless event.is_a?(Hash)
59
+
60
+ event
61
+ end
62
+
63
+ def signature_matches?(payload, signature, timestamp, secret)
64
+ return false unless payload.is_a?(String) && secret.is_a?(String) && !secret.empty?
65
+ return false unless [signature, timestamp].all? { |v| v.is_a?(String) || v.is_a?(Integer) }
66
+ return false unless parse_timestamp(timestamp)
67
+
68
+ expected = sign(payload: payload, timestamp: timestamp, secret: secret)
69
+ return false unless expected.bytesize == signature.to_s.bytesize
70
+
71
+ OpenSSL.fixed_length_secure_compare(expected, signature.to_s)
72
+ end
73
+
74
+ def timestamp_fresh?(timestamp, tolerance, now)
75
+ return true if tolerance.nil?
76
+
77
+ seconds = parse_timestamp(timestamp)
78
+ !seconds.nil? && (now.to_i - seconds).abs <= tolerance
79
+ end
80
+
81
+ def parse_timestamp(timestamp)
82
+ Integer(timestamp.to_s, 10)
83
+ rescue ::ArgumentError, TypeError
84
+ nil
85
+ end
86
+
87
+ def normalize_headers(headers)
88
+ headers.to_h.transform_keys { |name| name.to_s.sub(/\AHTTP_/i, "").tr("_", "-").downcase }
89
+ end
90
+
91
+ def header(normalized, suffix)
92
+ value = normalized["x-manza-#{suffix}"] || normalized["x-zazu-#{suffix}"]
93
+ return value.to_s unless value.nil? || value.to_s.empty?
94
+
95
+ raise Manza::ArgumentError, "missing X-Manza-#{suffix.capitalize} header"
96
+ end
97
+
98
+ private_class_method :signature_matches?, :timestamp_fresh?, :parse_timestamp, :normalize_headers, :header
99
+ end
100
+ end
data/lib/manza.rb CHANGED
@@ -21,6 +21,7 @@ require_relative "manza/errors"
21
21
  require_relative "manza/response"
22
22
  require_relative "manza/page"
23
23
  require_relative "manza/transfer_authorization"
24
+ require_relative "manza/webhook"
24
25
  require_relative "manza/resources/base"
25
26
  require_relative "manza/resources/accounts"
26
27
  require_relative "manza/resources/beneficiaries"
@@ -30,6 +31,8 @@ require_relative "manza/resources/entity"
30
31
  require_relative "manza/resources/invoices"
31
32
  require_relative "manza/resources/payee_trust_requests"
32
33
  require_relative "manza/resources/payment_links"
34
+ require_relative "manza/resources/payments"
35
+ require_relative "manza/resources/transfer_authorizer"
33
36
  require_relative "manza/resources/transfer_drafts"
34
37
  require_relative "manza/resources/webhook_endpoints"
35
38
  require_relative "manza/client"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: manza
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Manza
@@ -76,11 +76,14 @@ files:
76
76
  - lib/manza/resources/invoices.rb
77
77
  - lib/manza/resources/payee_trust_requests.rb
78
78
  - lib/manza/resources/payment_links.rb
79
+ - lib/manza/resources/payments.rb
80
+ - lib/manza/resources/transfer_authorizer.rb
79
81
  - lib/manza/resources/transfer_drafts.rb
80
82
  - lib/manza/resources/webhook_endpoints.rb
81
83
  - lib/manza/response.rb
82
84
  - lib/manza/transfer_authorization.rb
83
85
  - lib/manza/version.rb
86
+ - lib/manza/webhook.rb
84
87
  homepage: https://github.com/getmanza/manza-ruby
85
88
  licenses:
86
89
  - MIT