esaalat 0.1.0__tar.gz
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.
- esaalat-0.1.0/LICENSE +21 -0
- esaalat-0.1.0/PKG-INFO +115 -0
- esaalat-0.1.0/README.md +96 -0
- esaalat-0.1.0/esaalat/__init__.py +21 -0
- esaalat-0.1.0/esaalat/client.py +474 -0
- esaalat-0.1.0/esaalat/errors.py +45 -0
- esaalat-0.1.0/esaalat/py.typed +0 -0
- esaalat-0.1.0/esaalat.egg-info/PKG-INFO +115 -0
- esaalat-0.1.0/esaalat.egg-info/SOURCES.txt +12 -0
- esaalat-0.1.0/esaalat.egg-info/dependency_links.txt +1 -0
- esaalat-0.1.0/esaalat.egg-info/top_level.txt +1 -0
- esaalat-0.1.0/pyproject.toml +31 -0
- esaalat-0.1.0/setup.cfg +4 -0
- esaalat-0.1.0/tests/test_client.py +176 -0
esaalat-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Esaalat
|
|
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.
|
esaalat-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: esaalat
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Server-side Esaalat client: locked checkout sessions, payment intents and signed webhook verification.
|
|
5
|
+
Author-email: Esaalat <support@esaalat.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://esaalat.com/en/sdk
|
|
8
|
+
Project-URL: Documentation, https://esaalat.com/en/api-reference
|
|
9
|
+
Project-URL: Source, https://github.com/Esaalat/esaalat-python
|
|
10
|
+
Keywords: esaalat,payments,checkout,webhook,yemen
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# esaalat (Python)
|
|
21
|
+
|
|
22
|
+
**Server-side [Esaalat](https://esaalat.com) client.** It does the three things
|
|
23
|
+
a merchant backend always writes by hand — the 1-hour token, the idempotency
|
|
24
|
+
key, and the webhook HMAC — so an integration is three calls instead of forty
|
|
25
|
+
lines.
|
|
26
|
+
|
|
27
|
+
- **Locked checkout sessions** — create the session on your server, give the
|
|
28
|
+
browser only the token.
|
|
29
|
+
- **Payment intents** — create one server-side for platform orders, attach the
|
|
30
|
+
buyer's reference, read status.
|
|
31
|
+
- **Signed webhooks** — verify `X-Esaalat-Signature` over the raw body, with a
|
|
32
|
+
replay window.
|
|
33
|
+
- **No dependencies** — standard library only (`urllib`, `hmac`, `json`).
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install esaalat
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quick start
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from esaalat import EsaalatClient
|
|
45
|
+
|
|
46
|
+
# Reads ESAALAT_CLIENT_ID, ESAALAT_CLIENT_SECRET and ESAALAT_WEBHOOK_SECRET.
|
|
47
|
+
esaalat = EsaalatClient.from_env()
|
|
48
|
+
|
|
49
|
+
# 1. Your server decides the amount; the browser gets only the token.
|
|
50
|
+
session = esaalat.create_session(order_id="order-123", amount=5000)
|
|
51
|
+
session["session_token"] # → checkout.open({ sessionToken }) in the browser
|
|
52
|
+
session["portal_url"] # → or send the buyer straight to this page
|
|
53
|
+
|
|
54
|
+
# 2. Fulfil on the signed webhook, never on the buyer's page.
|
|
55
|
+
@app.post("/esaalat/webhooks")
|
|
56
|
+
def webhook():
|
|
57
|
+
try:
|
|
58
|
+
event = esaalat.verify_webhook(
|
|
59
|
+
request.data, # the RAW body
|
|
60
|
+
request.headers["X-Esaalat-Signature"],
|
|
61
|
+
)
|
|
62
|
+
except EsaalatWebhookError:
|
|
63
|
+
return "", 400
|
|
64
|
+
|
|
65
|
+
if event["event"] == "payment.verified":
|
|
66
|
+
mark_order_paid(event["order_id"]) # idempotent on intent_id
|
|
67
|
+
return "", 200
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Also available: `create_intent(wallet=..., amount=..., order_id=...)`,
|
|
71
|
+
`retrieve_intent(intent_id)`, `attach_reference(intent_id, reference)` and
|
|
72
|
+
`format_amount(value)`.
|
|
73
|
+
|
|
74
|
+
## What it handles for you
|
|
75
|
+
|
|
76
|
+
| | |
|
|
77
|
+
|---|---|
|
|
78
|
+
| **Token** | Issued once per client, cached, refreshed a minute before the JWT's own `exp`. |
|
|
79
|
+
| **Idempotency** | A UUID per create call, reused on every retry — a 429 or a timeout can never double-book an order. |
|
|
80
|
+
| **Retries** | `429`, `5xx` and network failures, honouring `Retry-After`. |
|
|
81
|
+
| **Errors** | `EsaalatError` with `code`, `status`, `detail`, `attr` and the API's per-field `errors`. |
|
|
82
|
+
| **Webhooks** | `hmac.compare_digest`, optional ±5 min window, `400`-shaped `EsaalatWebhookError`. |
|
|
83
|
+
|
|
84
|
+
## Configuration
|
|
85
|
+
|
|
86
|
+
| Argument | Environment | Default |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `client_id` | `ESAALAT_CLIENT_ID` | — (required) |
|
|
89
|
+
| `client_secret` | `ESAALAT_CLIENT_SECRET` | — (required) |
|
|
90
|
+
| `webhook_secret` | `ESAALAT_WEBHOOK_SECRET` | — (needed by `verify_webhook`) |
|
|
91
|
+
| `base_url` | `ESAALAT_BASE_URL` | `https://api.esaalat.com/api/v1` |
|
|
92
|
+
| `timeout` | — | `20` seconds |
|
|
93
|
+
| `max_retries` | — | `2` |
|
|
94
|
+
|
|
95
|
+
Every endpoint takes a `secret=` override in `verify_webhook`, because each
|
|
96
|
+
webhook endpoint on the dashboard has its own signing secret.
|
|
97
|
+
|
|
98
|
+
## Notes
|
|
99
|
+
|
|
100
|
+
- The client secret is a **server** secret. Never call this from a browser or
|
|
101
|
+
ship it in a mobile app; the browser only ever needs the session token.
|
|
102
|
+
- Amounts are whole Yemeni riyals. `format_amount(5000.5)` refuses before the
|
|
103
|
+
request leaves your process.
|
|
104
|
+
- The webhook body must be the **raw** bytes: `json.dumps(request.get_json())`
|
|
105
|
+
will not match the signature.
|
|
106
|
+
|
|
107
|
+
## Development
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
python -m unittest discover -s tests -t .
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT © Esaalat
|
esaalat-0.1.0/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# esaalat (Python)
|
|
2
|
+
|
|
3
|
+
**Server-side [Esaalat](https://esaalat.com) client.** It does the three things
|
|
4
|
+
a merchant backend always writes by hand — the 1-hour token, the idempotency
|
|
5
|
+
key, and the webhook HMAC — so an integration is three calls instead of forty
|
|
6
|
+
lines.
|
|
7
|
+
|
|
8
|
+
- **Locked checkout sessions** — create the session on your server, give the
|
|
9
|
+
browser only the token.
|
|
10
|
+
- **Payment intents** — create one server-side for platform orders, attach the
|
|
11
|
+
buyer's reference, read status.
|
|
12
|
+
- **Signed webhooks** — verify `X-Esaalat-Signature` over the raw body, with a
|
|
13
|
+
replay window.
|
|
14
|
+
- **No dependencies** — standard library only (`urllib`, `hmac`, `json`).
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install esaalat
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from esaalat import EsaalatClient
|
|
26
|
+
|
|
27
|
+
# Reads ESAALAT_CLIENT_ID, ESAALAT_CLIENT_SECRET and ESAALAT_WEBHOOK_SECRET.
|
|
28
|
+
esaalat = EsaalatClient.from_env()
|
|
29
|
+
|
|
30
|
+
# 1. Your server decides the amount; the browser gets only the token.
|
|
31
|
+
session = esaalat.create_session(order_id="order-123", amount=5000)
|
|
32
|
+
session["session_token"] # → checkout.open({ sessionToken }) in the browser
|
|
33
|
+
session["portal_url"] # → or send the buyer straight to this page
|
|
34
|
+
|
|
35
|
+
# 2. Fulfil on the signed webhook, never on the buyer's page.
|
|
36
|
+
@app.post("/esaalat/webhooks")
|
|
37
|
+
def webhook():
|
|
38
|
+
try:
|
|
39
|
+
event = esaalat.verify_webhook(
|
|
40
|
+
request.data, # the RAW body
|
|
41
|
+
request.headers["X-Esaalat-Signature"],
|
|
42
|
+
)
|
|
43
|
+
except EsaalatWebhookError:
|
|
44
|
+
return "", 400
|
|
45
|
+
|
|
46
|
+
if event["event"] == "payment.verified":
|
|
47
|
+
mark_order_paid(event["order_id"]) # idempotent on intent_id
|
|
48
|
+
return "", 200
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Also available: `create_intent(wallet=..., amount=..., order_id=...)`,
|
|
52
|
+
`retrieve_intent(intent_id)`, `attach_reference(intent_id, reference)` and
|
|
53
|
+
`format_amount(value)`.
|
|
54
|
+
|
|
55
|
+
## What it handles for you
|
|
56
|
+
|
|
57
|
+
| | |
|
|
58
|
+
|---|---|
|
|
59
|
+
| **Token** | Issued once per client, cached, refreshed a minute before the JWT's own `exp`. |
|
|
60
|
+
| **Idempotency** | A UUID per create call, reused on every retry — a 429 or a timeout can never double-book an order. |
|
|
61
|
+
| **Retries** | `429`, `5xx` and network failures, honouring `Retry-After`. |
|
|
62
|
+
| **Errors** | `EsaalatError` with `code`, `status`, `detail`, `attr` and the API's per-field `errors`. |
|
|
63
|
+
| **Webhooks** | `hmac.compare_digest`, optional ±5 min window, `400`-shaped `EsaalatWebhookError`. |
|
|
64
|
+
|
|
65
|
+
## Configuration
|
|
66
|
+
|
|
67
|
+
| Argument | Environment | Default |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `client_id` | `ESAALAT_CLIENT_ID` | — (required) |
|
|
70
|
+
| `client_secret` | `ESAALAT_CLIENT_SECRET` | — (required) |
|
|
71
|
+
| `webhook_secret` | `ESAALAT_WEBHOOK_SECRET` | — (needed by `verify_webhook`) |
|
|
72
|
+
| `base_url` | `ESAALAT_BASE_URL` | `https://api.esaalat.com/api/v1` |
|
|
73
|
+
| `timeout` | — | `20` seconds |
|
|
74
|
+
| `max_retries` | — | `2` |
|
|
75
|
+
|
|
76
|
+
Every endpoint takes a `secret=` override in `verify_webhook`, because each
|
|
77
|
+
webhook endpoint on the dashboard has its own signing secret.
|
|
78
|
+
|
|
79
|
+
## Notes
|
|
80
|
+
|
|
81
|
+
- The client secret is a **server** secret. Never call this from a browser or
|
|
82
|
+
ship it in a mobile app; the browser only ever needs the session token.
|
|
83
|
+
- Amounts are whole Yemeni riyals. `format_amount(5000.5)` refuses before the
|
|
84
|
+
request leaves your process.
|
|
85
|
+
- The webhook body must be the **raw** bytes: `json.dumps(request.get_json())`
|
|
86
|
+
will not match the signature.
|
|
87
|
+
|
|
88
|
+
## Development
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
python -m unittest discover -s tests -t .
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT © Esaalat
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Server-side Esaalat client.
|
|
2
|
+
|
|
3
|
+
from esaalat import EsaalatClient
|
|
4
|
+
|
|
5
|
+
esaalat = EsaalatClient.from_env()
|
|
6
|
+
session = esaalat.create_session(order_id="order-123", amount=5000)
|
|
7
|
+
event = esaalat.verify_webhook(request.data, signature)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .client import DEFAULT_API_BASE, EsaalatClient, format_amount
|
|
11
|
+
from .errors import EsaalatError, EsaalatWebhookError
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"DEFAULT_API_BASE",
|
|
17
|
+
"EsaalatClient",
|
|
18
|
+
"EsaalatError",
|
|
19
|
+
"EsaalatWebhookError",
|
|
20
|
+
"format_amount",
|
|
21
|
+
]
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"""Server-side Esaalat client.
|
|
2
|
+
|
|
3
|
+
from esaalat import EsaalatClient
|
|
4
|
+
|
|
5
|
+
esaalat = EsaalatClient.from_env()
|
|
6
|
+
session = esaalat.create_session(order_id="order-123", amount=5000)
|
|
7
|
+
# browser: checkout.open({ sessionToken: session["session_token"] })
|
|
8
|
+
|
|
9
|
+
event = esaalat.verify_webhook(request.data, request.headers["X-Esaalat-Signature"])
|
|
10
|
+
|
|
11
|
+
The client owns the three parts a merchant backend always writes by hand: the
|
|
12
|
+
1-hour token (cached, refreshed from its own ``exp`` claim), the idempotency
|
|
13
|
+
key (generated per create, reused on every retry) and the webhook HMAC (raw
|
|
14
|
+
body, constant-time compare, replay window). Standard library only.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import base64
|
|
20
|
+
import hashlib
|
|
21
|
+
import hmac
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import time
|
|
25
|
+
import urllib.error
|
|
26
|
+
import urllib.request
|
|
27
|
+
from decimal import Decimal
|
|
28
|
+
from typing import Any, Callable, Dict, Mapping, Optional, Tuple, Union
|
|
29
|
+
|
|
30
|
+
from .errors import EsaalatError, EsaalatWebhookError
|
|
31
|
+
|
|
32
|
+
DEFAULT_API_BASE = "https://api.esaalat.com/api/v1"
|
|
33
|
+
DEFAULT_TIMEOUT = 20.0
|
|
34
|
+
DEFAULT_TOKEN_SKEW = 60.0
|
|
35
|
+
# The JWT lives one hour and its `exp` is authoritative; this is the fallback
|
|
36
|
+
# for an opaque token.
|
|
37
|
+
DEFAULT_TOKEN_TTL = 3300.0
|
|
38
|
+
DEFAULT_WEBHOOK_TOLERANCE = 300
|
|
39
|
+
RETRY_BASE_SECONDS = 0.4
|
|
40
|
+
|
|
41
|
+
Amount = Union[str, int, float, Decimal]
|
|
42
|
+
|
|
43
|
+
# transport(method, url, headers, body, timeout) -> (status, headers, body)
|
|
44
|
+
Transport = Callable[
|
|
45
|
+
[str, str, Mapping[str, str], Optional[bytes], float],
|
|
46
|
+
Tuple[int, Mapping[str, str], bytes],
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def format_amount(value: Amount) -> str:
|
|
51
|
+
"""Whole riyals, as the API requires.
|
|
52
|
+
|
|
53
|
+
``5000``, ``5000.0``, ``Decimal("5000")`` and ``"5000"`` all become
|
|
54
|
+
``"5000"``; a fraction is refused here rather than by the API after the
|
|
55
|
+
buyer has clicked pay.
|
|
56
|
+
"""
|
|
57
|
+
if isinstance(value, bool):
|
|
58
|
+
raise EsaalatError(
|
|
59
|
+
"Amount must be a whole number of riyals, got a boolean.",
|
|
60
|
+
code="configuration",
|
|
61
|
+
)
|
|
62
|
+
if isinstance(value, int):
|
|
63
|
+
return str(value)
|
|
64
|
+
if isinstance(value, Decimal):
|
|
65
|
+
if value != value.to_integral_value():
|
|
66
|
+
raise EsaalatError(
|
|
67
|
+
"Amount must be a whole number of riyals, got %s." % value,
|
|
68
|
+
code="configuration",
|
|
69
|
+
)
|
|
70
|
+
return str(int(value))
|
|
71
|
+
if isinstance(value, float):
|
|
72
|
+
if not value.is_integer():
|
|
73
|
+
raise EsaalatError(
|
|
74
|
+
"Amount must be a whole number of riyals, got %s." % value,
|
|
75
|
+
code="configuration",
|
|
76
|
+
)
|
|
77
|
+
return str(int(value))
|
|
78
|
+
text = str(value).strip()
|
|
79
|
+
if not text.isdigit():
|
|
80
|
+
raise EsaalatError(
|
|
81
|
+
'Amount must be a whole number of riyals, got "%s".' % text,
|
|
82
|
+
code="configuration",
|
|
83
|
+
)
|
|
84
|
+
return text
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _jwt_expiry(token: str) -> Optional[float]:
|
|
88
|
+
"""The token's ``exp`` claim in epoch seconds, when it has one."""
|
|
89
|
+
parts = token.split(".")
|
|
90
|
+
if len(parts) != 3:
|
|
91
|
+
return None
|
|
92
|
+
payload = parts[1] + "=" * (-len(parts[1]) % 4)
|
|
93
|
+
try:
|
|
94
|
+
data = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")))
|
|
95
|
+
except Exception: # noqa: BLE001 - an opaque token is not an error here
|
|
96
|
+
return None
|
|
97
|
+
exp = data.get("exp") if isinstance(data, dict) else None
|
|
98
|
+
if isinstance(exp, (int, float)):
|
|
99
|
+
return float(exp)
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _urllib_transport(
|
|
104
|
+
method: str,
|
|
105
|
+
url: str,
|
|
106
|
+
headers: Mapping[str, str],
|
|
107
|
+
body: Optional[bytes],
|
|
108
|
+
timeout: float,
|
|
109
|
+
) -> Tuple[int, Mapping[str, str], bytes]:
|
|
110
|
+
request = urllib.request.Request(
|
|
111
|
+
url, data=body, headers=dict(headers), method=method
|
|
112
|
+
)
|
|
113
|
+
try:
|
|
114
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
115
|
+
return response.status, dict(response.headers), response.read()
|
|
116
|
+
except urllib.error.HTTPError as error:
|
|
117
|
+
return error.code, dict(error.headers or {}), error.read()
|
|
118
|
+
except urllib.error.URLError as error:
|
|
119
|
+
raise EsaalatError(
|
|
120
|
+
"Could not reach Esaalat at %s." % url, code="network"
|
|
121
|
+
) from error
|
|
122
|
+
except TimeoutError as error:
|
|
123
|
+
raise EsaalatError(
|
|
124
|
+
"Esaalat did not answer within %s s." % timeout, code="timeout"
|
|
125
|
+
) from error
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _api_error(status: int, raw: bytes) -> EsaalatError:
|
|
129
|
+
detail = ""
|
|
130
|
+
attr: Optional[str] = None
|
|
131
|
+
errors: list = []
|
|
132
|
+
try:
|
|
133
|
+
parsed = json.loads(raw.decode("utf-8")) if raw else {}
|
|
134
|
+
except (ValueError, UnicodeDecodeError):
|
|
135
|
+
parsed = {}
|
|
136
|
+
if isinstance(parsed, dict):
|
|
137
|
+
items = parsed.get("errors")
|
|
138
|
+
if not isinstance(items, list):
|
|
139
|
+
items = [parsed["detail"]] if "detail" in parsed else []
|
|
140
|
+
for item in items:
|
|
141
|
+
if isinstance(item, str):
|
|
142
|
+
errors.append({"code": "error", "detail": item, "attr": None})
|
|
143
|
+
elif isinstance(item, dict):
|
|
144
|
+
errors.append(
|
|
145
|
+
{
|
|
146
|
+
"code": item.get("code") or "error",
|
|
147
|
+
"detail": item.get("detail") or "",
|
|
148
|
+
"attr": item.get("attr"),
|
|
149
|
+
}
|
|
150
|
+
)
|
|
151
|
+
if errors:
|
|
152
|
+
detail = errors[0]["detail"] or ""
|
|
153
|
+
attr = errors[0]["attr"]
|
|
154
|
+
if not detail:
|
|
155
|
+
detail = "Esaalat rejected the request (HTTP %s)." % status
|
|
156
|
+
return EsaalatError(detail, code="api", status=status, detail=detail,
|
|
157
|
+
attr=attr, errors=errors)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class EsaalatClient:
|
|
161
|
+
"""Create sessions, intents and verify webhooks — with your client secret."""
|
|
162
|
+
|
|
163
|
+
def __init__(
|
|
164
|
+
self,
|
|
165
|
+
client_id: Optional[str] = None,
|
|
166
|
+
client_secret: Optional[str] = None,
|
|
167
|
+
*,
|
|
168
|
+
base_url: Optional[str] = None,
|
|
169
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
170
|
+
max_retries: int = 2,
|
|
171
|
+
webhook_secret: Optional[str] = None,
|
|
172
|
+
transport: Optional[Transport] = None,
|
|
173
|
+
) -> None:
|
|
174
|
+
self.client_id = client_id or os.environ.get("ESAALAT_CLIENT_ID", "")
|
|
175
|
+
self.client_secret = client_secret or os.environ.get(
|
|
176
|
+
"ESAALAT_CLIENT_SECRET", ""
|
|
177
|
+
)
|
|
178
|
+
if not self.client_id or not self.client_secret:
|
|
179
|
+
raise EsaalatError(
|
|
180
|
+
"Missing API credentials. Pass client_id/client_secret or set "
|
|
181
|
+
"ESAALAT_CLIENT_ID and ESAALAT_CLIENT_SECRET.",
|
|
182
|
+
code="configuration",
|
|
183
|
+
)
|
|
184
|
+
self.base_url = (
|
|
185
|
+
base_url
|
|
186
|
+
or os.environ.get("ESAALAT_BASE_URL")
|
|
187
|
+
or DEFAULT_API_BASE
|
|
188
|
+
).rstrip("/")
|
|
189
|
+
if not self.base_url.startswith(("http://", "https://")):
|
|
190
|
+
raise EsaalatError(
|
|
191
|
+
'base_url must be an http(s) URL, got "%s".' % self.base_url,
|
|
192
|
+
code="configuration",
|
|
193
|
+
)
|
|
194
|
+
self.timeout = float(timeout)
|
|
195
|
+
self.max_retries = int(max_retries)
|
|
196
|
+
self.webhook_secret = webhook_secret or os.environ.get(
|
|
197
|
+
"ESAALAT_WEBHOOK_SECRET"
|
|
198
|
+
)
|
|
199
|
+
self._transport = transport or _urllib_transport
|
|
200
|
+
self._token: Optional[str] = None
|
|
201
|
+
self._token_expires_at = 0.0
|
|
202
|
+
|
|
203
|
+
@classmethod
|
|
204
|
+
def from_env(cls, **kwargs: Any) -> "EsaalatClient":
|
|
205
|
+
"""Build the client from ``ESAALAT_*`` environment variables."""
|
|
206
|
+
return cls(**kwargs)
|
|
207
|
+
|
|
208
|
+
# -- public surface ----------------------------------------------------
|
|
209
|
+
|
|
210
|
+
def create_session(self, **input: Any) -> Dict[str, Any]:
|
|
211
|
+
"""Create a hosted checkout session and return the API's 201 payload.
|
|
212
|
+
|
|
213
|
+
``order_id``/``external_order_id`` become ``external_order_id``;
|
|
214
|
+
``amount`` must be whole riyals. The browser only needs
|
|
215
|
+
``session_token`` from the response.
|
|
216
|
+
"""
|
|
217
|
+
body = _session_body(input)
|
|
218
|
+
return self._request(
|
|
219
|
+
"POST",
|
|
220
|
+
"/checkout/merchant/sessions/",
|
|
221
|
+
body,
|
|
222
|
+
idempotency_key=input.get("idempotency_key")
|
|
223
|
+
or _uuid4(),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def create_intent(self, **input: Any) -> Dict[str, Any]:
|
|
227
|
+
"""Create a payment intent (server-driven orders: marketplaces, Shopify)."""
|
|
228
|
+
body = {
|
|
229
|
+
"wallet": input.get("wallet"),
|
|
230
|
+
"currency": input.get("currency"),
|
|
231
|
+
"external_order_id": input.get("order_id")
|
|
232
|
+
or input.get("external_order_id"),
|
|
233
|
+
"notes": input.get("notes"),
|
|
234
|
+
}
|
|
235
|
+
if input.get("amount") is not None:
|
|
236
|
+
body["amount"] = format_amount(input["amount"])
|
|
237
|
+
if input.get("items"):
|
|
238
|
+
body["items"] = _items(input["items"])
|
|
239
|
+
return self._request(
|
|
240
|
+
"POST",
|
|
241
|
+
"/merchant/intents/",
|
|
242
|
+
body,
|
|
243
|
+
idempotency_key=input.get("idempotency_key") or _uuid4(),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def retrieve_intent(self, intent_id: str) -> Dict[str, Any]:
|
|
247
|
+
"""Read one intent's current status."""
|
|
248
|
+
return self._request("GET", "/merchant/intents/%s/" % intent_id)
|
|
249
|
+
|
|
250
|
+
def attach_reference(self, intent_id: str, reference: str) -> Dict[str, Any]:
|
|
251
|
+
"""Attach the buyer's wallet reference to a pending intent."""
|
|
252
|
+
return self._request(
|
|
253
|
+
"PATCH",
|
|
254
|
+
"/merchant/intents/%s/" % intent_id,
|
|
255
|
+
{"reference": reference},
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def verify_webhook(
|
|
259
|
+
self,
|
|
260
|
+
raw_body: Union[bytes, str],
|
|
261
|
+
signature: str,
|
|
262
|
+
*,
|
|
263
|
+
secret: Optional[str] = None,
|
|
264
|
+
tolerance: int = DEFAULT_WEBHOOK_TOLERANCE,
|
|
265
|
+
) -> Dict[str, Any]:
|
|
266
|
+
"""Verify ``X-Esaalat-Signature`` over the raw body and return the event.
|
|
267
|
+
|
|
268
|
+
Pass the bytes you received, not a re-serialized object: the HMAC is
|
|
269
|
+
computed over exactly what Esaalat sent. Raises ``EsaalatWebhookError``
|
|
270
|
+
when the signature, its age, or the body itself does not hold up — catch
|
|
271
|
+
it and answer ``400``.
|
|
272
|
+
"""
|
|
273
|
+
secret = secret or self.webhook_secret
|
|
274
|
+
if not secret:
|
|
275
|
+
raise EsaalatError(
|
|
276
|
+
"No webhook secret. Pass webhook_secret to EsaalatClient, set "
|
|
277
|
+
"ESAALAT_WEBHOOK_SECRET, or pass secret= per call.",
|
|
278
|
+
code="configuration",
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
timestamp, provided = _parse_signature(signature)
|
|
282
|
+
if tolerance and abs(time.time() - timestamp) > tolerance:
|
|
283
|
+
raise EsaalatWebhookError(
|
|
284
|
+
"Webhook timestamp is outside the %ss window (possible replay)."
|
|
285
|
+
% tolerance
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
body = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
|
|
289
|
+
expected = hmac.new(
|
|
290
|
+
secret.encode("utf-8"),
|
|
291
|
+
("%s." % timestamp).encode("utf-8") + body,
|
|
292
|
+
hashlib.sha256,
|
|
293
|
+
).hexdigest()
|
|
294
|
+
if not hmac.compare_digest(expected, provided):
|
|
295
|
+
raise EsaalatWebhookError(
|
|
296
|
+
"Webhook signature does not match. Verify against the raw "
|
|
297
|
+
"request body and this endpoint's secret."
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
payload = json.loads(body.decode("utf-8"))
|
|
302
|
+
except (ValueError, UnicodeDecodeError) as error:
|
|
303
|
+
raise EsaalatWebhookError("Webhook body is not JSON.") from error
|
|
304
|
+
if not isinstance(payload, dict):
|
|
305
|
+
raise EsaalatWebhookError("Webhook body is not a JSON object.")
|
|
306
|
+
return payload
|
|
307
|
+
|
|
308
|
+
# -- internals ---------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
def _access_token(self) -> str:
|
|
311
|
+
now = time.time()
|
|
312
|
+
if self._token and self._token_expires_at - DEFAULT_TOKEN_SKEW > now:
|
|
313
|
+
return self._token
|
|
314
|
+
|
|
315
|
+
status, _headers, raw = self._transport(
|
|
316
|
+
"POST",
|
|
317
|
+
self.base_url + "/merchant/token",
|
|
318
|
+
{
|
|
319
|
+
"ESAALAT-CLIENT-ID": self.client_id,
|
|
320
|
+
"ESAALAT-CLIENT-SECRET": self.client_secret,
|
|
321
|
+
"Accept": "application/json",
|
|
322
|
+
},
|
|
323
|
+
None,
|
|
324
|
+
self.timeout,
|
|
325
|
+
)
|
|
326
|
+
if not 200 <= status < 300:
|
|
327
|
+
raise _api_error(status, raw)
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
data = json.loads(raw.decode("utf-8")) if raw else {}
|
|
331
|
+
except (ValueError, UnicodeDecodeError):
|
|
332
|
+
data = {}
|
|
333
|
+
token = data.get("access_token") if isinstance(data, dict) else None
|
|
334
|
+
if not isinstance(token, str) or not token:
|
|
335
|
+
raise EsaalatError(
|
|
336
|
+
"Esaalat did not return an access token.", status=status
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
self._token = token
|
|
340
|
+
self._token_expires_at = _jwt_expiry(token) or (now + DEFAULT_TOKEN_TTL)
|
|
341
|
+
return token
|
|
342
|
+
|
|
343
|
+
def _request(
|
|
344
|
+
self,
|
|
345
|
+
method: str,
|
|
346
|
+
path: str,
|
|
347
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
348
|
+
*,
|
|
349
|
+
idempotency_key: Optional[str] = None,
|
|
350
|
+
) -> Dict[str, Any]:
|
|
351
|
+
url = self.base_url + path
|
|
352
|
+
body = (
|
|
353
|
+
json.dumps(_prune(payload)).encode("utf-8")
|
|
354
|
+
if payload is not None
|
|
355
|
+
else None
|
|
356
|
+
)
|
|
357
|
+
authorization = self._access_token()
|
|
358
|
+
refreshed = False
|
|
359
|
+
retries = 0
|
|
360
|
+
|
|
361
|
+
while True:
|
|
362
|
+
headers = {"Accept": "application/json"}
|
|
363
|
+
if body is not None:
|
|
364
|
+
headers["Content-Type"] = "application/json"
|
|
365
|
+
if authorization:
|
|
366
|
+
headers["Authorization"] = "Bearer " + authorization
|
|
367
|
+
if idempotency_key:
|
|
368
|
+
headers["ESAALAT-IDEMPOTENCY-KEY"] = idempotency_key
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
status, response_headers, raw = self._transport(
|
|
372
|
+
method, url, headers, body, self.timeout
|
|
373
|
+
)
|
|
374
|
+
except EsaalatError as error:
|
|
375
|
+
if error.code in ("network", "timeout") and retries < self.max_retries:
|
|
376
|
+
time.sleep(RETRY_BASE_SECONDS * (2 ** retries))
|
|
377
|
+
retries += 1
|
|
378
|
+
continue
|
|
379
|
+
raise
|
|
380
|
+
|
|
381
|
+
if 200 <= status < 300:
|
|
382
|
+
if not raw:
|
|
383
|
+
return {}
|
|
384
|
+
try:
|
|
385
|
+
parsed = json.loads(raw.decode("utf-8"))
|
|
386
|
+
except (ValueError, UnicodeDecodeError) as error:
|
|
387
|
+
raise EsaalatError(
|
|
388
|
+
"Esaalat returned a response that is not JSON.",
|
|
389
|
+
status=status,
|
|
390
|
+
) from error
|
|
391
|
+
return parsed if isinstance(parsed, dict) else {"data": parsed}
|
|
392
|
+
|
|
393
|
+
if status == 401 and not refreshed and authorization:
|
|
394
|
+
refreshed = True
|
|
395
|
+
self._token = None
|
|
396
|
+
authorization = self._access_token()
|
|
397
|
+
continue
|
|
398
|
+
|
|
399
|
+
if (status == 429 or status >= 500) and retries < self.max_retries:
|
|
400
|
+
delay = _retry_after(response_headers)
|
|
401
|
+
time.sleep(delay if delay is not None
|
|
402
|
+
else RETRY_BASE_SECONDS * (2 ** retries))
|
|
403
|
+
retries += 1
|
|
404
|
+
continue
|
|
405
|
+
|
|
406
|
+
raise _api_error(status, raw)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _session_body(input: Dict[str, Any]) -> Dict[str, Any]:
|
|
410
|
+
body: Dict[str, Any] = {
|
|
411
|
+
"currency": input.get("currency"),
|
|
412
|
+
"external_order_id": input.get("order_id")
|
|
413
|
+
or input.get("external_order_id"),
|
|
414
|
+
"wallet": input.get("wallet"),
|
|
415
|
+
"notes": input.get("notes"),
|
|
416
|
+
}
|
|
417
|
+
if input.get("amount") is not None:
|
|
418
|
+
body["amount"] = format_amount(input["amount"])
|
|
419
|
+
if input.get("items"):
|
|
420
|
+
body["items"] = _items(input["items"])
|
|
421
|
+
if input.get("show_testing_wallet") is not None:
|
|
422
|
+
body["show_testing_wallet"] = bool(input["show_testing_wallet"])
|
|
423
|
+
return body
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _items(items: Any) -> list:
|
|
427
|
+
normalized = []
|
|
428
|
+
for item in items:
|
|
429
|
+
entry = {
|
|
430
|
+
"name": item.get("name"),
|
|
431
|
+
"description": item.get("description"),
|
|
432
|
+
"price": format_amount(item["price"]),
|
|
433
|
+
"quantity": item.get("quantity"),
|
|
434
|
+
}
|
|
435
|
+
normalized.append(_prune(entry))
|
|
436
|
+
return normalized
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _prune(payload: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
440
|
+
return {key: value for key, value in (payload or {}).items()
|
|
441
|
+
if value is not None}
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _parse_signature(header: str) -> Tuple[int, str]:
|
|
445
|
+
timestamp: Optional[int] = None
|
|
446
|
+
signature = ""
|
|
447
|
+
for part in header.split(","):
|
|
448
|
+
key, _, value = part.strip().partition("=")
|
|
449
|
+
if key == "t":
|
|
450
|
+
try:
|
|
451
|
+
timestamp = int(value)
|
|
452
|
+
except ValueError:
|
|
453
|
+
timestamp = None
|
|
454
|
+
elif key == "v1":
|
|
455
|
+
signature = value.lower()
|
|
456
|
+
if timestamp is None or not signature:
|
|
457
|
+
raise EsaalatWebhookError("Malformed X-Esaalat-Signature header.")
|
|
458
|
+
return timestamp, signature
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _retry_after(headers: Mapping[str, str]) -> Optional[float]:
|
|
462
|
+
for key, value in headers.items():
|
|
463
|
+
if key.lower() == "retry-after":
|
|
464
|
+
try:
|
|
465
|
+
return max(0.0, float(value))
|
|
466
|
+
except ValueError:
|
|
467
|
+
return None
|
|
468
|
+
return None
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _uuid4() -> str:
|
|
472
|
+
import uuid
|
|
473
|
+
|
|
474
|
+
return str(uuid.uuid4())
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Errors raised by the Esaalat server client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EsaalatError(Exception):
|
|
9
|
+
"""Every failure the client raises.
|
|
10
|
+
|
|
11
|
+
``code`` says whose fault it is in the caller's terms:
|
|
12
|
+
|
|
13
|
+
* ``configuration`` — a key, a URL or a missing webhook secret.
|
|
14
|
+
* ``network`` / ``timeout`` — retry later.
|
|
15
|
+
* ``api`` — the request reached Esaalat and was rejected; ``status``,
|
|
16
|
+
``detail`` and ``attr`` say what.
|
|
17
|
+
* ``webhook`` — a signature that does not verify.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
message: str,
|
|
23
|
+
*,
|
|
24
|
+
code: str = "api",
|
|
25
|
+
status: Optional[int] = None,
|
|
26
|
+
detail: Optional[str] = None,
|
|
27
|
+
attr: Optional[str] = None,
|
|
28
|
+
errors: Optional[List[Dict[str, Any]]] = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.code = code
|
|
32
|
+
self.status = status
|
|
33
|
+
self.detail = detail
|
|
34
|
+
self.attr = attr
|
|
35
|
+
self.errors = errors or []
|
|
36
|
+
|
|
37
|
+
def __str__(self) -> str: # pragma: no cover - inherited message is enough
|
|
38
|
+
return str(self.args[0]) if self.args else self.code
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class EsaalatWebhookError(EsaalatError):
|
|
42
|
+
"""A webhook body that does not match its signature (or is stale)."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, message: str, *, status: int = 400) -> None:
|
|
45
|
+
super().__init__(message, code="webhook", status=status)
|
|
File without changes
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: esaalat
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Server-side Esaalat client: locked checkout sessions, payment intents and signed webhook verification.
|
|
5
|
+
Author-email: Esaalat <support@esaalat.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://esaalat.com/en/sdk
|
|
8
|
+
Project-URL: Documentation, https://esaalat.com/en/api-reference
|
|
9
|
+
Project-URL: Source, https://github.com/Esaalat/esaalat-python
|
|
10
|
+
Keywords: esaalat,payments,checkout,webhook,yemen
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# esaalat (Python)
|
|
21
|
+
|
|
22
|
+
**Server-side [Esaalat](https://esaalat.com) client.** It does the three things
|
|
23
|
+
a merchant backend always writes by hand — the 1-hour token, the idempotency
|
|
24
|
+
key, and the webhook HMAC — so an integration is three calls instead of forty
|
|
25
|
+
lines.
|
|
26
|
+
|
|
27
|
+
- **Locked checkout sessions** — create the session on your server, give the
|
|
28
|
+
browser only the token.
|
|
29
|
+
- **Payment intents** — create one server-side for platform orders, attach the
|
|
30
|
+
buyer's reference, read status.
|
|
31
|
+
- **Signed webhooks** — verify `X-Esaalat-Signature` over the raw body, with a
|
|
32
|
+
replay window.
|
|
33
|
+
- **No dependencies** — standard library only (`urllib`, `hmac`, `json`).
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install esaalat
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quick start
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from esaalat import EsaalatClient
|
|
45
|
+
|
|
46
|
+
# Reads ESAALAT_CLIENT_ID, ESAALAT_CLIENT_SECRET and ESAALAT_WEBHOOK_SECRET.
|
|
47
|
+
esaalat = EsaalatClient.from_env()
|
|
48
|
+
|
|
49
|
+
# 1. Your server decides the amount; the browser gets only the token.
|
|
50
|
+
session = esaalat.create_session(order_id="order-123", amount=5000)
|
|
51
|
+
session["session_token"] # → checkout.open({ sessionToken }) in the browser
|
|
52
|
+
session["portal_url"] # → or send the buyer straight to this page
|
|
53
|
+
|
|
54
|
+
# 2. Fulfil on the signed webhook, never on the buyer's page.
|
|
55
|
+
@app.post("/esaalat/webhooks")
|
|
56
|
+
def webhook():
|
|
57
|
+
try:
|
|
58
|
+
event = esaalat.verify_webhook(
|
|
59
|
+
request.data, # the RAW body
|
|
60
|
+
request.headers["X-Esaalat-Signature"],
|
|
61
|
+
)
|
|
62
|
+
except EsaalatWebhookError:
|
|
63
|
+
return "", 400
|
|
64
|
+
|
|
65
|
+
if event["event"] == "payment.verified":
|
|
66
|
+
mark_order_paid(event["order_id"]) # idempotent on intent_id
|
|
67
|
+
return "", 200
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Also available: `create_intent(wallet=..., amount=..., order_id=...)`,
|
|
71
|
+
`retrieve_intent(intent_id)`, `attach_reference(intent_id, reference)` and
|
|
72
|
+
`format_amount(value)`.
|
|
73
|
+
|
|
74
|
+
## What it handles for you
|
|
75
|
+
|
|
76
|
+
| | |
|
|
77
|
+
|---|---|
|
|
78
|
+
| **Token** | Issued once per client, cached, refreshed a minute before the JWT's own `exp`. |
|
|
79
|
+
| **Idempotency** | A UUID per create call, reused on every retry — a 429 or a timeout can never double-book an order. |
|
|
80
|
+
| **Retries** | `429`, `5xx` and network failures, honouring `Retry-After`. |
|
|
81
|
+
| **Errors** | `EsaalatError` with `code`, `status`, `detail`, `attr` and the API's per-field `errors`. |
|
|
82
|
+
| **Webhooks** | `hmac.compare_digest`, optional ±5 min window, `400`-shaped `EsaalatWebhookError`. |
|
|
83
|
+
|
|
84
|
+
## Configuration
|
|
85
|
+
|
|
86
|
+
| Argument | Environment | Default |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `client_id` | `ESAALAT_CLIENT_ID` | — (required) |
|
|
89
|
+
| `client_secret` | `ESAALAT_CLIENT_SECRET` | — (required) |
|
|
90
|
+
| `webhook_secret` | `ESAALAT_WEBHOOK_SECRET` | — (needed by `verify_webhook`) |
|
|
91
|
+
| `base_url` | `ESAALAT_BASE_URL` | `https://api.esaalat.com/api/v1` |
|
|
92
|
+
| `timeout` | — | `20` seconds |
|
|
93
|
+
| `max_retries` | — | `2` |
|
|
94
|
+
|
|
95
|
+
Every endpoint takes a `secret=` override in `verify_webhook`, because each
|
|
96
|
+
webhook endpoint on the dashboard has its own signing secret.
|
|
97
|
+
|
|
98
|
+
## Notes
|
|
99
|
+
|
|
100
|
+
- The client secret is a **server** secret. Never call this from a browser or
|
|
101
|
+
ship it in a mobile app; the browser only ever needs the session token.
|
|
102
|
+
- Amounts are whole Yemeni riyals. `format_amount(5000.5)` refuses before the
|
|
103
|
+
request leaves your process.
|
|
104
|
+
- The webhook body must be the **raw** bytes: `json.dumps(request.get_json())`
|
|
105
|
+
will not match the signature.
|
|
106
|
+
|
|
107
|
+
## Development
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
python -m unittest discover -s tests -t .
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT © Esaalat
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
esaalat/__init__.py
|
|
5
|
+
esaalat/client.py
|
|
6
|
+
esaalat/errors.py
|
|
7
|
+
esaalat/py.typed
|
|
8
|
+
esaalat.egg-info/PKG-INFO
|
|
9
|
+
esaalat.egg-info/SOURCES.txt
|
|
10
|
+
esaalat.egg-info/dependency_links.txt
|
|
11
|
+
esaalat.egg-info/top_level.txt
|
|
12
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
esaalat
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "esaalat"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Server-side Esaalat client: locked checkout sessions, payment intents and signed webhook verification."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Esaalat", email = "support@esaalat.com" }]
|
|
13
|
+
keywords = ["esaalat", "payments", "checkout", "webhook", "yemen"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Topic :: Office/Business :: Financial",
|
|
19
|
+
]
|
|
20
|
+
dependencies = []
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://esaalat.com/en/sdk"
|
|
24
|
+
Documentation = "https://esaalat.com/en/api-reference"
|
|
25
|
+
Source = "https://github.com/Esaalat/esaalat-python"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
include = ["esaalat*"]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.package-data]
|
|
31
|
+
esaalat = ["py.typed"]
|
esaalat-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Tests for the Esaalat Python server client (standard library only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
import unittest
|
|
8
|
+
|
|
9
|
+
from esaalat import EsaalatClient, EsaalatError, EsaalatWebhookError, format_amount
|
|
10
|
+
|
|
11
|
+
SECRET = "whsec_test"
|
|
12
|
+
TOKEN = "token-abc"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ScriptedTransport:
|
|
16
|
+
"""Answers from a script and records what was sent."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, responses):
|
|
19
|
+
self._responses = list(responses)
|
|
20
|
+
self.calls = []
|
|
21
|
+
|
|
22
|
+
def __call__(self, method, url, headers, body, timeout):
|
|
23
|
+
self.calls.append(
|
|
24
|
+
{"method": method, "url": url, "headers": dict(headers), "body": body}
|
|
25
|
+
)
|
|
26
|
+
if len(self._responses) > 1:
|
|
27
|
+
status, extra_headers, payload = self._responses.pop(0)
|
|
28
|
+
else:
|
|
29
|
+
status, extra_headers, payload = self._responses[0]
|
|
30
|
+
if isinstance(payload, bytes):
|
|
31
|
+
raw = payload
|
|
32
|
+
else:
|
|
33
|
+
raw = json.dumps(payload).encode("utf-8")
|
|
34
|
+
return status, extra_headers, raw
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _client(*responses, **kwargs):
|
|
38
|
+
transport = ScriptedTransport(list(responses))
|
|
39
|
+
client = EsaalatClient(
|
|
40
|
+
"cid",
|
|
41
|
+
"secret",
|
|
42
|
+
webhook_secret=SECRET,
|
|
43
|
+
transport=transport,
|
|
44
|
+
**kwargs,
|
|
45
|
+
)
|
|
46
|
+
return client, transport
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _sign(body: bytes, timestamp: int, secret: str = SECRET) -> str:
|
|
50
|
+
import hashlib
|
|
51
|
+
import hmac
|
|
52
|
+
|
|
53
|
+
digest = hmac.new(
|
|
54
|
+
secret.encode(), str(timestamp).encode() + b"." + body, hashlib.sha256
|
|
55
|
+
).hexdigest()
|
|
56
|
+
return "t=%s,v1=%s" % (timestamp, digest)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SessionTests(unittest.TestCase):
|
|
60
|
+
def test_token_is_issued_once_and_reused(self):
|
|
61
|
+
client, transport = _client(
|
|
62
|
+
(200, {}, {"access_token": TOKEN}),
|
|
63
|
+
(201, {}, {"session_token": "s-1"}),
|
|
64
|
+
(201, {}, {"session_token": "s-2"}),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
client.create_session(order_id="order-1", amount=5000)
|
|
68
|
+
client.create_session(order_id="order-2", amount=6000)
|
|
69
|
+
|
|
70
|
+
self.assertEqual(len(transport.calls), 3)
|
|
71
|
+
self.assertTrue(transport.calls[0]["url"].endswith("/merchant/token"))
|
|
72
|
+
self.assertEqual(
|
|
73
|
+
transport.calls[1]["headers"]["Authorization"], "Bearer " + TOKEN
|
|
74
|
+
)
|
|
75
|
+
self.assertEqual(
|
|
76
|
+
transport.calls[2]["headers"]["Authorization"], "Bearer " + TOKEN
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def test_session_sends_amount_order_id_and_idempotency_key(self):
|
|
80
|
+
client, transport = _client(
|
|
81
|
+
(200, {}, {"access_token": TOKEN}),
|
|
82
|
+
(201, {}, {"session_token": "s-1"}),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
client.create_session(order_id="order-123", amount="5000")
|
|
86
|
+
|
|
87
|
+
request = transport.calls[1]
|
|
88
|
+
self.assertTrue(
|
|
89
|
+
request["url"].endswith("/checkout/merchant/sessions/")
|
|
90
|
+
)
|
|
91
|
+
self.assertEqual(
|
|
92
|
+
json.loads(request["body"]),
|
|
93
|
+
{"amount": "5000", "external_order_id": "order-123"},
|
|
94
|
+
)
|
|
95
|
+
self.assertIn("ESAALAT-IDEMPOTENCY-KEY", request["headers"])
|
|
96
|
+
|
|
97
|
+
def test_retries_a_429_with_the_same_idempotency_key(self):
|
|
98
|
+
client, transport = _client(
|
|
99
|
+
(200, {}, {"access_token": TOKEN}),
|
|
100
|
+
(429, {"Retry-After": "0"}, {}),
|
|
101
|
+
(201, {}, {"session_token": "s-1"}),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
session = client.create_session(order_id="order-9", amount=2500)
|
|
105
|
+
|
|
106
|
+
self.assertEqual(session["session_token"], "s-1")
|
|
107
|
+
creates = [call for call in transport.calls if "/sessions/" in call["url"]]
|
|
108
|
+
self.assertEqual(len(creates), 2)
|
|
109
|
+
self.assertEqual(
|
|
110
|
+
creates[0]["headers"]["ESAALAT-IDEMPOTENCY-KEY"],
|
|
111
|
+
creates[1]["headers"]["ESAALAT-IDEMPOTENCY-KEY"],
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def test_api_error_carries_the_field(self):
|
|
115
|
+
client, _ = _client(
|
|
116
|
+
(200, {}, {"access_token": TOKEN}),
|
|
117
|
+
(
|
|
118
|
+
400,
|
|
119
|
+
{},
|
|
120
|
+
{
|
|
121
|
+
"type": "validation_error",
|
|
122
|
+
"errors": [
|
|
123
|
+
{"code": "invalid", "detail": "Wallet not found.", "attr": "wallet"}
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
with self.assertRaises(EsaalatError) as caught:
|
|
130
|
+
client.create_intent(wallet="missing", amount=1000)
|
|
131
|
+
|
|
132
|
+
self.assertEqual(caught.exception.code, "api")
|
|
133
|
+
self.assertEqual(caught.exception.status, 400)
|
|
134
|
+
self.assertEqual(caught.exception.attr, "wallet")
|
|
135
|
+
self.assertEqual(caught.exception.detail, "Wallet not found.")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class WebhookTests(unittest.TestCase):
|
|
139
|
+
def test_verifies_a_signed_body(self):
|
|
140
|
+
client, _ = _client((200, {}, {"access_token": TOKEN}))
|
|
141
|
+
body = json.dumps({"event": "payment.verified", "intent_id": "i1"}).encode()
|
|
142
|
+
timestamp = int(time.time())
|
|
143
|
+
|
|
144
|
+
payload = client.verify_webhook(body, _sign(body, timestamp))
|
|
145
|
+
|
|
146
|
+
self.assertEqual(payload["event"], "payment.verified")
|
|
147
|
+
self.assertEqual(payload["intent_id"], "i1")
|
|
148
|
+
|
|
149
|
+
def test_rejects_a_tampered_body(self):
|
|
150
|
+
client, _ = _client((200, {}, {"access_token": TOKEN}))
|
|
151
|
+
body = b'{"event":"payment.verified","intent_id":"i1"}'
|
|
152
|
+
timestamp = int(time.time())
|
|
153
|
+
|
|
154
|
+
with self.assertRaises(EsaalatWebhookError):
|
|
155
|
+
client.verify_webhook(body.replace(b"i1", b"i2"), _sign(body, timestamp))
|
|
156
|
+
|
|
157
|
+
def test_rejects_a_stale_signature(self):
|
|
158
|
+
client, _ = _client((200, {}, {"access_token": TOKEN}))
|
|
159
|
+
body = b'{"event":"payment.verified"}'
|
|
160
|
+
stale = int(time.time()) - 3600
|
|
161
|
+
|
|
162
|
+
with self.assertRaises(EsaalatWebhookError):
|
|
163
|
+
client.verify_webhook(body, _sign(body, stale))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class AmountTests(unittest.TestCase):
|
|
167
|
+
def test_whole_riyals_only(self):
|
|
168
|
+
self.assertEqual(format_amount(5000), "5000")
|
|
169
|
+
self.assertEqual(format_amount("5000"), "5000")
|
|
170
|
+
self.assertEqual(format_amount(5000.0), "5000")
|
|
171
|
+
with self.assertRaises(EsaalatError):
|
|
172
|
+
format_amount(5000.5)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
if __name__ == "__main__":
|
|
176
|
+
unittest.main()
|