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 +63 -0
- manza/_env.py +39 -0
- manza/_version.py +1 -0
- manza/client.py +213 -0
- manza/errors.py +84 -0
- manza/page.py +54 -0
- manza/py.typed +0 -0
- manza/resources/__init__.py +0 -0
- manza/resources/accounts.py +64 -0
- manza/resources/base.py +89 -0
- manza/resources/beneficiaries.py +61 -0
- manza/resources/checkout_sessions.py +23 -0
- manza/resources/customers.py +38 -0
- manza/resources/entity.py +11 -0
- manza/resources/invoices.py +56 -0
- manza/resources/payee_trust_requests.py +25 -0
- manza/resources/payment_links.py +40 -0
- manza/resources/transfer_drafts.py +66 -0
- manza/resources/webhook_endpoints.py +53 -0
- manza/response.py +26 -0
- manza/transfer_authorization.py +84 -0
- manza-1.0.0.dist-info/METADATA +190 -0
- manza-1.0.0.dist-info/RECORD +25 -0
- manza-1.0.0.dist-info/WHEEL +4 -0
- manza-1.0.0.dist-info/licenses/LICENSE +21 -0
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()
|
manza/resources/base.py
ADDED
|
@@ -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
|
+
)
|