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.
@@ -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