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,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
+ )
@@ -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