manza 1.0.0__py3-none-any.whl

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.
manza/__init__.py ADDED
@@ -0,0 +1,63 @@
1
+ """Manza Python SDK. Mirrors lib/manza.rb."""
2
+
3
+ from . import transfer_authorization
4
+ from ._version import __version__
5
+ from .client import Manza
6
+ from .errors import (
7
+ ManzaArgumentError,
8
+ ManzaAuthenticationError,
9
+ ManzaConfigurationError,
10
+ ManzaConflictError,
11
+ ManzaConnectionError,
12
+ ManzaError,
13
+ ManzaForbiddenError,
14
+ ManzaNotFoundError,
15
+ ManzaRateLimitError,
16
+ ManzaServerError,
17
+ ManzaValidationError,
18
+ )
19
+ from .page import MAX_PER_PAGE, Page
20
+ from .resources.accounts import Accounts
21
+ from .resources.beneficiaries import Beneficiaries
22
+ from .resources.checkout_sessions import CheckoutSessions
23
+ from .resources.customers import Customers
24
+ from .resources.entity import Entity
25
+ from .resources.invoices import Invoices
26
+ from .resources.payee_trust_requests import PayeeTrustRequests
27
+ from .resources.payment_links import PaymentLinks
28
+ from .resources.transfer_drafts import TransferDrafts
29
+ from .resources.webhook_endpoints import WebhookEndpoints
30
+ from .response import ManzaResponse
31
+
32
+ VERSION = __version__
33
+
34
+ __all__ = [
35
+ "MAX_PER_PAGE",
36
+ "VERSION",
37
+ "Accounts",
38
+ "Beneficiaries",
39
+ "CheckoutSessions",
40
+ "Customers",
41
+ "Entity",
42
+ "Invoices",
43
+ "Manza",
44
+ "ManzaArgumentError",
45
+ "ManzaAuthenticationError",
46
+ "ManzaConfigurationError",
47
+ "ManzaConflictError",
48
+ "ManzaConnectionError",
49
+ "ManzaError",
50
+ "ManzaForbiddenError",
51
+ "ManzaNotFoundError",
52
+ "ManzaRateLimitError",
53
+ "ManzaResponse",
54
+ "ManzaServerError",
55
+ "ManzaValidationError",
56
+ "Page",
57
+ "PayeeTrustRequests",
58
+ "PaymentLinks",
59
+ "TransferDrafts",
60
+ "WebhookEndpoints",
61
+ "__version__",
62
+ "transfer_authorization",
63
+ ]
manza/_env.py ADDED
@@ -0,0 +1,39 @@
1
+ """Environment lookup: `MANZA_<NAME>` first, then the legacy `ZAZU_<NAME>`.
2
+
3
+ The legacy name still works for all of 1.x and warns once per variable."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import threading
9
+ import warnings
10
+
11
+ _warned: set[str] = set()
12
+ _lock = threading.Lock()
13
+
14
+
15
+ def get(name: str) -> str | None:
16
+ """Return `MANZA_<name>`, else `ZAZU_<name>` (warning once), else None."""
17
+ value = os.getenv(f"MANZA_{name}")
18
+ if value is not None:
19
+ return value
20
+ legacy = f"ZAZU_{name}"
21
+ value = os.getenv(legacy)
22
+ if value:
23
+ with _lock:
24
+ first = legacy not in _warned
25
+ _warned.add(legacy)
26
+ if first:
27
+ warnings.warn(
28
+ f"{legacy} is deprecated; set MANZA_{name} instead. "
29
+ "The ZAZU_* fallback will be removed in 2.0.",
30
+ FutureWarning,
31
+ stacklevel=3,
32
+ )
33
+ return value
34
+ return None
35
+
36
+
37
+ def reset_warnings() -> None:
38
+ """Forget which legacy variables already warned (for tests)."""
39
+ _warned.clear()
manza/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
manza/client.py ADDED
@@ -0,0 +1,213 @@
1
+ """Mirrors lib/manza/client.rb. Sync HTTP entry point built on httpx."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+ from urllib.parse import urlencode
8
+
9
+ import httpx
10
+
11
+ from . import _env
12
+ from ._version import __version__
13
+ from .errors import (
14
+ ManzaAuthenticationError,
15
+ ManzaConfigurationError,
16
+ ManzaConflictError,
17
+ ManzaConnectionError,
18
+ ManzaError,
19
+ ManzaForbiddenError,
20
+ ManzaNotFoundError,
21
+ ManzaRateLimitError,
22
+ ManzaServerError,
23
+ ManzaValidationError,
24
+ )
25
+ from .page import MAX_PER_PAGE, Page
26
+ from .resources.accounts import Accounts
27
+ from .resources.beneficiaries import Beneficiaries
28
+ from .resources.checkout_sessions import CheckoutSessions
29
+ from .resources.customers import Customers
30
+ from .resources.entity import Entity
31
+ from .resources.invoices import Invoices
32
+ from .resources.payee_trust_requests import PayeeTrustRequests
33
+ from .resources.payment_links import PaymentLinks
34
+ from .resources.transfer_drafts import TransferDrafts
35
+ from .resources.webhook_endpoints import WebhookEndpoints
36
+ from .response import ManzaResponse
37
+
38
+ # Morocco production. South Africa is https://za.manza.finance.
39
+ DEFAULT_BASE_URL = "https://ma.manza.finance"
40
+ DEFAULT_TIMEOUT = 30.0
41
+ USER_AGENT = f"manza-python/{__version__}"
42
+
43
+
44
+ class Manza:
45
+ """Top-level Manza client. Resources hang off it as attributes."""
46
+
47
+ def __init__(
48
+ self,
49
+ api_key: str | None = None,
50
+ *,
51
+ base_url: str | None = None,
52
+ api_version: str | None = None,
53
+ timeout: float | None = None,
54
+ http_client: httpx.Client | None = None,
55
+ ) -> None:
56
+ api_key = api_key or _env.get("API_KEY")
57
+ if not api_key:
58
+ raise ManzaConfigurationError(
59
+ "Missing api_key. Pass api_key= or set MANZA_API_KEY."
60
+ )
61
+ self.api_key = api_key
62
+ self.base_url = (base_url or _env.get("BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
63
+ self.api_version = api_version or _env.get("API_VERSION")
64
+ self.timeout = (
65
+ timeout if timeout is not None else _env_float("TIMEOUT", DEFAULT_TIMEOUT)
66
+ )
67
+ self._owns_client = http_client is None
68
+ self._http = http_client or httpx.Client(timeout=self.timeout)
69
+
70
+ self.accounts = Accounts(self)
71
+ self.beneficiaries = Beneficiaries(self)
72
+ self.checkout_sessions = CheckoutSessions(self)
73
+ self.customers = Customers(self)
74
+ self.entity = Entity(self)
75
+ self.invoices = Invoices(self)
76
+ self.payee_trust_requests = PayeeTrustRequests(self)
77
+ self.payment_links = PaymentLinks(self)
78
+ self.transfer_drafts = TransferDrafts(self)
79
+ self.webhook_endpoints = WebhookEndpoints(self)
80
+
81
+ def __enter__(self) -> Manza:
82
+ return self
83
+
84
+ def __exit__(self, *exc: Any) -> None:
85
+ self.close()
86
+
87
+ def close(self) -> None:
88
+ if self._owns_client:
89
+ self._http.close()
90
+
91
+ def request(
92
+ self,
93
+ method: str,
94
+ path: str,
95
+ *,
96
+ params: dict[str, Any] | None = None,
97
+ body: Any = None,
98
+ headers: dict[str, str] | None = None,
99
+ ) -> ManzaResponse:
100
+ url = self._build_url(path, params)
101
+ request_headers = {
102
+ "Authorization": f"Bearer {self.api_key}",
103
+ "User-Agent": USER_AGENT,
104
+ "Accept": "application/json",
105
+ }
106
+ if self.api_version:
107
+ request_headers["Manza-Version"] = self.api_version
108
+ if headers:
109
+ request_headers.update(headers)
110
+
111
+ request_kwargs: dict[str, Any] = {"headers": request_headers}
112
+ if body is not None:
113
+ request_headers["Content-Type"] = "application/json"
114
+ # Compact, raw UTF-8: byte-identical to manza-ruby's JSON.generate.
115
+ request_kwargs["content"] = json.dumps(
116
+ body, separators=(",", ":"), ensure_ascii=False
117
+ ).encode()
118
+
119
+ try:
120
+ raw = self._http.request(method.upper(), url, **request_kwargs)
121
+ except httpx.TimeoutException as err:
122
+ raise ManzaConnectionError(f"Request timed out after {self.timeout}s") from err
123
+ except httpx.HTTPError as err:
124
+ raise ManzaConnectionError(f"Connection failed: {err}") from err
125
+
126
+ parsed = _parse_body(raw)
127
+ response = ManzaResponse(raw, parsed)
128
+ if response.success:
129
+ return response
130
+ raise _build_error(response)
131
+
132
+ def _build_url(self, path: str, params: dict[str, Any] | None) -> str:
133
+ trimmed = path.lstrip("/")
134
+ url = f"{self.base_url}/{trimmed}"
135
+ if params:
136
+ cleaned = {k: v for k, v in params.items() if v is not None}
137
+ if cleaned:
138
+ url = f"{url}?{urlencode(cleaned, doseq=True)}"
139
+ return url
140
+
141
+
142
+ def _env_float(name: str, default: float) -> float:
143
+ raw = _env.get(name)
144
+ if not raw:
145
+ return default
146
+ try:
147
+ return float(raw)
148
+ except ValueError:
149
+ return default
150
+
151
+
152
+ def _parse_body(raw: httpx.Response) -> Any:
153
+ content_type = raw.headers.get("content-type", "")
154
+ if "json" not in content_type:
155
+ return None
156
+ text = raw.text
157
+ if not text:
158
+ return None
159
+ try:
160
+ return json.loads(text)
161
+ except json.JSONDecodeError:
162
+ return text
163
+
164
+
165
+ def _error_payload(body: Any) -> dict[str, Any]:
166
+ if not isinstance(body, dict):
167
+ return {}
168
+ err = body.get("error")
169
+ return err if isinstance(err, dict) else {}
170
+
171
+
172
+ def _build_error(response: ManzaResponse) -> ManzaError:
173
+ payload = _error_payload(response.body)
174
+ message = payload.get("message")
175
+ opts: dict[str, Any] = {
176
+ "status": response.status,
177
+ "request_id": response.request_id,
178
+ "type": payload.get("type"),
179
+ "param": payload.get("param"),
180
+ "body": response.body,
181
+ "headers": response.headers,
182
+ }
183
+
184
+ status = response.status
185
+ if status == 401:
186
+ return ManzaAuthenticationError(message or "Authentication failed", **opts)
187
+ if status == 403:
188
+ return ManzaForbiddenError(message or "Forbidden", **opts)
189
+ if status == 404:
190
+ return ManzaNotFoundError(message or "Not found", **opts)
191
+ if status == 400:
192
+ return ManzaValidationError(message or "Bad request", **opts)
193
+ if status == 422:
194
+ return ManzaValidationError(message or "Validation failed", **opts)
195
+ if status == 409:
196
+ return ManzaConflictError(
197
+ message or "Conflict", payment_id=payload.get("payment_id"), **opts
198
+ )
199
+ if status == 429:
200
+ retry_after = response.headers.get("retry-after")
201
+ try:
202
+ retry_after_int = int(retry_after) if retry_after else None
203
+ except ValueError:
204
+ retry_after_int = None
205
+ return ManzaRateLimitError(
206
+ message or "Rate limited", retry_after=retry_after_int, **opts
207
+ )
208
+ if 500 <= status < 600:
209
+ return ManzaServerError(message or f"Server error ({status})", **opts)
210
+ return ManzaError(message or f"Unexpected status {status}", **opts)
211
+
212
+
213
+ __all__ = ["MAX_PER_PAGE", "Manza", "Page"]
manza/errors.py ADDED
@@ -0,0 +1,84 @@
1
+ """Mirrors lib/manza/errors.rb. Ten-class hierarchy shared across SDKs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class ManzaError(Exception):
9
+ """Base class for every error raised by the SDK."""
10
+
11
+ def __init__(
12
+ self,
13
+ message: str,
14
+ *,
15
+ status: int | None = None,
16
+ request_id: str | None = None,
17
+ type: str | None = None,
18
+ param: str | None = None,
19
+ body: Any = None,
20
+ headers: Any = None,
21
+ ) -> None:
22
+ super().__init__(message)
23
+ self.message = message
24
+ self.status = status
25
+ self.request_id = request_id
26
+ self.type = type
27
+ self.param = param
28
+ self.body = body
29
+ self.headers = headers
30
+
31
+
32
+ class ManzaArgumentError(ManzaError):
33
+ """Caller passed bad arguments before any HTTP request was made."""
34
+
35
+
36
+ class ManzaConfigurationError(ManzaError):
37
+ """Missing or invalid client configuration (e.g. no API key)."""
38
+
39
+
40
+ class ManzaConnectionError(ManzaError):
41
+ """The HTTP request failed before getting a response (timeout, DNS, refused)."""
42
+
43
+
44
+ class ManzaAuthenticationError(ManzaError):
45
+ """HTTP 401."""
46
+
47
+
48
+ class ManzaForbiddenError(ManzaError):
49
+ """HTTP 403."""
50
+
51
+
52
+ class ManzaNotFoundError(ManzaError):
53
+ """HTTP 404."""
54
+
55
+
56
+ class ManzaValidationError(ManzaError):
57
+ """HTTP 400 (malformed request, e.g. a bad `limit`/`cursor`) or 422 (rejected body).
58
+
59
+ `param` carries the offending field name when the API supplies it.
60
+ """
61
+
62
+
63
+ class ManzaConflictError(ManzaError):
64
+ """HTTP 409. The request conflicts with an existing resource.
65
+
66
+ For a duplicate `client_reference` on a transfer draft (`type` is
67
+ "duplicate_client_reference"), `payment_id` names the draft that already holds it.
68
+ """
69
+
70
+ def __init__(self, message: str, *, payment_id: str | None = None, **kwargs: Any) -> None:
71
+ super().__init__(message, **kwargs)
72
+ self.payment_id = payment_id
73
+
74
+
75
+ class ManzaRateLimitError(ManzaError):
76
+ """HTTP 429. `retry_after` is the value of the Retry-After header in seconds, if present."""
77
+
78
+ def __init__(self, message: str, *, retry_after: int | None = None, **kwargs: Any) -> None:
79
+ super().__init__(message, **kwargs)
80
+ self.retry_after = retry_after
81
+
82
+
83
+ class ManzaServerError(ManzaError):
84
+ """HTTP 5xx."""
manza/page.py ADDED
@@ -0,0 +1,54 @@
1
+ """Mirrors lib/manza/page.rb. Cursor-based pagination, hard cap of 100 items per page."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterator
6
+ from typing import Any, Generic, TypeVar
7
+
8
+ from .response import ManzaResponse
9
+
10
+ MAX_PER_PAGE = 100
11
+
12
+ T = TypeVar("T")
13
+
14
+
15
+ class Page(Generic[T]):
16
+ """A single page of results plus a fetcher to load the next page on demand."""
17
+
18
+ def __init__(
19
+ self,
20
+ response: ManzaResponse,
21
+ fetcher: Callable[[str | None], "Page[T]"],
22
+ ) -> None:
23
+ self.response = response
24
+ self._fetcher = fetcher
25
+ body = response.body if isinstance(response.body, dict) else {}
26
+ self.data: list[T] = list(body.get("data") or [])
27
+ self.has_more: bool = bool(body.get("has_more"))
28
+ self.next_cursor: str | None = body.get("next_cursor")
29
+
30
+ def next(self) -> Page[T] | None:
31
+ """Fetch the next page, or None if there are no more results."""
32
+ if not self.has_more or not self.next_cursor:
33
+ return None
34
+ return self._fetcher(self.next_cursor)
35
+
36
+ def auto_paging_iter(self) -> Iterator[T]:
37
+ """Yield every item across all pages by chasing cursors."""
38
+ page: Page[T] | None = self
39
+ while page is not None:
40
+ yield from page.data
41
+ page = page.next()
42
+
43
+ def __iter__(self) -> Iterator[T]:
44
+ return iter(self.data)
45
+
46
+ def __len__(self) -> int:
47
+ return len(self.data)
48
+
49
+ def __repr__(self) -> str:
50
+ return f"Page(items={len(self.data)}, has_more={self.has_more})"
51
+
52
+
53
+ PageBody = dict[str, Any]
54
+ PageFetcher = Callable[[str | None], Page[Any]]
manza/py.typed ADDED
File without changes
File without changes
@@ -0,0 +1,64 @@
1
+ """Mirrors lib/manza/resources/accounts.rb."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+ from ..page import MAX_PER_PAGE, Page
9
+ from ..response import ManzaResponse
10
+ from .base import ResourceBase
11
+
12
+
13
+ class Accounts(ResourceBase):
14
+ def list(
15
+ self,
16
+ *,
17
+ status: str | None = None,
18
+ currency_code: str | None = None,
19
+ limit: int = MAX_PER_PAGE,
20
+ cursor: str | None = None,
21
+ ) -> Page[Any]:
22
+ return self.list_page(
23
+ "api/accounts",
24
+ {"status": status, "currency_code": currency_code},
25
+ limit=limit,
26
+ cursor=cursor,
27
+ )
28
+
29
+ def get(self, id: str) -> ManzaResponse:
30
+ return self.http_get(self.encode_path("api/accounts", id))
31
+
32
+ def list_transactions(
33
+ self,
34
+ account_id: str,
35
+ *,
36
+ operation: str | None = None,
37
+ posted_after: str | datetime | None = None,
38
+ posted_before: str | datetime | None = None,
39
+ limit: int = MAX_PER_PAGE,
40
+ cursor: str | None = None,
41
+ ) -> Page[Any]:
42
+ return self.list_page(
43
+ self.encode_path("api/accounts", account_id, "transactions"),
44
+ {
45
+ "operation": operation,
46
+ "posted_after": _serialize_time(posted_after),
47
+ "posted_before": _serialize_time(posted_before),
48
+ },
49
+ limit=limit,
50
+ cursor=cursor,
51
+ )
52
+
53
+ def get_transaction(self, account_id: str, transaction_id: str) -> ManzaResponse:
54
+ return self.http_get(
55
+ self.encode_path("api/accounts", account_id, "transactions", transaction_id)
56
+ )
57
+
58
+
59
+ def _serialize_time(value: str | datetime | None) -> str | None:
60
+ if value is None:
61
+ return None
62
+ if isinstance(value, str):
63
+ return value
64
+ return value.isoformat()
@@ -0,0 +1,89 @@
1
+ """Mirrors lib/manza/resources/base.rb."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+ from urllib.parse import quote
7
+
8
+ from ..errors import ManzaArgumentError
9
+ from ..page import MAX_PER_PAGE, Page
10
+ from ..response import ManzaResponse
11
+
12
+ if TYPE_CHECKING:
13
+ from ..client import Manza
14
+
15
+
16
+ class ResourceBase:
17
+ def __init__(self, client: Manza) -> None:
18
+ self._client = client
19
+
20
+ @staticmethod
21
+ def encode_path(base: str, *segments: str) -> str:
22
+ """Build a request path by joining a literal base path with dynamic
23
+ segments. The base is appended verbatim; each dynamic segment is
24
+ percent-encoded so an ID containing `/` or other special characters
25
+ cannot escape the intended path.
26
+
27
+ encode_path("api/accounts", "acc_xyz")
28
+ # => "api/accounts/acc_xyz"
29
+ encode_path("api/accounts", "acc 1", "transactions", "tx 1")
30
+ # => "api/accounts/acc%201/transactions/tx%201"
31
+ """
32
+ encoded = []
33
+ for seg in segments:
34
+ text = str(seg)
35
+ # An empty segment would silently turn `/things/:id` into
36
+ # `/things/`, which on most APIs redispatches to the list
37
+ # endpoint. Surface it loudly.
38
+ if not text:
39
+ raise ManzaArgumentError("path segment cannot be blank")
40
+ encoded.append(quote(text, safe=""))
41
+ return "/".join([base, *encoded])
42
+
43
+ def http_get(
44
+ self,
45
+ path: str,
46
+ *,
47
+ params: dict[str, Any] | None = None,
48
+ ) -> ManzaResponse:
49
+ return self._client.request("GET", path, params=params)
50
+
51
+ def http_post(
52
+ self,
53
+ path: str,
54
+ *,
55
+ body: Any = None,
56
+ params: dict[str, Any] | None = None,
57
+ ) -> ManzaResponse:
58
+ return self._client.request("POST", path, body=body, params=params)
59
+
60
+ def http_patch(
61
+ self,
62
+ path: str,
63
+ *,
64
+ body: Any = None,
65
+ ) -> ManzaResponse:
66
+ return self._client.request("PATCH", path, body=body)
67
+
68
+ def http_delete(self, path: str) -> ManzaResponse:
69
+ return self._client.request("DELETE", path)
70
+
71
+ def list_page(
72
+ self,
73
+ path: str,
74
+ params: dict[str, Any] | None = None,
75
+ *,
76
+ limit: int | None = None,
77
+ cursor: str | None = None,
78
+ ) -> Page[Any]:
79
+ merged: dict[str, Any] = dict(params or {})
80
+ if limit is not None:
81
+ merged["limit"] = min(limit, MAX_PER_PAGE)
82
+ if cursor is not None:
83
+ merged["cursor"] = cursor
84
+
85
+ def fetch(next_cursor: str | None) -> Page[Any]:
86
+ return self.list_page(path, params, limit=limit, cursor=next_cursor)
87
+
88
+ response = self.http_get(path, params=merged)
89
+ return Page(response, fetch)
@@ -0,0 +1,61 @@
1
+ """Mirrors lib/manza/resources/beneficiaries.rb.
2
+
3
+ Saved transfer recipients. Each beneficiary embeds its bank accounts; the one
4
+ flagged ``default`` is used when a transfer names only the beneficiary_id.
5
+ There is no update or delete via the API.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from ..page import MAX_PER_PAGE, Page
13
+ from ..response import ManzaResponse
14
+ from .base import ResourceBase
15
+
16
+
17
+ class Beneficiaries(ResourceBase):
18
+ def list(self, *, limit: int = MAX_PER_PAGE, cursor: str | None = None) -> Page[Any]:
19
+ return self.list_page("api/beneficiaries", {}, limit=limit, cursor=cursor)
20
+
21
+ def get(self, id: str) -> ManzaResponse:
22
+ return self.http_get(self.encode_path("api/beneficiaries", id))
23
+
24
+ def create(self, **attributes: Any) -> ManzaResponse:
25
+ """POST /api/beneficiaries
26
+
27
+ Keys: beneficiary_type ("individual" | "business"; inferred from
28
+ person_name / company_name when omitted), person_name, company_name,
29
+ email, phone_number. Values must be strings. Shares a 10/minute limit
30
+ with ``create_external_account``.
31
+ """
32
+ return self.http_post("api/beneficiaries", body=attributes)
33
+
34
+ def list_external_accounts(
35
+ self, beneficiary_id: str, *, limit: int = MAX_PER_PAGE, cursor: str | None = None
36
+ ) -> Page[Any]:
37
+ """GET /api/beneficiaries/:beneficiary_id/external_accounts"""
38
+ return self.list_page(
39
+ self.encode_path("api/beneficiaries", beneficiary_id, "external_accounts"),
40
+ {},
41
+ limit=limit,
42
+ cursor=cursor,
43
+ )
44
+
45
+ def get_external_account(self, beneficiary_id: str, id: str) -> ManzaResponse:
46
+ """GET /api/beneficiaries/:beneficiary_id/external_accounts/:id"""
47
+ return self.http_get(
48
+ self.encode_path("api/beneficiaries", beneficiary_id, "external_accounts", id)
49
+ )
50
+
51
+ def create_external_account(self, beneficiary_id: str, **attributes: Any) -> ManzaResponse:
52
+ """POST /api/beneficiaries/:beneficiary_id/external_accounts
53
+
54
+ Required: account_number. Optional: name, country_code, currency_code,
55
+ account_type ("bank" only), bank_identifier (required in ZA, rejected in
56
+ MA, where it is derived from the RIB).
57
+ """
58
+ return self.http_post(
59
+ self.encode_path("api/beneficiaries", beneficiary_id, "external_accounts"),
60
+ body=attributes,
61
+ )