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,526 @@
|
|
|
1
|
+
"""Yoma Bank MMQR: QR payments, status checks and callbacks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import inspect
|
|
8
|
+
import re
|
|
9
|
+
import threading
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import timedelta
|
|
13
|
+
from typing import Any, ClassVar, cast
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from ._amount import AmountInput, to_amount
|
|
18
|
+
from ._cache import AsyncTokenCache, MemoryTokenCache, TokenCache
|
|
19
|
+
from ._callback import CallbackRequest, lossless_body
|
|
20
|
+
from ._errors import ApiError, SignatureVerificationError
|
|
21
|
+
from ._http import (
|
|
22
|
+
DEFAULT_TIMEOUT,
|
|
23
|
+
AsyncGateway,
|
|
24
|
+
GatewayResponse,
|
|
25
|
+
HttpRequest,
|
|
26
|
+
SyncGateway,
|
|
27
|
+
form_request,
|
|
28
|
+
json_request,
|
|
29
|
+
)
|
|
30
|
+
from ._json import LosslessObject, to_plain_object
|
|
31
|
+
from ._results import PaymentCallback, PaymentStatusResult, QrPayment
|
|
32
|
+
from ._status import PaymentStatus, resolve_status
|
|
33
|
+
from ._support import (
|
|
34
|
+
EnvSource,
|
|
35
|
+
config_of,
|
|
36
|
+
default_env,
|
|
37
|
+
env_first,
|
|
38
|
+
env_sandbox,
|
|
39
|
+
hmac_sha256_hex,
|
|
40
|
+
optional_setting,
|
|
41
|
+
require_setting,
|
|
42
|
+
safe_equal,
|
|
43
|
+
sandbox_flag,
|
|
44
|
+
sha256_hex,
|
|
45
|
+
trim_url,
|
|
46
|
+
utc_now,
|
|
47
|
+
)
|
|
48
|
+
from ._validate import AmountRule, Validator
|
|
49
|
+
from ._values import get, is_nested, optional, trimmed
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"AsyncYomaMmqr",
|
|
53
|
+
"YomaMmqr",
|
|
54
|
+
"YomaMmqrConfig",
|
|
55
|
+
"YomaMmqrPaymentData",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class YomaMmqrConfig:
|
|
60
|
+
"""Yoma MMQR credentials and endpoints.
|
|
61
|
+
|
|
62
|
+
A missing credential raises a :class:`~python_myanmar_payments.ConfigurationError`.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
SANDBOX_URL: ClassVar[str] = "https://devapi.yomabank.net"
|
|
66
|
+
PRODUCTION_URL: ClassVar[str] = "https://paymenthubapi.yomabank.com"
|
|
67
|
+
DEFAULT_API_VERSION: ClassVar[str] = "v1rc"
|
|
68
|
+
|
|
69
|
+
merchant_id: str
|
|
70
|
+
"""The merchant ID Yoma issued."""
|
|
71
|
+
client_id: str
|
|
72
|
+
"""The OAuth client ID."""
|
|
73
|
+
client_secret: str
|
|
74
|
+
"""The OAuth client secret."""
|
|
75
|
+
webhook_hash_key: str
|
|
76
|
+
"""The hash key Yoma issued for verifying callbacks."""
|
|
77
|
+
webhook_secret: str | None
|
|
78
|
+
"""The secret you shared with Yoma; when set, callbacks must carry it in
|
|
79
|
+
``X-Webhook-Secret``."""
|
|
80
|
+
sandbox: bool
|
|
81
|
+
"""Whether the UAT payment hub is used."""
|
|
82
|
+
base_url: str
|
|
83
|
+
"""The base URL in use."""
|
|
84
|
+
api_version: str
|
|
85
|
+
"""The ``{version}`` segment of the API paths in use."""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
*,
|
|
90
|
+
merchant_id: str = "",
|
|
91
|
+
client_id: str = "",
|
|
92
|
+
client_secret: str = "",
|
|
93
|
+
webhook_hash_key: str = "",
|
|
94
|
+
webhook_secret: str | None = None,
|
|
95
|
+
sandbox: bool | str = True,
|
|
96
|
+
base_url: str | None = None,
|
|
97
|
+
api_version: str | None = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
self.merchant_id = require_setting("yoma_mmqr", "merchant_id", merchant_id)
|
|
100
|
+
self.client_id = require_setting("yoma_mmqr", "client_id", client_id)
|
|
101
|
+
self.client_secret = require_setting("yoma_mmqr", "client_secret", client_secret)
|
|
102
|
+
self.webhook_hash_key = require_setting("yoma_mmqr", "webhook_hashkey", webhook_hash_key)
|
|
103
|
+
self.webhook_secret = optional_setting(webhook_secret)
|
|
104
|
+
self.sandbox = sandbox = sandbox_flag(sandbox)
|
|
105
|
+
self.base_url = trim_url(
|
|
106
|
+
optional_setting(base_url) or (self.SANDBOX_URL if sandbox else self.PRODUCTION_URL)
|
|
107
|
+
)
|
|
108
|
+
self.api_version = optional_setting(api_version) or self.DEFAULT_API_VERSION
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def from_env(cls, env: EnvSource | None = None) -> YomaMmqrConfig:
|
|
112
|
+
"""Reads the ``YOMA_MMQR_*`` environment variables.
|
|
113
|
+
|
|
114
|
+
``YOMA_MMQR_MERCHANT_ID``, ``YOMA_MMQR_CLIENT_ID``,
|
|
115
|
+
``YOMA_MMQR_CLIENT_SECRET``, ``YOMA_MMQR_WEBHOOK_HASHKEY``,
|
|
116
|
+
``YOMA_MMQR_WEBHOOK_SECRET``, ``YOMA_MMQR_SANDBOX``, ``YOMA_MMQR_BASE_URL``
|
|
117
|
+
and ``YOMA_MMQR_API_VERSION``. Defaults to ``os.environ``.
|
|
118
|
+
"""
|
|
119
|
+
env = default_env() if env is None else env
|
|
120
|
+
return cls(
|
|
121
|
+
merchant_id=env_first(env, "YOMA_MMQR_MERCHANT_ID"),
|
|
122
|
+
client_id=env_first(env, "YOMA_MMQR_CLIENT_ID"),
|
|
123
|
+
client_secret=env_first(env, "YOMA_MMQR_CLIENT_SECRET"),
|
|
124
|
+
webhook_hash_key=env_first(env, "YOMA_MMQR_WEBHOOK_HASHKEY"),
|
|
125
|
+
webhook_secret=env_first(env, "YOMA_MMQR_WEBHOOK_SECRET"),
|
|
126
|
+
sandbox=env_sandbox(env, "YOMA_MMQR_SANDBOX"),
|
|
127
|
+
base_url=env_first(env, "YOMA_MMQR_BASE_URL"),
|
|
128
|
+
api_version=env_first(env, "YOMA_MMQR_API_VERSION"),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def __repr__(self) -> str:
|
|
132
|
+
return f"YomaMmqrConfig(merchant_id={self.merchant_id!r}, sandbox={self.sandbox!r})"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(kw_only=True)
|
|
136
|
+
class YomaMmqrPaymentData:
|
|
137
|
+
"""A Yoma MMQR order.
|
|
138
|
+
|
|
139
|
+
Yoma accepts each order number once; renew an expired QR with ``renew_qr()``.
|
|
140
|
+
Yoma documents no decimals, so amounts are whole kyat.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
order_id: str
|
|
144
|
+
"""Your unique order number, at most 20 characters."""
|
|
145
|
+
amount: AmountInput
|
|
146
|
+
"""The amount in whole kyat, e.g. ``Amount.kyat(1000)`` or ``1000``."""
|
|
147
|
+
description: str
|
|
148
|
+
"""Shown on the payment slip, at most 50 characters."""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
_STATUSES: Mapping[str, PaymentStatus] = {
|
|
152
|
+
"SUCCESS": PaymentStatus.SUCCESSFUL,
|
|
153
|
+
"PENDING": PaymentStatus.PENDING,
|
|
154
|
+
"FAILED": PaymentStatus.FAILED,
|
|
155
|
+
"FAIL": PaymentStatus.FAILED,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
_LEADING_INT = re.compile(r"\s*([+-]?[0-9]+)")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass(frozen=True)
|
|
162
|
+
class _Call:
|
|
163
|
+
path: str
|
|
164
|
+
url: str
|
|
165
|
+
data: Mapping[str, Any]
|
|
166
|
+
allowed_errors: Sequence[str] = ()
|
|
167
|
+
|
|
168
|
+
def request(self, token: str) -> HttpRequest:
|
|
169
|
+
return json_request(self.url, self.data, {"Authorization": f"Bearer {token}"})
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class _YomaMmqrBase:
|
|
173
|
+
"""What the sync and async Yoma clients share: requests, parsing and callbacks."""
|
|
174
|
+
|
|
175
|
+
QR_LIFETIME_SECONDS: ClassVar[int] = 120
|
|
176
|
+
"""How long a generated QR stays payable, in seconds."""
|
|
177
|
+
|
|
178
|
+
config: YomaMmqrConfig
|
|
179
|
+
"""The configuration in use."""
|
|
180
|
+
|
|
181
|
+
def __init__(self, config: YomaMmqrConfig | Mapping[str, Any]) -> None:
|
|
182
|
+
self.config = config_of(YomaMmqrConfig, config)
|
|
183
|
+
|
|
184
|
+
@staticmethod
|
|
185
|
+
def validate(data: YomaMmqrPaymentData) -> None:
|
|
186
|
+
"""Checks the order against Yoma's documented limits.
|
|
187
|
+
|
|
188
|
+
Raises an :class:`~python_myanmar_payments.InvalidPaymentDataError`.
|
|
189
|
+
"""
|
|
190
|
+
(
|
|
191
|
+
Validator()
|
|
192
|
+
.required("order_id", data.order_id)
|
|
193
|
+
.max("order_id", data.order_id, 20)
|
|
194
|
+
.amount("amount", data.amount, AmountRule("Yoma MMQR", 0))
|
|
195
|
+
.required("description", data.description)
|
|
196
|
+
.max("description", data.description, 50)
|
|
197
|
+
.validate()
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def handle_callback(self, request: CallbackRequest) -> PaymentCallback:
|
|
201
|
+
"""Verifies Yoma's payment callback.
|
|
202
|
+
|
|
203
|
+
The hash is HMAC-SHA256 of ``orderNumber=..&status=..`` keyed with the
|
|
204
|
+
order number followed by the webhook hash key.
|
|
205
|
+
"""
|
|
206
|
+
payload = lossless_body(request)
|
|
207
|
+
|
|
208
|
+
if self.config.webhook_secret is not None and not safe_equal(
|
|
209
|
+
self.config.webhook_secret, request.header("X-Webhook-Secret") or ""
|
|
210
|
+
):
|
|
211
|
+
raise SignatureVerificationError(
|
|
212
|
+
"Yoma MMQR callback has a missing or wrong X-Webhook-Secret header.",
|
|
213
|
+
to_plain_object(payload),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
order_number = get(payload, "orderNumber")
|
|
217
|
+
status = trimmed(payload, "status")
|
|
218
|
+
expected = hmac_sha256_hex(
|
|
219
|
+
order_number + self.config.webhook_hash_key,
|
|
220
|
+
f"orderNumber={order_number}&status={status}",
|
|
221
|
+
)
|
|
222
|
+
if (
|
|
223
|
+
order_number == ""
|
|
224
|
+
or is_nested(payload.get("status"))
|
|
225
|
+
or not safe_equal(expected, get(payload, "hashValue").lower())
|
|
226
|
+
):
|
|
227
|
+
raise SignatureVerificationError(
|
|
228
|
+
"Yoma MMQR callback hash verification failed.", to_plain_object(payload)
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
return PaymentCallback(
|
|
232
|
+
order_id=order_number,
|
|
233
|
+
status=resolve_status(_STATUSES, status.upper()),
|
|
234
|
+
gateway_status=status,
|
|
235
|
+
raw=to_plain_object(payload),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# --- Requests and results shared by both clients ------------------------------
|
|
239
|
+
|
|
240
|
+
def _api(self, path: str, data: Mapping[str, Any], allowed: Sequence[str] = ()) -> _Call:
|
|
241
|
+
url = f"{self.config.base_url}/payment-gateway/{self.config.api_version}/api/{path}"
|
|
242
|
+
return _Call(path, url, data, allowed)
|
|
243
|
+
|
|
244
|
+
def _checkout_call(self, data: YomaMmqrPaymentData) -> _Call:
|
|
245
|
+
self.validate(data)
|
|
246
|
+
return self._api(
|
|
247
|
+
"payment/checkout",
|
|
248
|
+
{
|
|
249
|
+
"merchantId": self.config.merchant_id,
|
|
250
|
+
"orderNumber": data.order_id,
|
|
251
|
+
"amount": str(to_amount(data.amount)),
|
|
252
|
+
"description": data.description,
|
|
253
|
+
},
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
@staticmethod
|
|
257
|
+
def _check_checkout(body: LosslessObject) -> None:
|
|
258
|
+
if body.get("checkOutStatus") is not True:
|
|
259
|
+
raise ApiError("Yoma MMQR did not confirm the checkout.", raw=to_plain_object(body))
|
|
260
|
+
|
|
261
|
+
def _renew_call(self, order_id: str) -> _Call:
|
|
262
|
+
return self._api(
|
|
263
|
+
"qr/generate", {"merchantId": self.config.merchant_id, "orderNumber": order_id}
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def _qr_payment(self, order_id: str, body: LosslessObject) -> QrPayment:
|
|
267
|
+
qr_string = get(body, "qrString")
|
|
268
|
+
reference = get(body, "refLabel")
|
|
269
|
+
if qr_string == "" or reference == "":
|
|
270
|
+
raise ApiError("Yoma MMQR did not return a QR.", raw=to_plain_object(body))
|
|
271
|
+
return QrPayment(
|
|
272
|
+
order_id=order_id,
|
|
273
|
+
qr_image=qr_string,
|
|
274
|
+
expires_at=utc_now() + timedelta(seconds=self.QR_LIFETIME_SECONDS),
|
|
275
|
+
reference=reference,
|
|
276
|
+
raw=to_plain_object(body),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def _status_call(self, reference: str) -> _Call:
|
|
280
|
+
return self._api(
|
|
281
|
+
"payment/check-status",
|
|
282
|
+
{"merchantId": self.config.merchant_id, "refLabel": reference},
|
|
283
|
+
["QR EXPIRED"],
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
@staticmethod
|
|
287
|
+
def _status_result(reference: str, body: LosslessObject) -> PaymentStatusResult:
|
|
288
|
+
if get(body, "errorCode") == "QR EXPIRED":
|
|
289
|
+
return PaymentStatusResult(
|
|
290
|
+
status=PaymentStatus.EXPIRED,
|
|
291
|
+
gateway_status="QR EXPIRED",
|
|
292
|
+
gateway_reference=reference,
|
|
293
|
+
raw=to_plain_object(body),
|
|
294
|
+
)
|
|
295
|
+
payment_status = trimmed(body, "paymentStatus")
|
|
296
|
+
return PaymentStatusResult(
|
|
297
|
+
status=resolve_status(_STATUSES, payment_status.upper()),
|
|
298
|
+
gateway_status=payment_status,
|
|
299
|
+
gateway_reference=get(body, "refLabel") or reference,
|
|
300
|
+
raw=to_plain_object(body),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
@staticmethod
|
|
304
|
+
def _body(call: _Call, response: GatewayResponse) -> LosslessObject:
|
|
305
|
+
body = response.json()
|
|
306
|
+
error_code = get(body, "errorCode")
|
|
307
|
+
if not response.successful() or (
|
|
308
|
+
error_code != "" and error_code not in call.allowed_errors
|
|
309
|
+
):
|
|
310
|
+
raise _api_error(call.path, response.status, body, error_code)
|
|
311
|
+
return body
|
|
312
|
+
|
|
313
|
+
def _token_request(self) -> HttpRequest:
|
|
314
|
+
credentials = f"{self.config.client_id}:{self.config.client_secret}"
|
|
315
|
+
encoded = base64.b64encode(credentials.encode()).decode()
|
|
316
|
+
return form_request(
|
|
317
|
+
f"{self.config.base_url}/token",
|
|
318
|
+
{"grant_type": "client_credentials"},
|
|
319
|
+
{"Authorization": f"Basic {encoded}"},
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
@staticmethod
|
|
323
|
+
def _token(response: GatewayResponse) -> tuple[str, int]:
|
|
324
|
+
"""The access token and how many seconds to cache it."""
|
|
325
|
+
body = response.json()
|
|
326
|
+
token = get(body, "access_token")
|
|
327
|
+
if not response.successful() or token == "":
|
|
328
|
+
raise _api_error("token", response.status, body, get(body, "error"))
|
|
329
|
+
match = _LEADING_INT.match(get(body, "expires_in"))
|
|
330
|
+
expires_in = int(match.group(1)) if match else 0
|
|
331
|
+
return token, max(60, (expires_in if expires_in > 0 else 3600) - 60)
|
|
332
|
+
|
|
333
|
+
def _token_cache_key(self) -> str:
|
|
334
|
+
digest = sha256_hex(f"{self.config.base_url}|{self.config.client_id}")
|
|
335
|
+
return f"myanmar-payments.yoma-mmqr.token.{digest}"
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _api_error(endpoint: str, status: int, body: LosslessObject, error_code: str) -> ApiError:
|
|
339
|
+
message = optional(body, "errorDescription") or optional(body, "error_description")
|
|
340
|
+
return ApiError(
|
|
341
|
+
f"Yoma MMQR {endpoint} failed: [{error_code}] {message or ''}".rstrip()
|
|
342
|
+
if error_code
|
|
343
|
+
else f"Yoma MMQR {endpoint} failed with HTTP {status}.",
|
|
344
|
+
gateway_code=error_code or None,
|
|
345
|
+
gateway_message=message or None,
|
|
346
|
+
http_status=status,
|
|
347
|
+
raw=to_plain_object(body),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class YomaMmqr(_YomaMmqrBase, SyncGateway):
|
|
352
|
+
"""Yoma Bank MMQR with a synchronous HTTP client.
|
|
353
|
+
|
|
354
|
+
QR payments, status checks and callbacks. Access tokens are cached in a
|
|
355
|
+
:class:`~python_myanmar_payments.TokenCache`; share the gateway (or pass a
|
|
356
|
+
shared cache) so they survive between calls. Use :class:`AsyncYomaMmqr` in
|
|
357
|
+
async code.
|
|
358
|
+
"""
|
|
359
|
+
|
|
360
|
+
def __init__(
|
|
361
|
+
self,
|
|
362
|
+
config: YomaMmqrConfig | Mapping[str, Any],
|
|
363
|
+
*,
|
|
364
|
+
token_cache: TokenCache | None = None,
|
|
365
|
+
http_client: httpx.Client | None = None,
|
|
366
|
+
timeout: float | None = DEFAULT_TIMEOUT,
|
|
367
|
+
) -> None:
|
|
368
|
+
super().__init__(config)
|
|
369
|
+
self._init_transport(http_client, timeout)
|
|
370
|
+
self._cache: TokenCache = token_cache if token_cache is not None else MemoryTokenCache()
|
|
371
|
+
self._token_lock = threading.Lock()
|
|
372
|
+
|
|
373
|
+
@classmethod
|
|
374
|
+
def from_env(
|
|
375
|
+
cls,
|
|
376
|
+
env: EnvSource | None = None,
|
|
377
|
+
*,
|
|
378
|
+
token_cache: TokenCache | None = None,
|
|
379
|
+
http_client: httpx.Client | None = None,
|
|
380
|
+
timeout: float | None = DEFAULT_TIMEOUT,
|
|
381
|
+
) -> YomaMmqr:
|
|
382
|
+
"""A gateway configured from the ``YOMA_MMQR_*`` environment variables."""
|
|
383
|
+
return cls(
|
|
384
|
+
YomaMmqrConfig.from_env(env),
|
|
385
|
+
token_cache=token_cache,
|
|
386
|
+
http_client=http_client,
|
|
387
|
+
timeout=timeout,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
def initiate(self, data: YomaMmqrPaymentData) -> QrPayment:
|
|
391
|
+
"""Checks the order out with Yoma and generates its first QR.
|
|
392
|
+
|
|
393
|
+
Call it once per order.
|
|
394
|
+
"""
|
|
395
|
+
self._check_checkout(self._send(self._checkout_call(data)))
|
|
396
|
+
return self.renew_qr(data.order_id)
|
|
397
|
+
|
|
398
|
+
def renew_qr(self, order_id: str) -> QrPayment:
|
|
399
|
+
"""Generates a new QR for an order that is already checked out.
|
|
400
|
+
|
|
401
|
+
Use it after the previous QR expired. The previous QR's reference stops
|
|
402
|
+
working.
|
|
403
|
+
"""
|
|
404
|
+
return self._qr_payment(order_id, self._send(self._renew_call(order_id)))
|
|
405
|
+
|
|
406
|
+
def status(self, reference: str) -> PaymentStatusResult:
|
|
407
|
+
"""Checks a QR's payment status by its reference (``QrPayment.reference``).
|
|
408
|
+
|
|
409
|
+
An expired QR reports ``expired``.
|
|
410
|
+
"""
|
|
411
|
+
return self._status_result(reference, self._send(self._status_call(reference)))
|
|
412
|
+
|
|
413
|
+
def forget_token(self) -> None:
|
|
414
|
+
"""Drops the cached access token, e.g. after rotating the client secret."""
|
|
415
|
+
self._cache.delete(self._token_cache_key())
|
|
416
|
+
|
|
417
|
+
def _send(self, call: _Call) -> LosslessObject:
|
|
418
|
+
response = self._transport.send(call.request(self._access_token()))
|
|
419
|
+
if response.status == 401:
|
|
420
|
+
self.forget_token()
|
|
421
|
+
response = self._transport.send(call.request(self._access_token()))
|
|
422
|
+
return self._body(call, response)
|
|
423
|
+
|
|
424
|
+
def _access_token(self) -> str:
|
|
425
|
+
key = self._token_cache_key()
|
|
426
|
+
cached = self._cache.get(key)
|
|
427
|
+
if isinstance(cached, str) and cached != "":
|
|
428
|
+
return cached
|
|
429
|
+
# Concurrent calls share one token request.
|
|
430
|
+
with self._token_lock:
|
|
431
|
+
cached = self._cache.get(key)
|
|
432
|
+
if isinstance(cached, str) and cached != "":
|
|
433
|
+
return cached
|
|
434
|
+
token, ttl = self._token(self._transport.send(self._token_request()))
|
|
435
|
+
self._cache.set(key, token, ttl)
|
|
436
|
+
return token
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
AnyTokenCache = TokenCache | AsyncTokenCache
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
class AsyncYomaMmqr(_YomaMmqrBase, AsyncGateway):
|
|
443
|
+
"""Yoma Bank MMQR with an async HTTP client. The same API as :class:`YomaMmqr`, awaited.
|
|
444
|
+
|
|
445
|
+
``token_cache`` takes a :class:`~python_myanmar_payments.TokenCache` or an
|
|
446
|
+
:class:`~python_myanmar_payments.AsyncTokenCache`.
|
|
447
|
+
"""
|
|
448
|
+
|
|
449
|
+
def __init__(
|
|
450
|
+
self,
|
|
451
|
+
config: YomaMmqrConfig | Mapping[str, Any],
|
|
452
|
+
*,
|
|
453
|
+
token_cache: AnyTokenCache | None = None,
|
|
454
|
+
http_client: httpx.AsyncClient | None = None,
|
|
455
|
+
timeout: float | None = DEFAULT_TIMEOUT,
|
|
456
|
+
) -> None:
|
|
457
|
+
super().__init__(config)
|
|
458
|
+
self._init_transport(http_client, timeout)
|
|
459
|
+
self._cache: AnyTokenCache = token_cache if token_cache is not None else MemoryTokenCache()
|
|
460
|
+
self._token_lock = asyncio.Lock()
|
|
461
|
+
|
|
462
|
+
@classmethod
|
|
463
|
+
def from_env(
|
|
464
|
+
cls,
|
|
465
|
+
env: EnvSource | None = None,
|
|
466
|
+
*,
|
|
467
|
+
token_cache: AnyTokenCache | None = None,
|
|
468
|
+
http_client: httpx.AsyncClient | None = None,
|
|
469
|
+
timeout: float | None = DEFAULT_TIMEOUT,
|
|
470
|
+
) -> AsyncYomaMmqr:
|
|
471
|
+
"""A gateway configured from the ``YOMA_MMQR_*`` environment variables."""
|
|
472
|
+
return cls(
|
|
473
|
+
YomaMmqrConfig.from_env(env),
|
|
474
|
+
token_cache=token_cache,
|
|
475
|
+
http_client=http_client,
|
|
476
|
+
timeout=timeout,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
async def initiate(self, data: YomaMmqrPaymentData) -> QrPayment:
|
|
480
|
+
"""Checks the order out with Yoma and generates its first QR. Call it once per order."""
|
|
481
|
+
self._check_checkout(await self._send(self._checkout_call(data)))
|
|
482
|
+
return await self.renew_qr(data.order_id)
|
|
483
|
+
|
|
484
|
+
async def renew_qr(self, order_id: str) -> QrPayment:
|
|
485
|
+
"""Generates a new QR for an order that is already checked out."""
|
|
486
|
+
return self._qr_payment(order_id, await self._send(self._renew_call(order_id)))
|
|
487
|
+
|
|
488
|
+
async def status(self, reference: str) -> PaymentStatusResult:
|
|
489
|
+
"""Checks a QR's payment status by its reference. An expired QR reports ``expired``."""
|
|
490
|
+
return self._status_result(reference, await self._send(self._status_call(reference)))
|
|
491
|
+
|
|
492
|
+
async def forget_token(self) -> None:
|
|
493
|
+
"""Drops the cached access token, e.g. after rotating the client secret."""
|
|
494
|
+
await _resolve(self._cache.delete(self._token_cache_key()))
|
|
495
|
+
|
|
496
|
+
async def _send(self, call: _Call) -> LosslessObject:
|
|
497
|
+
response = await self._transport.send(call.request(await self._access_token()))
|
|
498
|
+
if response.status == 401:
|
|
499
|
+
await self.forget_token()
|
|
500
|
+
response = await self._transport.send(call.request(await self._access_token()))
|
|
501
|
+
return self._body(call, response)
|
|
502
|
+
|
|
503
|
+
async def _cached_token(self, key: str) -> str | None:
|
|
504
|
+
cached = await _resolve(self._cache.get(key))
|
|
505
|
+
return cached if isinstance(cached, str) and cached != "" else None
|
|
506
|
+
|
|
507
|
+
async def _access_token(self) -> str:
|
|
508
|
+
key = self._token_cache_key()
|
|
509
|
+
cached = await self._cached_token(key)
|
|
510
|
+
if cached is not None:
|
|
511
|
+
return cached
|
|
512
|
+
# Concurrent calls share one token request.
|
|
513
|
+
async with self._token_lock:
|
|
514
|
+
cached = await self._cached_token(key)
|
|
515
|
+
if cached is not None:
|
|
516
|
+
return cached
|
|
517
|
+
token, ttl = self._token(await self._transport.send(self._token_request()))
|
|
518
|
+
await _resolve(self._cache.set(key, token, ttl))
|
|
519
|
+
return token
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
async def _resolve(value: Any) -> Any:
|
|
523
|
+
"""Awaits ``value`` when a cache method returned an awaitable."""
|
|
524
|
+
if inspect.isawaitable(value):
|
|
525
|
+
return await cast("Any", value)
|
|
526
|
+
return value
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: python-myanmar-payments
|
|
3
|
+
Version: 4.0.0a1
|
|
4
|
+
Summary: Python SDK for Myanmar payment gateways: KBZ Pay, Wave Money, AYA Pay, Yoma MMQR and CyberSource. Typed requests and results, exact amounts, sync and async clients.
|
|
5
|
+
Project-URL: Homepage, https://laranex.vercel.app/python-myanmar-payments
|
|
6
|
+
Project-URL: Documentation, https://laranex.vercel.app/python-myanmar-payments
|
|
7
|
+
Project-URL: Repository, https://github.com/laranex/python-myanmar-payments
|
|
8
|
+
Project-URL: Issues, https://github.com/laranex/python-myanmar-payments/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/laranex/python-myanmar-payments/blob/dev/CHANGELOG.md
|
|
10
|
+
Author-email: Nay Thu Khant <naythukhant644@gmail.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE.md
|
|
13
|
+
Keywords: aya-pay,cybersource,kbzpay,laranex,mmqr,myanmar,payment-gateway,payments,python-myanmar-payments,wave-money,wavepay,yoma
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Framework :: AsyncIO
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
26
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
27
|
+
Classifier: Typing :: Typed
|
|
28
|
+
Requires-Python: >=3.10
|
|
29
|
+
Requires-Dist: httpx>=0.25
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# Python Myanmar Payments
|
|
33
|
+
|
|
34
|
+
[](https://pypi.org/project/python-myanmar-payments/)
|
|
35
|
+
[](https://github.com/laranex/python-myanmar-payments/actions/workflows/tests.yml)
|
|
36
|
+
[](https://pypi.org/project/python-myanmar-payments/)
|
|
37
|
+
[](LICENSE.md)
|
|
38
|
+
|
|
39
|
+
Python SDK for Myanmar payment gateways: KBZ Pay, Wave Money, AYA Pay, Yoma MMQR and CyberSource. Typed requests and results, exact amounts, sync and async clients. Built for humans and AI agents.
|
|
40
|
+
|
|
41
|
+
## Documentation
|
|
42
|
+
|
|
43
|
+
Full documentation, including installation, usage and the AI agent skill, lives at **[laranex.vercel.app/python-myanmar-payments](https://laranex.vercel.app/python-myanmar-payments)**.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
python_myanmar_payments/__init__.py,sha256=FI4lGJ1-7fFQ6B_AiLWmp1uHHHq18qfUHrcwQLxG20c,2633
|
|
2
|
+
python_myanmar_payments/_amount.py,sha256=YoacvJ6_ZB9JM5exgkGPmzZM4IkZFR5e4cpvPv1iz5s,6720
|
|
3
|
+
python_myanmar_payments/_cache.py,sha256=qxAGw7RjNWUWJQFbhcf-ROAv7oAXLtagUxKFn1EeLHM,2482
|
|
4
|
+
python_myanmar_payments/_callback.py,sha256=xwrmWfFF5zXdtCyyfc--6ie3lCsq8Zu6Vurog2_lrd4,6678
|
|
5
|
+
python_myanmar_payments/_errors.py,sha256=4QPyVdMSTig_-CZHb24j3JN93NNA6XQ59kigFrXHd6M,2841
|
|
6
|
+
python_myanmar_payments/_facade.py,sha256=OieDF_OdpmQOmhvLWDNJ3-zgi9_4E9febEg3RcBkiGg,11194
|
|
7
|
+
python_myanmar_payments/_http.py,sha256=vtjLdcO0o_ogfAeWisUAjhusO3sdCARwTclURHszceI,6272
|
|
8
|
+
python_myanmar_payments/_json.py,sha256=FyN7J7fpHqfqa8GGlroqGTIOSR1K0JiYBouz20FGp0A,2608
|
|
9
|
+
python_myanmar_payments/_results.py,sha256=Klf9U9pgG7ej5FnSJ-tJX5HSa0tSx3DCkscenZD4Ubk,9234
|
|
10
|
+
python_myanmar_payments/_status.py,sha256=DRGkOrP1aE9hvt-vaH8f1rQcIo70IYZdqhhdaeGesrI,2326
|
|
11
|
+
python_myanmar_payments/_support.py,sha256=iL5TO-IPN3sx3noWkvwbOZGvh2keSUIwyXVvzHogpDg,4971
|
|
12
|
+
python_myanmar_payments/_validate.py,sha256=OYZokICLELcAEo42ODLYE4kNIn5FCVplTB0anirr0F8,6209
|
|
13
|
+
python_myanmar_payments/_values.py,sha256=19LviQmhRbOatqjfGcZ7bi6GEparzUmUg0fz-V30Ok0,1953
|
|
14
|
+
python_myanmar_payments/_version.py,sha256=0RKQvZCh46QOqtdJaDVgeyftE3UvgFVB8C9x4fITksU,24
|
|
15
|
+
python_myanmar_payments/aya_pay.py,sha256=pUSC20v_Ay0t2cvmKsLe8XEjO5UneBRYE3_DFvj7xOg,18076
|
|
16
|
+
python_myanmar_payments/cyber_source.py,sha256=WCzcv9vZ4SFm6OeI2A9G1DceBNjOdblvFDTt-pFwars,11457
|
|
17
|
+
python_myanmar_payments/kbz_pay.py,sha256=TOzEZLbqgbSUXek75EXF_k6ZyALZiAZ5itogi_XgRYY,19873
|
|
18
|
+
python_myanmar_payments/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
|
+
python_myanmar_payments/wave_money.py,sha256=cXyWOc6ix14-_GSAVMLzMvJvzvkgJX39RBbJSkTtbTw,16234
|
|
20
|
+
python_myanmar_payments/yoma_mmqr.py,sha256=x1XDMWfjww83Kiq5OCrUgFc_Yy3mWF5_mj0mY5K9lS0,19486
|
|
21
|
+
python_myanmar_payments-4.0.0a1.dist-info/METADATA,sha256=kb_a_m8p7THe2QxNUz4MhjvBu_gfb3C90ubKpWJOCSg,2643
|
|
22
|
+
python_myanmar_payments-4.0.0a1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
23
|
+
python_myanmar_payments-4.0.0a1.dist-info/licenses/LICENSE.md,sha256=so-zBzmHxeh5dKArUhl-gYkpoljJXaw5TxWD9yOUVXU,1065
|
|
24
|
+
python_myanmar_payments-4.0.0a1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Nay Thu Khant
|
|
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.
|