manza 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manza
4
+ # Base class for every Manza SDK error.
5
+ #
6
+ # Carries the HTTP status, the API request id (header `X-Request-Id`),
7
+ # the parsed error type from the API (`error.type`), and the raw
8
+ # response body so callers can introspect anything the SDK didn't
9
+ # explicitly model.
10
+ class Error < StandardError
11
+ attr_reader :status, :request_id, :type, :param, :body
12
+
13
+ def initialize(message = nil, status: nil, request_id: nil, type: nil, param: nil, body: nil)
14
+ super(message)
15
+ @status = status
16
+ @request_id = request_id
17
+ @type = type
18
+ @param = param
19
+ @body = body
20
+ end
21
+
22
+ def to_h
23
+ {
24
+ error: self.class.name.split("::").last,
25
+ message:,
26
+ status:,
27
+ request_id:,
28
+ type:,
29
+ param:
30
+ }.compact
31
+ end
32
+ end
33
+
34
+ # 401 — bearer token missing, malformed, or revoked.
35
+ class AuthenticationError < Error; end
36
+
37
+ # 403 — token valid but lacks the required scope, OR the entity is
38
+ # not yet active, OR the API feature flag is off for this entity.
39
+ class ForbiddenError < Error; end
40
+
41
+ # 404 — the requested resource does not exist (or this entity cannot
42
+ # see it).
43
+ class NotFoundError < Error; end
44
+
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.
49
+ class ValidationError < Error; end
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
+
68
+ # 429 — rate limited. Retry after the `Retry-After` header (seconds).
69
+ class RateLimitError < Error
70
+ attr_reader :retry_after
71
+
72
+ def initialize(message = nil, retry_after: nil, **)
73
+ super(message, **)
74
+ @retry_after = retry_after
75
+ end
76
+ end
77
+
78
+ # 5xx — server error. Worth retrying once with backoff.
79
+ class ServerError < Error; end
80
+
81
+ # Network timeout, connection refused, DNS failure — anything that
82
+ # prevents the SDK from hearing back from the API.
83
+ class ConnectionError < Error; end
84
+
85
+ # The SDK was misconfigured (no API key, invalid base URL, etc.).
86
+ # Raised before any HTTP request is attempted.
87
+ class ConfigurationError < Error; end
88
+
89
+ # Caller passed a value the SDK refuses to send (e.g. `limit > 100`).
90
+ # Distinct from `ValidationError`, which represents server-side
91
+ # validation rejection.
92
+ class ArgumentError < Error; end
93
+ end
data/lib/manza/page.rb ADDED
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manza
4
+ # A single page of a list endpoint's response.
5
+ #
6
+ # The Manza API uses cursor pagination. Every list endpoint returns
7
+ # `{data: [...], has_more: bool, next_cursor: string|null}`. This
8
+ # class wraps that shape and exposes a cursor-walking helper.
9
+ #
10
+ # Pages are intentionally not auto-paginating. The SDK refuses to
11
+ # let callers iterate every record across many pages with a single
12
+ # call — that's the failure mode that caused unbounded fetches in
13
+ # the CLI's `--all` flag. Callers walk pages explicitly:
14
+ #
15
+ # page = client.invoices.list(limit: 100)
16
+ # while page
17
+ # page.data.each { |inv| ... }
18
+ # page = page.next
19
+ # end
20
+ #
21
+ # Or with a max-items cap that the caller can reason about:
22
+ #
23
+ # client.invoices.each_page(max_items: 500) { |inv| ... }
24
+ class Page
25
+ # Hard ceiling on per-page size. Server enforces this too; we
26
+ # refuse to send a larger value rather than silently get clamped.
27
+ MAX_PER_PAGE = 100
28
+
29
+ attr_reader :response, :data, :has_more, :next_cursor
30
+
31
+ def initialize(response, fetcher:)
32
+ @response = response
33
+ @fetcher = fetcher
34
+
35
+ body = response.body
36
+ unless body.is_a?(Hash) && body["data"].is_a?(Array)
37
+ raise Manza::Error.new("List response missing 'data' array",
38
+ body:)
39
+ end
40
+
41
+ @data = body["data"]
42
+ @has_more = body.fetch("has_more", false)
43
+ @next_cursor = body["next_cursor"]
44
+ end
45
+
46
+ def request_id
47
+ response.request_id
48
+ end
49
+
50
+ # Fetches the next page. Returns nil when there are no more pages.
51
+ def next
52
+ return nil unless has_more && next_cursor
53
+
54
+ @fetcher.call(next_cursor)
55
+ end
56
+
57
+ def each(&)
58
+ data.each(&)
59
+ end
60
+
61
+ include Enumerable
62
+
63
+ def inspect
64
+ "#<#{self.class.name} count=#{data.size} has_more=#{has_more} next_cursor=#{next_cursor.inspect}>"
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manza
4
+ module Resources
5
+ # Accounts and their transactions.
6
+ class Accounts < Base
7
+ # GET /api/accounts
8
+ #
9
+ # @param status [String, nil] filter by account status
10
+ # @param currency_code [String, nil] e.g. "MAD" or "ZAR"
11
+ # @param limit [Integer] page size (max 100)
12
+ # @param cursor [String, nil] pagination cursor
13
+ # @return [Manza::Page]
14
+ def list(status: nil, currency_code: nil, limit: MAX_PER_PAGE, cursor: nil)
15
+ list_page(
16
+ "api/accounts",
17
+ status: status,
18
+ currency_code: currency_code,
19
+ limit: limit,
20
+ cursor: cursor
21
+ )
22
+ end
23
+
24
+ # GET /api/accounts/:id
25
+ def get(id)
26
+ http_get(encode_path("api/accounts", id))
27
+ end
28
+
29
+ # GET /api/accounts/:account_id/transactions
30
+ #
31
+ # @param operation [String, nil] filter by movement operation
32
+ # @param posted_after [String, Time, nil] ISO-8601 timestamp lower bound
33
+ # @param posted_before [String, Time, nil] ISO-8601 timestamp upper bound
34
+ def list_transactions(account_id, operation: nil, posted_after: nil, posted_before: nil, limit: MAX_PER_PAGE,
35
+ cursor: nil)
36
+ list_page(
37
+ encode_path("api/accounts", account_id, "transactions"),
38
+ operation: operation,
39
+ posted_after: serialize_time(posted_after),
40
+ posted_before: serialize_time(posted_before),
41
+ limit: limit,
42
+ cursor: cursor
43
+ )
44
+ end
45
+
46
+ # GET /api/accounts/:account_id/transactions/:id
47
+ def get_transaction(account_id, transaction_id)
48
+ http_get(encode_path("api/accounts", account_id, "transactions", transaction_id))
49
+ end
50
+
51
+ private
52
+
53
+ def serialize_time(value)
54
+ return nil if value.nil?
55
+ return value if value.is_a?(String)
56
+
57
+ value.iso8601
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manza
4
+ module Resources
5
+ # Shared scaffolding for every resource module. Carries a back-
6
+ # reference to the client and exposes thin HTTP helpers that
7
+ # delegate to {Manza::Client#request}.
8
+ #
9
+ # Note on naming: the helpers are `http_get`, `http_post`, etc.
10
+ # rather than `get`/`post` so they do not shadow the public
11
+ # methods on resource subclasses. Public resources commonly
12
+ # define a `get(id)` method, and a same-named private helper on
13
+ # the base class would let `Base#list_page` accidentally dispatch
14
+ # to the subclass version when a list endpoint is hit.
15
+ #
16
+ # Pagination:
17
+ #
18
+ # Every resource that has a list endpoint exposes `#list` which
19
+ # returns a {Manza::Page}. Callers can walk pages explicitly via
20
+ # `page.next` or use `each_page_record` for capped iteration.
21
+ class Base
22
+ MAX_PER_PAGE = Page::MAX_PER_PAGE
23
+
24
+ attr_reader :client
25
+
26
+ def initialize(client)
27
+ @client = client
28
+ end
29
+
30
+ private
31
+
32
+ def http_get(path, params: nil)
33
+ client.request(:get, path, params: params)
34
+ end
35
+
36
+ def http_post(path, body: nil)
37
+ client.request(:post, path, body: body)
38
+ end
39
+
40
+ def http_patch(path, body: nil)
41
+ client.request(:patch, path, body: body)
42
+ end
43
+
44
+ def http_delete(path)
45
+ client.request(:delete, path)
46
+ end
47
+
48
+ # Builds a paginated list. `path` is the collection endpoint;
49
+ # `params` is everything else (filters, etc.). `limit` is enforced
50
+ # at MAX_PER_PAGE; the caller can pass `cursor:` to fetch a
51
+ # specific page.
52
+ def list_page(path, limit: MAX_PER_PAGE, cursor: nil, **params)
53
+ validated_limit = validate_limit!(limit)
54
+
55
+ fetcher = lambda { |next_cursor|
56
+ query = params.merge(limit: validated_limit, cursor: next_cursor).compact
57
+ response = http_get(path, params: query)
58
+ Page.new(response, fetcher: fetcher)
59
+ }
60
+
61
+ initial_query = params.merge(limit: validated_limit, cursor: cursor).compact
62
+ response = http_get(path, params: initial_query)
63
+ Page.new(response, fetcher: fetcher)
64
+ end
65
+
66
+ # Iterates list-endpoint records up to `max_items`, fetching
67
+ # additional pages on demand. Caps ensure callers never
68
+ # accidentally pull a full table.
69
+ #
70
+ # Pass either a block or get an Enumerator back.
71
+ def each_page_record(path, max_items:, **params, &block)
72
+ return enum_for(:each_page_record, path, max_items: max_items, **params) unless block
73
+
74
+ unless max_items.is_a?(Integer) && max_items.positive?
75
+ raise ArgumentError,
76
+ "max_items must be a positive integer"
77
+ end
78
+
79
+ seen = 0
80
+ page = list_page(path, **params)
81
+
82
+ loop do
83
+ page.data.each do |record|
84
+ return seen if seen >= max_items
85
+
86
+ yield record
87
+ seen += 1
88
+ end
89
+
90
+ break unless page.has_more && seen < max_items
91
+
92
+ page = page.next
93
+ break if page.nil?
94
+ end
95
+
96
+ seen
97
+ end
98
+
99
+ def validate_limit!(limit)
100
+ return MAX_PER_PAGE if limit.nil?
101
+
102
+ raise Manza::ArgumentError, "limit must be a positive integer (got #{limit.inspect})" unless limit.is_a?(Integer) && limit.positive?
103
+
104
+ raise Manza::ArgumentError, "limit cannot exceed #{MAX_PER_PAGE} (got #{limit})" if limit > MAX_PER_PAGE
105
+
106
+ limit
107
+ end
108
+
109
+ # Builds a request path by joining a literal base path with one
110
+ # or more dynamic segments. The base is appended verbatim; each
111
+ # dynamic segment is percent-encoded so an ID containing `/` or
112
+ # other special characters cannot escape the intended path.
113
+ #
114
+ # encode_path('api/accounts', 'acc_xyz')
115
+ # # => "api/accounts/acc_xyz"
116
+ #
117
+ # encode_path('api/accounts', 'acc 1', 'transactions', 'tx 1')
118
+ # # => "api/accounts/acc%201/transactions/tx%201"
119
+ def encode_path(base, *segments)
120
+ encoded_segments = segments.map do |s|
121
+ str = s.to_s
122
+ # An empty segment would silently turn `/things/:id` into
123
+ # `/things/`, which on most APIs redispatches to the list
124
+ # endpoint and returns a Page-shaped body. Surface it loudly.
125
+ raise Manza::ArgumentError, "path segment cannot be blank" if str.empty?
126
+
127
+ # CGI.escape replaces ' ' with '+', which is wrong for path
128
+ # segments. Use a manual escape that targets only characters
129
+ # that would change path semantics.
130
+ str.gsub(/[^A-Za-z0-9._~-]/) { |c| format("%%%02X", c.ord) }
131
+ end
132
+ ([base] + encoded_segments).join("/")
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manza
4
+ module Resources
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.
8
+ class Beneficiaries < Base
9
+ # GET /api/beneficiaries
10
+ def list(limit: MAX_PER_PAGE, cursor: nil)
11
+ list_page("api/beneficiaries", limit: limit, cursor: cursor)
12
+ end
13
+
14
+ # GET /api/beneficiaries/:id
15
+ def get(id)
16
+ http_get(encode_path("api/beneficiaries", id))
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
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manza
4
+ module Resources
5
+ # One-off hosted checkout sessions. Pre-API there's no list,
6
+ # update, or delete — sessions are created and inspected by id.
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`.
10
+ class CheckoutSessions < Base
11
+ # GET /api/checkout_sessions/:id
12
+ def get(id)
13
+ http_get(encode_path("api/checkout_sessions", id))
14
+ end
15
+
16
+ # POST /api/checkout_sessions
17
+ #
18
+ # @param attributes [Hash] checkout-session attributes — see API docs.
19
+ # Required: account_id, amount, success_url.
20
+ # Optional: metadata, customer_email, customer_name, cancel_url,
21
+ # description, expires_at, collect_billing_address, billing_address.
22
+ def create(**attributes)
23
+ http_post("api/checkout_sessions", body: attributes)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manza
4
+ module Resources
5
+ # Customers — individuals or businesses the entity invoices.
6
+ class Customers < Base
7
+ # GET /api/customers
8
+ #
9
+ # @param q [String, nil] search query (matches company name, person name, email)
10
+ def list(q: nil, limit: MAX_PER_PAGE, cursor: nil)
11
+ list_page("api/customers", q: q, limit: limit, cursor: cursor)
12
+ end
13
+
14
+ # GET /api/customers/:id
15
+ def get(id)
16
+ http_get(encode_path("api/customers", id))
17
+ end
18
+
19
+ # POST /api/customers
20
+ #
21
+ # @param attributes [Hash] customer attributes — see API docs.
22
+ # Common keys: customer_type ("individual"|"business"),
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.
27
+ def create(**attributes)
28
+ http_post("api/customers", body: attributes)
29
+ end
30
+
31
+ # PATCH /api/customers/:id
32
+ def update(id, **attributes)
33
+ http_patch(encode_path("api/customers", id), body: attributes)
34
+ end
35
+
36
+ # DELETE /api/customers/:id
37
+ def delete(id)
38
+ http_delete(encode_path("api/customers", id))
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manza
4
+ module Resources
5
+ # The current entity (the tenant the API key belongs to).
6
+ #
7
+ # client.entity.get # => Manza::Response
8
+ class Entity < Base
9
+ def get
10
+ http_get("api/entity")
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manza
4
+ module Resources
5
+ # Invoices and their lifecycle actions.
6
+ class Invoices < Base
7
+ # GET /api/invoices
8
+ def list(status: nil, customer_id: nil, limit: MAX_PER_PAGE, cursor: nil)
9
+ list_page(
10
+ "api/invoices",
11
+ status: status,
12
+ customer_id: customer_id,
13
+ limit: limit,
14
+ cursor: cursor
15
+ )
16
+ end
17
+
18
+ # GET /api/invoices/:id
19
+ def get(id)
20
+ http_get(encode_path("api/invoices", id))
21
+ end
22
+
23
+ # POST /api/invoices
24
+ def create(**attributes)
25
+ http_post("api/invoices", body: attributes)
26
+ end
27
+
28
+ # PATCH /api/invoices/:id
29
+ def update(id, **attributes)
30
+ http_patch(encode_path("api/invoices", id), body: attributes)
31
+ end
32
+
33
+ # POST /api/invoices/:id/send
34
+ def send_invoice(id)
35
+ http_post(encode_path("api/invoices", id, "send"))
36
+ end
37
+
38
+ # POST /api/invoices/:id/mark_as_paid
39
+ def mark_as_paid(id)
40
+ http_post(encode_path("api/invoices", id, "mark_as_paid"))
41
+ end
42
+
43
+ # POST /api/invoices/:id/cancel
44
+ def cancel(id)
45
+ http_post(encode_path("api/invoices", id, "cancel"))
46
+ end
47
+
48
+ # POST /api/invoices/:id/credit_note
49
+ def credit_note(id)
50
+ http_post(encode_path("api/invoices", id, "credit_note"))
51
+ end
52
+
53
+ # DELETE /api/invoices/:id
54
+ def delete(id)
55
+ http_delete(encode_path("api/invoices", id))
56
+ end
57
+
58
+ # POST /api/invoices/:invoice_id/payment_link
59
+ #
60
+ # @param account_id [String] the funding account for the link
61
+ def create_payment_link(invoice_id, account_id:)
62
+ http_post(
63
+ encode_path("api/invoices", invoice_id, "payment_link"),
64
+ body: { account_id: account_id }
65
+ )
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manza
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 Manza 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
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manza
4
+ module Resources
5
+ # Standalone payment links (not attached to an invoice).
6
+ class PaymentLinks < Base
7
+ # GET /api/payment_links
8
+ def list(status: nil, link_type: nil, limit: MAX_PER_PAGE, cursor: nil)
9
+ list_page(
10
+ "api/payment_links",
11
+ status: status,
12
+ link_type: link_type,
13
+ limit: limit,
14
+ cursor: cursor
15
+ )
16
+ end
17
+
18
+ # GET /api/payment_links/:id
19
+ def get(id)
20
+ http_get(encode_path("api/payment_links", id))
21
+ end
22
+
23
+ # POST /api/payment_links
24
+ #
25
+ # Optional billing keys: collect_billing_address, billing_address.
26
+ # Responses carry `settled_at`; status includes `clearing`.
27
+ def create(**attributes)
28
+ http_post("api/payment_links", body: attributes)
29
+ end
30
+
31
+ # POST /api/payment_links/:id/cancel
32
+ def cancel(id)
33
+ http_post(encode_path("api/payment_links", id, "cancel"))
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manza
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
61
+ end
62
+ end