epusdt 0.2.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.
epusdt/__init__.py ADDED
@@ -0,0 +1,78 @@
1
+ from .client import EpusdtClient
2
+ from .exceptions import (
3
+ APIError,
4
+ AuthenticationError,
5
+ ClientError,
6
+ EpusdtError,
7
+ NetworkError,
8
+ RequestTimeoutError,
9
+ ServerError,
10
+ SignatureError,
11
+ ValidationError,
12
+ )
13
+ from .models import (
14
+ CheckStatusResponse,
15
+ CheckoutOrder,
16
+ CreateOrderResponse,
17
+ EPayRedirectResponse,
18
+ EpayCallback,
19
+ EpayDefaults,
20
+ GmpayCallback,
21
+ ManualPaymentResponse,
22
+ Network,
23
+ OkpayConfig,
24
+ OrderStatus,
25
+ PaymentType,
26
+ PublicConfig,
27
+ SiteConfig,
28
+ SupportedAsset,
29
+ Token,
30
+ TradeStatus,
31
+ )
32
+ from .signature import (
33
+ build_epay_signing_string,
34
+ build_gmpay_signing_string,
35
+ generate_epay_signature,
36
+ generate_gmpay_signature,
37
+ verify_epay_signature,
38
+ verify_gmpay_signature,
39
+ )
40
+
41
+ __all__ = [
42
+ "APIError",
43
+ "AuthenticationError",
44
+ "CheckStatusResponse",
45
+ "CheckoutOrder",
46
+ "ClientError",
47
+ "CreateOrderResponse",
48
+ "EPayRedirectResponse",
49
+ "EpayCallback",
50
+ "EpayDefaults",
51
+ "EpusdtClient",
52
+ "EpusdtError",
53
+ "GmpayCallback",
54
+ "ManualPaymentResponse",
55
+ "Network",
56
+ "NetworkError",
57
+ "OkpayConfig",
58
+ "OrderStatus",
59
+ "PaymentType",
60
+ "PublicConfig",
61
+ "RequestTimeoutError",
62
+ "ServerError",
63
+ "SignatureError",
64
+ "SiteConfig",
65
+ "SupportedAsset",
66
+ "Token",
67
+ "TradeStatus",
68
+ "ValidationError",
69
+ "build_epay_signing_string",
70
+ "build_gmpay_signing_string",
71
+ "generate_epay_signature",
72
+ "generate_gmpay_signature",
73
+ "verify_epay_signature",
74
+ "verify_gmpay_signature",
75
+ ]
76
+
77
+ __version__ = "0.2.0"
78
+ __url__ = "https://github.com/Yufeifeio/epusdt-python-sdk"
epusdt/client.py ADDED
@@ -0,0 +1,463 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from decimal import Decimal
6
+ from enum import Enum
7
+ from typing import Any, Mapping, MutableMapping, Optional
8
+ from urllib.parse import urlencode, urljoin, urlparse
9
+
10
+ import requests
11
+
12
+ from .exceptions import (
13
+ APIError,
14
+ AuthenticationError,
15
+ ClientError,
16
+ NetworkError,
17
+ RequestTimeoutError,
18
+ ServerError,
19
+ SignatureError,
20
+ ValidationError,
21
+ )
22
+ from .models import (
23
+ CheckStatusResponse,
24
+ CheckoutOrder,
25
+ CreateOrderResponse,
26
+ EPayRedirectResponse,
27
+ EpayCallback,
28
+ GmpayCallback,
29
+ ManualPaymentResponse,
30
+ PublicConfig,
31
+ )
32
+ from .retry import call_with_retry
33
+ from .signature import (
34
+ generate_epay_signature,
35
+ generate_gmpay_signature,
36
+ stringify_params,
37
+ verify_epay_signature,
38
+ verify_gmpay_signature,
39
+ )
40
+
41
+
42
+ logger = logging.getLogger(__name__)
43
+ USER_AGENT = "epusdt/0.2.0"
44
+
45
+
46
+ def _require_text(name: str, value: Any) -> str:
47
+ if isinstance(value, Enum):
48
+ value = value.value
49
+ if value is None:
50
+ raise ValidationError(f"{name} is required")
51
+ text = str(value).strip()
52
+ if not text:
53
+ raise ValidationError(f"{name} is required")
54
+ return text
55
+
56
+
57
+ def _optional_text(name: str, value: Any) -> Optional[str]:
58
+ if value is None:
59
+ return None
60
+ if isinstance(value, Enum):
61
+ value = value.value
62
+ text = str(value).strip()
63
+ if not text:
64
+ return None
65
+ return text
66
+
67
+
68
+ def _validate_url(name: str, value: Optional[str]) -> Optional[str]:
69
+ if value is None:
70
+ return None
71
+ parsed = urlparse(value)
72
+ if parsed.scheme not in ("http", "https") or not parsed.netloc:
73
+ raise ValidationError(f"{name} must be a valid http(s) URL")
74
+ return value
75
+
76
+
77
+ def _is_numeric_pid(value: str) -> bool:
78
+ return value.isdigit()
79
+
80
+
81
+ def _normalize_amount(name: str, value: Any) -> Any:
82
+ if isinstance(value, bool):
83
+ raise ValidationError(f"{name} must be a number")
84
+ if isinstance(value, Decimal):
85
+ numeric = value
86
+ elif isinstance(value, (int, float)):
87
+ numeric = Decimal(str(value))
88
+ else:
89
+ raise ValidationError(f"{name} must be a number")
90
+ if numeric <= Decimal("0.01"):
91
+ raise ValidationError(f"{name} must be greater than 0.01")
92
+ if numeric == numeric.to_integral():
93
+ return int(numeric)
94
+ return float(numeric)
95
+
96
+
97
+ def _json_value(value: Any) -> Any:
98
+ if isinstance(value, Enum):
99
+ return value.value
100
+ if isinstance(value, Decimal):
101
+ if value == value.to_integral():
102
+ return int(value)
103
+ return float(value)
104
+ return value
105
+
106
+
107
+ class EpusdtClient:
108
+ def __init__(
109
+ self,
110
+ *,
111
+ base_url: str,
112
+ pid: Any,
113
+ secret_key: str,
114
+ timeout: float = 30.0,
115
+ max_retries: int = 2,
116
+ retry_delay: float = 0.5,
117
+ session: Optional[requests.Session] = None,
118
+ ) -> None:
119
+ parsed = urlparse(base_url)
120
+ if parsed.scheme not in ("http", "https") or not parsed.netloc:
121
+ raise ValidationError("base_url must be a valid http(s) URL")
122
+ if timeout <= 0:
123
+ raise ValidationError("timeout must be greater than 0")
124
+ if max_retries < 0:
125
+ raise ValidationError("max_retries must be >= 0")
126
+ if retry_delay < 0:
127
+ raise ValidationError("retry_delay must be >= 0")
128
+
129
+ self.base_url = base_url.rstrip("/")
130
+ self.pid = _require_text("pid", pid)
131
+ self.secret_key = _require_text("secret_key", secret_key)
132
+ self.timeout = timeout
133
+ self.max_retries = max_retries
134
+ self.retry_delay = retry_delay
135
+ self.session = session or requests.Session()
136
+ self.session.headers.setdefault("Accept", "application/json")
137
+ self.session.headers.setdefault("User-Agent", USER_AGENT)
138
+
139
+ def create_order(
140
+ self,
141
+ *,
142
+ order_id: Any,
143
+ amount: Any,
144
+ currency: Any = "cny",
145
+ notify_url: str,
146
+ token: Optional[Any] = None,
147
+ network: Optional[Any] = None,
148
+ redirect_url: Optional[str] = None,
149
+ name: Optional[Any] = None,
150
+ payment_type: Optional[Any] = None,
151
+ use_form: bool = False,
152
+ ) -> CreateOrderResponse:
153
+ token_text = _optional_text("token", token)
154
+ network_text = _optional_text("network", network)
155
+ if bool(token_text) != bool(network_text):
156
+ raise ValidationError("token and network must be both provided or both omitted")
157
+
158
+ payload: MutableMapping[str, Any] = {
159
+ "pid": self.pid,
160
+ "order_id": _require_text("order_id", order_id),
161
+ "currency": _require_text("currency", currency),
162
+ "amount": _normalize_amount("amount", amount),
163
+ "notify_url": _validate_url("notify_url", _require_text("notify_url", notify_url)),
164
+ }
165
+ if token_text:
166
+ payload["token"] = token_text
167
+ if network_text:
168
+ payload["network"] = network_text
169
+ redirect = _optional_text("redirect_url", redirect_url)
170
+ if redirect:
171
+ payload["redirect_url"] = _validate_url("redirect_url", redirect)
172
+ name_text = _optional_text("name", name)
173
+ if name_text:
174
+ payload["name"] = name_text
175
+ payment_type_text = _optional_text("payment_type", payment_type)
176
+ if payment_type_text:
177
+ if payment_type_text.lower() == "epay" and not _is_numeric_pid(self.pid):
178
+ raise ValidationError("payment_type=Epay requires a numeric pid")
179
+ payload["payment_type"] = payment_type_text
180
+
181
+ payload["signature"] = generate_gmpay_signature(payload, self.secret_key)
182
+ body = self._json_request(
183
+ "POST",
184
+ "/payments/gmpay/v1/order/create-transaction",
185
+ json_payload=payload if not use_form else None,
186
+ form_payload=payload if use_form else None,
187
+ )
188
+ return CreateOrderResponse.from_dict(body["data"])
189
+
190
+ def get_public_config(self) -> PublicConfig:
191
+ body = self._json_request("GET", "/payments/gmpay/v1/config")
192
+ return PublicConfig.from_dict(body["data"])
193
+
194
+ def get_checkout(self, trade_id: Any) -> CheckoutOrder:
195
+ body = self._json_request(
196
+ "GET",
197
+ f"/pay/checkout-counter-resp/{_require_text('trade_id', trade_id)}",
198
+ )
199
+ return CheckoutOrder.from_dict(body["data"])
200
+
201
+ def check_status(self, trade_id: Any) -> CheckStatusResponse:
202
+ body = self._json_request(
203
+ "GET",
204
+ f"/pay/check-status/{_require_text('trade_id', trade_id)}",
205
+ )
206
+ return CheckStatusResponse.from_dict(body["data"])
207
+
208
+ def switch_network(self, *, trade_id: Any, token: Any, network: Any) -> CheckoutOrder:
209
+ payload = {
210
+ "trade_id": _require_text("trade_id", trade_id),
211
+ "token": _require_text("token", token),
212
+ "network": _require_text("network", network),
213
+ }
214
+ body = self._json_request("POST", "/pay/switch-network", json_payload=payload)
215
+ return CheckoutOrder.from_dict(body["data"])
216
+
217
+ def submit_tx_hash(self, *, trade_id: Any, block_transaction_id: Any) -> ManualPaymentResponse:
218
+ payload = {
219
+ "block_transaction_id": _require_text("block_transaction_id", block_transaction_id),
220
+ }
221
+ body = self._json_request(
222
+ "POST",
223
+ f"/pay/submit-tx-hash/{_require_text('trade_id', trade_id)}",
224
+ json_payload=payload,
225
+ )
226
+ return ManualPaymentResponse.from_dict(body["data"])
227
+
228
+ def build_epay_params(
229
+ self,
230
+ *,
231
+ out_trade_no: Any,
232
+ money: Any,
233
+ notify_url: str,
234
+ return_url: Optional[str] = None,
235
+ name: Optional[Any] = None,
236
+ type: Any = "alipay",
237
+ token: Optional[Any] = None,
238
+ network: Optional[Any] = None,
239
+ currency: Optional[Any] = None,
240
+ sign_type: str = "MD5",
241
+ ) -> dict[str, str]:
242
+ if not _is_numeric_pid(self.pid):
243
+ raise ValidationError("EPay compatibility mode requires a numeric pid")
244
+
245
+ params: MutableMapping[str, Any] = {
246
+ "pid": self.pid,
247
+ "money": _normalize_amount("money", money),
248
+ "out_trade_no": _require_text("out_trade_no", out_trade_no),
249
+ "notify_url": _validate_url("notify_url", _require_text("notify_url", notify_url)),
250
+ "type": _require_text("type", type),
251
+ }
252
+ if return_url:
253
+ params["return_url"] = _validate_url("return_url", _require_text("return_url", return_url))
254
+ name_text = _optional_text("name", name)
255
+ if name_text:
256
+ params["name"] = name_text
257
+ token_text = _optional_text("token", token)
258
+ if token_text:
259
+ params["token"] = token_text
260
+ network_text = _optional_text("network", network)
261
+ if network_text:
262
+ params["network"] = network_text
263
+ currency_text = _optional_text("currency", currency)
264
+ if currency_text:
265
+ params["currency"] = currency_text
266
+ params["sign_type"] = _require_text("sign_type", sign_type)
267
+ params["sign"] = generate_epay_signature(params, self.secret_key)
268
+ return dict(stringify_params(params))
269
+
270
+ def build_epay_redirect_url(self, **kwargs: Any) -> str:
271
+ params = self.build_epay_params(**kwargs)
272
+ query = urlencode(params)
273
+ return f"{self.base_url}/payments/epay/v1/order/create-transaction/submit.php?{query}"
274
+
275
+ def create_epay_order(self, *, method: str = "GET", **kwargs: Any) -> EPayRedirectResponse:
276
+ params = self.build_epay_params(**kwargs)
277
+ method_upper = method.upper()
278
+ if method_upper not in {"GET", "POST"}:
279
+ raise ValidationError("method must be GET or POST")
280
+
281
+ if method_upper == "GET":
282
+ response = self._request(
283
+ method_upper,
284
+ "/payments/epay/v1/order/create-transaction/submit.php",
285
+ params=params,
286
+ allow_redirects=False,
287
+ )
288
+ else:
289
+ response = self._request(
290
+ method_upper,
291
+ "/payments/epay/v1/order/create-transaction/submit.php",
292
+ data=params,
293
+ allow_redirects=False,
294
+ )
295
+
296
+ if 300 <= response.status_code < 400 and response.headers.get("Location"):
297
+ location = response.headers["Location"]
298
+ return EPayRedirectResponse(
299
+ status_code=response.status_code,
300
+ location=location,
301
+ checkout_url=urljoin(f"{self.base_url}/", location),
302
+ params=params,
303
+ )
304
+
305
+ payload = None
306
+ try:
307
+ payload = response.json()
308
+ except ValueError:
309
+ payload = None
310
+ self._raise_for_response(response, payload if isinstance(payload, Mapping) else None)
311
+ raise ClientError(
312
+ f"unexpected response status: {response.status_code}",
313
+ http_status=response.status_code,
314
+ response_text=response.text,
315
+ )
316
+
317
+ def verify_gmpay_callback(
318
+ self,
319
+ payload: Mapping[str, Any],
320
+ *,
321
+ secret_key: Optional[str] = None,
322
+ ) -> bool:
323
+ return verify_gmpay_signature(payload, secret_key or self.secret_key)
324
+
325
+ def parse_gmpay_callback(
326
+ self,
327
+ payload: Mapping[str, Any],
328
+ *,
329
+ verify: bool = True,
330
+ secret_key: Optional[str] = None,
331
+ ) -> GmpayCallback:
332
+ if verify and not self.verify_gmpay_callback(payload, secret_key=secret_key):
333
+ raise SignatureError("invalid GMPay callback signature")
334
+ return GmpayCallback.from_dict(payload)
335
+
336
+ def verify_epay_callback(
337
+ self,
338
+ params: Mapping[str, Any],
339
+ *,
340
+ secret_key: Optional[str] = None,
341
+ ) -> bool:
342
+ return verify_epay_signature(params, secret_key or self.secret_key)
343
+
344
+ def parse_epay_callback(
345
+ self,
346
+ params: Mapping[str, Any],
347
+ *,
348
+ verify: bool = True,
349
+ secret_key: Optional[str] = None,
350
+ ) -> EpayCallback:
351
+ if verify and not self.verify_epay_callback(params, secret_key=secret_key):
352
+ raise SignatureError("invalid EPay callback signature")
353
+ return EpayCallback.from_dict(params)
354
+
355
+ def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
356
+ url = f"{self.base_url}{path}"
357
+
358
+ def send() -> requests.Response:
359
+ try:
360
+ response = self.session.request(method, url, timeout=self.timeout, **kwargs)
361
+ except requests.Timeout as exc:
362
+ raise RequestTimeoutError(str(exc)) from exc
363
+ except requests.RequestException as exc:
364
+ raise NetworkError(str(exc)) from exc
365
+ if response.status_code >= 500:
366
+ raise ServerError(
367
+ f"gateway returned HTTP {response.status_code}",
368
+ http_status=response.status_code,
369
+ response_text=response.text,
370
+ )
371
+ return response
372
+
373
+ return call_with_retry(
374
+ send,
375
+ max_retries=self.max_retries,
376
+ retry_delay=self.retry_delay,
377
+ retry_name=f"{method} {path}",
378
+ )
379
+
380
+ def _json_request(
381
+ self,
382
+ method: str,
383
+ path: str,
384
+ *,
385
+ json_payload: Optional[Mapping[str, Any]] = None,
386
+ form_payload: Optional[Mapping[str, Any]] = None,
387
+ ) -> Mapping[str, Any]:
388
+ kwargs: dict[str, Any] = {}
389
+ if json_payload is not None:
390
+ kwargs["json"] = {key: _json_value(value) for key, value in json_payload.items()}
391
+ if form_payload is not None:
392
+ kwargs["data"] = stringify_params(form_payload)
393
+ response = self._request(method, path, **kwargs)
394
+ return self._parse_json_response(response)
395
+
396
+ def _parse_json_response(self, response: requests.Response) -> Mapping[str, Any]:
397
+ try:
398
+ payload = response.json()
399
+ except ValueError as exc:
400
+ if response.status_code >= 400:
401
+ self._raise_for_response(response)
402
+ raise ClientError(
403
+ "gateway did not return valid JSON",
404
+ http_status=response.status_code,
405
+ response_text=response.text,
406
+ ) from exc
407
+
408
+ if not isinstance(payload, dict):
409
+ raise ClientError(
410
+ "gateway JSON response must be an object",
411
+ http_status=response.status_code,
412
+ response_text=json.dumps(payload, ensure_ascii=False),
413
+ )
414
+
415
+ if response.status_code >= 400:
416
+ self._raise_for_response(response, payload)
417
+
418
+ business_code = payload.get("status_code")
419
+ if business_code != 200:
420
+ raise APIError(
421
+ str(payload.get("message", "epusdt API error")),
422
+ business_code=int(business_code) if business_code is not None else None,
423
+ http_status=response.status_code,
424
+ response=payload,
425
+ )
426
+ return payload
427
+
428
+ def _raise_for_response(
429
+ self,
430
+ response: requests.Response,
431
+ payload: Optional[Mapping[str, Any]] = None,
432
+ ) -> None:
433
+ message = ""
434
+ business_code = None
435
+ if isinstance(payload, Mapping):
436
+ message = str(payload.get("message", "")).strip()
437
+ raw_code = payload.get("status_code")
438
+ if raw_code is not None:
439
+ try:
440
+ business_code = int(raw_code)
441
+ except (TypeError, ValueError):
442
+ business_code = None
443
+ if not message:
444
+ message = response.text.strip() or f"HTTP {response.status_code}"
445
+
446
+ if response.status_code == 401:
447
+ raise AuthenticationError(
448
+ message,
449
+ http_status=response.status_code,
450
+ response_text=response.text,
451
+ )
452
+ if payload is not None:
453
+ raise APIError(
454
+ message,
455
+ business_code=business_code,
456
+ http_status=response.status_code,
457
+ response=payload,
458
+ )
459
+ raise ClientError(
460
+ message,
461
+ http_status=response.status_code,
462
+ response_text=response.text,
463
+ )
epusdt/exceptions.py ADDED
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+
6
+ class EpusdtError(Exception):
7
+ """Base exception for this SDK."""
8
+
9
+
10
+ class ValidationError(EpusdtError):
11
+ """Raised when user input is invalid before the request is sent."""
12
+
13
+
14
+ class SignatureError(EpusdtError):
15
+ """Raised when callback signature verification fails."""
16
+
17
+
18
+ class NetworkError(EpusdtError):
19
+ """Raised when the request cannot reach the gateway."""
20
+
21
+
22
+ class RequestTimeoutError(EpusdtError):
23
+ """Raised when the gateway request times out."""
24
+
25
+
26
+ class HTTPError(EpusdtError):
27
+ """Base class for raw HTTP failures."""
28
+
29
+ def __init__(
30
+ self,
31
+ message: str,
32
+ *,
33
+ http_status: Optional[int] = None,
34
+ response_text: Optional[str] = None,
35
+ ) -> None:
36
+ super().__init__(message)
37
+ self.http_status = http_status
38
+ self.response_text = response_text
39
+
40
+
41
+ class ClientError(HTTPError):
42
+ """Raised for non-auth client-side HTTP errors."""
43
+
44
+
45
+ class AuthenticationError(ClientError):
46
+ """Raised when the gateway rejects credentials or signature."""
47
+
48
+
49
+ class ServerError(HTTPError):
50
+ """Raised for retryable 5xx gateway errors."""
51
+
52
+
53
+ class APIError(EpusdtError):
54
+ """Raised when the gateway returns a business error payload."""
55
+
56
+ def __init__(
57
+ self,
58
+ message: str,
59
+ *,
60
+ business_code: Optional[int] = None,
61
+ http_status: Optional[int] = None,
62
+ response: Optional[Any] = None,
63
+ ) -> None:
64
+ super().__init__(message)
65
+ self.business_code = business_code
66
+ self.http_status = http_status
67
+ self.response = response
68
+