epoint-python-sdk 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.
- epoint_python_sdk-0.2.0.dist-info/METADATA +154 -0
- epoint_python_sdk-0.2.0.dist-info/RECORD +19 -0
- epoint_python_sdk-0.2.0.dist-info/WHEEL +4 -0
- epoint_python_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
- epoint_sdk/__init__.py +73 -0
- epoint_sdk/_core.py +209 -0
- epoint_sdk/_version.py +1 -0
- epoint_sdk/callbacks.py +31 -0
- epoint_sdk/client.py +55 -0
- epoint_sdk/config.py +108 -0
- epoint_sdk/enums.py +61 -0
- epoint_sdk/errors.py +73 -0
- epoint_sdk/messages.py +60 -0
- epoint_sdk/messages.yaml +43 -0
- epoint_sdk/models.py +278 -0
- epoint_sdk/money.py +40 -0
- epoint_sdk/py.typed +0 -0
- epoint_sdk/resources.py +374 -0
- epoint_sdk/signing.py +49 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: epoint-python-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Typed Python client for the Epoint (epoint.az) payment gateway, sync and async, safe against double charges.
|
|
5
|
+
Project-URL: Homepage, https://theqiyas.cc/projects/epoint-python-sdk/
|
|
6
|
+
Project-URL: Source, https://github.com/qiyascc/epoint-python-sdk
|
|
7
|
+
Project-URL: Author, https://theqiyas.cc
|
|
8
|
+
Project-URL: Issues, https://github.com/qiyascc/epoint-python-sdk/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/qiyascc/epoint-python-sdk/blob/main/CHANGELOG.md
|
|
10
|
+
Author-email: qiyascc <qiyasturk@gmail.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: azerbaijan,azn,checkout,epoint,gateway,payment,sdk
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: goodconf>=7.1
|
|
26
|
+
Requires-Dist: httpx<1,>=0.24
|
|
27
|
+
Requires-Dist: pydantic>=2.7
|
|
28
|
+
Requires-Dist: pyyaml>=6
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: goodconf[toml]>=7.1; extra == 'dev'
|
|
31
|
+
Requires-Dist: goodconf[yaml]>=7.1; extra == 'dev'
|
|
32
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
35
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
36
|
+
Requires-Dist: types-pyyaml; extra == 'dev'
|
|
37
|
+
Provides-Extra: toml
|
|
38
|
+
Requires-Dist: goodconf[toml]>=7.1; extra == 'toml'
|
|
39
|
+
Provides-Extra: yaml
|
|
40
|
+
Requires-Dist: goodconf[yaml]>=7.1; extra == 'yaml'
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
|
|
43
|
+
# epoint-python-sdk
|
|
44
|
+
|
|
45
|
+
[](https://www.python.org)
|
|
46
|
+
[](https://epoint.az)
|
|
47
|
+
[](https://mypy-lang.org)
|
|
48
|
+
[](https://www.python-httpx.org)
|
|
49
|
+
[](https://docs.astral.sh/ruff)
|
|
50
|
+
[](#apple-pay-and-google-pay)
|
|
51
|
+
[](#apple-pay-and-google-pay)
|
|
52
|
+
[](https://github.com/qiyascc/epoint-python-sdk/blob/main/LICENSE)
|
|
53
|
+
|
|
54
|
+
Typed Python client for the [Epoint](https://epoint.az) payment gateway. Sync and async, every endpoint, signed callbacks, and retry rules built so a payment is never sent twice.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install epoint-python-sdk
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Pay
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from epoint_sdk import Epoint
|
|
64
|
+
|
|
65
|
+
with Epoint("i000000001", "PRIVATE_KEY", success_url="https://shop.az/ok", error_url="https://shop.az/fail") as ep:
|
|
66
|
+
checkout = ep.payments.create("19.90", "ORDER-1042", description="Pro plan")
|
|
67
|
+
redirect(checkout.redirect_url)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Confirm
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
tx = ep.verify_callback(request.POST)
|
|
74
|
+
if tx.paid:
|
|
75
|
+
fulfil(tx.order_id, tx.amount, tx.transaction)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Never trust the redirect alone. Use the signed callback, or ask the gateway:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
ep.payments.status(checkout.transaction).paid
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Apple Pay and Google Pay
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
widget = ep.widgets.create("19.90", "ORDER-1043", "Pro plan")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```html
|
|
91
|
+
<iframe src="{{ widget.widget_url }}" allow="payment" title="Apple Pay / Google Pay"></iframe>
|
|
92
|
+
<script>
|
|
93
|
+
addEventListener("message", (e) => {
|
|
94
|
+
if (e.origin !== "{{ widget.origin }}") return;
|
|
95
|
+
fetch("/orders/1043/check/", { method: "POST" });
|
|
96
|
+
});
|
|
97
|
+
</script>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The page needs `Permissions-Policy: payment=(self "https://epoint.az")`. The `postMessage` is a hint only: `WidgetMessage.from_event(data)` reads it, then confirm on the server with `ep.payments.status(widget.transaction)` or the signed callback. Epoint enables token payments per merchant. `ep.widgets.split(...)` is the split variant.
|
|
101
|
+
|
|
102
|
+
## Safety
|
|
103
|
+
|
|
104
|
+
| Situation | What the client does |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| Connection refused, nothing sent | Retries, then `NotSentError` |
|
|
107
|
+
| Timeout, cut connection or 5xx on a call that moves money | No retry, `OutcomeUnknownError` with the `order_id` |
|
|
108
|
+
| Same failure on a read (status, card, lists) | Retries with backoff |
|
|
109
|
+
| 429 | Retries, the gateway did not process it |
|
|
110
|
+
| `status: error` from the gateway | `GatewayError` with `code` and `trace_id` |
|
|
111
|
+
| Declined card on a direct charge | Returns `Transaction` with `failed`, never raises |
|
|
112
|
+
| Amount like `1.005`, `0`, `NaN` or `True` | `InvalidRequestError` before anything is sent |
|
|
113
|
+
| `base_url` over plain http | `ConfigurationError`, except localhost or `allow_insecure_http` |
|
|
114
|
+
| Callback `data` over 64 KB | Rejected before hashing |
|
|
115
|
+
| Callback for another order or amount | `tx.matches(order_id=..., amount=...)` is `False` |
|
|
116
|
+
|
|
117
|
+
Reuse the same `order_id` when you retry. Epoint refuses a used one, so a double click or a crash can not charge twice. Amounts are `Decimal` end to end, and signatures are compared in constant time.
|
|
118
|
+
|
|
119
|
+
## Endpoints
|
|
120
|
+
|
|
121
|
+
| Namespace | Methods |
|
|
122
|
+
| --- | --- |
|
|
123
|
+
| `payments` | `create` `form` `status` `reverse` `hold` `capture` |
|
|
124
|
+
| `cards` | `register` `register_and_pay` `charge` `status` `payout` |
|
|
125
|
+
| `split` | `create` `charge` `register_and_pay` |
|
|
126
|
+
| `installments` | `plans` `create` |
|
|
127
|
+
| `wallets` | `list` `pay` |
|
|
128
|
+
| `widgets` | `create` `split` (Apple Pay, Google Pay) |
|
|
129
|
+
| `invoices` | `create` `update` `get` `list` `send_sms` `send_email` |
|
|
130
|
+
| `transfers` | `create` `status` (B2B) |
|
|
131
|
+
| client | `heartbeat` `verify_callback` `request` |
|
|
132
|
+
|
|
133
|
+
`AsyncEpoint` has the same methods, awaited.
|
|
134
|
+
|
|
135
|
+
## Config
|
|
136
|
+
|
|
137
|
+
Pass values in code, or set `EPOINT_PUBLIC_KEY`, `EPOINT_PRIVATE_KEY`, `EPOINT_SUCCESS_URL`, `EPOINT_ERROR_URL`, `EPOINT_LANGUAGE`, `EPOINT_TIMEOUT`, `EPOINT_MAX_RETRIES`, or put them in `epoint.toml`. Every path is a setting (`EPOINT_ENDPOINTS__PAYMENT=...`) and `base_url` can point at a sandbox such as [epoint-sandbox](https://github.com/martian56/epoint-sandbox). Messages live in `messages.yaml` and can be replaced with `messages.use(...)`.
|
|
138
|
+
|
|
139
|
+
## Develop
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
pip install -e ".[dev]" && pytest && ruff check . && mypy src
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Links
|
|
146
|
+
|
|
147
|
+
- Source code: [github.com/qiyascc/epoint-python-sdk](https://github.com/qiyascc/epoint-python-sdk)
|
|
148
|
+
- PyPI: [pypi.org/project/epoint-python-sdk](https://pypi.org/project/epoint-python-sdk/)
|
|
149
|
+
- Project page: [theqiyas.cc/projects/epoint-python-sdk](https://theqiyas.cc/projects/epoint-python-sdk/)
|
|
150
|
+
- Author: [Qiyas Sadıqov](https://theqiyas.cc), more projects at [theqiyas.cc](https://theqiyas.cc)
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
MIT
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
epoint_sdk/__init__.py,sha256=WYFCCz55UnbNnEjfKIHHwmKXyNrnJVAcZ0uR-TtLVVU,1437
|
|
2
|
+
epoint_sdk/_core.py,sha256=AuaYPignhuMOFngYyNXGR3alE4GTU6zHVUDqlcX3yAY,8803
|
|
3
|
+
epoint_sdk/_version.py,sha256=Zn1KFblwuFHiDRdRAiRnDBRkbPttWh44jKa5zG2ov0E,22
|
|
4
|
+
epoint_sdk/callbacks.py,sha256=tAkpr98K_tnsCjQ943I0-aaoSt1ghCfnLgXrRUPXHBY,1021
|
|
5
|
+
epoint_sdk/client.py,sha256=MYDPrbxPT9h7sboDUpsd3T9qKCm9wsbuNykJLWAEi3M,2019
|
|
6
|
+
epoint_sdk/config.py,sha256=18Gcsdk3GGFVmbuKQDGWRDBGWwniNFO1ZHenLxA-pX0,5084
|
|
7
|
+
epoint_sdk/enums.py,sha256=RCCUEeUwTwKhxqxBah64-_fC84YvtcWc6n_17hKDl6A,1185
|
|
8
|
+
epoint_sdk/errors.py,sha256=2Xa9GwYhom4jDlS1XO69neubPlQEWVJbzalnYots30E,1863
|
|
9
|
+
epoint_sdk/messages.py,sha256=GqqTi5PvVlt2G-F6llTb3_gMTlRgVj4DHMUXZQzr7ZY,1601
|
|
10
|
+
epoint_sdk/messages.yaml,sha256=NnpfG5laHqoIn7I_9Ok5Qs-Rp23zuM15wZB48ArCY1I,2419
|
|
11
|
+
epoint_sdk/models.py,sha256=45ElUKtm3HeiYqdfgSifoWEZfQ_PhSqKhBdqBlnSr0Y,9262
|
|
12
|
+
epoint_sdk/money.py,sha256=sgmV7wT0Q_khLGY7pO--LAyJDPMwEdJXspuNsxSG03Y,1443
|
|
13
|
+
epoint_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
epoint_sdk/resources.py,sha256=IamTdeJTgjab5A3I8KOyc-S4YXG-RDzOJxC9LBnX9RU,19984
|
|
15
|
+
epoint_sdk/signing.py,sha256=ZHDgS9D5gjFhITB_GSoFq8pHGE43dNdZW8LjYVdT_R0,1552
|
|
16
|
+
epoint_python_sdk-0.2.0.dist-info/METADATA,sha256=eaNaIf7tbmeJ3E7RR0QULF6eJ8i1goxEH34drqFzrIU,6847
|
|
17
|
+
epoint_python_sdk-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
18
|
+
epoint_python_sdk-0.2.0.dist-info/licenses/LICENSE,sha256=ENVL-_Eh4n1SumZuLuTjO2K0OOL_eXh4pkeme35b02I,1064
|
|
19
|
+
epoint_python_sdk-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 qiyascc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
epoint_sdk/__init__.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from . import messages, signing
|
|
2
|
+
from ._version import __version__
|
|
3
|
+
from .callbacks import callback_form, verify_callback
|
|
4
|
+
from .client import AsyncEpoint, Epoint
|
|
5
|
+
from .config import Settings, load_settings
|
|
6
|
+
from .enums import CardStatus, Currency, Language, OperationCode, Status
|
|
7
|
+
from .errors import (
|
|
8
|
+
ConfigurationError,
|
|
9
|
+
EpointError,
|
|
10
|
+
GatewayError,
|
|
11
|
+
HTTPStatusError,
|
|
12
|
+
InvalidRequestError,
|
|
13
|
+
NotSentError,
|
|
14
|
+
OutcomeUnknownError,
|
|
15
|
+
SignatureError,
|
|
16
|
+
TransportError,
|
|
17
|
+
)
|
|
18
|
+
from .models import (
|
|
19
|
+
Callback,
|
|
20
|
+
Card,
|
|
21
|
+
CardRegistration,
|
|
22
|
+
CheckoutForm,
|
|
23
|
+
InstallmentPlan,
|
|
24
|
+
Invoice,
|
|
25
|
+
Redirect,
|
|
26
|
+
Result,
|
|
27
|
+
Transaction,
|
|
28
|
+
Transfer,
|
|
29
|
+
Wallet,
|
|
30
|
+
Widget,
|
|
31
|
+
WidgetMessage,
|
|
32
|
+
origin_of,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"__version__",
|
|
37
|
+
"Epoint",
|
|
38
|
+
"AsyncEpoint",
|
|
39
|
+
"Settings",
|
|
40
|
+
"load_settings",
|
|
41
|
+
"messages",
|
|
42
|
+
"signing",
|
|
43
|
+
"verify_callback",
|
|
44
|
+
"callback_form",
|
|
45
|
+
"Status",
|
|
46
|
+
"CardStatus",
|
|
47
|
+
"Currency",
|
|
48
|
+
"Language",
|
|
49
|
+
"OperationCode",
|
|
50
|
+
"Callback",
|
|
51
|
+
"Card",
|
|
52
|
+
"CardRegistration",
|
|
53
|
+
"CheckoutForm",
|
|
54
|
+
"InstallmentPlan",
|
|
55
|
+
"Invoice",
|
|
56
|
+
"Redirect",
|
|
57
|
+
"Result",
|
|
58
|
+
"Transaction",
|
|
59
|
+
"Transfer",
|
|
60
|
+
"Wallet",
|
|
61
|
+
"Widget",
|
|
62
|
+
"WidgetMessage",
|
|
63
|
+
"origin_of",
|
|
64
|
+
"EpointError",
|
|
65
|
+
"ConfigurationError",
|
|
66
|
+
"InvalidRequestError",
|
|
67
|
+
"SignatureError",
|
|
68
|
+
"TransportError",
|
|
69
|
+
"NotSentError",
|
|
70
|
+
"OutcomeUnknownError",
|
|
71
|
+
"HTTPStatusError",
|
|
72
|
+
"GatewayError",
|
|
73
|
+
]
|
epoint_sdk/_core.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import random
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Callable, Mapping, Optional, TypeVar
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from . import signing
|
|
12
|
+
from .config import Settings, load_settings
|
|
13
|
+
from .enums import Status
|
|
14
|
+
from .errors import GatewayError, HTTPStatusError, NotSentError, OutcomeUnknownError, TransportError
|
|
15
|
+
from .messages import text
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
|
|
19
|
+
NOT_SENT = (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout, httpx.UnsupportedProtocol)
|
|
20
|
+
RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Op:
|
|
25
|
+
endpoint: str
|
|
26
|
+
payload: Optional[dict[str, Any]] = None
|
|
27
|
+
parse: Callable[[Any], Any] = lambda body: body
|
|
28
|
+
safe: bool = False
|
|
29
|
+
method: str = "POST"
|
|
30
|
+
signed: bool = True
|
|
31
|
+
json_body: bool = False
|
|
32
|
+
path_params: Mapping[str, Any] = field(default_factory=dict)
|
|
33
|
+
payment_state: bool = False
|
|
34
|
+
order_id: Optional[str] = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def clean(payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
38
|
+
return {key: value for key, value in payload.items() if value is not None and value != ""}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Outcome:
|
|
42
|
+
def __init__(self, value: Any = None, error: Optional[Exception] = None, retry: bool = False) -> None:
|
|
43
|
+
self.value = value
|
|
44
|
+
self.error = error
|
|
45
|
+
self.retry = retry
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Engine:
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
public_key: Optional[str] = None,
|
|
52
|
+
private_key: Optional[str] = None,
|
|
53
|
+
*,
|
|
54
|
+
settings: Optional[Settings] = None,
|
|
55
|
+
config_file: Optional[str] = None,
|
|
56
|
+
**overrides: Any,
|
|
57
|
+
) -> None:
|
|
58
|
+
if settings is None:
|
|
59
|
+
settings = load_settings(config_file, public_key=public_key, private_key=private_key, **overrides)
|
|
60
|
+
elif public_key or private_key or overrides:
|
|
61
|
+
changes = {k: v for k, v in dict(overrides, public_key=public_key, private_key=private_key).items()
|
|
62
|
+
if v is not None}
|
|
63
|
+
current = settings.model_dump()
|
|
64
|
+
current["private_key"] = settings.private_key.get_secret_value()
|
|
65
|
+
settings = load_settings(None, **{**current, **changes})
|
|
66
|
+
self.settings = settings
|
|
67
|
+
settings.check_transport()
|
|
68
|
+
self.public_key, self._private_key = settings.keys()
|
|
69
|
+
|
|
70
|
+
def __repr__(self) -> str:
|
|
71
|
+
return f"{type(self).__name__}(public_key={self.public_key!r}, base_url={self.settings.base_url!r})"
|
|
72
|
+
|
|
73
|
+
def _timeout(self) -> httpx.Timeout:
|
|
74
|
+
return httpx.Timeout(self.settings.timeout, connect=self.settings.connect_timeout)
|
|
75
|
+
|
|
76
|
+
def _headers(self) -> dict[str, str]:
|
|
77
|
+
return {"Accept": "application/json", "User-Agent": self.settings.resolved_user_agent()}
|
|
78
|
+
|
|
79
|
+
def sign(self, payload: Mapping[str, Any]) -> dict[str, str]:
|
|
80
|
+
body = {"public_key": self.public_key, **clean(payload)}
|
|
81
|
+
data = signing.encode(body)
|
|
82
|
+
return {"data": data, "signature": signing.sign(self._private_key, data)}
|
|
83
|
+
|
|
84
|
+
def _request_args(self, op: Op) -> dict[str, Any]:
|
|
85
|
+
args: dict[str, Any] = {"headers": self._headers()}
|
|
86
|
+
if op.method == "POST":
|
|
87
|
+
body = self.sign(op.payload or {}) if op.signed else clean(op.payload or {})
|
|
88
|
+
args["json" if op.json_body else "data"] = body
|
|
89
|
+
return args
|
|
90
|
+
|
|
91
|
+
def _wait(self, attempt: int) -> float:
|
|
92
|
+
base = self.settings.backoff_base
|
|
93
|
+
return min(self.settings.backoff_cap, base * (2 ** attempt)) + random.uniform(0, base / 2)
|
|
94
|
+
|
|
95
|
+
def _on_exception(self, op: Op, exc: Exception, attempt: int) -> Outcome:
|
|
96
|
+
path = self.settings.url(op.endpoint, **op.path_params)
|
|
97
|
+
if isinstance(exc, NOT_SENT):
|
|
98
|
+
if attempt < self.settings.max_retries:
|
|
99
|
+
return Outcome(retry=True)
|
|
100
|
+
return Outcome(error=NotSentError(text("error.connect", error=exc), path=path, order_id=op.order_id))
|
|
101
|
+
if op.safe:
|
|
102
|
+
if attempt < self.settings.max_retries:
|
|
103
|
+
return Outcome(retry=True)
|
|
104
|
+
return Outcome(error=TransportError(text("error.timeout", error=exc), path=path, order_id=op.order_id))
|
|
105
|
+
return Outcome(error=OutcomeUnknownError(text("error.unknown_outcome", reason=type(exc).__name__), path=path,
|
|
106
|
+
order_id=op.order_id))
|
|
107
|
+
|
|
108
|
+
def _on_response(self, op: Op, response: httpx.Response, attempt: int) -> Outcome:
|
|
109
|
+
path = self.settings.url(op.endpoint, **op.path_params)
|
|
110
|
+
status = response.status_code
|
|
111
|
+
if status in RETRY_STATUSES and (op.safe or status == 429) and attempt < self.settings.max_retries:
|
|
112
|
+
return Outcome(retry=True)
|
|
113
|
+
if status >= 500 and not op.safe:
|
|
114
|
+
return Outcome(error=OutcomeUnknownError(text("error.unknown_outcome", reason=f"HTTP {status}"),
|
|
115
|
+
status_code=status, path=path, order_id=op.order_id))
|
|
116
|
+
try:
|
|
117
|
+
body = response.json()
|
|
118
|
+
except ValueError:
|
|
119
|
+
snippet = response.text[:200]
|
|
120
|
+
if status < 400 and not op.safe:
|
|
121
|
+
return Outcome(error=OutcomeUnknownError(text("error.body", body=snippet), status_code=status,
|
|
122
|
+
path=path, order_id=op.order_id))
|
|
123
|
+
return Outcome(error=HTTPStatusError(text("error.body", body=snippet), status_code=status, body=snippet,
|
|
124
|
+
path=path))
|
|
125
|
+
if isinstance(body, dict):
|
|
126
|
+
refused = Status.of(body.get("status")) is Status.ERROR
|
|
127
|
+
if refused and not (op.payment_state and body.get("transaction")):
|
|
128
|
+
message = str(body.get("message") or body.get("detail") or text("error.refused"))
|
|
129
|
+
code = body.get("code")
|
|
130
|
+
return Outcome(error=GatewayError(message, code=str(code) if code not in (None, "") else None,
|
|
131
|
+
trace_id=body.get("trace_id"), status_code=status, payload=body,
|
|
132
|
+
path=path))
|
|
133
|
+
if status >= 400:
|
|
134
|
+
detail = body.get("detail") or body.get("message") if isinstance(body, dict) else None
|
|
135
|
+
return Outcome(error=HTTPStatusError(str(detail or text("error.status", status=status)), status_code=status,
|
|
136
|
+
body=body, path=path))
|
|
137
|
+
return Outcome(value=op.parse(body))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class SyncEngine(Engine):
|
|
141
|
+
def __init__(self, *args: Any, http_client: Optional[httpx.Client] = None, **kwargs: Any) -> None:
|
|
142
|
+
super().__init__(*args, **kwargs)
|
|
143
|
+
self._owns_http = http_client is None
|
|
144
|
+
self._http = http_client or httpx.Client(timeout=self._timeout())
|
|
145
|
+
|
|
146
|
+
def close(self) -> None:
|
|
147
|
+
if self._owns_http:
|
|
148
|
+
self._http.close()
|
|
149
|
+
|
|
150
|
+
def __enter__(self: T) -> T:
|
|
151
|
+
return self
|
|
152
|
+
|
|
153
|
+
def __exit__(self, *exc: Any) -> None:
|
|
154
|
+
self.close()
|
|
155
|
+
|
|
156
|
+
def _run(self, op: Op) -> Any:
|
|
157
|
+
url = self.settings.url(op.endpoint, **op.path_params)
|
|
158
|
+
args = self._request_args(op)
|
|
159
|
+
attempt = 0
|
|
160
|
+
while True:
|
|
161
|
+
try:
|
|
162
|
+
response = self._http.request(op.method, url, **args)
|
|
163
|
+
except httpx.HTTPError as exc:
|
|
164
|
+
outcome = self._on_exception(op, exc, attempt)
|
|
165
|
+
else:
|
|
166
|
+
outcome = self._on_response(op, response, attempt)
|
|
167
|
+
if outcome.retry:
|
|
168
|
+
time.sleep(self._wait(attempt))
|
|
169
|
+
attempt += 1
|
|
170
|
+
continue
|
|
171
|
+
if outcome.error is not None:
|
|
172
|
+
raise outcome.error
|
|
173
|
+
return outcome.value
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class AsyncEngine(Engine):
|
|
177
|
+
def __init__(self, *args: Any, http_client: Optional[httpx.AsyncClient] = None, **kwargs: Any) -> None:
|
|
178
|
+
super().__init__(*args, **kwargs)
|
|
179
|
+
self._owns_http = http_client is None
|
|
180
|
+
self._http = http_client or httpx.AsyncClient(timeout=self._timeout())
|
|
181
|
+
|
|
182
|
+
async def close(self) -> None:
|
|
183
|
+
if self._owns_http:
|
|
184
|
+
await self._http.aclose()
|
|
185
|
+
|
|
186
|
+
async def __aenter__(self: T) -> T:
|
|
187
|
+
return self
|
|
188
|
+
|
|
189
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
190
|
+
await self.close()
|
|
191
|
+
|
|
192
|
+
async def _run(self, op: Op) -> Any:
|
|
193
|
+
url = self.settings.url(op.endpoint, **op.path_params)
|
|
194
|
+
args = self._request_args(op)
|
|
195
|
+
attempt = 0
|
|
196
|
+
while True:
|
|
197
|
+
try:
|
|
198
|
+
response = await self._http.request(op.method, url, **args)
|
|
199
|
+
except httpx.HTTPError as exc:
|
|
200
|
+
outcome = self._on_exception(op, exc, attempt)
|
|
201
|
+
else:
|
|
202
|
+
outcome = self._on_response(op, response, attempt)
|
|
203
|
+
if outcome.retry:
|
|
204
|
+
await asyncio.sleep(self._wait(attempt))
|
|
205
|
+
attempt += 1
|
|
206
|
+
continue
|
|
207
|
+
if outcome.error is not None:
|
|
208
|
+
raise outcome.error
|
|
209
|
+
return outcome.value
|
epoint_sdk/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
epoint_sdk/callbacks.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Mapping, Union
|
|
4
|
+
|
|
5
|
+
from . import signing
|
|
6
|
+
from .errors import SignatureError
|
|
7
|
+
from .messages import text
|
|
8
|
+
from .models import Transaction
|
|
9
|
+
|
|
10
|
+
Form = Mapping[str, Any]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _field(form: Form, key: str) -> str:
|
|
14
|
+
value = form.get(key)
|
|
15
|
+
if isinstance(value, (list, tuple)):
|
|
16
|
+
value = value[0] if value else ""
|
|
17
|
+
return str(value or "").strip()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def verify_callback(private_key: str, data: Union[str, Form], signature: str = "") -> Transaction:
|
|
21
|
+
raw = data if isinstance(data, str) else _field(data, "data")
|
|
22
|
+
if not isinstance(data, str):
|
|
23
|
+
signature = _field(data, "signature")
|
|
24
|
+
if not raw or not signature:
|
|
25
|
+
raise SignatureError(text("error.callback_fields"))
|
|
26
|
+
return Transaction.from_api(signing.verify(private_key, raw, signature))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def callback_form(private_key: str, payload: Mapping[str, Any]) -> dict[str, str]:
|
|
30
|
+
data = signing.encode(dict(payload))
|
|
31
|
+
return {"data": data, "signature": signing.sign(private_key, data)}
|
epoint_sdk/client.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Mapping, Optional, Union
|
|
4
|
+
|
|
5
|
+
from ._core import AsyncEngine, Op, SyncEngine
|
|
6
|
+
from .callbacks import verify_callback
|
|
7
|
+
from .models import Transaction
|
|
8
|
+
from .resources import Cards, Installments, Invoices, Payments, Split, Transfers, Wallets, Widgets
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _alive(body: Any) -> bool:
|
|
12
|
+
return isinstance(body, Mapping) and str(body.get("status", "")).lower() == "ok"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _Namespaces:
|
|
16
|
+
_private_key: str
|
|
17
|
+
|
|
18
|
+
def _mount(self) -> None:
|
|
19
|
+
self.payments = Payments(self)
|
|
20
|
+
self.cards = Cards(self)
|
|
21
|
+
self.split = Split(self)
|
|
22
|
+
self.installments = Installments(self)
|
|
23
|
+
self.wallets = Wallets(self)
|
|
24
|
+
self.widgets = Widgets(self)
|
|
25
|
+
self.invoices = Invoices(self)
|
|
26
|
+
self.transfers = Transfers(self)
|
|
27
|
+
|
|
28
|
+
def verify_callback(self, data: Union[str, Mapping[str, Any]], signature: str = "") -> Transaction:
|
|
29
|
+
return verify_callback(self._private_key, data, signature)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Epoint(SyncEngine, _Namespaces):
|
|
33
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
34
|
+
super().__init__(*args, **kwargs)
|
|
35
|
+
self._mount()
|
|
36
|
+
|
|
37
|
+
def heartbeat(self) -> bool:
|
|
38
|
+
result: bool = self._run(Op("heartbeat", None, _alive, safe=True, method="GET", signed=False))
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
def request(self, endpoint: str, payload: Optional[Mapping[str, Any]] = None, *, safe: bool = False) -> Any:
|
|
42
|
+
return self._run(Op(endpoint, dict(payload or {}), safe=safe))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AsyncEpoint(AsyncEngine, _Namespaces):
|
|
46
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
47
|
+
super().__init__(*args, **kwargs)
|
|
48
|
+
self._mount()
|
|
49
|
+
|
|
50
|
+
async def heartbeat(self) -> bool:
|
|
51
|
+
result: bool = await self._run(Op("heartbeat", None, _alive, safe=True, method="GET", signed=False))
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
async def request(self, endpoint: str, payload: Optional[Mapping[str, Any]] = None, *, safe: bool = False) -> Any:
|
|
55
|
+
return await self._run(Op(endpoint, dict(payload or {}), safe=safe))
|