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 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
@@ -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
+ 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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()