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.
Files changed (41) hide show
  1. cinetpay/__init__.py +175 -0
  2. cinetpay/api/__init__.py +17 -0
  3. cinetpay/api/async_balance.py +70 -0
  4. cinetpay/api/async_payment.py +118 -0
  5. cinetpay/api/async_transfer.py +118 -0
  6. cinetpay/api/balance.py +70 -0
  7. cinetpay/api/payment.py +118 -0
  8. cinetpay/api/transfer.py +118 -0
  9. cinetpay/async_client.py +290 -0
  10. cinetpay/auth/__init__.py +11 -0
  11. cinetpay/auth/async_authenticator.py +119 -0
  12. cinetpay/auth/authenticator.py +140 -0
  13. cinetpay/auth/token_store.py +72 -0
  14. cinetpay/client.py +256 -0
  15. cinetpay/constants/__init__.py +15 -0
  16. cinetpay/constants/channels.py +12 -0
  17. cinetpay/constants/currencies.py +14 -0
  18. cinetpay/constants/payment_methods.py +43 -0
  19. cinetpay/constants/statuses.py +44 -0
  20. cinetpay/errors/__init__.py +14 -0
  21. cinetpay/errors/api_error.py +45 -0
  22. cinetpay/errors/auth_error.py +12 -0
  23. cinetpay/errors/base.py +18 -0
  24. cinetpay/errors/network_errors.py +29 -0
  25. cinetpay/http/__init__.py +9 -0
  26. cinetpay/http/async_http_client.py +173 -0
  27. cinetpay/http/http_client.py +173 -0
  28. cinetpay/logger.py +70 -0
  29. cinetpay/py.typed +0 -0
  30. cinetpay/types/__init__.py +59 -0
  31. cinetpay/types/balance.py +35 -0
  32. cinetpay/types/config.py +139 -0
  33. cinetpay/types/payment.py +210 -0
  34. cinetpay/types/transfer.py +163 -0
  35. cinetpay/types/webhook.py +59 -0
  36. cinetpay/validation.py +163 -0
  37. cinetpay/webhooks/__init__.py +8 -0
  38. cinetpay/webhooks/notification.py +79 -0
  39. cinetpay_python-0.1.0.dist-info/METADATA +376 -0
  40. cinetpay_python-0.1.0.dist-info/RECORD +41 -0
  41. 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)
@@ -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
@@ -0,0 +1,9 @@
1
+ """HTTP client module — sync and async wrappers around httpx."""
2
+
3
+ from cinetpay.http.async_http_client import AsyncHttpClient
4
+ from cinetpay.http.http_client import HttpClient
5
+
6
+ __all__ = [
7
+ "AsyncHttpClient",
8
+ "HttpClient",
9
+ ]