cinetpay-python 0.1.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.
- cinetpay/__init__.py +175 -0
- cinetpay/api/__init__.py +17 -0
- cinetpay/api/async_balance.py +70 -0
- cinetpay/api/async_payment.py +118 -0
- cinetpay/api/async_transfer.py +118 -0
- cinetpay/api/balance.py +70 -0
- cinetpay/api/payment.py +118 -0
- cinetpay/api/transfer.py +118 -0
- cinetpay/async_client.py +290 -0
- cinetpay/auth/__init__.py +11 -0
- cinetpay/auth/async_authenticator.py +119 -0
- cinetpay/auth/authenticator.py +140 -0
- cinetpay/auth/token_store.py +72 -0
- cinetpay/client.py +256 -0
- cinetpay/constants/__init__.py +15 -0
- cinetpay/constants/channels.py +12 -0
- cinetpay/constants/currencies.py +14 -0
- cinetpay/constants/payment_methods.py +43 -0
- cinetpay/constants/statuses.py +44 -0
- cinetpay/errors/__init__.py +14 -0
- cinetpay/errors/api_error.py +45 -0
- cinetpay/errors/auth_error.py +12 -0
- cinetpay/errors/base.py +18 -0
- cinetpay/errors/network_errors.py +29 -0
- cinetpay/http/__init__.py +9 -0
- cinetpay/http/async_http_client.py +173 -0
- cinetpay/http/http_client.py +173 -0
- cinetpay/logger.py +70 -0
- cinetpay/py.typed +0 -0
- cinetpay/types/__init__.py +59 -0
- cinetpay/types/balance.py +35 -0
- cinetpay/types/config.py +139 -0
- cinetpay/types/payment.py +210 -0
- cinetpay/types/transfer.py +163 -0
- cinetpay/types/webhook.py +59 -0
- cinetpay/validation.py +163 -0
- cinetpay/webhooks/__init__.py +8 -0
- cinetpay/webhooks/notification.py +79 -0
- cinetpay_python-0.1.0.dist-info/METADATA +376 -0
- cinetpay_python-0.1.0.dist-info/RECORD +41 -0
- cinetpay_python-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Asynchronous HTTP client wrapping :mod:`httpx`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from cinetpay.errors.api_error import ApiError
|
|
10
|
+
from cinetpay.errors.network_errors import NetworkError, TimeoutError
|
|
11
|
+
from cinetpay.logger import LoggerProtocol
|
|
12
|
+
|
|
13
|
+
# Fields whose values must be masked in log output
|
|
14
|
+
_SENSITIVE_KEYWORDS = frozenset({"password", "secret", "token", "api_key"})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AsyncHttpClient:
|
|
18
|
+
"""Internal asynchronous HTTP client.
|
|
19
|
+
|
|
20
|
+
Wraps :class:`httpx.AsyncClient` with JSON defaults, timeout, Bearer auth,
|
|
21
|
+
sensitive field sanitization in logs, and error mapping to SDK exceptions.
|
|
22
|
+
|
|
23
|
+
.. note::
|
|
24
|
+
Internal — used by :class:`~cinetpay.async_client.AsyncCinetPayClient`.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
base_url: str,
|
|
30
|
+
timeout: float,
|
|
31
|
+
logger: LoggerProtocol,
|
|
32
|
+
) -> None:
|
|
33
|
+
self._base_url = base_url
|
|
34
|
+
self._timeout = timeout
|
|
35
|
+
self._logger = logger
|
|
36
|
+
self._client = httpx.AsyncClient(
|
|
37
|
+
base_url=base_url,
|
|
38
|
+
timeout=httpx.Timeout(timeout),
|
|
39
|
+
headers={
|
|
40
|
+
"Accept": "application/json",
|
|
41
|
+
"Content-Type": "application/json",
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# ------------------------------------------------------------------
|
|
46
|
+
# Public API
|
|
47
|
+
# ------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
async def post(self, path: str, body: Any, token: str | None = None) -> dict[str, Any]:
|
|
50
|
+
"""Send a POST request and return the parsed JSON response.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
path: API path (e.g. ``/v1/payment``).
|
|
54
|
+
body: Request body (will be JSON-encoded).
|
|
55
|
+
token: Optional JWT Bearer token.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Parsed JSON response as a dict.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ApiError: If the API returns an error.
|
|
62
|
+
NetworkError: If the HTTP request fails.
|
|
63
|
+
TimeoutError: If the request exceeds the configured timeout.
|
|
64
|
+
"""
|
|
65
|
+
return await self._request("POST", path, body=body, token=token)
|
|
66
|
+
|
|
67
|
+
async def get(self, path: str, token: str | None = None) -> dict[str, Any]:
|
|
68
|
+
"""Send a GET request and return the parsed JSON response.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
path: API path (e.g. ``/v1/balances``).
|
|
72
|
+
token: Optional JWT Bearer token.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Parsed JSON response as a dict.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
ApiError: If the API returns an error.
|
|
79
|
+
NetworkError: If the HTTP request fails.
|
|
80
|
+
TimeoutError: If the request exceeds the configured timeout.
|
|
81
|
+
"""
|
|
82
|
+
return await self._request("GET", path, token=token)
|
|
83
|
+
|
|
84
|
+
async def close(self) -> None:
|
|
85
|
+
"""Close the underlying :class:`httpx.AsyncClient`."""
|
|
86
|
+
await self._client.aclose()
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
# Internal
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
async def _request(
|
|
93
|
+
self,
|
|
94
|
+
method: str,
|
|
95
|
+
path: str,
|
|
96
|
+
body: Any = None,
|
|
97
|
+
token: str | None = None,
|
|
98
|
+
) -> dict[str, Any]:
|
|
99
|
+
headers: dict[str, str] = {}
|
|
100
|
+
if token:
|
|
101
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
102
|
+
|
|
103
|
+
if body is not None:
|
|
104
|
+
self._logger.debug(f"{method} {path}", {"body": _sanitize_body(body)})
|
|
105
|
+
else:
|
|
106
|
+
self._logger.debug(f"{method} {path}")
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
response = await self._client.request(
|
|
110
|
+
method,
|
|
111
|
+
path,
|
|
112
|
+
json=body,
|
|
113
|
+
headers=headers,
|
|
114
|
+
)
|
|
115
|
+
data: dict[str, Any] = response.json()
|
|
116
|
+
|
|
117
|
+
self._logger.debug(
|
|
118
|
+
f"{method} {path} -> {response.status_code}",
|
|
119
|
+
{"code": data.get("code"), "status": data.get("status")},
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return self._handle_response(data, response.status_code, method, path)
|
|
123
|
+
|
|
124
|
+
except ApiError:
|
|
125
|
+
raise
|
|
126
|
+
except httpx.TimeoutException:
|
|
127
|
+
self._logger.error(f"{method} {path} -> timeout ({self._timeout}s)")
|
|
128
|
+
raise TimeoutError(self._timeout) from None
|
|
129
|
+
except httpx.HTTPError as exc:
|
|
130
|
+
self._logger.error(
|
|
131
|
+
f"{method} {path} -> network error",
|
|
132
|
+
{"message": str(exc)},
|
|
133
|
+
)
|
|
134
|
+
raise NetworkError(f"Request to {method} {path} failed", exc) from exc
|
|
135
|
+
|
|
136
|
+
def _handle_response(
|
|
137
|
+
self,
|
|
138
|
+
data: dict[str, Any],
|
|
139
|
+
http_status: int,
|
|
140
|
+
method: str,
|
|
141
|
+
path: str,
|
|
142
|
+
) -> dict[str, Any]:
|
|
143
|
+
code = data.get("code")
|
|
144
|
+
|
|
145
|
+
if http_status >= 400 or (
|
|
146
|
+
code is not None
|
|
147
|
+
and code not in (200, 100, 2001, 2002)
|
|
148
|
+
):
|
|
149
|
+
if data.get("description") or (code is not None and http_status >= 400):
|
|
150
|
+
error = ApiError.from_response(data)
|
|
151
|
+
self._logger.error(
|
|
152
|
+
f"{method} {path} -> API error",
|
|
153
|
+
{
|
|
154
|
+
"api_code": error.api_code,
|
|
155
|
+
"api_status": error.api_status,
|
|
156
|
+
"description": error.description,
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
raise error
|
|
160
|
+
|
|
161
|
+
return data
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _sanitize_body(body: Any) -> Any:
|
|
165
|
+
"""Mask sensitive fields (credentials, tokens) in log output."""
|
|
166
|
+
if not isinstance(body, dict):
|
|
167
|
+
return body
|
|
168
|
+
sanitized = dict(body)
|
|
169
|
+
for key in sanitized:
|
|
170
|
+
lower = key.lower()
|
|
171
|
+
if any(kw in lower for kw in _SENSITIVE_KEYWORDS):
|
|
172
|
+
sanitized[key] = "***"
|
|
173
|
+
return sanitized
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Synchronous HTTP client wrapping :mod:`httpx`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from cinetpay.errors.api_error import ApiError
|
|
10
|
+
from cinetpay.errors.network_errors import NetworkError, TimeoutError
|
|
11
|
+
from cinetpay.logger import LoggerProtocol
|
|
12
|
+
|
|
13
|
+
# Fields whose values must be masked in log output
|
|
14
|
+
_SENSITIVE_KEYWORDS = frozenset({"password", "secret", "token", "api_key"})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HttpClient:
|
|
18
|
+
"""Internal synchronous HTTP client.
|
|
19
|
+
|
|
20
|
+
Wraps :class:`httpx.Client` with JSON defaults, timeout, Bearer auth,
|
|
21
|
+
sensitive field sanitization in logs, and error mapping to SDK exceptions.
|
|
22
|
+
|
|
23
|
+
.. note::
|
|
24
|
+
Internal — used by :class:`~cinetpay.client.CinetPayClient`.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
base_url: str,
|
|
30
|
+
timeout: float,
|
|
31
|
+
logger: LoggerProtocol,
|
|
32
|
+
) -> None:
|
|
33
|
+
self._base_url = base_url
|
|
34
|
+
self._timeout = timeout
|
|
35
|
+
self._logger = logger
|
|
36
|
+
self._client = httpx.Client(
|
|
37
|
+
base_url=base_url,
|
|
38
|
+
timeout=httpx.Timeout(timeout),
|
|
39
|
+
headers={
|
|
40
|
+
"Accept": "application/json",
|
|
41
|
+
"Content-Type": "application/json",
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# ------------------------------------------------------------------
|
|
46
|
+
# Public API
|
|
47
|
+
# ------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def post(self, path: str, body: Any, token: str | None = None) -> dict[str, Any]:
|
|
50
|
+
"""Send a POST request and return the parsed JSON response.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
path: API path (e.g. ``/v1/payment``).
|
|
54
|
+
body: Request body (will be JSON-encoded).
|
|
55
|
+
token: Optional JWT Bearer token.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Parsed JSON response as a dict.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ApiError: If the API returns an error.
|
|
62
|
+
NetworkError: If the HTTP request fails.
|
|
63
|
+
TimeoutError: If the request exceeds the configured timeout.
|
|
64
|
+
"""
|
|
65
|
+
return self._request("POST", path, body=body, token=token)
|
|
66
|
+
|
|
67
|
+
def get(self, path: str, token: str | None = None) -> dict[str, Any]:
|
|
68
|
+
"""Send a GET request and return the parsed JSON response.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
path: API path (e.g. ``/v1/balances``).
|
|
72
|
+
token: Optional JWT Bearer token.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Parsed JSON response as a dict.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
ApiError: If the API returns an error.
|
|
79
|
+
NetworkError: If the HTTP request fails.
|
|
80
|
+
TimeoutError: If the request exceeds the configured timeout.
|
|
81
|
+
"""
|
|
82
|
+
return self._request("GET", path, token=token)
|
|
83
|
+
|
|
84
|
+
def close(self) -> None:
|
|
85
|
+
"""Close the underlying :class:`httpx.Client`."""
|
|
86
|
+
self._client.close()
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
# Internal
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def _request(
|
|
93
|
+
self,
|
|
94
|
+
method: str,
|
|
95
|
+
path: str,
|
|
96
|
+
body: Any = None,
|
|
97
|
+
token: str | None = None,
|
|
98
|
+
) -> dict[str, Any]:
|
|
99
|
+
headers: dict[str, str] = {}
|
|
100
|
+
if token:
|
|
101
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
102
|
+
|
|
103
|
+
if body is not None:
|
|
104
|
+
self._logger.debug(f"{method} {path}", {"body": _sanitize_body(body)})
|
|
105
|
+
else:
|
|
106
|
+
self._logger.debug(f"{method} {path}")
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
response = self._client.request(
|
|
110
|
+
method,
|
|
111
|
+
path,
|
|
112
|
+
json=body,
|
|
113
|
+
headers=headers,
|
|
114
|
+
)
|
|
115
|
+
data: dict[str, Any] = response.json()
|
|
116
|
+
|
|
117
|
+
self._logger.debug(
|
|
118
|
+
f"{method} {path} -> {response.status_code}",
|
|
119
|
+
{"code": data.get("code"), "status": data.get("status")},
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return self._handle_response(data, response.status_code, method, path)
|
|
123
|
+
|
|
124
|
+
except ApiError:
|
|
125
|
+
raise
|
|
126
|
+
except httpx.TimeoutException:
|
|
127
|
+
self._logger.error(f"{method} {path} -> timeout ({self._timeout}s)")
|
|
128
|
+
raise TimeoutError(self._timeout) from None
|
|
129
|
+
except httpx.HTTPError as exc:
|
|
130
|
+
self._logger.error(
|
|
131
|
+
f"{method} {path} -> network error",
|
|
132
|
+
{"message": str(exc)},
|
|
133
|
+
)
|
|
134
|
+
raise NetworkError(f"Request to {method} {path} failed", exc) from exc
|
|
135
|
+
|
|
136
|
+
def _handle_response(
|
|
137
|
+
self,
|
|
138
|
+
data: dict[str, Any],
|
|
139
|
+
http_status: int,
|
|
140
|
+
method: str,
|
|
141
|
+
path: str,
|
|
142
|
+
) -> dict[str, Any]:
|
|
143
|
+
code = data.get("code")
|
|
144
|
+
|
|
145
|
+
if http_status >= 400 or (
|
|
146
|
+
code is not None
|
|
147
|
+
and code not in (200, 100, 2001, 2002)
|
|
148
|
+
):
|
|
149
|
+
if data.get("description") or (code is not None and http_status >= 400):
|
|
150
|
+
error = ApiError.from_response(data)
|
|
151
|
+
self._logger.error(
|
|
152
|
+
f"{method} {path} -> API error",
|
|
153
|
+
{
|
|
154
|
+
"api_code": error.api_code,
|
|
155
|
+
"api_status": error.api_status,
|
|
156
|
+
"description": error.description,
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
raise error
|
|
160
|
+
|
|
161
|
+
return data
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _sanitize_body(body: Any) -> Any:
|
|
165
|
+
"""Mask sensitive fields (credentials, tokens) in log output."""
|
|
166
|
+
if not isinstance(body, dict):
|
|
167
|
+
return body
|
|
168
|
+
sanitized = dict(body)
|
|
169
|
+
for key in sanitized:
|
|
170
|
+
lower = key.lower()
|
|
171
|
+
if any(kw in lower for kw in _SENSITIVE_KEYWORDS):
|
|
172
|
+
sanitized[key] = "***"
|
|
173
|
+
return sanitized
|
cinetpay/logger.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Pluggable logging interface for the CinetPay SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@runtime_checkable
|
|
10
|
+
class LoggerProtocol(Protocol):
|
|
11
|
+
"""Interface for injectable loggers.
|
|
12
|
+
|
|
13
|
+
Implement this protocol to plug in your own logger (structlog, loguru, etc.)::
|
|
14
|
+
|
|
15
|
+
import structlog
|
|
16
|
+
|
|
17
|
+
log = structlog.get_logger()
|
|
18
|
+
client = CinetPayClient(
|
|
19
|
+
credentials={...},
|
|
20
|
+
logger=MyStructlogAdapter(log),
|
|
21
|
+
)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def debug(self, message: str, data: dict[str, Any] | None = None) -> None:
|
|
25
|
+
"""Log request/response details, cached tokens, etc."""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
def warn(self, message: str, data: dict[str, Any] | None = None) -> None:
|
|
29
|
+
"""Log token refresh retries, fallbacks."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
def error(self, message: str, data: dict[str, Any] | None = None) -> None:
|
|
33
|
+
"""Log API errors, network errors, timeouts."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class StandardLibLogger:
|
|
38
|
+
"""Logger backed by the Python standard library :mod:`logging`.
|
|
39
|
+
|
|
40
|
+
Writes messages with a ``[cinetpay]`` prefix. Used when ``debug=True``
|
|
41
|
+
is passed without a custom logger.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, name: str = "cinetpay") -> None:
|
|
45
|
+
self._logger = logging.getLogger(name)
|
|
46
|
+
|
|
47
|
+
def debug(self, message: str, data: dict[str, Any] | None = None) -> None:
|
|
48
|
+
"""Log a debug-level message with optional structured data."""
|
|
49
|
+
self._logger.debug("[cinetpay] %s %s", message, data or "")
|
|
50
|
+
|
|
51
|
+
def warn(self, message: str, data: dict[str, Any] | None = None) -> None:
|
|
52
|
+
"""Log a warning-level message with optional structured data."""
|
|
53
|
+
self._logger.warning("[cinetpay] %s %s", message, data or "")
|
|
54
|
+
|
|
55
|
+
def error(self, message: str, data: dict[str, Any] | None = None) -> None:
|
|
56
|
+
"""Log an error-level message with optional structured data."""
|
|
57
|
+
self._logger.error("[cinetpay] %s %s", message, data or "")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class NoopLogger:
|
|
61
|
+
"""Silent logger — produces no output. Used by default."""
|
|
62
|
+
|
|
63
|
+
def debug(self, message: str = "", data: dict[str, Any] | None = None) -> None:
|
|
64
|
+
"""Accept and discard a debug-level message (no-op)."""
|
|
65
|
+
|
|
66
|
+
def warn(self, message: str = "", data: dict[str, Any] | None = None) -> None:
|
|
67
|
+
"""Accept and discard a warning-level message (no-op)."""
|
|
68
|
+
|
|
69
|
+
def error(self, message: str = "", data: dict[str, Any] | None = None) -> None:
|
|
70
|
+
"""Accept and discard an error-level message (no-op)."""
|
cinetpay/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Public type re-exports for the CinetPay SDK."""
|
|
2
|
+
|
|
3
|
+
from cinetpay.types.balance import Balance
|
|
4
|
+
from cinetpay.types.config import (
|
|
5
|
+
API_KEY_PREFIX_LIVE,
|
|
6
|
+
API_KEY_PREFIX_TEST,
|
|
7
|
+
AsyncTokenStoreProtocol,
|
|
8
|
+
ClientConfig,
|
|
9
|
+
CountryCredentials,
|
|
10
|
+
DEFAULT_BASE_URL,
|
|
11
|
+
DEFAULT_TIMEOUT,
|
|
12
|
+
DEFAULT_TOKEN_TTL,
|
|
13
|
+
PRODUCTION_BASE_URL,
|
|
14
|
+
TokenStoreProtocol,
|
|
15
|
+
)
|
|
16
|
+
from cinetpay.types.payment import (
|
|
17
|
+
PaymentDetails,
|
|
18
|
+
PaymentRequest,
|
|
19
|
+
PaymentResponse,
|
|
20
|
+
PaymentStatus,
|
|
21
|
+
PaymentStatusUser,
|
|
22
|
+
)
|
|
23
|
+
from cinetpay.types.transfer import (
|
|
24
|
+
TransferRequest,
|
|
25
|
+
TransferResponse,
|
|
26
|
+
TransferStatus,
|
|
27
|
+
TransferStatusUser,
|
|
28
|
+
)
|
|
29
|
+
from cinetpay.types.webhook import WebhookPayload, WebhookUser
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
# config
|
|
33
|
+
"API_KEY_PREFIX_LIVE",
|
|
34
|
+
"API_KEY_PREFIX_TEST",
|
|
35
|
+
"AsyncTokenStoreProtocol",
|
|
36
|
+
"ClientConfig",
|
|
37
|
+
"CountryCredentials",
|
|
38
|
+
"DEFAULT_BASE_URL",
|
|
39
|
+
"DEFAULT_TIMEOUT",
|
|
40
|
+
"DEFAULT_TOKEN_TTL",
|
|
41
|
+
"PRODUCTION_BASE_URL",
|
|
42
|
+
"TokenStoreProtocol",
|
|
43
|
+
# payment
|
|
44
|
+
"PaymentDetails",
|
|
45
|
+
"PaymentRequest",
|
|
46
|
+
"PaymentResponse",
|
|
47
|
+
"PaymentStatus",
|
|
48
|
+
"PaymentStatusUser",
|
|
49
|
+
# transfer
|
|
50
|
+
"TransferRequest",
|
|
51
|
+
"TransferResponse",
|
|
52
|
+
"TransferStatus",
|
|
53
|
+
"TransferStatusUser",
|
|
54
|
+
# balance
|
|
55
|
+
"Balance",
|
|
56
|
+
# webhook
|
|
57
|
+
"WebhookPayload",
|
|
58
|
+
"WebhookUser",
|
|
59
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Balance type and deserialization helper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from cinetpay.constants.currencies import Currency
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class Balance:
|
|
13
|
+
"""Merchant account balance returned by ``client.balance.get()``.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
code: HTTP response code.
|
|
17
|
+
status: Textual response status.
|
|
18
|
+
available_balance: Available balance (used for transfers).
|
|
19
|
+
currency: Account currency.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
code: int
|
|
23
|
+
status: str
|
|
24
|
+
available_balance: str
|
|
25
|
+
currency: Currency
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def to_balance(raw: dict[str, Any]) -> Balance:
|
|
29
|
+
"""Transform a raw API response dict into a typed :class:`Balance`."""
|
|
30
|
+
return Balance(
|
|
31
|
+
code=int(raw.get("code", 0)),
|
|
32
|
+
status=str(raw.get("status", "")),
|
|
33
|
+
available_balance=str(raw.get("available_balance", "")),
|
|
34
|
+
currency=raw.get("currency", "XOF"),
|
|
35
|
+
)
|
cinetpay/types/config.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Configuration types for the CinetPay SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
from cinetpay.logger import LoggerProtocol
|
|
9
|
+
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
# Constants
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
DEFAULT_BASE_URL: str = "https://api.cinetpay.net"
|
|
15
|
+
"""Base URL for the CinetPay Sandbox API."""
|
|
16
|
+
|
|
17
|
+
PRODUCTION_BASE_URL: str = "https://api.cinetpay.co"
|
|
18
|
+
"""Base URL for the CinetPay Production API."""
|
|
19
|
+
|
|
20
|
+
API_KEY_PREFIX_TEST: str = "sk_test_"
|
|
21
|
+
"""Prefix for sandbox API keys."""
|
|
22
|
+
|
|
23
|
+
API_KEY_PREFIX_LIVE: str = "sk_live_"
|
|
24
|
+
"""Prefix for production API keys."""
|
|
25
|
+
|
|
26
|
+
DEFAULT_TOKEN_TTL: int = 82_800
|
|
27
|
+
"""Default JWT token cache TTL in seconds (23 h — safety margin under the 86 400 s API TTL)."""
|
|
28
|
+
|
|
29
|
+
DEFAULT_TIMEOUT: float = 30.0
|
|
30
|
+
"""Default HTTP request timeout in seconds."""
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Dataclasses & Protocols
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class CountryCredentials:
|
|
39
|
+
"""API credentials for a single country.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
api_key: API key provided by CinetPay (``sk_test_...`` or ``sk_live_...``).
|
|
43
|
+
api_password: API password provided by CinetPay.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
api_key: str
|
|
47
|
+
api_password: str
|
|
48
|
+
|
|
49
|
+
def __repr__(self) -> str:
|
|
50
|
+
"""Mask credentials in repr to prevent accidental leakage in logs."""
|
|
51
|
+
masked_key = self.api_key[:8] + "***" if len(self.api_key) > 8 else "***"
|
|
52
|
+
return f"CountryCredentials(api_key='{masked_key}', api_password='***')"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@runtime_checkable
|
|
56
|
+
class TokenStoreProtocol(Protocol):
|
|
57
|
+
"""Interface for JWT token storage backends.
|
|
58
|
+
|
|
59
|
+
Implement this protocol to use Redis, a database, or any other shared store::
|
|
60
|
+
|
|
61
|
+
class RedisTokenStore:
|
|
62
|
+
def get(self, key: str) -> str | None:
|
|
63
|
+
return redis.get(key)
|
|
64
|
+
|
|
65
|
+
def set(self, key: str, value: str, ttl_seconds: int) -> None:
|
|
66
|
+
redis.setex(key, ttl_seconds, value)
|
|
67
|
+
|
|
68
|
+
def delete(self, key: str) -> None:
|
|
69
|
+
redis.delete(key)
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def get(self, key: str) -> str | None:
|
|
73
|
+
"""Retrieve a token from the cache. Return ``None`` if absent or expired."""
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
def set(self, key: str, value: str, ttl_seconds: int) -> None:
|
|
77
|
+
"""Store a token with a TTL in seconds."""
|
|
78
|
+
...
|
|
79
|
+
|
|
80
|
+
def delete(self, key: str) -> None:
|
|
81
|
+
"""Remove a token from the cache."""
|
|
82
|
+
...
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@runtime_checkable
|
|
86
|
+
class AsyncTokenStoreProtocol(Protocol):
|
|
87
|
+
"""Async interface for JWT token storage backends.
|
|
88
|
+
|
|
89
|
+
Implement this protocol for async stores (aioredis, etc.)::
|
|
90
|
+
|
|
91
|
+
class AsyncRedisTokenStore:
|
|
92
|
+
async def get(self, key: str) -> str | None: ...
|
|
93
|
+
async def set(self, key: str, value: str, ttl_seconds: int) -> None: ...
|
|
94
|
+
async def delete(self, key: str) -> None: ...
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
async def get(self, key: str) -> str | None:
|
|
98
|
+
"""Retrieve a token from the cache. Return ``None`` if absent or expired."""
|
|
99
|
+
...
|
|
100
|
+
|
|
101
|
+
async def set(self, key: str, value: str, ttl_seconds: int) -> None:
|
|
102
|
+
"""Store a token with a TTL in seconds."""
|
|
103
|
+
...
|
|
104
|
+
|
|
105
|
+
async def delete(self, key: str) -> None:
|
|
106
|
+
"""Remove a token from the cache."""
|
|
107
|
+
...
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(slots=True)
|
|
111
|
+
class ClientConfig:
|
|
112
|
+
"""Configuration for the CinetPay client.
|
|
113
|
+
|
|
114
|
+
Example::
|
|
115
|
+
|
|
116
|
+
config = ClientConfig(
|
|
117
|
+
credentials={
|
|
118
|
+
"CI": CountryCredentials(api_key="sk_test_...", api_password="..."),
|
|
119
|
+
"SN": CountryCredentials(api_key="sk_test_...", api_password="..."),
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
Attributes:
|
|
124
|
+
credentials: Credentials keyed by ISO 3166-1 alpha-2 country code.
|
|
125
|
+
base_url: API base URL. Auto-detected from key prefixes if omitted.
|
|
126
|
+
token_ttl: JWT token cache TTL in seconds (default: 82 800 = 23 h).
|
|
127
|
+
token_store: Custom sync token store (default: in-memory).
|
|
128
|
+
timeout: HTTP request timeout in seconds (default: 30).
|
|
129
|
+
debug: Enable logging with the standard library logger.
|
|
130
|
+
logger: Custom logger implementing :class:`~cinetpay.logger.LoggerProtocol`.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
credentials: dict[str, CountryCredentials]
|
|
134
|
+
base_url: str | None = None
|
|
135
|
+
token_ttl: int = field(default=DEFAULT_TOKEN_TTL)
|
|
136
|
+
token_store: TokenStoreProtocol | None = None
|
|
137
|
+
timeout: float = field(default=DEFAULT_TIMEOUT)
|
|
138
|
+
debug: bool = False
|
|
139
|
+
logger: LoggerProtocol | None = None
|