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.
@@ -0,0 +1,15 @@
1
+ node_modules/
2
+ dist/
3
+ .next/
4
+ .turbo/
5
+ coverage/
6
+ *.tsbuildinfo
7
+ .env
8
+ .env.*.local
9
+ !.env.example
10
+ next-env.d.ts
11
+ playwright-report/
12
+ test-results/
13
+ .DS_Store
14
+ e2e/test-results/
15
+ e2e/playwright-report/
@@ -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()