onepay 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.
onepay/__init__.py ADDED
@@ -0,0 +1,88 @@
1
+ """OnePay Python SDK — Official SDK for the OnePay payment gateway.
2
+
3
+ Quick start::
4
+
5
+ from onepay import OnePay
6
+
7
+ client = OnePay(
8
+ app_id="YOUR_APP_ID",
9
+ hash_salt="YOUR_HASH_SALT",
10
+ app_token="YOUR_APP_TOKEN",
11
+ )
12
+
13
+ result = client.checkout.create(
14
+ amount=1000.00,
15
+ currency="LKR",
16
+ reference="ORDER-123",
17
+ customer_first_name="Amila",
18
+ customer_last_name="Perera",
19
+ customer_phone_number="+94771234567",
20
+ customer_email="amila@store.lk",
21
+ transaction_redirect_url="https://store.lk/thank-you",
22
+ )
23
+ print(result.redirect_url)
24
+
25
+ For async usage::
26
+
27
+ from onepay import AsyncOnePay
28
+
29
+ client = AsyncOnePay(...)
30
+ result = await client.checkout.create(...)
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from onepay._auth import generate_hash
36
+ from onepay._version import __version__
37
+ from onepay.client import AsyncOnePay, OnePay
38
+
39
+ # Exceptions
40
+ from onepay.exceptions import (
41
+ APIError,
42
+ AuthenticationError,
43
+ InvalidAmountError,
44
+ InvalidAppIdError,
45
+ InvalidCurrencyError,
46
+ InvalidRequestError,
47
+ NetworkError,
48
+ OnePayError,
49
+ RateLimitError,
50
+ RefundNotAllowedError,
51
+ )
52
+
53
+ # Enums
54
+ from onepay.models.enums import (
55
+ Currency,
56
+ PaymentStatus,
57
+ RefundReason,
58
+ SubscriptionInterval,
59
+ )
60
+ from onepay.webhook import Webhook, WebhookEvent
61
+
62
+ __all__ = [
63
+ # Version
64
+ "__version__",
65
+ # Clients
66
+ "OnePay",
67
+ "AsyncOnePay",
68
+ # Utilities
69
+ "generate_hash",
70
+ "Webhook",
71
+ "WebhookEvent",
72
+ # Exceptions
73
+ "OnePayError",
74
+ "APIError",
75
+ "AuthenticationError",
76
+ "InvalidRequestError",
77
+ "InvalidAppIdError",
78
+ "InvalidAmountError",
79
+ "InvalidCurrencyError",
80
+ "RefundNotAllowedError",
81
+ "RateLimitError",
82
+ "NetworkError",
83
+ # Enums
84
+ "Currency",
85
+ "PaymentStatus",
86
+ "RefundReason",
87
+ "SubscriptionInterval",
88
+ ]
onepay/_auth.py ADDED
@@ -0,0 +1,59 @@
1
+ """Authentication utilities for the OnePay SDK.
2
+
3
+ Provides SHA-256 hash generation for payment requests and auth header
4
+ construction for different API endpoint groups.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ from typing import Dict
11
+
12
+
13
+ def generate_hash(app_id: str, currency: str, amount: str, hash_salt: str) -> str:
14
+ """Generate SHA-256 hash for OnePay payment requests.
15
+
16
+ The hash is computed as ``SHA256(app_id + currency + amount + hash_salt)``
17
+ with all values concatenated as plain strings with no separators.
18
+
19
+ Args:
20
+ app_id: Your OnePay application identifier.
21
+ currency: Three-letter ISO currency code (e.g., ``"LKR"``).
22
+ amount: The transaction amount as a string (e.g., ``"100.00"``).
23
+ hash_salt: Your OnePay hash salt (secret key).
24
+
25
+ Returns:
26
+ Lowercase hexadecimal SHA-256 hash string.
27
+
28
+ Example:
29
+ >>> generate_hash("APP123", "LKR", "100.00", "SALT456")
30
+ 'a1b2c3...'
31
+ """
32
+ payload = f"{app_id}{currency}{amount}{hash_salt}"
33
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
34
+
35
+
36
+ def build_auth_header(token: str) -> Dict[str, str]:
37
+ """Build the Authorization header dictionary.
38
+
39
+ Args:
40
+ token: The bearer/API token value.
41
+
42
+ Returns:
43
+ Dictionary with ``Authorization`` and ``Content-Type`` headers.
44
+ """
45
+ return {
46
+ "Authorization": token,
47
+ "Content-Type": "application/json",
48
+ }
49
+
50
+
51
+ def build_json_header() -> Dict[str, str]:
52
+ """Build a basic JSON Content-Type header (no auth).
53
+
54
+ Returns:
55
+ Dictionary with ``Content-Type`` header only.
56
+ """
57
+ return {
58
+ "Content-Type": "application/json",
59
+ }
onepay/_config.py ADDED
@@ -0,0 +1,123 @@
1
+ """Configuration management for the OnePay SDK.
2
+
3
+ Loads credentials from constructor arguments or environment variables.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from dataclasses import dataclass, field
10
+
11
+ _DEFAULT_BASE_URL = "https://api.onepay.lk"
12
+ _DEFAULT_TIMEOUT = 30.0
13
+ _DEFAULT_MAX_RETRIES = 3
14
+
15
+
16
+ @dataclass
17
+ class OnePayConfig:
18
+ """Holds all configuration for an OnePay client instance.
19
+
20
+ Values can be provided explicitly or fall back to environment variables:
21
+ - ``ONEPAY_APP_ID``
22
+ - ``ONEPAY_HASH_SALT``
23
+ - ``ONEPAY_APP_TOKEN``
24
+ - ``ONEPAY_API_KEY``
25
+ - ``ONEPAY_ACCESS_TOKEN``
26
+ - ``ONEPAY_BASE_URL``
27
+ """
28
+
29
+ app_id: str = ""
30
+ hash_salt: str = ""
31
+ app_token: str = ""
32
+ api_key: str = ""
33
+ access_token: str = ""
34
+ base_url: str = _DEFAULT_BASE_URL
35
+ timeout: float = _DEFAULT_TIMEOUT
36
+ max_retries: int = _DEFAULT_MAX_RETRIES
37
+ debug: bool = False
38
+
39
+ # Internal — resolved after __post_init__
40
+ _resolved: bool = field(default=False, repr=False, init=False)
41
+
42
+ def __post_init__(self) -> None:
43
+ """Resolve unset fields from environment variables."""
44
+ if not self._resolved:
45
+ self.app_id = self.app_id or os.environ.get("ONEPAY_APP_ID", "")
46
+ self.hash_salt = self.hash_salt or os.environ.get("ONEPAY_HASH_SALT", "")
47
+ self.app_token = self.app_token or os.environ.get("ONEPAY_APP_TOKEN", "")
48
+ self.api_key = self.api_key or os.environ.get("ONEPAY_API_KEY", "")
49
+ self.access_token = self.access_token or os.environ.get("ONEPAY_ACCESS_TOKEN", "")
50
+ self.base_url = self.base_url or os.environ.get(
51
+ "ONEPAY_BASE_URL", _DEFAULT_BASE_URL
52
+ )
53
+ # Strip trailing slashes from base URL
54
+ self.base_url = self.base_url.rstrip("/")
55
+ self._resolved = True
56
+
57
+ def get_app_token_or_raise(self) -> str:
58
+ """Return the app token, raising if not configured."""
59
+ if not self.app_token:
60
+ raise ValueError(
61
+ "app_token is required for this operation. "
62
+ "Pass it to the OnePay constructor or set the ONEPAY_APP_TOKEN environment variable."
63
+ )
64
+ return self.app_token
65
+
66
+ def get_api_key_or_raise(self) -> str:
67
+ """Return the API key, raising if not configured."""
68
+ if not self.api_key:
69
+ raise ValueError(
70
+ "api_key is required for this operation. "
71
+ "Pass it to the OnePay constructor or set the ONEPAY_API_KEY environment variable."
72
+ )
73
+ return self.api_key
74
+
75
+ def get_access_token_or_raise(self) -> str:
76
+ """Return the access token, raising if not configured."""
77
+ if not self.access_token:
78
+ raise ValueError(
79
+ "access_token is required for this operation. "
80
+ "Pass it to the OnePay constructor or set the ONEPAY_ACCESS_TOKEN environment variable."
81
+ )
82
+ return self.access_token
83
+
84
+ def get_hash_salt_or_raise(self) -> str:
85
+ """Return the hash salt, raising if not configured."""
86
+ if not self.hash_salt:
87
+ raise ValueError(
88
+ "hash_salt is required for this operation. "
89
+ "Pass it to the OnePay constructor or set the ONEPAY_HASH_SALT environment variable."
90
+ )
91
+ return self.hash_salt
92
+
93
+ def get_app_id_or_raise(self) -> str:
94
+ """Return the app ID, raising if not configured."""
95
+ if not self.app_id:
96
+ raise ValueError(
97
+ "app_id is required for this operation. "
98
+ "Pass it to the OnePay constructor or set the ONEPAY_APP_ID environment variable."
99
+ )
100
+ return self.app_id
101
+
102
+ def redacted_repr(self) -> str:
103
+ """Return a string representation with secrets redacted."""
104
+ def _redact(val: str) -> str:
105
+ if not val:
106
+ return "(not set)"
107
+ if len(val) <= 8:
108
+ return "***"
109
+ return val[:4] + "***" + val[-4:]
110
+
111
+ return (
112
+ f"OnePayConfig("
113
+ f"app_id={_redact(self.app_id)!r}, "
114
+ f"hash_salt={_redact(self.hash_salt)!r}, "
115
+ f"app_token={_redact(self.app_token)!r}, "
116
+ f"api_key={_redact(self.api_key)!r}, "
117
+ f"access_token={_redact(self.access_token)!r}, "
118
+ f"base_url={self.base_url!r}, "
119
+ f"timeout={self.timeout}, "
120
+ f"max_retries={self.max_retries}, "
121
+ f"debug={self.debug}"
122
+ f")"
123
+ )
onepay/_http.py ADDED
@@ -0,0 +1,326 @@
1
+ """HTTP client layer for the OnePay SDK.
2
+
3
+ Provides both synchronous and asynchronous HTTP clients with automatic
4
+ retry logic, error parsing, and structured logging.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import random
11
+ import time
12
+ from typing import Any, Dict, Optional, TypeVar
13
+
14
+ import httpx
15
+
16
+ from onepay._config import OnePayConfig
17
+ from onepay._version import __version__
18
+ from onepay.exceptions import (
19
+ APIError,
20
+ AuthenticationError,
21
+ InvalidRequestError,
22
+ NetworkError,
23
+ OnePayError,
24
+ RateLimitError,
25
+ )
26
+
27
+ logger = logging.getLogger("onepay")
28
+
29
+ T = TypeVar("T")
30
+
31
+ _USER_AGENT = f"onepay-python/{__version__}"
32
+
33
+
34
+ def _parse_error_response(
35
+ status_code: int,
36
+ response_data: Dict[str, Any],
37
+ ) -> APIError:
38
+ """Parse an HTTP error response into the appropriate exception type."""
39
+ message = response_data.get("message", "") or response_data.get("error", "")
40
+ if not message:
41
+ message = str(response_data)
42
+
43
+ # Detect specific 400 error types from the message
44
+ msg_lower = message.lower() if isinstance(message, str) else ""
45
+
46
+ if status_code == 401:
47
+ return AuthenticationError(message=message, raw_response=response_data)
48
+
49
+ if status_code == 429:
50
+ return RateLimitError(message=message, raw_response=response_data)
51
+
52
+ if status_code == 400:
53
+ if "invalid app" in msg_lower:
54
+ from onepay.exceptions import InvalidAppIdError
55
+
56
+ return InvalidAppIdError(message=message, raw_response=response_data)
57
+ if "invalid amount" in msg_lower:
58
+ from onepay.exceptions import InvalidAmountError
59
+
60
+ return InvalidAmountError(message=message, raw_response=response_data)
61
+ if "currency" in msg_lower and ("not available" in msg_lower or "invalid" in msg_lower):
62
+ from onepay.exceptions import InvalidCurrencyError
63
+
64
+ return InvalidCurrencyError(message=message, raw_response=response_data)
65
+ if "refund" in msg_lower:
66
+ from onepay.exceptions import RefundNotAllowedError
67
+
68
+ return RefundNotAllowedError(message=message, raw_response=response_data)
69
+ return InvalidRequestError(message=message, raw_response=response_data)
70
+
71
+ return APIError(message=message, status_code=status_code, raw_response=response_data)
72
+
73
+
74
+ def _should_retry(status_code: int) -> bool:
75
+ """Determine if a request should be retried based on status code."""
76
+ return status_code == 429 or status_code >= 500
77
+
78
+
79
+ def _backoff_delay(attempt: int, base: float = 0.5, max_delay: float = 30.0) -> float:
80
+ """Calculate exponential backoff delay with jitter."""
81
+ delay = min(base * (2 ** attempt), max_delay)
82
+ jitter = random.uniform(0, delay * 0.5) # noqa: S311
83
+ return float(delay + jitter)
84
+
85
+
86
+ class SyncHttpClient:
87
+ """Synchronous HTTP client backed by ``httpx.Client``."""
88
+
89
+ def __init__(self, config: OnePayConfig) -> None:
90
+ self._config = config
91
+ self._client = httpx.Client(
92
+ base_url=config.base_url,
93
+ timeout=httpx.Timeout(config.timeout),
94
+ headers={"User-Agent": _USER_AGENT},
95
+ )
96
+
97
+ def request(
98
+ self,
99
+ method: str,
100
+ path: str,
101
+ *,
102
+ json: Optional[Dict[str, Any]] = None,
103
+ params: Optional[Dict[str, Any]] = None,
104
+ headers: Optional[Dict[str, str]] = None,
105
+ ) -> Dict[str, Any]:
106
+ """Execute an HTTP request with retry logic.
107
+
108
+ Args:
109
+ method: HTTP method (GET, POST, PUT, DELETE).
110
+ path: API endpoint path (e.g., ``/v3/checkout/link/``).
111
+ json: JSON body for the request.
112
+ params: Query parameters.
113
+ headers: Additional headers.
114
+
115
+ Returns:
116
+ Parsed JSON response as a dictionary.
117
+
118
+ Raises:
119
+ APIError: On API error responses.
120
+ NetworkError: On connection or timeout failures.
121
+ """
122
+ last_error: Optional[Exception] = None
123
+
124
+ for attempt in range(self._config.max_retries + 1):
125
+ try:
126
+ if self._config.debug:
127
+ logger.debug(
128
+ "OnePay API request: %s %s (attempt %d/%d)",
129
+ method,
130
+ path,
131
+ attempt + 1,
132
+ self._config.max_retries + 1,
133
+ )
134
+
135
+ response = self._client.request(
136
+ method=method,
137
+ url=path,
138
+ json=json,
139
+ params=params,
140
+ headers=headers,
141
+ )
142
+
143
+ if self._config.debug:
144
+ logger.debug(
145
+ "OnePay API response: %d %s",
146
+ response.status_code,
147
+ path,
148
+ )
149
+
150
+ # Parse response
151
+ try:
152
+ response_data = response.json()
153
+ except Exception:
154
+ response_data = {"message": response.text}
155
+
156
+ # Success
157
+ if response.is_success:
158
+ return response_data # type: ignore[no-any-return]
159
+
160
+ # Check if we should retry
161
+ if _should_retry(response.status_code) and attempt < self._config.max_retries:
162
+ delay = _backoff_delay(attempt)
163
+ if self._config.debug:
164
+ logger.debug(
165
+ "Retrying in %.2fs after HTTP %d",
166
+ delay,
167
+ response.status_code,
168
+ )
169
+ time.sleep(delay)
170
+ last_error = _parse_error_response(response.status_code, response_data)
171
+ continue
172
+
173
+ # Non-retryable error
174
+ raise _parse_error_response(response.status_code, response_data)
175
+
176
+ except OnePayError:
177
+ raise
178
+ except httpx.TimeoutException as exc:
179
+ last_error = NetworkError(
180
+ message=f"Request timed out: {method} {path}",
181
+ original_error=exc,
182
+ )
183
+ if attempt < self._config.max_retries:
184
+ delay = _backoff_delay(attempt)
185
+ time.sleep(delay)
186
+ continue
187
+ raise last_error from exc
188
+ except httpx.HTTPError as exc:
189
+ last_error = NetworkError(
190
+ message=f"Network error: {exc}",
191
+ original_error=exc,
192
+ )
193
+ if attempt < self._config.max_retries:
194
+ delay = _backoff_delay(attempt)
195
+ time.sleep(delay)
196
+ continue
197
+ raise last_error from exc
198
+
199
+ # Should not reach here, but just in case
200
+ if last_error:
201
+ raise last_error
202
+ raise NetworkError(message="Request failed after all retries.")
203
+
204
+ def close(self) -> None:
205
+ """Close the underlying HTTP client."""
206
+ self._client.close()
207
+
208
+
209
+ class AsyncHttpClient:
210
+ """Asynchronous HTTP client backed by ``httpx.AsyncClient``."""
211
+
212
+ def __init__(self, config: OnePayConfig) -> None:
213
+ self._config = config
214
+ self._client = httpx.AsyncClient(
215
+ base_url=config.base_url,
216
+ timeout=httpx.Timeout(config.timeout),
217
+ headers={"User-Agent": _USER_AGENT},
218
+ )
219
+
220
+ async def request(
221
+ self,
222
+ method: str,
223
+ path: str,
224
+ *,
225
+ json: Optional[Dict[str, Any]] = None,
226
+ params: Optional[Dict[str, Any]] = None,
227
+ headers: Optional[Dict[str, str]] = None,
228
+ ) -> Dict[str, Any]:
229
+ """Execute an async HTTP request with retry logic.
230
+
231
+ Args:
232
+ method: HTTP method (GET, POST, PUT, DELETE).
233
+ path: API endpoint path (e.g., ``/v3/checkout/link/``).
234
+ json: JSON body for the request.
235
+ params: Query parameters.
236
+ headers: Additional headers.
237
+
238
+ Returns:
239
+ Parsed JSON response as a dictionary.
240
+
241
+ Raises:
242
+ APIError: On API error responses.
243
+ NetworkError: On connection or timeout failures.
244
+ """
245
+ import asyncio
246
+
247
+ last_error: Optional[Exception] = None
248
+
249
+ for attempt in range(self._config.max_retries + 1):
250
+ try:
251
+ if self._config.debug:
252
+ logger.debug(
253
+ "OnePay API request: %s %s (attempt %d/%d)",
254
+ method,
255
+ path,
256
+ attempt + 1,
257
+ self._config.max_retries + 1,
258
+ )
259
+
260
+ response = await self._client.request(
261
+ method=method,
262
+ url=path,
263
+ json=json,
264
+ params=params,
265
+ headers=headers,
266
+ )
267
+
268
+ if self._config.debug:
269
+ logger.debug(
270
+ "OnePay API response: %d %s",
271
+ response.status_code,
272
+ path,
273
+ )
274
+
275
+ try:
276
+ response_data = response.json()
277
+ except Exception:
278
+ response_data = {"message": response.text}
279
+
280
+ if response.is_success:
281
+ return response_data # type: ignore[no-any-return]
282
+
283
+ if _should_retry(response.status_code) and attempt < self._config.max_retries:
284
+ delay = _backoff_delay(attempt)
285
+ if self._config.debug:
286
+ logger.debug(
287
+ "Retrying in %.2fs after HTTP %d",
288
+ delay,
289
+ response.status_code,
290
+ )
291
+ await asyncio.sleep(delay)
292
+ last_error = _parse_error_response(response.status_code, response_data)
293
+ continue
294
+
295
+ raise _parse_error_response(response.status_code, response_data)
296
+
297
+ except OnePayError:
298
+ raise
299
+ except httpx.TimeoutException as exc:
300
+ last_error = NetworkError(
301
+ message=f"Request timed out: {method} {path}",
302
+ original_error=exc,
303
+ )
304
+ if attempt < self._config.max_retries:
305
+ delay = _backoff_delay(attempt)
306
+ await asyncio.sleep(delay)
307
+ continue
308
+ raise last_error from exc
309
+ except httpx.HTTPError as exc:
310
+ last_error = NetworkError(
311
+ message=f"Network error: {exc}",
312
+ original_error=exc,
313
+ )
314
+ if attempt < self._config.max_retries:
315
+ delay = _backoff_delay(attempt)
316
+ await asyncio.sleep(delay)
317
+ continue
318
+ raise last_error from exc
319
+
320
+ if last_error:
321
+ raise last_error
322
+ raise NetworkError(message="Request failed after all retries.")
323
+
324
+ async def close(self) -> None:
325
+ """Close the underlying async HTTP client."""
326
+ await self._client.aclose()
onepay/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"