instantpay-sa 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.
- instantpay_sa-0.1.0/.gitignore +15 -0
- instantpay_sa-0.1.0/LICENSE +21 -0
- instantpay_sa-0.1.0/PKG-INFO +45 -0
- instantpay_sa-0.1.0/README.md +33 -0
- instantpay_sa-0.1.0/instantpay/__init__.py +15 -0
- instantpay_sa-0.1.0/instantpay/client.py +240 -0
- instantpay_sa-0.1.0/instantpay/errors.py +23 -0
- instantpay_sa-0.1.0/instantpay/webhooks.py +61 -0
- instantpay_sa-0.1.0/pyproject.toml +20 -0
- instantpay_sa-0.1.0/tests/test_client.py +109 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 InstantPay
|
|
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.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: instantpay-sa
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python library for the InstantPay API (Saudi merchant payments).
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# instantpay (Python)
|
|
14
|
+
|
|
15
|
+
Official Python library for the [InstantPay](https://developers.instantpay.sa) API. Python 3.9+,
|
|
16
|
+
standard library only.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install instantpay-sa # the package is instantpay-sa; you import instantpay
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import instantpay
|
|
24
|
+
|
|
25
|
+
ip = instantpay.InstantPay(os.environ["INSTANTPAY_API_KEY"],
|
|
26
|
+
base_url="https://instantpay-api.onrender.com") # demo sandbox
|
|
27
|
+
|
|
28
|
+
checkout = ip.payment_intents.create(amount="250.00", description="Order 1001",
|
|
29
|
+
reference="1001", return_url="https://shop.example.sa/orders/1001")
|
|
30
|
+
# redirect the customer to checkout["checkout_url"]
|
|
31
|
+
|
|
32
|
+
for payment in ip.payments.all(state="settled"): # every page
|
|
33
|
+
print(payment["id"], payment["amount"]["amount"])
|
|
34
|
+
|
|
35
|
+
ip.refunds.create(payment_id="pay_…", amount="20.00", reason="One item returned")
|
|
36
|
+
|
|
37
|
+
# Webhooks (Flask): the raw body, then the event
|
|
38
|
+
event = instantpay.webhooks.construct_event(request.get_data(),
|
|
39
|
+
request.headers.get("InstantPay-Signature"),
|
|
40
|
+
os.environ["INSTANTPAY_WEBHOOK_SECRET"])
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Errors are `instantpay.InstantPayError` with `status`, `code`, `param` and `request_id`.
|
|
44
|
+
Options: `base_url`, `timeout` (30), `max_retries` (2), `transport`. Tests:
|
|
45
|
+
`python3 -m unittest discover -s tests`.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# instantpay (Python)
|
|
2
|
+
|
|
3
|
+
Official Python library for the [InstantPay](https://developers.instantpay.sa) API. Python 3.9+,
|
|
4
|
+
standard library only.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install instantpay-sa # the package is instantpay-sa; you import instantpay
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
import instantpay
|
|
12
|
+
|
|
13
|
+
ip = instantpay.InstantPay(os.environ["INSTANTPAY_API_KEY"],
|
|
14
|
+
base_url="https://instantpay-api.onrender.com") # demo sandbox
|
|
15
|
+
|
|
16
|
+
checkout = ip.payment_intents.create(amount="250.00", description="Order 1001",
|
|
17
|
+
reference="1001", return_url="https://shop.example.sa/orders/1001")
|
|
18
|
+
# redirect the customer to checkout["checkout_url"]
|
|
19
|
+
|
|
20
|
+
for payment in ip.payments.all(state="settled"): # every page
|
|
21
|
+
print(payment["id"], payment["amount"]["amount"])
|
|
22
|
+
|
|
23
|
+
ip.refunds.create(payment_id="pay_…", amount="20.00", reason="One item returned")
|
|
24
|
+
|
|
25
|
+
# Webhooks (Flask): the raw body, then the event
|
|
26
|
+
event = instantpay.webhooks.construct_event(request.get_data(),
|
|
27
|
+
request.headers.get("InstantPay-Signature"),
|
|
28
|
+
os.environ["INSTANTPAY_WEBHOOK_SECRET"])
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Errors are `instantpay.InstantPayError` with `status`, `code`, `param` and `request_id`.
|
|
32
|
+
Options: `base_url`, `timeout` (30), `max_retries` (2), `transport`. Tests:
|
|
33
|
+
`python3 -m unittest discover -s tests`.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Official Python library for the InstantPay API.
|
|
2
|
+
|
|
3
|
+
import instantpay
|
|
4
|
+
ip = instantpay.InstantPay("ipk_test_...", base_url="https://instantpay-api.onrender.com")
|
|
5
|
+
checkout = ip.payment_intents.create(amount="250.00", description="Order 1001")
|
|
6
|
+
|
|
7
|
+
Standard library only (urllib, hmac, json).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .client import DEFAULT_BASE_URL, VERSION, InstantPay
|
|
11
|
+
from .errors import InstantPayError
|
|
12
|
+
from . import webhooks
|
|
13
|
+
|
|
14
|
+
__all__ = ["InstantPay", "InstantPayError", "webhooks", "DEFAULT_BASE_URL", "VERSION"]
|
|
15
|
+
__version__ = VERSION
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import random
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
import uuid
|
|
9
|
+
from typing import Any, Callable, Dict, Iterator, Optional
|
|
10
|
+
|
|
11
|
+
from . import webhooks as _webhooks
|
|
12
|
+
from .errors import InstantPayError
|
|
13
|
+
|
|
14
|
+
VERSION = "0.1.0"
|
|
15
|
+
#: Production API. For the public demo use https://instantpay-api.onrender.com.
|
|
16
|
+
DEFAULT_BASE_URL = "https://api.instantpay.sa"
|
|
17
|
+
|
|
18
|
+
JSON = Dict[str, Any]
|
|
19
|
+
# (method, url, headers, body, timeout) -> (status, headers, body)
|
|
20
|
+
Transport = Callable[[str, str, Dict[str, str], Optional[bytes], float], "tuple[int, Dict[str, str], bytes]"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _urllib_transport(method, url, headers, body, timeout):
|
|
24
|
+
req = urllib.request.Request(url, data=body, method=method, headers=headers)
|
|
25
|
+
try:
|
|
26
|
+
with urllib.request.urlopen(req, timeout=timeout) as res: # noqa: S310 (https by default)
|
|
27
|
+
return res.status, {k.lower(): v for k, v in res.headers.items()}, res.read()
|
|
28
|
+
except urllib.error.HTTPError as e:
|
|
29
|
+
return e.code, {k.lower(): v for k, v in e.headers.items()}, e.read()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _Resource:
|
|
33
|
+
def __init__(self, client: "InstantPay") -> None:
|
|
34
|
+
self._c = client
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PaymentIntents(_Resource):
|
|
38
|
+
"""Online checkout: send the customer to ``checkout_url``; confirm with the webhook or retrieve()."""
|
|
39
|
+
|
|
40
|
+
def create(self, idempotency_key: Optional[str] = None, **params: Any) -> JSON:
|
|
41
|
+
return self._c.request("POST", "/v1/payment_intents", body=params, idempotency_key=idempotency_key)
|
|
42
|
+
|
|
43
|
+
def retrieve(self, intent_id: str) -> JSON:
|
|
44
|
+
return self._c.request("GET", f"/v1/payment_intents/{_q(intent_id)}")
|
|
45
|
+
|
|
46
|
+
def list(self, **params: Any) -> JSON:
|
|
47
|
+
return self._c.request("GET", "/v1/payment_intents", query=params)
|
|
48
|
+
|
|
49
|
+
def all(self, **params: Any) -> Iterator[JSON]:
|
|
50
|
+
return self._c.paginate("/v1/payment_intents", params)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PaymentLinks(_Resource):
|
|
54
|
+
def create(self, idempotency_key: Optional[str] = None, **params: Any) -> JSON:
|
|
55
|
+
params.setdefault("currency", "SAR")
|
|
56
|
+
params.setdefault("expires_in_days", 7)
|
|
57
|
+
return self._c.request("POST", "/v1/payment_links", body=params, idempotency_key=idempotency_key)
|
|
58
|
+
|
|
59
|
+
def retrieve(self, link_id: str) -> JSON:
|
|
60
|
+
return self._c.request("GET", f"/v1/payment_links/{_q(link_id)}")
|
|
61
|
+
|
|
62
|
+
def list(self, **params: Any) -> JSON:
|
|
63
|
+
return self._c.request("GET", "/v1/payment_links", query=params)
|
|
64
|
+
|
|
65
|
+
def all(self, **params: Any) -> Iterator[JSON]:
|
|
66
|
+
return self._c.paginate("/v1/payment_links", params)
|
|
67
|
+
|
|
68
|
+
def cancel(self, link_id: str) -> JSON:
|
|
69
|
+
return self._c.request("POST", f"/v1/payment_links/{_q(link_id)}/cancel")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Payments(_Resource):
|
|
73
|
+
def retrieve(self, payment_id: str) -> JSON:
|
|
74
|
+
return self._c.request("GET", f"/v1/payments/{_q(payment_id)}")
|
|
75
|
+
|
|
76
|
+
def list(self, **params: Any) -> JSON:
|
|
77
|
+
"""Filters: channel, state (approved|settled), q (order number, RRN, last 4)."""
|
|
78
|
+
return self._c.request("GET", "/v1/payments", query=params)
|
|
79
|
+
|
|
80
|
+
def all(self, **params: Any) -> Iterator[JSON]:
|
|
81
|
+
return self._c.paginate("/v1/payments", params)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Refunds(_Resource):
|
|
85
|
+
def create(self, idempotency_key: Optional[str] = None, **params: Any) -> JSON:
|
|
86
|
+
"""Full or partial; the amount leaves your wallet at once."""
|
|
87
|
+
return self._c.request("POST", "/v1/refunds", body=params, idempotency_key=idempotency_key)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Payouts(_Resource):
|
|
91
|
+
def create(self, idempotency_key: Optional[str] = None, **params: Any) -> JSON:
|
|
92
|
+
params.setdefault("currency", "SAR")
|
|
93
|
+
return self._c.request("POST", "/v1/payouts", body=params, idempotency_key=idempotency_key)
|
|
94
|
+
|
|
95
|
+
def retrieve(self, payout_id: str) -> JSON:
|
|
96
|
+
return self._c.request("GET", f"/v1/payouts/{_q(payout_id)}")
|
|
97
|
+
|
|
98
|
+
def list(self, **params: Any) -> JSON:
|
|
99
|
+
return self._c.request("GET", "/v1/payouts", query=params)
|
|
100
|
+
|
|
101
|
+
def all(self, **params: Any) -> Iterator[JSON]:
|
|
102
|
+
return self._c.paginate("/v1/payouts", params)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Beneficiaries(_Resource):
|
|
106
|
+
def list(self) -> JSON:
|
|
107
|
+
return self._c.request("GET", "/v1/beneficiaries")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Wallets(_Resource):
|
|
111
|
+
def list(self) -> JSON:
|
|
112
|
+
return self._c.request("GET", "/v1/wallets")
|
|
113
|
+
|
|
114
|
+
def retrieve(self, wallet_id: str) -> JSON:
|
|
115
|
+
return self._c.request("GET", f"/v1/wallets/{_q(wallet_id)}")
|
|
116
|
+
|
|
117
|
+
def transactions(self, wallet_id: str, **params: Any) -> JSON:
|
|
118
|
+
return self._c.request("GET", f"/v1/wallets/{_q(wallet_id)}/transactions", query=params)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class WebhookEndpoints(_Resource):
|
|
122
|
+
def create(self, **params: Any) -> JSON:
|
|
123
|
+
"""The response carries the signing ``secret`` once; store it."""
|
|
124
|
+
return self._c.request("POST", "/v1/webhook_endpoints", body=params)
|
|
125
|
+
|
|
126
|
+
def list(self) -> JSON:
|
|
127
|
+
return self._c.request("GET", "/v1/webhook_endpoints")
|
|
128
|
+
|
|
129
|
+
def retrieve(self, endpoint_id: str) -> JSON:
|
|
130
|
+
return self._c.request("GET", f"/v1/webhook_endpoints/{_q(endpoint_id)}")
|
|
131
|
+
|
|
132
|
+
def delete(self, endpoint_id: str) -> None:
|
|
133
|
+
self._c.request("DELETE", f"/v1/webhook_endpoints/{_q(endpoint_id)}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _q(value: str) -> str:
|
|
137
|
+
return urllib.parse.quote(value, safe="")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class InstantPay:
|
|
141
|
+
"""Client for one API key. Thread-safe: it keeps no per-request state."""
|
|
142
|
+
|
|
143
|
+
webhooks = _webhooks
|
|
144
|
+
|
|
145
|
+
def __init__(
|
|
146
|
+
self,
|
|
147
|
+
api_key: str,
|
|
148
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
149
|
+
timeout: float = 30.0,
|
|
150
|
+
max_retries: int = 2,
|
|
151
|
+
transport: Optional[Transport] = None,
|
|
152
|
+
) -> None:
|
|
153
|
+
if not re.match(r"^ipk_(test|live)_[0-9A-Za-z]{16,}$", api_key or ""):
|
|
154
|
+
raise ValueError("InstantPay: pass an API key like ipk_test_... (Portal > Developers).")
|
|
155
|
+
self._key = api_key
|
|
156
|
+
self._base = base_url.rstrip("/")
|
|
157
|
+
self._timeout = timeout
|
|
158
|
+
self._retries = max_retries
|
|
159
|
+
self._transport = transport or _urllib_transport
|
|
160
|
+
self.payment_intents = PaymentIntents(self)
|
|
161
|
+
self.payment_links = PaymentLinks(self)
|
|
162
|
+
self.payments = Payments(self)
|
|
163
|
+
self.refunds = Refunds(self)
|
|
164
|
+
self.payouts = Payouts(self)
|
|
165
|
+
self.beneficiaries = Beneficiaries(self)
|
|
166
|
+
self.wallets = Wallets(self)
|
|
167
|
+
self.webhook_endpoints = WebhookEndpoints(self)
|
|
168
|
+
|
|
169
|
+
def request(
|
|
170
|
+
self,
|
|
171
|
+
method: str,
|
|
172
|
+
path: str,
|
|
173
|
+
body: Optional[JSON] = None,
|
|
174
|
+
query: Optional[Dict[str, Any]] = None,
|
|
175
|
+
idempotency_key: Optional[str] = None,
|
|
176
|
+
) -> Any:
|
|
177
|
+
url = self._base + path
|
|
178
|
+
clean = {k: _param(v) for k, v in (query or {}).items() if v is not None and v != ""}
|
|
179
|
+
if clean:
|
|
180
|
+
url += "?" + urllib.parse.urlencode(clean)
|
|
181
|
+
headers = {
|
|
182
|
+
"Authorization": f"Bearer {self._key}",
|
|
183
|
+
"Accept": "application/json",
|
|
184
|
+
"User-Agent": f"instantpay-python/{VERSION}",
|
|
185
|
+
}
|
|
186
|
+
data = None
|
|
187
|
+
if body is not None:
|
|
188
|
+
headers["Content-Type"] = "application/json"
|
|
189
|
+
data = json.dumps(body).encode()
|
|
190
|
+
if method != "GET":
|
|
191
|
+
# Retries of a write reuse the key, so the API does it once.
|
|
192
|
+
headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
|
|
193
|
+
|
|
194
|
+
attempt = 0
|
|
195
|
+
while True:
|
|
196
|
+
try:
|
|
197
|
+
status, res_headers, raw = self._transport(method, url, headers, data, self._timeout)
|
|
198
|
+
except (urllib.error.URLError, OSError) as e:
|
|
199
|
+
if attempt < self._retries:
|
|
200
|
+
time.sleep(_backoff(attempt))
|
|
201
|
+
attempt += 1
|
|
202
|
+
continue
|
|
203
|
+
raise InstantPayError(f"Could not reach InstantPay: {e}", 0, "network_error") from e
|
|
204
|
+
if (status == 429 or status >= 500) and attempt < self._retries:
|
|
205
|
+
after = res_headers.get("retry-after", "")
|
|
206
|
+
time.sleep(min(float(after), 20.0) if after.isdigit() else _backoff(attempt))
|
|
207
|
+
attempt += 1
|
|
208
|
+
continue
|
|
209
|
+
if status == 204 or not raw:
|
|
210
|
+
return None
|
|
211
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
212
|
+
if status >= 400:
|
|
213
|
+
err = payload.get("error", {}) if isinstance(payload, dict) else {}
|
|
214
|
+
raise InstantPayError(
|
|
215
|
+
err.get("message", f"HTTP {status}"),
|
|
216
|
+
status,
|
|
217
|
+
err.get("code", "http_error"),
|
|
218
|
+
err.get("param"),
|
|
219
|
+
res_headers.get("x-request-id"),
|
|
220
|
+
)
|
|
221
|
+
return payload
|
|
222
|
+
|
|
223
|
+
def paginate(self, path: str, params: Dict[str, Any]) -> Iterator[JSON]:
|
|
224
|
+
"""Yields every item across pages: ``for p in ip.payments.all(state="settled"): ...``"""
|
|
225
|
+
query = dict(params)
|
|
226
|
+
while True:
|
|
227
|
+
page = self.request("GET", path, query=query)
|
|
228
|
+
for item in page.get("data", []):
|
|
229
|
+
yield item
|
|
230
|
+
if not page.get("has_more") or not page.get("next_cursor"):
|
|
231
|
+
return
|
|
232
|
+
query["starting_after"] = page["next_cursor"]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _param(v: Any) -> str:
|
|
236
|
+
return ("true" if v else "false") if isinstance(v, bool) else str(v)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _backoff(attempt: int) -> float:
|
|
240
|
+
return min(8.0, 0.5 * 2**attempt) + random.random() * 0.25
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class InstantPayError(Exception):
|
|
5
|
+
"""Every API failure. ``code`` is stable (e.g. ``insufficient_funds``); ``message`` is for people."""
|
|
6
|
+
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
message: str,
|
|
10
|
+
status: int,
|
|
11
|
+
code: str,
|
|
12
|
+
param: Optional[str] = None,
|
|
13
|
+
request_id: Optional[str] = None,
|
|
14
|
+
) -> None:
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.message = message
|
|
17
|
+
self.status = status
|
|
18
|
+
self.code = code
|
|
19
|
+
self.param = param
|
|
20
|
+
self.request_id = request_id
|
|
21
|
+
|
|
22
|
+
def __repr__(self) -> str:
|
|
23
|
+
return f"InstantPayError(status={self.status}, code={self.code!r}, message={self.message!r})"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Webhook signatures: ``InstantPay-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body">``."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Dict, Optional, Union
|
|
8
|
+
|
|
9
|
+
from .errors import InstantPayError
|
|
10
|
+
|
|
11
|
+
SIGNATURE_TOLERANCE_SECONDS = 300
|
|
12
|
+
|
|
13
|
+
Payload = Union[str, bytes]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _text(payload: Payload) -> str:
|
|
17
|
+
return payload.decode("utf-8") if isinstance(payload, bytes) else payload
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def sign(payload: Payload, secret: str, timestamp: Optional[int] = None) -> str:
|
|
21
|
+
t = int(time.time()) if timestamp is None else timestamp
|
|
22
|
+
v1 = hmac.new(secret.encode(), f"{t}.{_text(payload)}".encode(), hashlib.sha256).hexdigest()
|
|
23
|
+
return f"t={t},v1={v1}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def verify(
|
|
27
|
+
payload: Payload,
|
|
28
|
+
header: Optional[str],
|
|
29
|
+
secret: str,
|
|
30
|
+
tolerance_seconds: int = SIGNATURE_TOLERANCE_SECONDS,
|
|
31
|
+
now: Optional[float] = None,
|
|
32
|
+
) -> bool:
|
|
33
|
+
"""True when the signature is ours and fresh. Pass the raw request body."""
|
|
34
|
+
if not header:
|
|
35
|
+
return False
|
|
36
|
+
parts: Dict[str, str] = {}
|
|
37
|
+
for piece in header.split(","):
|
|
38
|
+
key, _, value = piece.partition("=")
|
|
39
|
+
parts[key.strip()] = value.strip()
|
|
40
|
+
try:
|
|
41
|
+
t = int(parts.get("t", ""))
|
|
42
|
+
except ValueError:
|
|
43
|
+
return False
|
|
44
|
+
current = time.time() if now is None else now
|
|
45
|
+
if abs(current - t) > tolerance_seconds:
|
|
46
|
+
return False
|
|
47
|
+
expected = sign(payload, secret, t).split("v1=", 1)[1]
|
|
48
|
+
return hmac.compare_digest(expected, parts.get("v1", ""))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def construct_event(
|
|
52
|
+
payload: Payload,
|
|
53
|
+
header: Optional[str],
|
|
54
|
+
secret: str,
|
|
55
|
+
tolerance_seconds: int = SIGNATURE_TOLERANCE_SECONDS,
|
|
56
|
+
now: Optional[float] = None,
|
|
57
|
+
) -> Dict[str, Any]:
|
|
58
|
+
"""Verifies, then parses. Raises InstantPayError('invalid_signature') when it does not match."""
|
|
59
|
+
if not verify(payload, header, secret, tolerance_seconds, now):
|
|
60
|
+
raise InstantPayError("Webhook signature is not valid.", 400, "invalid_signature")
|
|
61
|
+
return json.loads(_text(payload))
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "instantpay-sa"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python library for the InstantPay API (Saudi merchant payments)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = []
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build.targets.wheel]
|
|
20
|
+
packages = ["instantpay"]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
import instantpay
|
|
7
|
+
from instantpay import InstantPay, InstantPayError, webhooks
|
|
8
|
+
|
|
9
|
+
KEY = "ipk_test_abcdefghijklmnopqrstuvwxyz012345"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FakeTransport:
|
|
13
|
+
def __init__(self, responses):
|
|
14
|
+
self.responses = list(responses)
|
|
15
|
+
self.calls = []
|
|
16
|
+
|
|
17
|
+
def __call__(self, method, url, headers, body, timeout):
|
|
18
|
+
self.calls.append((method, url, dict(headers), body))
|
|
19
|
+
nxt = self.responses.pop(0)
|
|
20
|
+
if isinstance(nxt, Exception):
|
|
21
|
+
raise nxt
|
|
22
|
+
status, payload, extra = nxt if len(nxt) == 3 else (*nxt, {})
|
|
23
|
+
return status, extra, json.dumps(payload).encode() if payload is not None else b""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ClientTest(unittest.TestCase):
|
|
27
|
+
def test_refuses_non_keys(self):
|
|
28
|
+
with self.assertRaises(ValueError):
|
|
29
|
+
InstantPay("sk_live_x")
|
|
30
|
+
|
|
31
|
+
def test_retries_write_with_same_key(self):
|
|
32
|
+
t = FakeTransport([(503, {"error": {"code": "x", "message": "later"}}, {"retry-after": "0"}),
|
|
33
|
+
(201, {"id": "pi_1"})])
|
|
34
|
+
ip = InstantPay(KEY, base_url="https://api.test/", transport=t)
|
|
35
|
+
pi = ip.payment_intents.create(amount="10.00", description="Order 1")
|
|
36
|
+
self.assertEqual(pi["id"], "pi_1")
|
|
37
|
+
self.assertEqual(len(t.calls), 2)
|
|
38
|
+
(m1, u1, h1, b1), (_, _, h2, _) = t.calls
|
|
39
|
+
self.assertEqual((m1, u1), ("POST", "https://api.test/v1/payment_intents"))
|
|
40
|
+
self.assertEqual(h1["Authorization"], f"Bearer {KEY}")
|
|
41
|
+
self.assertEqual(h1["Idempotency-Key"], h2["Idempotency-Key"])
|
|
42
|
+
self.assertEqual(json.loads(b1), {"amount": "10.00", "description": "Order 1"})
|
|
43
|
+
|
|
44
|
+
def test_errors(self):
|
|
45
|
+
t = FakeTransport([(422, {"error": {"code": "insufficient_funds", "message": "No.", "param": "amount"}},
|
|
46
|
+
{"x-request-id": "req_1"})])
|
|
47
|
+
ip = InstantPay(KEY, base_url="https://api.test", transport=t)
|
|
48
|
+
with self.assertRaises(InstantPayError) as cm:
|
|
49
|
+
ip.refunds.create(payment_id="pay_1", amount="5.00", reason="x")
|
|
50
|
+
e = cm.exception
|
|
51
|
+
self.assertEqual((e.status, e.code, e.param, e.request_id), (422, "insufficient_funds", "amount", "req_1"))
|
|
52
|
+
|
|
53
|
+
def test_network_errors_give_up(self):
|
|
54
|
+
t = FakeTransport([OSError("down"), OSError("down")])
|
|
55
|
+
ip = InstantPay(KEY, base_url="https://api.test", transport=t, max_retries=1)
|
|
56
|
+
with self.assertRaises(InstantPayError) as cm:
|
|
57
|
+
ip.payments.retrieve("pay_1")
|
|
58
|
+
self.assertEqual(cm.exception.code, "network_error")
|
|
59
|
+
|
|
60
|
+
def test_pagination(self):
|
|
61
|
+
t = FakeTransport([(200, {"data": [{"id": "a"}, {"id": "b"}], "has_more": True, "next_cursor": "b"}),
|
|
62
|
+
(200, {"data": [{"id": "c"}], "has_more": False, "next_cursor": None})])
|
|
63
|
+
ip = InstantPay(KEY, base_url="https://api.test", transport=t)
|
|
64
|
+
self.assertEqual([p["id"] for p in ip.payments.all(limit=2, pos=True)], ["a", "b", "c"])
|
|
65
|
+
self.assertIn("starting_after=b", t.calls[1][1])
|
|
66
|
+
self.assertIn("pos=true", t.calls[0][1])
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class WebhookTest(unittest.TestCase):
|
|
70
|
+
secret = "whsec_test123"
|
|
71
|
+
body = json.dumps({"id": "evt_1", "object": "event", "type": "payment.succeeded", "data": {}})
|
|
72
|
+
|
|
73
|
+
def test_verify(self):
|
|
74
|
+
header = webhooks.sign(self.body, self.secret, 1_000_000)
|
|
75
|
+
self.assertTrue(webhooks.verify(self.body, header, self.secret, now=1_000_010))
|
|
76
|
+
self.assertTrue(webhooks.verify(self.body.encode(), header, self.secret, now=1_000_010))
|
|
77
|
+
self.assertFalse(webhooks.verify(self.body + " ", header, self.secret, now=1_000_010))
|
|
78
|
+
self.assertFalse(webhooks.verify(self.body, header, "whsec_other", now=1_000_010))
|
|
79
|
+
self.assertFalse(webhooks.verify(self.body, header, self.secret, now=1_000_400))
|
|
80
|
+
self.assertFalse(webhooks.verify(self.body, None, self.secret))
|
|
81
|
+
self.assertEqual(webhooks.construct_event(self.body, header, self.secret, now=1_000_000)["type"],
|
|
82
|
+
"payment.succeeded")
|
|
83
|
+
with self.assertRaises(InstantPayError):
|
|
84
|
+
webhooks.construct_event(self.body, "t=1,v1=00", self.secret)
|
|
85
|
+
|
|
86
|
+
def test_matches_node_signature(self):
|
|
87
|
+
# Same vector as the API's own signer: HMAC-SHA256 over "t.body".
|
|
88
|
+
self.assertEqual(webhooks.sign("{}", "whsec_x", 1700000000),
|
|
89
|
+
"t=1700000000,v1=" + __import__("hmac").new(b"whsec_x", b"1700000000.{}", "sha256").hexdigest())
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@unittest.skipUnless(os.environ.get("INSTANTPAY_API_KEY"), "set INSTANTPAY_API_KEY for the live sandbox")
|
|
93
|
+
class LiveTest(unittest.TestCase):
|
|
94
|
+
def test_checkout_and_link(self):
|
|
95
|
+
ip = InstantPay(os.environ["INSTANTPAY_API_KEY"],
|
|
96
|
+
base_url=os.environ.get("INSTANTPAY_API_URL", "http://localhost:4000"))
|
|
97
|
+
pi = ip.payment_intents.create(amount="12.34", description="Python SDK order",
|
|
98
|
+
reference=f"py-{int(time.time())}", return_url="https://shop.example.sa/thanks")
|
|
99
|
+
self.assertRegex(pi["checkout_url"], r"/pay/[a-z0-9]{10}$")
|
|
100
|
+
self.assertEqual(ip.payment_intents.retrieve(pi["id"])["status"], "requires_payment")
|
|
101
|
+
link = ip.payment_links.create(amount="5.00", description="Python link")
|
|
102
|
+
self.assertEqual(ip.payment_links.cancel(link["id"])["status"], "cancelled")
|
|
103
|
+
with self.assertRaises(InstantPayError) as cm:
|
|
104
|
+
ip.payments.retrieve("pay_00000000000000000000000000")
|
|
105
|
+
self.assertEqual(cm.exception.status, 404)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
unittest.main()
|