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,72 @@
|
|
|
1
|
+
"""In-memory token store (default implementation)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class _CacheEntry:
|
|
11
|
+
"""Internal cache entry with monotonic expiration timestamp."""
|
|
12
|
+
|
|
13
|
+
value: str
|
|
14
|
+
expires_at: float
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MemoryTokenStore:
|
|
18
|
+
"""In-memory JWT token store with lazy expiration.
|
|
19
|
+
|
|
20
|
+
Expired tokens are automatically evicted on read. Suitable for a single
|
|
21
|
+
process. For multi-instance production deployments, implement
|
|
22
|
+
:class:`~cinetpay.types.config.TokenStoreProtocol` with Redis or another
|
|
23
|
+
shared backend.
|
|
24
|
+
|
|
25
|
+
Example::
|
|
26
|
+
|
|
27
|
+
from cinetpay.auth.token_store import MemoryTokenStore
|
|
28
|
+
store = MemoryTokenStore()
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self._cache: dict[str, _CacheEntry] = {}
|
|
33
|
+
|
|
34
|
+
def get(self, key: str) -> str | None:
|
|
35
|
+
"""Retrieve a token from the cache.
|
|
36
|
+
|
|
37
|
+
If the token is expired, it is deleted and ``None`` is returned.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
key: Cache key (e.g. ``cinetpay_token_ci``).
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The token string, or ``None`` if absent/expired.
|
|
44
|
+
"""
|
|
45
|
+
entry = self._cache.get(key)
|
|
46
|
+
if entry is None:
|
|
47
|
+
return None
|
|
48
|
+
if time.monotonic() >= entry.expires_at:
|
|
49
|
+
del self._cache[key]
|
|
50
|
+
return None
|
|
51
|
+
return entry.value
|
|
52
|
+
|
|
53
|
+
def set(self, key: str, value: str, ttl_seconds: int) -> None:
|
|
54
|
+
"""Store a token with a time-to-live.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
key: Cache key.
|
|
58
|
+
value: JWT token string.
|
|
59
|
+
ttl_seconds: Time-to-live in seconds.
|
|
60
|
+
"""
|
|
61
|
+
self._cache[key] = _CacheEntry(
|
|
62
|
+
value=value,
|
|
63
|
+
expires_at=time.monotonic() + ttl_seconds,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def delete(self, key: str) -> None:
|
|
67
|
+
"""Remove a token from the cache.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
key: Cache key to delete.
|
|
71
|
+
"""
|
|
72
|
+
self._cache.pop(key, None)
|
cinetpay/client.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Synchronous CinetPay client — main entry point of the SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
from cinetpay.api.balance import BalanceApi
|
|
8
|
+
from cinetpay.api.payment import PaymentApi
|
|
9
|
+
from cinetpay.api.transfer import TransferApi
|
|
10
|
+
from cinetpay.auth.authenticator import Authenticator
|
|
11
|
+
from cinetpay.auth.token_store import MemoryTokenStore
|
|
12
|
+
from cinetpay.http.http_client import HttpClient
|
|
13
|
+
from cinetpay.logger import LoggerProtocol, NoopLogger, StandardLibLogger
|
|
14
|
+
from cinetpay.types.config import (
|
|
15
|
+
API_KEY_PREFIX_LIVE,
|
|
16
|
+
API_KEY_PREFIX_TEST,
|
|
17
|
+
ClientConfig,
|
|
18
|
+
CountryCredentials,
|
|
19
|
+
DEFAULT_BASE_URL,
|
|
20
|
+
DEFAULT_TIMEOUT,
|
|
21
|
+
DEFAULT_TOKEN_TTL,
|
|
22
|
+
PRODUCTION_BASE_URL,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Hostnames allowed for the base URL (SSRF protection)
|
|
26
|
+
_ALLOWED_HOSTS = frozenset({
|
|
27
|
+
"api.cinetpay.net", # Sandbox
|
|
28
|
+
"api.cinetpay.co", # Production
|
|
29
|
+
"localhost",
|
|
30
|
+
"127.0.0.1",
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CinetPayClient:
|
|
35
|
+
"""Synchronous CinetPay SDK client.
|
|
36
|
+
|
|
37
|
+
Manages multi-country JWT authentication, token caching, and exposes
|
|
38
|
+
the payment, transfer, and balance APIs.
|
|
39
|
+
|
|
40
|
+
Example::
|
|
41
|
+
|
|
42
|
+
from cinetpay import CinetPayClient, ClientConfig, CountryCredentials
|
|
43
|
+
|
|
44
|
+
client = CinetPayClient(ClientConfig(
|
|
45
|
+
credentials={
|
|
46
|
+
"CI": CountryCredentials(api_key="sk_test_...", api_password="..."),
|
|
47
|
+
"SN": CountryCredentials(api_key="sk_test_...", api_password="..."),
|
|
48
|
+
},
|
|
49
|
+
debug=True,
|
|
50
|
+
))
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
payment: Web payment API (initialize, get_status).
|
|
54
|
+
transfer: Money transfer API (create, get_status).
|
|
55
|
+
balance: Merchant balance API (get).
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
payment: PaymentApi
|
|
59
|
+
"""Web payment API -- initialization and status check."""
|
|
60
|
+
|
|
61
|
+
transfer: TransferApi
|
|
62
|
+
"""Money transfer API -- send and check status."""
|
|
63
|
+
|
|
64
|
+
balance: BalanceApi
|
|
65
|
+
"""Merchant account balance API."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, config: ClientConfig) -> None:
|
|
68
|
+
credentials = config.credentials
|
|
69
|
+
if not credentials:
|
|
70
|
+
raise TypeError(
|
|
71
|
+
"At least one country credential must be provided in config.credentials"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
token_ttl = config.token_ttl if config.token_ttl is not None else DEFAULT_TOKEN_TTL
|
|
75
|
+
timeout = config.timeout if config.timeout is not None else DEFAULT_TIMEOUT
|
|
76
|
+
token_store = config.token_store if config.token_store is not None else MemoryTokenStore()
|
|
77
|
+
|
|
78
|
+
# Resolve logger: explicit > debug flag > silent
|
|
79
|
+
logger: LoggerProtocol
|
|
80
|
+
if config.logger is not None:
|
|
81
|
+
logger = config.logger
|
|
82
|
+
elif config.debug:
|
|
83
|
+
logger = StandardLibLogger()
|
|
84
|
+
else:
|
|
85
|
+
logger = NoopLogger()
|
|
86
|
+
|
|
87
|
+
# Detect environment from API key prefixes
|
|
88
|
+
entries = list(credentials.items())
|
|
89
|
+
detected_env = _detect_environment(entries, logger)
|
|
90
|
+
|
|
91
|
+
# Resolve base URL: explicit > auto-detect > sandbox default
|
|
92
|
+
if config.base_url is not None:
|
|
93
|
+
base_url = config.base_url
|
|
94
|
+
elif detected_env == "live":
|
|
95
|
+
base_url = PRODUCTION_BASE_URL
|
|
96
|
+
else:
|
|
97
|
+
base_url = DEFAULT_BASE_URL
|
|
98
|
+
|
|
99
|
+
# Validate key/URL coherence
|
|
100
|
+
_validate_key_url_coherence(detected_env, base_url, logger)
|
|
101
|
+
|
|
102
|
+
# Enforce HTTPS (except localhost)
|
|
103
|
+
parsed = urlparse(base_url)
|
|
104
|
+
if parsed.scheme != "https" and parsed.hostname not in ("localhost", "127.0.0.1"):
|
|
105
|
+
raise TypeError(
|
|
106
|
+
"base_url must use HTTPS for security. "
|
|
107
|
+
"Use https:// or localhost for development."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# SSRF protection: warn on unknown hosts
|
|
111
|
+
if parsed.hostname and not any(
|
|
112
|
+
parsed.hostname == h or parsed.hostname.endswith(f".{h}")
|
|
113
|
+
for h in _ALLOWED_HOSTS
|
|
114
|
+
):
|
|
115
|
+
logger.warn(
|
|
116
|
+
f'base_url hostname "{parsed.hostname}" is not a known CinetPay domain. '
|
|
117
|
+
f"Expected: {', '.join(sorted(_ALLOWED_HOSTS))}. Proceeding anyway."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Validate URL
|
|
121
|
+
if not parsed.scheme or not parsed.hostname:
|
|
122
|
+
raise TypeError(f'base_url "{base_url}" is not a valid URL.')
|
|
123
|
+
|
|
124
|
+
# Build HTTP client and authenticators
|
|
125
|
+
http_client = HttpClient(base_url, timeout, logger)
|
|
126
|
+
|
|
127
|
+
self._authenticators: dict[str, Authenticator] = {}
|
|
128
|
+
for country, creds in entries:
|
|
129
|
+
key = country.upper()
|
|
130
|
+
self._authenticators[key] = Authenticator(
|
|
131
|
+
http_client, key, creds, token_ttl, token_store, logger,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
self.payment = PaymentApi(http_client, self._authenticators)
|
|
135
|
+
self.transfer = TransferApi(http_client, self._authenticators)
|
|
136
|
+
self.balance = BalanceApi(http_client, self._authenticators)
|
|
137
|
+
|
|
138
|
+
self._http_client = http_client
|
|
139
|
+
self._logger = logger
|
|
140
|
+
|
|
141
|
+
logger.debug("CinetPayClient initialized", {
|
|
142
|
+
"countries": self.countries(),
|
|
143
|
+
"base_url": base_url,
|
|
144
|
+
"token_ttl": token_ttl,
|
|
145
|
+
"timeout": timeout,
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
def countries(self) -> list[str]:
|
|
149
|
+
"""Return the list of configured country codes.
|
|
150
|
+
|
|
151
|
+
Example::
|
|
152
|
+
|
|
153
|
+
client.countries() # ["CI", "SN"]
|
|
154
|
+
"""
|
|
155
|
+
return list(self._authenticators.keys())
|
|
156
|
+
|
|
157
|
+
def revoke_token(self, country: str) -> None:
|
|
158
|
+
"""Revoke the cached JWT token for a country.
|
|
159
|
+
|
|
160
|
+
The next API call will trigger a fresh authentication.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
country: ISO country code (e.g. ``"CI"``).
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
TypeError: If the country is not configured.
|
|
167
|
+
"""
|
|
168
|
+
key = country.upper()
|
|
169
|
+
auth = self._authenticators.get(key)
|
|
170
|
+
if auth is None:
|
|
171
|
+
raise TypeError(
|
|
172
|
+
f'No credentials configured for country "{key}". '
|
|
173
|
+
f"Available: {', '.join(self.countries())}"
|
|
174
|
+
)
|
|
175
|
+
auth.clear_cache()
|
|
176
|
+
|
|
177
|
+
def revoke_all_tokens(self) -> None:
|
|
178
|
+
"""Revoke all cached JWT tokens for all configured countries."""
|
|
179
|
+
for auth in self._authenticators.values():
|
|
180
|
+
auth.clear_cache()
|
|
181
|
+
|
|
182
|
+
def close(self) -> None:
|
|
183
|
+
"""Close the underlying HTTP client and release resources."""
|
|
184
|
+
self._http_client.close()
|
|
185
|
+
|
|
186
|
+
def __enter__(self) -> CinetPayClient:
|
|
187
|
+
return self
|
|
188
|
+
|
|
189
|
+
def __exit__(self, *args: object) -> None:
|
|
190
|
+
self.close()
|
|
191
|
+
|
|
192
|
+
def __repr__(self) -> str:
|
|
193
|
+
"""Prevent credential leakage via repr()."""
|
|
194
|
+
return f"CinetPayClient(countries={self.countries()!r})"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
# Private helpers
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _detect_environment(
|
|
203
|
+
entries: list[tuple[str, CountryCredentials]],
|
|
204
|
+
logger: LoggerProtocol,
|
|
205
|
+
) -> str:
|
|
206
|
+
"""Detect test/live/mixed/unknown from API key prefixes."""
|
|
207
|
+
envs: set[str] = set()
|
|
208
|
+
for country, creds in entries:
|
|
209
|
+
if creds.api_key.startswith(API_KEY_PREFIX_TEST):
|
|
210
|
+
envs.add("test")
|
|
211
|
+
elif creds.api_key.startswith(API_KEY_PREFIX_LIVE):
|
|
212
|
+
envs.add("live")
|
|
213
|
+
else:
|
|
214
|
+
logger.warn(
|
|
215
|
+
f'API key for country {country.upper()} does not start with '
|
|
216
|
+
f'"{API_KEY_PREFIX_TEST}" or "{API_KEY_PREFIX_LIVE}". '
|
|
217
|
+
f"Expected format: sk_test_... (sandbox) or sk_live_... (production)."
|
|
218
|
+
)
|
|
219
|
+
envs.add("unknown")
|
|
220
|
+
|
|
221
|
+
if "test" in envs and "live" in envs:
|
|
222
|
+
logger.error(
|
|
223
|
+
"MIXED ENVIRONMENTS: some credentials use sk_test_ (sandbox) and "
|
|
224
|
+
"others use sk_live_ (production). This will cause authentication "
|
|
225
|
+
"failures. Use the same environment for all countries."
|
|
226
|
+
)
|
|
227
|
+
return "mixed"
|
|
228
|
+
|
|
229
|
+
if "live" in envs:
|
|
230
|
+
return "live"
|
|
231
|
+
if "test" in envs:
|
|
232
|
+
return "test"
|
|
233
|
+
return "unknown"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _validate_key_url_coherence(
|
|
237
|
+
detected_env: str,
|
|
238
|
+
base_url: str,
|
|
239
|
+
logger: LoggerProtocol,
|
|
240
|
+
) -> None:
|
|
241
|
+
"""Warn if API key type and base URL are mismatched."""
|
|
242
|
+
is_sandbox_url = "cinetpay.net" in base_url
|
|
243
|
+
is_production_url = "cinetpay.co" in base_url
|
|
244
|
+
|
|
245
|
+
if detected_env == "live" and is_sandbox_url:
|
|
246
|
+
logger.error(
|
|
247
|
+
f"ENVIRONMENT MISMATCH: production keys (sk_live_) are used with "
|
|
248
|
+
f'sandbox URL ({base_url}). Use base_url="{PRODUCTION_BASE_URL}" '
|
|
249
|
+
f"for production, or remove base_url to auto-detect."
|
|
250
|
+
)
|
|
251
|
+
if detected_env == "test" and is_production_url:
|
|
252
|
+
logger.error(
|
|
253
|
+
f"ENVIRONMENT MISMATCH: sandbox keys (sk_test_) are used with "
|
|
254
|
+
f'production URL ({base_url}). Use base_url="{DEFAULT_BASE_URL}" '
|
|
255
|
+
f"for sandbox, or remove base_url to auto-detect."
|
|
256
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from cinetpay.constants.currencies import CURRENCIES, Currency
|
|
2
|
+
from cinetpay.constants.channels import CHANNELS, Channel
|
|
3
|
+
from cinetpay.constants.payment_methods import PAYMENT_METHODS, PAYMENT_METHODS_BY_COUNTRY, PaymentMethod
|
|
4
|
+
from cinetpay.constants.statuses import (
|
|
5
|
+
TRANSACTION_STATUSES, API_CODES, COUNTRY_CODES,
|
|
6
|
+
TransactionStatus, CountryCode, is_final_status,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"CURRENCIES", "Currency",
|
|
11
|
+
"CHANNELS", "Channel",
|
|
12
|
+
"PAYMENT_METHODS", "PAYMENT_METHODS_BY_COUNTRY", "PaymentMethod",
|
|
13
|
+
"TRANSACTION_STATUSES", "API_CODES", "COUNTRY_CODES",
|
|
14
|
+
"TransactionStatus", "CountryCode", "is_final_status",
|
|
15
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Payment channels supported by CinetPay (PUSH, OTP, QRCODE)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
Channel = Literal["PUSH", "OTP", "QRCODE"]
|
|
7
|
+
|
|
8
|
+
CHANNELS: dict[str, Channel] = {
|
|
9
|
+
"PUSH": "PUSH",
|
|
10
|
+
"OTP": "OTP",
|
|
11
|
+
"QRCODE": "QRCODE",
|
|
12
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Supported currencies for CinetPay transactions (XOF, XAF, GNF, CDF, USD)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
Currency = Literal["XOF", "XAF", "GNF", "CDF", "USD"]
|
|
7
|
+
|
|
8
|
+
CURRENCIES: dict[str, Currency] = {
|
|
9
|
+
"XOF": "XOF",
|
|
10
|
+
"XAF": "XAF",
|
|
11
|
+
"GNF": "GNF",
|
|
12
|
+
"CDF": "CDF",
|
|
13
|
+
"USD": "USD",
|
|
14
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Mobile money payment methods by operator and country (e.g. OM_CI, WAVE_SN)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
PaymentMethod = Literal[
|
|
7
|
+
"OM_CI", "MOOV_CI", "MTN_CI", "WAVE_CI",
|
|
8
|
+
"OM_BF", "MOOV_BF", "WAVE_BF",
|
|
9
|
+
"OM_ML", "MOOV_ML",
|
|
10
|
+
"OM_SN", "FREE_SN", "EXPRESSO_SN", "WAVE_SN",
|
|
11
|
+
"MOOV_TG", "TMONEY_TG",
|
|
12
|
+
"OM_GN", "MTN_GN",
|
|
13
|
+
"OM_CM", "MTN_CM",
|
|
14
|
+
"MOOV_BJ", "MTN_BJ",
|
|
15
|
+
"OM_CD", "AIRTEL_CD", "MPESA_CD", "AFRICELL_CD",
|
|
16
|
+
"AIRTEL_NE", "MOOV_NE", "ZAMANI_NE",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
PAYMENT_METHODS: dict[str, PaymentMethod] = {
|
|
20
|
+
"OM_CI": "OM_CI", "MOOV_CI": "MOOV_CI", "MTN_CI": "MTN_CI", "WAVE_CI": "WAVE_CI",
|
|
21
|
+
"OM_BF": "OM_BF", "MOOV_BF": "MOOV_BF", "WAVE_BF": "WAVE_BF",
|
|
22
|
+
"OM_ML": "OM_ML", "MOOV_ML": "MOOV_ML",
|
|
23
|
+
"OM_SN": "OM_SN", "FREE_SN": "FREE_SN", "EXPRESSO_SN": "EXPRESSO_SN", "WAVE_SN": "WAVE_SN",
|
|
24
|
+
"MOOV_TG": "MOOV_TG", "TMONEY_TG": "TMONEY_TG",
|
|
25
|
+
"OM_GN": "OM_GN", "MTN_GN": "MTN_GN",
|
|
26
|
+
"OM_CM": "OM_CM", "MTN_CM": "MTN_CM",
|
|
27
|
+
"MOOV_BJ": "MOOV_BJ", "MTN_BJ": "MTN_BJ",
|
|
28
|
+
"OM_CD": "OM_CD", "AIRTEL_CD": "AIRTEL_CD", "MPESA_CD": "MPESA_CD", "AFRICELL_CD": "AFRICELL_CD",
|
|
29
|
+
"AIRTEL_NE": "AIRTEL_NE", "MOOV_NE": "MOOV_NE", "ZAMANI_NE": "ZAMANI_NE",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
PAYMENT_METHODS_BY_COUNTRY: dict[str, tuple[PaymentMethod, ...]] = {
|
|
33
|
+
"CI": ("OM_CI", "MOOV_CI", "MTN_CI", "WAVE_CI"),
|
|
34
|
+
"BF": ("OM_BF", "MOOV_BF", "WAVE_BF"),
|
|
35
|
+
"ML": ("OM_ML", "MOOV_ML"),
|
|
36
|
+
"SN": ("OM_SN", "FREE_SN", "EXPRESSO_SN", "WAVE_SN"),
|
|
37
|
+
"TG": ("MOOV_TG", "TMONEY_TG"),
|
|
38
|
+
"GN": ("OM_GN", "MTN_GN"),
|
|
39
|
+
"CM": ("OM_CM", "MTN_CM"),
|
|
40
|
+
"BJ": ("MOOV_BJ", "MTN_BJ"),
|
|
41
|
+
"CD": ("OM_CD", "AIRTEL_CD", "MPESA_CD", "AFRICELL_CD"),
|
|
42
|
+
"NE": ("AIRTEL_NE", "MOOV_NE", "ZAMANI_NE"),
|
|
43
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
TransactionStatus = Literal[
|
|
5
|
+
"OK", "SUCCESS", "OPERATION_ERROR", "NOT_FOUND",
|
|
6
|
+
"INVALID_CREDENTIALS", "INVALID_PARAMS", "EXPIRED_TOKEN", "INVALID_TOKEN",
|
|
7
|
+
"TRANSACTION_EXIST", "INITIATED", "PENDING", "EXPIRED",
|
|
8
|
+
"OTP_ERROR", "OTP_EXPIRED", "INSUFFICIENT_BALANCE",
|
|
9
|
+
"USER_NOT_FOUND", "USER_IS_BLOCKED", "FAILED", "NOT_ALLOWED",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
TRANSACTION_STATUSES: dict[str, TransactionStatus] = {
|
|
13
|
+
"OK": "OK", "SUCCESS": "SUCCESS", "OPERATION_ERROR": "OPERATION_ERROR",
|
|
14
|
+
"NOT_FOUND": "NOT_FOUND", "INVALID_CREDENTIALS": "INVALID_CREDENTIALS",
|
|
15
|
+
"INVALID_PARAMS": "INVALID_PARAMS", "EXPIRED_TOKEN": "EXPIRED_TOKEN",
|
|
16
|
+
"INVALID_TOKEN": "INVALID_TOKEN", "TRANSACTION_EXIST": "TRANSACTION_EXIST",
|
|
17
|
+
"INITIATED": "INITIATED", "PENDING": "PENDING", "EXPIRED": "EXPIRED",
|
|
18
|
+
"OTP_ERROR": "OTP_ERROR", "OTP_EXPIRED": "OTP_EXPIRED",
|
|
19
|
+
"INSUFFICIENT_BALANCE": "INSUFFICIENT_BALANCE",
|
|
20
|
+
"USER_NOT_FOUND": "USER_NOT_FOUND", "USER_IS_BLOCKED": "USER_IS_BLOCKED",
|
|
21
|
+
"FAILED": "FAILED", "NOT_ALLOWED": "NOT_ALLOWED",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_FINAL_STATUSES = frozenset({"SUCCESS", "FAILED", "TRANSACTION_EXIST", "INSUFFICIENT_BALANCE"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_final_status(status: str) -> bool:
|
|
28
|
+
"""Returns True if the status is final (no more changes possible)."""
|
|
29
|
+
return status in _FINAL_STATUSES
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
API_CODES: dict[int, TransactionStatus] = {
|
|
33
|
+
200: "OK", 100: "SUCCESS", -1: "OPERATION_ERROR", 404: "NOT_FOUND",
|
|
34
|
+
1005: "INVALID_CREDENTIALS", 1004: "INVALID_PARAMS",
|
|
35
|
+
1003: "EXPIRED_TOKEN", 1002: "INVALID_TOKEN", 1200: "TRANSACTION_EXIST",
|
|
36
|
+
2001: "INITIATED", 2002: "PENDING", 2003: "EXPIRED",
|
|
37
|
+
2004: "OTP_ERROR", 2008: "OTP_EXPIRED", 2005: "INSUFFICIENT_BALANCE",
|
|
38
|
+
2006: "USER_NOT_FOUND", 2007: "USER_IS_BLOCKED",
|
|
39
|
+
2010: "FAILED", 2011: "NOT_ALLOWED",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
CountryCode = Literal["CI", "BF", "ML", "SN", "TG", "GN", "CM", "BJ", "CD", "NE"]
|
|
43
|
+
|
|
44
|
+
COUNTRY_CODES: tuple[CountryCode, ...] = ("CI", "BF", "ML", "SN", "TG", "GN", "CM", "BJ", "CD", "NE")
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""CinetPay SDK error hierarchy."""
|
|
2
|
+
|
|
3
|
+
from cinetpay.errors.api_error import ApiError
|
|
4
|
+
from cinetpay.errors.auth_error import AuthenticationError
|
|
5
|
+
from cinetpay.errors.base import CinetPayError
|
|
6
|
+
from cinetpay.errors.network_errors import NetworkError, TimeoutError
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ApiError",
|
|
10
|
+
"AuthenticationError",
|
|
11
|
+
"CinetPayError",
|
|
12
|
+
"NetworkError",
|
|
13
|
+
"TimeoutError",
|
|
14
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""API error returned by the CinetPay backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from cinetpay.errors.base import CinetPayError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ApiError(CinetPayError):
|
|
11
|
+
"""Error returned by the CinetPay API (code + status + description).
|
|
12
|
+
|
|
13
|
+
Example::
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
client.payment.initialize(request, "CI")
|
|
17
|
+
except ApiError as exc:
|
|
18
|
+
print(exc.api_code) # 1200
|
|
19
|
+
print(exc.api_status) # "TRANSACTION_EXIST"
|
|
20
|
+
print(exc.description) # "La transaction existe deja"
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
__slots__ = ("api_code", "api_status", "description")
|
|
24
|
+
|
|
25
|
+
def __init__(self, api_code: int, api_status: str, description: str) -> None:
|
|
26
|
+
super().__init__(f"[{api_code}] {api_status}: {description}")
|
|
27
|
+
self.api_code: int = api_code
|
|
28
|
+
self.api_status: str = api_status
|
|
29
|
+
self.description: str = description
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_response(cls, data: dict[str, Any]) -> ApiError:
|
|
33
|
+
"""Build an :class:`ApiError` from a raw API response dict.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
data: Raw JSON-decoded response body.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
A new :class:`ApiError` instance.
|
|
40
|
+
"""
|
|
41
|
+
return cls(
|
|
42
|
+
api_code=int(data.get("code", 0)),
|
|
43
|
+
api_status=str(data.get("status", "UNKNOWN")),
|
|
44
|
+
description=str(data.get("description") or data.get("message") or ""),
|
|
45
|
+
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Authentication error — invalid credentials or missing JWT token."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from cinetpay.errors.base import CinetPayError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuthenticationError(CinetPayError):
|
|
9
|
+
"""Raised when API credentials are invalid or the API did not return a JWT token."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str = "Authentication failed") -> None:
|
|
12
|
+
super().__init__(message)
|
cinetpay/errors/base.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Base exception for the CinetPay SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CinetPayError(Exception):
|
|
7
|
+
"""Base class for all CinetPay SDK errors.
|
|
8
|
+
|
|
9
|
+
All SDK errors inherit from this class, allowing a simple catch-all::
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
client.payment.initialize(request, "CI")
|
|
13
|
+
except CinetPayError as exc:
|
|
14
|
+
...
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str) -> None:
|
|
18
|
+
super().__init__(message)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Network and timeout errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from cinetpay.errors.base import CinetPayError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NetworkError(CinetPayError):
|
|
9
|
+
"""Raised when an HTTP request fails (DNS, connection refused, etc.).
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
cause: The original exception that triggered the network failure.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, message: str, cause: BaseException | None = None) -> None:
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.cause: BaseException | None = cause
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TimeoutError(CinetPayError): # noqa: A001 — intentional shadow of builtins.TimeoutError
|
|
21
|
+
"""Raised when an HTTP request exceeds the configured timeout.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
timeout_seconds: The timeout value in seconds.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, timeout_seconds: float) -> None:
|
|
28
|
+
super().__init__(f"Request timed out after {timeout_seconds}s")
|
|
29
|
+
self.timeout_seconds: float = timeout_seconds
|