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.
@@ -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).
@@ -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__"]