python-myanmar-payments 4.0.0a1__py3-none-any.whl
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.
- python_myanmar_payments/__init__.py +111 -0
- python_myanmar_payments/_amount.py +178 -0
- python_myanmar_payments/_cache.py +80 -0
- python_myanmar_payments/_callback.py +188 -0
- python_myanmar_payments/_errors.py +94 -0
- python_myanmar_payments/_facade.py +271 -0
- python_myanmar_payments/_http.py +201 -0
- python_myanmar_payments/_json.py +94 -0
- python_myanmar_payments/_results.py +265 -0
- python_myanmar_payments/_status.py +66 -0
- python_myanmar_payments/_support.py +172 -0
- python_myanmar_payments/_validate.py +157 -0
- python_myanmar_payments/_values.py +59 -0
- python_myanmar_payments/_version.py +1 -0
- python_myanmar_payments/aya_pay.py +509 -0
- python_myanmar_payments/cyber_source.py +310 -0
- python_myanmar_payments/kbz_pay.py +556 -0
- python_myanmar_payments/py.typed +0 -0
- python_myanmar_payments/wave_money.py +431 -0
- python_myanmar_payments/yoma_mmqr.py +526 -0
- python_myanmar_payments-4.0.0a1.dist-info/METADATA +43 -0
- python_myanmar_payments-4.0.0a1.dist-info/RECORD +24 -0
- python_myanmar_payments-4.0.0a1.dist-info/WHEEL +4 -0
- python_myanmar_payments-4.0.0a1.dist-info/licenses/LICENSE.md +21 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
"""KBZ Pay: PWA (redirect), QR and in-app payments, order status and notifications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
from typing import Any, ClassVar
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ._amount import AmountInput, to_amount
|
|
14
|
+
from ._callback import CallbackRequest, lossless_body
|
|
15
|
+
from ._errors import ApiError, SignatureVerificationError
|
|
16
|
+
from ._http import (
|
|
17
|
+
DEFAULT_TIMEOUT,
|
|
18
|
+
AsyncGateway,
|
|
19
|
+
GatewayResponse,
|
|
20
|
+
HttpRequest,
|
|
21
|
+
SyncGateway,
|
|
22
|
+
json_request,
|
|
23
|
+
)
|
|
24
|
+
from ._json import LosslessObject, to_plain_object
|
|
25
|
+
from ._results import (
|
|
26
|
+
Acknowledgement,
|
|
27
|
+
AppPayment,
|
|
28
|
+
PaymentCallback,
|
|
29
|
+
PaymentStatusResult,
|
|
30
|
+
QrPayment,
|
|
31
|
+
RedirectPayment,
|
|
32
|
+
)
|
|
33
|
+
from ._status import PaymentStatus, resolve_status
|
|
34
|
+
from ._support import (
|
|
35
|
+
EnvSource,
|
|
36
|
+
config_of,
|
|
37
|
+
default_env,
|
|
38
|
+
env_first,
|
|
39
|
+
env_sandbox,
|
|
40
|
+
optional_setting,
|
|
41
|
+
query_escape,
|
|
42
|
+
random_hex,
|
|
43
|
+
require_setting,
|
|
44
|
+
safe_equal,
|
|
45
|
+
sandbox_flag,
|
|
46
|
+
sha256_hex,
|
|
47
|
+
trim_url,
|
|
48
|
+
unix_time,
|
|
49
|
+
utc_now,
|
|
50
|
+
)
|
|
51
|
+
from ._validate import AmountRule, Validator
|
|
52
|
+
from ._values import get, is_nested, object_at, optional, scalar_string, trimmed
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"AsyncKbzPay",
|
|
56
|
+
"KbzPay",
|
|
57
|
+
"KbzPayConfig",
|
|
58
|
+
"KbzPayPaymentData",
|
|
59
|
+
"KbzPaySigner",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class KbzPayConfig:
|
|
64
|
+
"""KBZ Pay credentials and endpoints.
|
|
65
|
+
|
|
66
|
+
A missing credential raises a :class:`~python_myanmar_payments.ConfigurationError`.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
SANDBOX_API_URL: ClassVar[str] = "http://api-uat.kbzpay.com/payment/gateway/uat"
|
|
70
|
+
PRODUCTION_API_URL: ClassVar[str] = "https://api.kbzpay.com/payment/gateway"
|
|
71
|
+
SANDBOX_PWA_URL: ClassVar[str] = "https://static.kbzpay.com/pgw/uat/pwa/#/"
|
|
72
|
+
PRODUCTION_PWA_URL: ClassVar[str] = "https://wap.kbzpay.com/pgw/pwa/#/"
|
|
73
|
+
|
|
74
|
+
app_id: str
|
|
75
|
+
"""The ``appid`` KBZ issued for your merchant app."""
|
|
76
|
+
app_key: str
|
|
77
|
+
"""The secret key used to sign requests."""
|
|
78
|
+
merchant_code: str
|
|
79
|
+
"""The ``merch_code`` KBZ issued."""
|
|
80
|
+
sandbox: bool
|
|
81
|
+
"""Whether the UAT endpoints are used. UAT and production issue separate credentials."""
|
|
82
|
+
api_url: str
|
|
83
|
+
"""The API base URL in use."""
|
|
84
|
+
pwa_url: str
|
|
85
|
+
"""The PWA checkout URL in use, always ending in ``/``."""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
*,
|
|
90
|
+
app_id: str = "",
|
|
91
|
+
app_key: str = "",
|
|
92
|
+
merchant_code: str = "",
|
|
93
|
+
sandbox: bool | str = True,
|
|
94
|
+
api_url: str | None = None,
|
|
95
|
+
pwa_url: str | None = None,
|
|
96
|
+
) -> None:
|
|
97
|
+
self.app_id = require_setting("kbz_pay", "app_id", app_id)
|
|
98
|
+
self.app_key = require_setting("kbz_pay", "app_key", app_key)
|
|
99
|
+
self.merchant_code = require_setting("kbz_pay", "merchant_code", merchant_code)
|
|
100
|
+
self.sandbox = sandbox = sandbox_flag(sandbox)
|
|
101
|
+
self.api_url = trim_url(
|
|
102
|
+
optional_setting(api_url)
|
|
103
|
+
or (self.SANDBOX_API_URL if sandbox else self.PRODUCTION_API_URL)
|
|
104
|
+
)
|
|
105
|
+
self.pwa_url = (
|
|
106
|
+
trim_url(
|
|
107
|
+
optional_setting(pwa_url)
|
|
108
|
+
or (self.SANDBOX_PWA_URL if sandbox else self.PRODUCTION_PWA_URL)
|
|
109
|
+
)
|
|
110
|
+
+ "/"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def from_env(cls, env: EnvSource | None = None) -> KbzPayConfig:
|
|
115
|
+
"""Reads the ``KBZ_PAY_*`` environment variables.
|
|
116
|
+
|
|
117
|
+
``KBZ_PAY_APP_ID``, ``KBZ_PAY_APP_KEY``, ``KBZ_PAY_MERCHANT_CODE``,
|
|
118
|
+
``KBZ_PAY_SANDBOX``, ``KBZ_PAY_BASE_URL`` and
|
|
119
|
+
``KBZ_PAY_PWA_BASE_REDIRECT_URL``. Defaults to ``os.environ``.
|
|
120
|
+
"""
|
|
121
|
+
env = default_env() if env is None else env
|
|
122
|
+
return cls(
|
|
123
|
+
app_id=env_first(env, "KBZ_PAY_APP_ID"),
|
|
124
|
+
app_key=env_first(env, "KBZ_PAY_APP_KEY"),
|
|
125
|
+
merchant_code=env_first(env, "KBZ_PAY_MERCHANT_CODE"),
|
|
126
|
+
sandbox=env_sandbox(env, "KBZ_PAY_SANDBOX"),
|
|
127
|
+
api_url=env_first(env, "KBZ_PAY_BASE_URL"),
|
|
128
|
+
pwa_url=env_first(env, "KBZ_PAY_PWA_BASE_REDIRECT_URL"),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def __repr__(self) -> str:
|
|
132
|
+
return f"KbzPayConfig(app_id={self.app_id!r}, sandbox={self.sandbox!r})"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(kw_only=True)
|
|
136
|
+
class KbzPayPaymentData:
|
|
137
|
+
"""A KBZ Pay order, used for the PWA, QR and in-app flows alike.
|
|
138
|
+
|
|
139
|
+
KBZ Pay only accepts MMK.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
order_id: str
|
|
143
|
+
"""Your unique order ID (``merch_order_id``): letters, digits and ``_`` only, at
|
|
144
|
+
most 40."""
|
|
145
|
+
amount: AmountInput
|
|
146
|
+
"""Kyat, greater than 0 with at most 2 decimal places, e.g. ``Amount.kyat(1000)``
|
|
147
|
+
or ``Amount.parse("1000.50")``."""
|
|
148
|
+
callback_url: str
|
|
149
|
+
"""The public URL KBZ posts the result to (``notify_url``): at most 512
|
|
150
|
+
characters, no query string."""
|
|
151
|
+
title: str | None = None
|
|
152
|
+
"""The product name shown to the customer."""
|
|
153
|
+
timeout_minutes: int | None = None
|
|
154
|
+
"""How long the order stays payable, 1 to 120 minutes. KBZ defaults to 120."""
|
|
155
|
+
callback_info: str | None = None
|
|
156
|
+
"""Free text KBZ sends back unchanged in the callback, at most 512 characters
|
|
157
|
+
once URL-encoded."""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class KbzPaySigner:
|
|
161
|
+
"""KBZ Pay's signature.
|
|
162
|
+
|
|
163
|
+
Every non-empty scalar field except ``sign`` and ``sign_type``, sorted by
|
|
164
|
+
key, joined as raw ``key=value`` pairs (not URL-encoded), with
|
|
165
|
+
``&key=<app key>`` appended, hashed with SHA-256 and uppercased.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def __init__(self, app_key: str) -> None:
|
|
169
|
+
self._app_key = app_key
|
|
170
|
+
|
|
171
|
+
def sign_string(self, fields: Mapping[str, object]) -> str:
|
|
172
|
+
"""The sorted ``key=value`` string, without the app key."""
|
|
173
|
+
pairs = []
|
|
174
|
+
for key in sorted(fields):
|
|
175
|
+
if key in ("sign", "sign_type"):
|
|
176
|
+
continue
|
|
177
|
+
value = scalar_string(fields[key])
|
|
178
|
+
if value is not None and value != "":
|
|
179
|
+
pairs.append(f"{key}={value}")
|
|
180
|
+
return "&".join(pairs)
|
|
181
|
+
|
|
182
|
+
def sign(self, fields: Mapping[str, object]) -> str:
|
|
183
|
+
"""The uppercase SHA-256 signature of ``fields``."""
|
|
184
|
+
return sha256_hex(f"{self.sign_string(fields)}&key={self._app_key}").upper()
|
|
185
|
+
|
|
186
|
+
def verify(self, fields: Mapping[str, object]) -> bool:
|
|
187
|
+
"""Whether ``fields["sign"]`` matches, compared in constant time.
|
|
188
|
+
|
|
189
|
+
Nested values are never signed, so fields carrying one are rejected
|
|
190
|
+
rather than partly trusted.
|
|
191
|
+
"""
|
|
192
|
+
if any(is_nested(value) for value in fields.values()):
|
|
193
|
+
return False
|
|
194
|
+
sign = fields.get("sign")
|
|
195
|
+
return isinstance(sign, str) and safe_equal(self.sign(fields), sign.upper())
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
_ORDER_ID = re.compile(r"[A-Za-z0-9_]+")
|
|
199
|
+
|
|
200
|
+
_STATUSES: Mapping[str, PaymentStatus] = {
|
|
201
|
+
"PAY_SUCCESS": PaymentStatus.SUCCESSFUL,
|
|
202
|
+
"WAIT_PAY": PaymentStatus.PENDING,
|
|
203
|
+
"PAYING": PaymentStatus.PENDING,
|
|
204
|
+
"PAY_FAILED": PaymentStatus.FAILED,
|
|
205
|
+
"ORDER_CLOSED": PaymentStatus.CANCELED,
|
|
206
|
+
"ORDER_EXPIRED": PaymentStatus.EXPIRED,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass(frozen=True)
|
|
211
|
+
class _Call:
|
|
212
|
+
endpoint: str
|
|
213
|
+
request: HttpRequest
|
|
214
|
+
nonce: str
|
|
215
|
+
timestamp: str
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass(frozen=True)
|
|
219
|
+
class _Order:
|
|
220
|
+
prepay_id: str
|
|
221
|
+
nonce: str
|
|
222
|
+
timestamp: str
|
|
223
|
+
response: LosslessObject
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class _KbzPayBase:
|
|
227
|
+
"""What the sync and async KBZ Pay clients share: signing, validation, parsing."""
|
|
228
|
+
|
|
229
|
+
config: KbzPayConfig
|
|
230
|
+
"""The configuration in use."""
|
|
231
|
+
signer: KbzPaySigner
|
|
232
|
+
"""KBZ Pay's request signer, for custom calls."""
|
|
233
|
+
|
|
234
|
+
def __init__(self, config: KbzPayConfig | Mapping[str, Any]) -> None:
|
|
235
|
+
self.config = config_of(KbzPayConfig, config)
|
|
236
|
+
self.signer = KbzPaySigner(self.config.app_key)
|
|
237
|
+
|
|
238
|
+
@staticmethod
|
|
239
|
+
def validate(data: KbzPayPaymentData) -> None:
|
|
240
|
+
"""Checks the order against KBZ's documented limits.
|
|
241
|
+
|
|
242
|
+
Raises an :class:`~python_myanmar_payments.InvalidPaymentDataError`.
|
|
243
|
+
"""
|
|
244
|
+
callback_url = data.callback_url if isinstance(data.callback_url, str) else ""
|
|
245
|
+
callback_info = data.callback_info
|
|
246
|
+
(
|
|
247
|
+
Validator()
|
|
248
|
+
.required("order_id", data.order_id)
|
|
249
|
+
.max("order_id", data.order_id, 40)
|
|
250
|
+
.pattern("order_id", data.order_id, _ORDER_ID, "letters, numbers and underscores")
|
|
251
|
+
.amount("amount", data.amount, AmountRule("KBZ Pay", 2))
|
|
252
|
+
.required("callback_url", data.callback_url)
|
|
253
|
+
.url("callback_url", callback_url)
|
|
254
|
+
.max("callback_url", callback_url, 512)
|
|
255
|
+
.when(
|
|
256
|
+
"?" in callback_url,
|
|
257
|
+
"callback_url",
|
|
258
|
+
"The callback_url field must not contain a query string.",
|
|
259
|
+
)
|
|
260
|
+
.string("title", data.title)
|
|
261
|
+
.between("timeout_minutes", data.timeout_minutes, 1, 120)
|
|
262
|
+
.string("callback_info", callback_info)
|
|
263
|
+
.max(
|
|
264
|
+
"callback_info",
|
|
265
|
+
query_escape(callback_info) if isinstance(callback_info, str) else None,
|
|
266
|
+
512,
|
|
267
|
+
)
|
|
268
|
+
.validate()
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def handle_callback(self, request: CallbackRequest) -> PaymentCallback:
|
|
272
|
+
"""Verifies KBZ Pay's payment notification.
|
|
273
|
+
|
|
274
|
+
Reply with the callback's ``acknowledgement`` (plain ``success``) or KBZ
|
|
275
|
+
retries.
|
|
276
|
+
"""
|
|
277
|
+
payload = lossless_body(request)
|
|
278
|
+
request_fields = object_at(payload, "Request")
|
|
279
|
+
fields = payload if request_fields is None else request_fields
|
|
280
|
+
|
|
281
|
+
if not self.signer.verify(fields):
|
|
282
|
+
raise SignatureVerificationError(
|
|
283
|
+
"KBZ Pay callback signature verification failed.", to_plain_object(payload)
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
trade_status = trimmed(fields, "trade_status")
|
|
287
|
+
return PaymentCallback(
|
|
288
|
+
order_id=get(fields, "merch_order_id"),
|
|
289
|
+
status=resolve_status(_STATUSES, trade_status),
|
|
290
|
+
gateway_status=trade_status,
|
|
291
|
+
gateway_reference=optional(fields, "mm_order_id"),
|
|
292
|
+
amount=optional(fields, "total_amount"),
|
|
293
|
+
raw=to_plain_object(fields),
|
|
294
|
+
acknowledgement=Acknowledgement(body="success"),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# --- Requests and results shared by both clients ------------------------------
|
|
298
|
+
|
|
299
|
+
def _precreate_call(self, data: KbzPayPaymentData, trade_type: str) -> _Call:
|
|
300
|
+
self.validate(data)
|
|
301
|
+
biz = {
|
|
302
|
+
"appid": self.config.app_id,
|
|
303
|
+
"merch_code": self.config.merchant_code,
|
|
304
|
+
"merch_order_id": data.order_id,
|
|
305
|
+
"trade_type": trade_type,
|
|
306
|
+
"total_amount": str(to_amount(data.amount)),
|
|
307
|
+
"trans_currency": "MMK",
|
|
308
|
+
}
|
|
309
|
+
if data.title:
|
|
310
|
+
biz["title"] = data.title
|
|
311
|
+
if data.timeout_minutes is not None:
|
|
312
|
+
biz["timeout_express"] = f"{data.timeout_minutes}m"
|
|
313
|
+
if data.callback_info:
|
|
314
|
+
biz["callback_info"] = query_escape(data.callback_info)
|
|
315
|
+
return self._call(
|
|
316
|
+
"precreate",
|
|
317
|
+
"kbz.payment.precreate",
|
|
318
|
+
"1.0",
|
|
319
|
+
biz,
|
|
320
|
+
{"notify_url": data.callback_url},
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
def _status_call(self, order_id: str) -> _Call:
|
|
324
|
+
return self._call(
|
|
325
|
+
"queryorder",
|
|
326
|
+
"kbz.payment.queryorder",
|
|
327
|
+
"3.0",
|
|
328
|
+
{
|
|
329
|
+
"appid": self.config.app_id,
|
|
330
|
+
"merch_code": self.config.merchant_code,
|
|
331
|
+
"merch_order_id": order_id,
|
|
332
|
+
},
|
|
333
|
+
{},
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
def _call(
|
|
337
|
+
self,
|
|
338
|
+
endpoint: str,
|
|
339
|
+
method: str,
|
|
340
|
+
version: str,
|
|
341
|
+
biz: dict[str, str],
|
|
342
|
+
extra: dict[str, str],
|
|
343
|
+
) -> _Call:
|
|
344
|
+
nonce = random_hex()
|
|
345
|
+
timestamp = str(unix_time())
|
|
346
|
+
common = {
|
|
347
|
+
"timestamp": timestamp,
|
|
348
|
+
"method": method,
|
|
349
|
+
"nonce_str": nonce,
|
|
350
|
+
"version": version,
|
|
351
|
+
**extra,
|
|
352
|
+
}
|
|
353
|
+
request = {
|
|
354
|
+
**common,
|
|
355
|
+
"sign_type": "SHA256",
|
|
356
|
+
"sign": self.signer.sign({**common, **biz}),
|
|
357
|
+
"biz_content": biz,
|
|
358
|
+
}
|
|
359
|
+
return _Call(
|
|
360
|
+
endpoint,
|
|
361
|
+
json_request(f"{self.config.api_url}/{endpoint}", {"Request": request}),
|
|
362
|
+
nonce,
|
|
363
|
+
timestamp,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
@staticmethod
|
|
367
|
+
def _result(call: _Call, response: GatewayResponse) -> LosslessObject:
|
|
368
|
+
"""The ``Response`` object, raising when KBZ reports an error."""
|
|
369
|
+
body = response.json()
|
|
370
|
+
result = object_at(body, "Response") or {}
|
|
371
|
+
if (
|
|
372
|
+
not response.successful()
|
|
373
|
+
or get(result, "result") != "SUCCESS"
|
|
374
|
+
or get(result, "code") != "0"
|
|
375
|
+
):
|
|
376
|
+
code = optional(result, "code")
|
|
377
|
+
message = optional(result, "msg")
|
|
378
|
+
raise ApiError(
|
|
379
|
+
f"KBZ Pay {call.endpoint} failed: [{code}] {message or ''}".rstrip()
|
|
380
|
+
if code
|
|
381
|
+
else f"KBZ Pay {call.endpoint} failed with HTTP {response.status}.",
|
|
382
|
+
gateway_code=code,
|
|
383
|
+
gateway_message=message,
|
|
384
|
+
http_status=response.status,
|
|
385
|
+
raw=to_plain_object(body),
|
|
386
|
+
)
|
|
387
|
+
return result
|
|
388
|
+
|
|
389
|
+
def _order(self, call: _Call, response: GatewayResponse) -> _Order:
|
|
390
|
+
result = self._result(call, response)
|
|
391
|
+
prepay_id = get(result, "prepay_id")
|
|
392
|
+
if prepay_id == "":
|
|
393
|
+
raise ApiError("KBZ Pay did not return a prepay_id.", raw=to_plain_object(result))
|
|
394
|
+
return _Order(prepay_id, call.nonce, call.timestamp, result)
|
|
395
|
+
|
|
396
|
+
def _order_info_fields(self, order: _Order) -> dict[str, str]:
|
|
397
|
+
return {
|
|
398
|
+
"appid": self.config.app_id,
|
|
399
|
+
"merch_code": self.config.merchant_code,
|
|
400
|
+
"nonce_str": order.nonce,
|
|
401
|
+
"prepay_id": order.prepay_id,
|
|
402
|
+
"timestamp": order.timestamp,
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
def _pwa_payment(self, data: KbzPayPaymentData, order: _Order) -> RedirectPayment:
|
|
406
|
+
fields = self._order_info_fields(order)
|
|
407
|
+
query = f"{self.signer.sign_string(fields)}&sign={self.signer.sign(fields)}"
|
|
408
|
+
return RedirectPayment(
|
|
409
|
+
order_id=data.order_id,
|
|
410
|
+
url=f"{self.config.pwa_url}?{query}",
|
|
411
|
+
gateway_reference=order.prepay_id,
|
|
412
|
+
raw=to_plain_object(order.response),
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
@staticmethod
|
|
416
|
+
def _qr_payment(data: KbzPayPaymentData, order: _Order) -> QrPayment:
|
|
417
|
+
qr_code = get(order.response, "qrCode")
|
|
418
|
+
if qr_code == "":
|
|
419
|
+
raise ApiError("KBZ Pay did not return a QR code.", raw=to_plain_object(order.response))
|
|
420
|
+
return QrPayment(
|
|
421
|
+
order_id=data.order_id,
|
|
422
|
+
qr_string=qr_code,
|
|
423
|
+
expires_at=None
|
|
424
|
+
if data.timeout_minutes is None
|
|
425
|
+
else utc_now() + timedelta(minutes=data.timeout_minutes),
|
|
426
|
+
reference=order.prepay_id,
|
|
427
|
+
raw=to_plain_object(order.response),
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
def _app_payment(self, data: KbzPayPaymentData, order: _Order) -> AppPayment:
|
|
431
|
+
fields = self._order_info_fields(order)
|
|
432
|
+
return AppPayment(
|
|
433
|
+
order_id=data.order_id,
|
|
434
|
+
order_info=self.signer.sign_string(fields),
|
|
435
|
+
sign=self.signer.sign(fields),
|
|
436
|
+
sign_type="SHA256",
|
|
437
|
+
raw=to_plain_object(order.response),
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
def _status_result(
|
|
441
|
+
self, call: _Call, response: GatewayResponse, order_id: str
|
|
442
|
+
) -> PaymentStatusResult:
|
|
443
|
+
result = self._result(call, response)
|
|
444
|
+
trade_status = trimmed(result, "trade_status")
|
|
445
|
+
return PaymentStatusResult(
|
|
446
|
+
order_id=optional(result, "merch_order_id") or order_id,
|
|
447
|
+
status=resolve_status(_STATUSES, trade_status),
|
|
448
|
+
gateway_status=trade_status,
|
|
449
|
+
gateway_reference=optional(result, "mm_order_id"),
|
|
450
|
+
amount=optional(result, "total_amount"),
|
|
451
|
+
raw=to_plain_object(result),
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
class KbzPay(_KbzPayBase, SyncGateway):
|
|
456
|
+
"""KBZ Pay with a synchronous HTTP client.
|
|
457
|
+
|
|
458
|
+
PWA (redirect), QR and in-app payments, order status queries and payment
|
|
459
|
+
notifications. Use :class:`AsyncKbzPay` in async code.
|
|
460
|
+
"""
|
|
461
|
+
|
|
462
|
+
def __init__(
|
|
463
|
+
self,
|
|
464
|
+
config: KbzPayConfig | Mapping[str, Any],
|
|
465
|
+
*,
|
|
466
|
+
http_client: httpx.Client | None = None,
|
|
467
|
+
timeout: float | None = DEFAULT_TIMEOUT,
|
|
468
|
+
) -> None:
|
|
469
|
+
super().__init__(config)
|
|
470
|
+
self._init_transport(http_client, timeout)
|
|
471
|
+
|
|
472
|
+
@classmethod
|
|
473
|
+
def from_env(
|
|
474
|
+
cls,
|
|
475
|
+
env: EnvSource | None = None,
|
|
476
|
+
*,
|
|
477
|
+
http_client: httpx.Client | None = None,
|
|
478
|
+
timeout: float | None = DEFAULT_TIMEOUT,
|
|
479
|
+
) -> KbzPay:
|
|
480
|
+
"""A gateway configured from the ``KBZ_PAY_*`` environment variables."""
|
|
481
|
+
return cls(KbzPayConfig.from_env(env), http_client=http_client, timeout=timeout)
|
|
482
|
+
|
|
483
|
+
def pwa(self, data: KbzPayPaymentData) -> RedirectPayment:
|
|
484
|
+
"""Creates an order and returns the KBZ Pay PWA checkout URL.
|
|
485
|
+
|
|
486
|
+
The redirect only works on a phone with the KBZ Pay app installed, and its
|
|
487
|
+
Referer must match the URL registered with KBZ.
|
|
488
|
+
"""
|
|
489
|
+
return self._pwa_payment(data, self._precreate(data, "PWAAPP"))
|
|
490
|
+
|
|
491
|
+
def qr(self, data: KbzPayPaymentData) -> QrPayment:
|
|
492
|
+
"""Creates an order and returns a QR payload to scan with the KBZ Pay app."""
|
|
493
|
+
return self._qr_payment(data, self._precreate(data, "PAY_BY_QRCODE"))
|
|
494
|
+
|
|
495
|
+
def app(self, data: KbzPayPaymentData) -> AppPayment:
|
|
496
|
+
"""Creates an order and returns the signed values for ``KBZPay.startPay()``.
|
|
497
|
+
|
|
498
|
+
The SDK's own result only means the payment screen closed; rely on the
|
|
499
|
+
callback or :meth:`status`.
|
|
500
|
+
"""
|
|
501
|
+
return self._app_payment(data, self._precreate(data, "APP"))
|
|
502
|
+
|
|
503
|
+
def status(self, order_id: str) -> PaymentStatusResult:
|
|
504
|
+
"""Asks KBZ Pay for the current state of an order (``queryorder``)."""
|
|
505
|
+
call = self._status_call(order_id)
|
|
506
|
+
return self._status_result(call, self._transport.send(call.request), order_id)
|
|
507
|
+
|
|
508
|
+
def _precreate(self, data: KbzPayPaymentData, trade_type: str) -> _Order:
|
|
509
|
+
call = self._precreate_call(data, trade_type)
|
|
510
|
+
return self._order(call, self._transport.send(call.request))
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
class AsyncKbzPay(_KbzPayBase, AsyncGateway):
|
|
514
|
+
"""KBZ Pay with an async HTTP client. The same API as :class:`KbzPay`, awaited."""
|
|
515
|
+
|
|
516
|
+
def __init__(
|
|
517
|
+
self,
|
|
518
|
+
config: KbzPayConfig | Mapping[str, Any],
|
|
519
|
+
*,
|
|
520
|
+
http_client: httpx.AsyncClient | None = None,
|
|
521
|
+
timeout: float | None = DEFAULT_TIMEOUT,
|
|
522
|
+
) -> None:
|
|
523
|
+
super().__init__(config)
|
|
524
|
+
self._init_transport(http_client, timeout)
|
|
525
|
+
|
|
526
|
+
@classmethod
|
|
527
|
+
def from_env(
|
|
528
|
+
cls,
|
|
529
|
+
env: EnvSource | None = None,
|
|
530
|
+
*,
|
|
531
|
+
http_client: httpx.AsyncClient | None = None,
|
|
532
|
+
timeout: float | None = DEFAULT_TIMEOUT,
|
|
533
|
+
) -> AsyncKbzPay:
|
|
534
|
+
"""A gateway configured from the ``KBZ_PAY_*`` environment variables."""
|
|
535
|
+
return cls(KbzPayConfig.from_env(env), http_client=http_client, timeout=timeout)
|
|
536
|
+
|
|
537
|
+
async def pwa(self, data: KbzPayPaymentData) -> RedirectPayment:
|
|
538
|
+
"""Creates an order and returns the KBZ Pay PWA checkout URL."""
|
|
539
|
+
return self._pwa_payment(data, await self._precreate(data, "PWAAPP"))
|
|
540
|
+
|
|
541
|
+
async def qr(self, data: KbzPayPaymentData) -> QrPayment:
|
|
542
|
+
"""Creates an order and returns a QR payload to scan with the KBZ Pay app."""
|
|
543
|
+
return self._qr_payment(data, await self._precreate(data, "PAY_BY_QRCODE"))
|
|
544
|
+
|
|
545
|
+
async def app(self, data: KbzPayPaymentData) -> AppPayment:
|
|
546
|
+
"""Creates an order and returns the signed values for ``KBZPay.startPay()``."""
|
|
547
|
+
return self._app_payment(data, await self._precreate(data, "APP"))
|
|
548
|
+
|
|
549
|
+
async def status(self, order_id: str) -> PaymentStatusResult:
|
|
550
|
+
"""Asks KBZ Pay for the current state of an order (``queryorder``)."""
|
|
551
|
+
call = self._status_call(order_id)
|
|
552
|
+
return self._status_result(call, await self._transport.send(call.request), order_id)
|
|
553
|
+
|
|
554
|
+
async def _precreate(self, data: KbzPayPaymentData, trade_type: str) -> _Order:
|
|
555
|
+
call = self._precreate_call(data, trade_type)
|
|
556
|
+
return self._order(call, await self._transport.send(call.request))
|
|
File without changes
|