khqrpay 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.
- khqrpay-0.1.0/.gitignore +33 -0
- khqrpay-0.1.0/LICENSE +21 -0
- khqrpay-0.1.0/PKG-INFO +295 -0
- khqrpay-0.1.0/README.md +265 -0
- khqrpay-0.1.0/pyproject.toml +49 -0
- khqrpay-0.1.0/src/khqrpay/__init__.py +12 -0
- khqrpay-0.1.0/src/khqrpay/_client.py +369 -0
- khqrpay-0.1.0/src/khqrpay/_errors.py +44 -0
- khqrpay-0.1.0/src/khqrpay/_version.py +1 -0
- khqrpay-0.1.0/src/khqrpay/py.typed +0 -0
- khqrpay-0.1.0/src/khqrpay/webhooks.py +176 -0
- khqrpay-0.1.0/tests/conftest.py +39 -0
- khqrpay-0.1.0/tests/fake_api.py +106 -0
- khqrpay-0.1.0/tests/test-vectors.json +134 -0
- khqrpay-0.1.0/tests/test_client.py +374 -0
- khqrpay-0.1.0/tests/test_webhooks.py +194 -0
khqrpay-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Virtual environments
|
|
2
|
+
.venv/
|
|
3
|
+
venv/
|
|
4
|
+
env/
|
|
5
|
+
|
|
6
|
+
# Python
|
|
7
|
+
__pycache__/
|
|
8
|
+
*.py[cod]
|
|
9
|
+
*.egg-info/
|
|
10
|
+
.eggs/
|
|
11
|
+
|
|
12
|
+
# Build output
|
|
13
|
+
build/
|
|
14
|
+
dist/
|
|
15
|
+
|
|
16
|
+
# Test and tool caches
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
.coverage
|
|
21
|
+
.coverage.*
|
|
22
|
+
htmlcov/
|
|
23
|
+
.tox/
|
|
24
|
+
.nox/
|
|
25
|
+
|
|
26
|
+
# Editors and OS
|
|
27
|
+
.idea/
|
|
28
|
+
.vscode/
|
|
29
|
+
.DS_Store
|
|
30
|
+
|
|
31
|
+
# Local secrets
|
|
32
|
+
.env
|
|
33
|
+
.env.*
|
khqrpay-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 KHQRPay
|
|
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.
|
khqrpay-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: khqrpay
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official KHQRPay SDK for Python: accept KHQR payments in Cambodia from ABA, ACLEDA, Wing and every Bakong app.
|
|
5
|
+
Project-URL: Homepage, https://khqrpay.dev
|
|
6
|
+
Project-URL: Documentation, https://khqrpay.dev/docs
|
|
7
|
+
Project-URL: Guide, https://khqrpay.dev/guides/khqr-api-python
|
|
8
|
+
Project-URL: Source, https://github.com/DaraXBT/khqrpay-python
|
|
9
|
+
Project-URL: Issues, https://github.com/DaraXBT/khqrpay-python/issues
|
|
10
|
+
Author: KHQRPay
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: aba,acleda,bakong,cambodia,khqr,payments,qr,sdk,wing
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
26
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
27
|
+
Classifier: Typing :: Typed
|
|
28
|
+
Requires-Python: >=3.8
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# KHQRPay for Python: official KHQR payments SDK for Cambodia
|
|
32
|
+
|
|
33
|
+
Accept KHQR payments from ABA, ACLEDA, Wing and every Bakong app, with the money going straight to your ABA account. [KHQRPay](https://khqrpay.dev) creates the QR, watches for the payment and sends you a signed webhook when it’s paid. It never holds your money. The full API reference is at [khqrpay.dev/docs](https://khqrpay.dev/docs).
|
|
34
|
+
|
|
35
|
+
This is the official Python SDK. It needs Python 3.8 or newer and has no dependencies: it uses only the standard library.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
pip install khqrpay
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Create a payment
|
|
44
|
+
|
|
45
|
+
Get a secret key from your dashboard. Keep it on your server, in an environment variable, and never in your code or your logs. Use a `kp_test_` key while you build: it creates simulated payments. A `kp_live_` key moves real money.
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import os
|
|
49
|
+
|
|
50
|
+
from khqrpay import KHQRPay
|
|
51
|
+
|
|
52
|
+
client = KHQRPay(os.environ["KHQRPAY_SECRET_KEY"])
|
|
53
|
+
|
|
54
|
+
payment = client.payments.create({
|
|
55
|
+
"amount": "16.88",
|
|
56
|
+
"currency": "USD",
|
|
57
|
+
"reference_id": "INV-2048",
|
|
58
|
+
"metadata": {"cart_id": "c_1"},
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
print(payment["id"], payment["status"]) # pending
|
|
62
|
+
print(payment["checkout_url"]) # send the customer here
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Then redirect the customer to `checkout_url`. KHQRPay hosts the page with the QR. In Flask:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from flask import redirect
|
|
69
|
+
|
|
70
|
+
@app.post("/checkout")
|
|
71
|
+
def checkout():
|
|
72
|
+
payment = client.payments.create(
|
|
73
|
+
{"amount": "16.88", "reference_id": "INV-2048"},
|
|
74
|
+
idempotency_key="order-INV-2048",
|
|
75
|
+
)
|
|
76
|
+
return redirect(payment["checkout_url"], code=303)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
If you’d rather draw the QR yourself, `payment["qr_string"]` is the KHQR payload.
|
|
80
|
+
|
|
81
|
+
Amounts:
|
|
82
|
+
|
|
83
|
+
- Send amounts as strings: `"16.88"` for USD (up to 2 decimals), `"20000"` for KHR (whole riel).
|
|
84
|
+
- You may pass a number, such as `16.88` or `20000`. The SDK turns it into the same string, with no rounding.
|
|
85
|
+
- `currency` is `"USD"` or `"KHR"`, and defaults to USD. When it differs from your ABA link’s currency, KHQRPay converts the amount. The payment’s `amount` and `currency` are what the payer pays. `requested_amount` and `requested_currency` are what you sent.
|
|
86
|
+
|
|
87
|
+
Apart from turning a numeric amount into a string, the SDK sends your dict as the JSON body exactly as given. Every create also sends an `Idempotency-Key`. If you don’t pass one, the SDK makes a random one. Pass your own, tied to your order, so a retry gets the same payment back.
|
|
88
|
+
|
|
89
|
+
The other calls:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
payment = client.payments.retrieve(payment["id"])
|
|
93
|
+
|
|
94
|
+
page = client.payments.list(status="paid", limit=50)
|
|
95
|
+
for item in page["data"]:
|
|
96
|
+
print(item["id"], item["amount"], item["currency"])
|
|
97
|
+
while page["has_more"]:
|
|
98
|
+
page = client.payments.list(status="paid", limit=50, cursor=page["next_cursor"])
|
|
99
|
+
|
|
100
|
+
usage = client.usage.retrieve() # {"plan": "free", "limit": ..., "used": ..., ...}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Every call returns the API’s JSON as a dict. List items leave out `metadata`: read it with `retrieve`.
|
|
104
|
+
|
|
105
|
+
The calls block, like any `urllib` call. In an `async` app, run them in a thread. On Python 3.9 and up, that’s `await asyncio.to_thread(client.payments.create, params)`.
|
|
106
|
+
|
|
107
|
+
## Verify a webhook
|
|
108
|
+
|
|
109
|
+
When a payment is scanned, paid, expired or failed, KHQRPay sends a POST to your endpoint. It’s signed in the `X-KHQRPay-Signature` header with your endpoint’s signing secret (`whsec_...`).
|
|
110
|
+
|
|
111
|
+
> **Verify the raw body, before you parse it.** Pass the exact bytes you received. If your framework parses the JSON and you serialize it again, the bytes change and the check fails. Never verify `request.json`.
|
|
112
|
+
|
|
113
|
+
- `khqrpay.webhooks.construct_event(raw_body, signature_header, secret)` checks the signature and returns the parsed event. If the check fails, it raises `KHQRPayError` with `code="invalid_signature"`. If a correctly signed body isn’t a JSON object, it raises `code="invalid_payload"`.
|
|
114
|
+
- `khqrpay.webhooks.verify(...)` takes the same arguments and returns `True` or `False`. It never raises.
|
|
115
|
+
- Both reject a timestamp more than 5 minutes off. Change that with `tolerance_sec=`.
|
|
116
|
+
- Neither needs an API key. `KHQRPay.webhooks` and `client.webhooks` are the same functions.
|
|
117
|
+
|
|
118
|
+
### Flask
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
import os
|
|
122
|
+
|
|
123
|
+
import khqrpay
|
|
124
|
+
from flask import Flask, request
|
|
125
|
+
|
|
126
|
+
app = Flask(__name__)
|
|
127
|
+
WEBHOOK_SECRET = os.environ["KHQRPAY_WEBHOOK_SECRET"]
|
|
128
|
+
|
|
129
|
+
@app.post("/webhooks/khqrpay")
|
|
130
|
+
def khqrpay_webhook():
|
|
131
|
+
try:
|
|
132
|
+
event = khqrpay.webhooks.construct_event(
|
|
133
|
+
request.get_data(), # the raw bytes, not request.json
|
|
134
|
+
request.headers.get("X-KHQRPay-Signature"),
|
|
135
|
+
WEBHOOK_SECRET,
|
|
136
|
+
)
|
|
137
|
+
except khqrpay.KHQRPayError:
|
|
138
|
+
return "", 400
|
|
139
|
+
|
|
140
|
+
if event["type"] == "payment.completed":
|
|
141
|
+
payment = event["data"]["payment"]
|
|
142
|
+
mark_order_paid(payment["reference_id"], event_id=event["id"])
|
|
143
|
+
return "", 200
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Django
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
import os
|
|
150
|
+
|
|
151
|
+
import khqrpay
|
|
152
|
+
from django.http import HttpResponse
|
|
153
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
154
|
+
from django.views.decorators.http import require_POST
|
|
155
|
+
|
|
156
|
+
WEBHOOK_SECRET = os.environ["KHQRPAY_WEBHOOK_SECRET"]
|
|
157
|
+
|
|
158
|
+
@csrf_exempt # KHQRPay can't send a CSRF token; the signature protects this view
|
|
159
|
+
@require_POST
|
|
160
|
+
def khqrpay_webhook(request):
|
|
161
|
+
try:
|
|
162
|
+
event = khqrpay.webhooks.construct_event(
|
|
163
|
+
request.body, # the raw bytes, not json.loads(...)
|
|
164
|
+
request.headers.get("X-KHQRPay-Signature"),
|
|
165
|
+
WEBHOOK_SECRET,
|
|
166
|
+
)
|
|
167
|
+
except khqrpay.KHQRPayError:
|
|
168
|
+
return HttpResponse(status=400)
|
|
169
|
+
|
|
170
|
+
if event["type"] == "payment.completed":
|
|
171
|
+
payment = event["data"]["payment"]
|
|
172
|
+
mark_order_paid(payment["reference_id"], event_id=event["id"])
|
|
173
|
+
return HttpResponse(status=200)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### FastAPI
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
import os
|
|
180
|
+
|
|
181
|
+
import khqrpay
|
|
182
|
+
from fastapi import FastAPI, Request, Response
|
|
183
|
+
|
|
184
|
+
app = FastAPI()
|
|
185
|
+
WEBHOOK_SECRET = os.environ["KHQRPAY_WEBHOOK_SECRET"]
|
|
186
|
+
|
|
187
|
+
@app.post("/webhooks/khqrpay")
|
|
188
|
+
async def khqrpay_webhook(request: Request):
|
|
189
|
+
raw_body = await request.body() # the raw bytes; don't declare a Pydantic body model
|
|
190
|
+
try:
|
|
191
|
+
event = khqrpay.webhooks.construct_event(
|
|
192
|
+
raw_body,
|
|
193
|
+
request.headers.get("X-KHQRPay-Signature"),
|
|
194
|
+
WEBHOOK_SECRET,
|
|
195
|
+
)
|
|
196
|
+
except khqrpay.KHQRPayError:
|
|
197
|
+
return Response(status_code=400)
|
|
198
|
+
|
|
199
|
+
if event["type"] == "payment.completed":
|
|
200
|
+
payment = event["data"]["payment"]
|
|
201
|
+
await mark_order_paid(payment["reference_id"], event_id=event["id"])
|
|
202
|
+
return Response(status_code=200)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
A few rules for the handler:
|
|
206
|
+
|
|
207
|
+
- Event types are `payment.completed`, `payment.scanned`, `payment.expired` and `payment.failed`.
|
|
208
|
+
- Answer with a 2xx quickly. Anything else, or no answer, is retried.
|
|
209
|
+
- Every retry has the same `event["id"]`. Store it and skip events you’ve already handled.
|
|
210
|
+
- Check `payment["reference_id"]`, `amount` and `currency` against your order before you ship.
|
|
211
|
+
|
|
212
|
+
## Errors and retries
|
|
213
|
+
|
|
214
|
+
Every failed call raises `KHQRPayError`:
|
|
215
|
+
|
|
216
|
+
| Attribute | What it holds |
|
|
217
|
+
|---|---|
|
|
218
|
+
| `code` | The stable error code. Branch on this. |
|
|
219
|
+
| `message` | An explanation for people. It may change, so don’t branch on it. |
|
|
220
|
+
| `status` | The HTTP status, or `0` when there was no response. |
|
|
221
|
+
| `retry_after` | Seconds from the `Retry-After` header, or `None`. |
|
|
222
|
+
|
|
223
|
+
Codes come from the API, for example `invalid_request`, `invalid_amount`, `unauthorized`, `quota_exceeded`, `payment_not_found` and `rate_limited`. For API calls, the SDK adds two of its own:
|
|
224
|
+
|
|
225
|
+
- `network_error`: KHQRPay couldn’t be reached, or the call timed out (30 seconds by default).
|
|
226
|
+
- `invalid_response`: the reply wasn’t the JSON the SDK expected.
|
|
227
|
+
|
|
228
|
+
The SDK never retries by itself. A create is safe to retry with the same idempotency key: you get the same payment back, never a second one. Retry on 429, 503, 502 and 500, after `Retry-After` when it’s set. A `network_error` is safe to retry the same way.
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
import time
|
|
232
|
+
|
|
233
|
+
from khqrpay import KHQRPayError
|
|
234
|
+
|
|
235
|
+
RETRY_STATUSES = {429, 500, 502, 503}
|
|
236
|
+
|
|
237
|
+
def create_payment(client, params, idempotency_key, attempts=4):
|
|
238
|
+
for attempt in range(attempts):
|
|
239
|
+
try:
|
|
240
|
+
# The same key on every attempt.
|
|
241
|
+
return client.payments.create(params, idempotency_key=idempotency_key)
|
|
242
|
+
except KHQRPayError as err:
|
|
243
|
+
retryable = err.status in RETRY_STATUSES or err.code == "network_error"
|
|
244
|
+
if not retryable or attempt == attempts - 1:
|
|
245
|
+
raise
|
|
246
|
+
time.sleep(err.retry_after if err.retry_after is not None else 2 ** attempt)
|
|
247
|
+
|
|
248
|
+
payment = create_payment(client, {"amount": "16.88", "reference_id": "INV-2048"}, "order-INV-2048")
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Branch on the code for the rest:
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
try:
|
|
255
|
+
payment = client.payments.create({"amount": "0.001"})
|
|
256
|
+
except KHQRPayError as err:
|
|
257
|
+
if err.code in ("invalid_amount", "amount_too_low", "amount_too_high"):
|
|
258
|
+
... # show the customer a message
|
|
259
|
+
else:
|
|
260
|
+
raise
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
You can change the timeout and the base URL:
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
client = KHQRPay(os.environ["KHQRPAY_SECRET_KEY"], timeout=10, base_url="https://khqrpay.dev")
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## Test mode
|
|
270
|
+
|
|
271
|
+
With a `kp_test_` key, payments are simulated. No money moves, and test payments never count against your quota. Move one through its life with `simulate`. KHQRPay sends the webhook just as it would for a live payment, so you can test your handler end to end.
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
client = KHQRPay(os.environ["KHQRPAY_TEST_SECRET_KEY"]) # kp_test_...
|
|
275
|
+
|
|
276
|
+
payment = client.payments.create({"amount": "1.00", "reference_id": "TEST-1"})
|
|
277
|
+
|
|
278
|
+
client.payments.simulate(payment["id"], "scanned")
|
|
279
|
+
paid = client.payments.simulate(payment["id"], "paid")
|
|
280
|
+
assert paid["status"] == "paid"
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
The status is `"scanned"`, `"paid"`, `"expired"` or `"failed"`. `simulate` only works with test keys.
|
|
284
|
+
|
|
285
|
+
## Guide
|
|
286
|
+
|
|
287
|
+
For a step-by-step walkthrough, read [Accept KHQR payments with Python](https://khqrpay.dev/guides/khqr-api-python).
|
|
288
|
+
|
|
289
|
+
## Troubleshooting
|
|
290
|
+
|
|
291
|
+
**`network_error` with `CERTIFICATE_VERIFY_FAILED` on a Mac:** Python from python.org doesn’t trust any HTTPS certificates until you run **Install Certificates.command** (in `/Applications/Python 3.x/`). Run it once and try again. Nothing in your code needs to change.
|
|
292
|
+
|
|
293
|
+
## License
|
|
294
|
+
|
|
295
|
+
MIT. See [LICENSE](LICENSE).
|
khqrpay-0.1.0/README.md
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# KHQRPay for Python: official KHQR payments SDK for Cambodia
|
|
2
|
+
|
|
3
|
+
Accept KHQR payments from ABA, ACLEDA, Wing and every Bakong app, with the money going straight to your ABA account. [KHQRPay](https://khqrpay.dev) creates the QR, watches for the payment and sends you a signed webhook when it’s paid. It never holds your money. The full API reference is at [khqrpay.dev/docs](https://khqrpay.dev/docs).
|
|
4
|
+
|
|
5
|
+
This is the official Python SDK. It needs Python 3.8 or newer and has no dependencies: it uses only the standard library.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pip install khqrpay
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Create a payment
|
|
14
|
+
|
|
15
|
+
Get a secret key from your dashboard. Keep it on your server, in an environment variable, and never in your code or your logs. Use a `kp_test_` key while you build: it creates simulated payments. A `kp_live_` key moves real money.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import os
|
|
19
|
+
|
|
20
|
+
from khqrpay import KHQRPay
|
|
21
|
+
|
|
22
|
+
client = KHQRPay(os.environ["KHQRPAY_SECRET_KEY"])
|
|
23
|
+
|
|
24
|
+
payment = client.payments.create({
|
|
25
|
+
"amount": "16.88",
|
|
26
|
+
"currency": "USD",
|
|
27
|
+
"reference_id": "INV-2048",
|
|
28
|
+
"metadata": {"cart_id": "c_1"},
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
print(payment["id"], payment["status"]) # pending
|
|
32
|
+
print(payment["checkout_url"]) # send the customer here
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Then redirect the customer to `checkout_url`. KHQRPay hosts the page with the QR. In Flask:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from flask import redirect
|
|
39
|
+
|
|
40
|
+
@app.post("/checkout")
|
|
41
|
+
def checkout():
|
|
42
|
+
payment = client.payments.create(
|
|
43
|
+
{"amount": "16.88", "reference_id": "INV-2048"},
|
|
44
|
+
idempotency_key="order-INV-2048",
|
|
45
|
+
)
|
|
46
|
+
return redirect(payment["checkout_url"], code=303)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
If you’d rather draw the QR yourself, `payment["qr_string"]` is the KHQR payload.
|
|
50
|
+
|
|
51
|
+
Amounts:
|
|
52
|
+
|
|
53
|
+
- Send amounts as strings: `"16.88"` for USD (up to 2 decimals), `"20000"` for KHR (whole riel).
|
|
54
|
+
- You may pass a number, such as `16.88` or `20000`. The SDK turns it into the same string, with no rounding.
|
|
55
|
+
- `currency` is `"USD"` or `"KHR"`, and defaults to USD. When it differs from your ABA link’s currency, KHQRPay converts the amount. The payment’s `amount` and `currency` are what the payer pays. `requested_amount` and `requested_currency` are what you sent.
|
|
56
|
+
|
|
57
|
+
Apart from turning a numeric amount into a string, the SDK sends your dict as the JSON body exactly as given. Every create also sends an `Idempotency-Key`. If you don’t pass one, the SDK makes a random one. Pass your own, tied to your order, so a retry gets the same payment back.
|
|
58
|
+
|
|
59
|
+
The other calls:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
payment = client.payments.retrieve(payment["id"])
|
|
63
|
+
|
|
64
|
+
page = client.payments.list(status="paid", limit=50)
|
|
65
|
+
for item in page["data"]:
|
|
66
|
+
print(item["id"], item["amount"], item["currency"])
|
|
67
|
+
while page["has_more"]:
|
|
68
|
+
page = client.payments.list(status="paid", limit=50, cursor=page["next_cursor"])
|
|
69
|
+
|
|
70
|
+
usage = client.usage.retrieve() # {"plan": "free", "limit": ..., "used": ..., ...}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Every call returns the API’s JSON as a dict. List items leave out `metadata`: read it with `retrieve`.
|
|
74
|
+
|
|
75
|
+
The calls block, like any `urllib` call. In an `async` app, run them in a thread. On Python 3.9 and up, that’s `await asyncio.to_thread(client.payments.create, params)`.
|
|
76
|
+
|
|
77
|
+
## Verify a webhook
|
|
78
|
+
|
|
79
|
+
When a payment is scanned, paid, expired or failed, KHQRPay sends a POST to your endpoint. It’s signed in the `X-KHQRPay-Signature` header with your endpoint’s signing secret (`whsec_...`).
|
|
80
|
+
|
|
81
|
+
> **Verify the raw body, before you parse it.** Pass the exact bytes you received. If your framework parses the JSON and you serialize it again, the bytes change and the check fails. Never verify `request.json`.
|
|
82
|
+
|
|
83
|
+
- `khqrpay.webhooks.construct_event(raw_body, signature_header, secret)` checks the signature and returns the parsed event. If the check fails, it raises `KHQRPayError` with `code="invalid_signature"`. If a correctly signed body isn’t a JSON object, it raises `code="invalid_payload"`.
|
|
84
|
+
- `khqrpay.webhooks.verify(...)` takes the same arguments and returns `True` or `False`. It never raises.
|
|
85
|
+
- Both reject a timestamp more than 5 minutes off. Change that with `tolerance_sec=`.
|
|
86
|
+
- Neither needs an API key. `KHQRPay.webhooks` and `client.webhooks` are the same functions.
|
|
87
|
+
|
|
88
|
+
### Flask
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
import os
|
|
92
|
+
|
|
93
|
+
import khqrpay
|
|
94
|
+
from flask import Flask, request
|
|
95
|
+
|
|
96
|
+
app = Flask(__name__)
|
|
97
|
+
WEBHOOK_SECRET = os.environ["KHQRPAY_WEBHOOK_SECRET"]
|
|
98
|
+
|
|
99
|
+
@app.post("/webhooks/khqrpay")
|
|
100
|
+
def khqrpay_webhook():
|
|
101
|
+
try:
|
|
102
|
+
event = khqrpay.webhooks.construct_event(
|
|
103
|
+
request.get_data(), # the raw bytes, not request.json
|
|
104
|
+
request.headers.get("X-KHQRPay-Signature"),
|
|
105
|
+
WEBHOOK_SECRET,
|
|
106
|
+
)
|
|
107
|
+
except khqrpay.KHQRPayError:
|
|
108
|
+
return "", 400
|
|
109
|
+
|
|
110
|
+
if event["type"] == "payment.completed":
|
|
111
|
+
payment = event["data"]["payment"]
|
|
112
|
+
mark_order_paid(payment["reference_id"], event_id=event["id"])
|
|
113
|
+
return "", 200
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Django
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
import os
|
|
120
|
+
|
|
121
|
+
import khqrpay
|
|
122
|
+
from django.http import HttpResponse
|
|
123
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
124
|
+
from django.views.decorators.http import require_POST
|
|
125
|
+
|
|
126
|
+
WEBHOOK_SECRET = os.environ["KHQRPAY_WEBHOOK_SECRET"]
|
|
127
|
+
|
|
128
|
+
@csrf_exempt # KHQRPay can't send a CSRF token; the signature protects this view
|
|
129
|
+
@require_POST
|
|
130
|
+
def khqrpay_webhook(request):
|
|
131
|
+
try:
|
|
132
|
+
event = khqrpay.webhooks.construct_event(
|
|
133
|
+
request.body, # the raw bytes, not json.loads(...)
|
|
134
|
+
request.headers.get("X-KHQRPay-Signature"),
|
|
135
|
+
WEBHOOK_SECRET,
|
|
136
|
+
)
|
|
137
|
+
except khqrpay.KHQRPayError:
|
|
138
|
+
return HttpResponse(status=400)
|
|
139
|
+
|
|
140
|
+
if event["type"] == "payment.completed":
|
|
141
|
+
payment = event["data"]["payment"]
|
|
142
|
+
mark_order_paid(payment["reference_id"], event_id=event["id"])
|
|
143
|
+
return HttpResponse(status=200)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### FastAPI
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
import os
|
|
150
|
+
|
|
151
|
+
import khqrpay
|
|
152
|
+
from fastapi import FastAPI, Request, Response
|
|
153
|
+
|
|
154
|
+
app = FastAPI()
|
|
155
|
+
WEBHOOK_SECRET = os.environ["KHQRPAY_WEBHOOK_SECRET"]
|
|
156
|
+
|
|
157
|
+
@app.post("/webhooks/khqrpay")
|
|
158
|
+
async def khqrpay_webhook(request: Request):
|
|
159
|
+
raw_body = await request.body() # the raw bytes; don't declare a Pydantic body model
|
|
160
|
+
try:
|
|
161
|
+
event = khqrpay.webhooks.construct_event(
|
|
162
|
+
raw_body,
|
|
163
|
+
request.headers.get("X-KHQRPay-Signature"),
|
|
164
|
+
WEBHOOK_SECRET,
|
|
165
|
+
)
|
|
166
|
+
except khqrpay.KHQRPayError:
|
|
167
|
+
return Response(status_code=400)
|
|
168
|
+
|
|
169
|
+
if event["type"] == "payment.completed":
|
|
170
|
+
payment = event["data"]["payment"]
|
|
171
|
+
await mark_order_paid(payment["reference_id"], event_id=event["id"])
|
|
172
|
+
return Response(status_code=200)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
A few rules for the handler:
|
|
176
|
+
|
|
177
|
+
- Event types are `payment.completed`, `payment.scanned`, `payment.expired` and `payment.failed`.
|
|
178
|
+
- Answer with a 2xx quickly. Anything else, or no answer, is retried.
|
|
179
|
+
- Every retry has the same `event["id"]`. Store it and skip events you’ve already handled.
|
|
180
|
+
- Check `payment["reference_id"]`, `amount` and `currency` against your order before you ship.
|
|
181
|
+
|
|
182
|
+
## Errors and retries
|
|
183
|
+
|
|
184
|
+
Every failed call raises `KHQRPayError`:
|
|
185
|
+
|
|
186
|
+
| Attribute | What it holds |
|
|
187
|
+
|---|---|
|
|
188
|
+
| `code` | The stable error code. Branch on this. |
|
|
189
|
+
| `message` | An explanation for people. It may change, so don’t branch on it. |
|
|
190
|
+
| `status` | The HTTP status, or `0` when there was no response. |
|
|
191
|
+
| `retry_after` | Seconds from the `Retry-After` header, or `None`. |
|
|
192
|
+
|
|
193
|
+
Codes come from the API, for example `invalid_request`, `invalid_amount`, `unauthorized`, `quota_exceeded`, `payment_not_found` and `rate_limited`. For API calls, the SDK adds two of its own:
|
|
194
|
+
|
|
195
|
+
- `network_error`: KHQRPay couldn’t be reached, or the call timed out (30 seconds by default).
|
|
196
|
+
- `invalid_response`: the reply wasn’t the JSON the SDK expected.
|
|
197
|
+
|
|
198
|
+
The SDK never retries by itself. A create is safe to retry with the same idempotency key: you get the same payment back, never a second one. Retry on 429, 503, 502 and 500, after `Retry-After` when it’s set. A `network_error` is safe to retry the same way.
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
import time
|
|
202
|
+
|
|
203
|
+
from khqrpay import KHQRPayError
|
|
204
|
+
|
|
205
|
+
RETRY_STATUSES = {429, 500, 502, 503}
|
|
206
|
+
|
|
207
|
+
def create_payment(client, params, idempotency_key, attempts=4):
|
|
208
|
+
for attempt in range(attempts):
|
|
209
|
+
try:
|
|
210
|
+
# The same key on every attempt.
|
|
211
|
+
return client.payments.create(params, idempotency_key=idempotency_key)
|
|
212
|
+
except KHQRPayError as err:
|
|
213
|
+
retryable = err.status in RETRY_STATUSES or err.code == "network_error"
|
|
214
|
+
if not retryable or attempt == attempts - 1:
|
|
215
|
+
raise
|
|
216
|
+
time.sleep(err.retry_after if err.retry_after is not None else 2 ** attempt)
|
|
217
|
+
|
|
218
|
+
payment = create_payment(client, {"amount": "16.88", "reference_id": "INV-2048"}, "order-INV-2048")
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Branch on the code for the rest:
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
try:
|
|
225
|
+
payment = client.payments.create({"amount": "0.001"})
|
|
226
|
+
except KHQRPayError as err:
|
|
227
|
+
if err.code in ("invalid_amount", "amount_too_low", "amount_too_high"):
|
|
228
|
+
... # show the customer a message
|
|
229
|
+
else:
|
|
230
|
+
raise
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
You can change the timeout and the base URL:
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
client = KHQRPay(os.environ["KHQRPAY_SECRET_KEY"], timeout=10, base_url="https://khqrpay.dev")
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Test mode
|
|
240
|
+
|
|
241
|
+
With a `kp_test_` key, payments are simulated. No money moves, and test payments never count against your quota. Move one through its life with `simulate`. KHQRPay sends the webhook just as it would for a live payment, so you can test your handler end to end.
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
client = KHQRPay(os.environ["KHQRPAY_TEST_SECRET_KEY"]) # kp_test_...
|
|
245
|
+
|
|
246
|
+
payment = client.payments.create({"amount": "1.00", "reference_id": "TEST-1"})
|
|
247
|
+
|
|
248
|
+
client.payments.simulate(payment["id"], "scanned")
|
|
249
|
+
paid = client.payments.simulate(payment["id"], "paid")
|
|
250
|
+
assert paid["status"] == "paid"
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
The status is `"scanned"`, `"paid"`, `"expired"` or `"failed"`. `simulate` only works with test keys.
|
|
254
|
+
|
|
255
|
+
## Guide
|
|
256
|
+
|
|
257
|
+
For a step-by-step walkthrough, read [Accept KHQR payments with Python](https://khqrpay.dev/guides/khqr-api-python).
|
|
258
|
+
|
|
259
|
+
## Troubleshooting
|
|
260
|
+
|
|
261
|
+
**`network_error` with `CERTIFICATE_VERIFY_FAILED` on a Mac:** Python from python.org doesn’t trust any HTTPS certificates until you run **Install Certificates.command** (in `/Applications/Python 3.x/`). Run it once and try again. Nothing in your code needs to change.
|
|
262
|
+
|
|
263
|
+
## License
|
|
264
|
+
|
|
265
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "khqrpay"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Official KHQRPay SDK for Python: accept KHQR payments in Cambodia from ABA, ACLEDA, Wing and every Bakong app."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "KHQRPay" }]
|
|
13
|
+
keywords = ["khqr", "bakong", "aba", "acleda", "wing", "cambodia", "payments", "qr", "sdk"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Programming Language :: Python :: 3.8",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Office/Business :: Financial",
|
|
27
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
28
|
+
"Typing :: Typed",
|
|
29
|
+
]
|
|
30
|
+
dependencies = []
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://khqrpay.dev"
|
|
34
|
+
Documentation = "https://khqrpay.dev/docs"
|
|
35
|
+
Guide = "https://khqrpay.dev/guides/khqr-api-python"
|
|
36
|
+
Source = "https://github.com/DaraXBT/khqrpay-python"
|
|
37
|
+
Issues = "https://github.com/DaraXBT/khqrpay-python/issues"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.version]
|
|
40
|
+
path = "src/khqrpay/_version.py"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["src/khqrpay"]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.sdist]
|
|
46
|
+
include = ["src/khqrpay", "tests", "README.md", "LICENSE", "pyproject.toml"]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""KHQRPay for Python: the official KHQR payments SDK for Cambodia.
|
|
2
|
+
|
|
3
|
+
Accept KHQR payments from ABA, ACLEDA, Wing and every Bakong app, with the
|
|
4
|
+
money going straight to your ABA account. Docs: https://khqrpay.dev/docs
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from . import webhooks
|
|
8
|
+
from ._client import KHQRPay
|
|
9
|
+
from ._errors import KHQRPayError
|
|
10
|
+
from ._version import __version__
|
|
11
|
+
|
|
12
|
+
__all__ = ["KHQRPay", "KHQRPayError", "webhooks", "__version__"]
|