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 +4 -4
- data/CHANGELOG.md +103 -0
- data/README.md +67 -9
- data/lib/manza/client.rb +13 -1
- data/lib/manza/resources/beneficiaries.rb +4 -1
- data/lib/manza/resources/payments.rb +94 -0
- data/lib/manza/resources/transfer_authorizer.rb +27 -0
- data/lib/manza/resources/transfer_drafts.rb +4 -56
- data/lib/manza/transfer_authorization.rb +2 -2
- data/lib/manza/version.rb +1 -1
- data/lib/manza/webhook.rb +100 -0
- data/lib/manza.rb +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 460a7caba9195a45a73b84bbadbd1fdc3476ad4fdf8244e0d554fd451b493b28
|
|
4
|
+
data.tar.gz: ecabf7b8a26eeb01ff6405821305ad54e9192674f77982df77f3b53437aefeaa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
98
|
-
manza.
|
|
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`.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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/
|
|
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
|
-
|
|
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.
|
|
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
|
-
#
|
|
6
|
-
#
|
|
7
|
-
|
|
8
|
-
|
|
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::
|
|
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.
|
|
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
|
@@ -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.
|
|
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
|