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,59 @@
1
+ """Reads gateway values as the exact text gateways sign."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Mapping
7
+ from decimal import Decimal
8
+
9
+ from ._json import JsonNumber, LosslessObject
10
+
11
+ __all__ = ["get", "is_nested", "object_at", "optional", "scalar_string", "trimmed"]
12
+
13
+
14
+ def scalar_string(value: object) -> str | None:
15
+ """A scalar as the text a gateway signs.
16
+
17
+ Strings as is, numbers exactly as sent, booleans as ``true``/``false``.
18
+ Objects, lists and ``None`` return ``None``.
19
+ """
20
+ if isinstance(value, str):
21
+ return value
22
+ if isinstance(value, bool):
23
+ return "true" if value else "false"
24
+ if isinstance(value, JsonNumber):
25
+ return value.text
26
+ if isinstance(value, int):
27
+ return str(value)
28
+ if isinstance(value, float):
29
+ return repr(value) if math.isfinite(value) else None
30
+ if isinstance(value, Decimal):
31
+ return str(value) if value.is_finite() else None
32
+ return None
33
+
34
+
35
+ def is_nested(value: object) -> bool:
36
+ """Whether ``value`` is an object or a list, which no gateway signs."""
37
+ return isinstance(value, (dict, list))
38
+
39
+
40
+ def get(source: Mapping[str, object], key: str) -> str:
41
+ """The value at ``key`` as text, or ``""`` when it is missing or not a scalar."""
42
+ text = scalar_string(source.get(key))
43
+ return "" if text is None else text
44
+
45
+
46
+ def optional(source: Mapping[str, object], key: str) -> str | None:
47
+ """The value at ``key`` as text, or ``None`` when it is missing or not a scalar."""
48
+ return scalar_string(source.get(key))
49
+
50
+
51
+ def trimmed(source: Mapping[str, object], key: str) -> str:
52
+ """The value at ``key`` with surrounding whitespace removed."""
53
+ return get(source, key).strip()
54
+
55
+
56
+ def object_at(source: Mapping[str, object], key: str) -> LosslessObject | None:
57
+ """The value at ``key`` when it is an object."""
58
+ value = source.get(key)
59
+ return value if isinstance(value, dict) else None
@@ -0,0 +1 @@
1
+ __version__ = "4.0.0a1"
@@ -0,0 +1,509 @@
1
+ """The AYA Payment Gateway (APG): hosted checkout, channels, enquiry and callbacks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Any, ClassVar
8
+
9
+ import httpx
10
+
11
+ from ._amount import AmountInput, to_amount
12
+ from ._callback import CallbackRequest, lossless_input, lossless_query_input
13
+ from ._errors import ApiError, SignatureVerificationError
14
+ from ._http import (
15
+ DEFAULT_TIMEOUT,
16
+ AsyncGateway,
17
+ GatewayResponse,
18
+ HttpRequest,
19
+ SyncGateway,
20
+ json_request,
21
+ )
22
+ from ._json import LosslessObject, parse_object, to_plain_object
23
+ from ._results import FormField, FormPayment, PaymentCallback, PaymentStatusResult
24
+ from ._status import PaymentStatus, _StrEnum, resolve_status
25
+ from ._support import (
26
+ EnvSource,
27
+ config_of,
28
+ decode_base64,
29
+ default_env,
30
+ env_first,
31
+ env_sandbox,
32
+ hmac_sha256_hex,
33
+ optional_setting,
34
+ require_setting,
35
+ safe_equal,
36
+ sandbox_flag,
37
+ trim_url,
38
+ unix_time,
39
+ )
40
+ from ._validate import AmountRule, Validator
41
+ from ._values import get, is_nested, object_at, optional, scalar_string, trimmed
42
+
43
+ __all__ = [
44
+ "AsyncAyaPay",
45
+ "AyaPay",
46
+ "AyaPayConfig",
47
+ "AyaPayMethod",
48
+ "AyaPayPaymentData",
49
+ "AyaPayService",
50
+ ]
51
+
52
+
53
+ class AyaPayConfig:
54
+ """AYA Payment Gateway (APG) credentials and endpoints.
55
+
56
+ A missing credential raises a :class:`~python_myanmar_payments.ConfigurationError`.
57
+ """
58
+
59
+ SANDBOX_URL: ClassVar[str] = "https://uat-pgw.ayainnovation.com"
60
+ PRODUCTION_URL: ClassVar[str] = "https://pgw.ayainnovation.com"
61
+
62
+ app_key: str
63
+ """The public application key, sent with every request."""
64
+ app_secret: str
65
+ """The secret that signs requests and verifies callbacks."""
66
+ sandbox: bool
67
+ """Whether the UAT environment is used."""
68
+ base_url: str
69
+ """The base URL in use."""
70
+
71
+ def __init__(
72
+ self,
73
+ *,
74
+ app_key: str = "",
75
+ app_secret: str = "",
76
+ sandbox: bool | str = True,
77
+ base_url: str | None = None,
78
+ ) -> None:
79
+ self.app_key = require_setting("aya_pay", "app_key", app_key)
80
+ self.app_secret = require_setting("aya_pay", "app_secret", app_secret)
81
+ self.sandbox = sandbox = sandbox_flag(sandbox)
82
+ self.base_url = trim_url(
83
+ optional_setting(base_url) or (self.SANDBOX_URL if sandbox else self.PRODUCTION_URL)
84
+ )
85
+
86
+ @classmethod
87
+ def from_env(cls, env: EnvSource | None = None) -> AyaPayConfig:
88
+ """Reads the ``AYA_PAY_*`` environment variables.
89
+
90
+ ``AYA_PAY_APP_KEY``, ``AYA_PAY_APP_SECRET``, ``AYA_PAY_SANDBOX`` and
91
+ ``AYA_PAY_BASE_URL``, falling back to the ``AYA_PGW_*`` names. Defaults to
92
+ ``os.environ``.
93
+ """
94
+ env = default_env() if env is None else env
95
+ return cls(
96
+ app_key=env_first(env, "AYA_PAY_APP_KEY", "AYA_PGW_APP_KEY"),
97
+ app_secret=env_first(env, "AYA_PAY_APP_SECRET", "AYA_PGW_APP_SECRET"),
98
+ sandbox=env_sandbox(env, "AYA_PAY_SANDBOX"),
99
+ base_url=env_first(env, "AYA_PAY_BASE_URL", "AYA_PGW_BASE_URL"),
100
+ )
101
+
102
+ def __repr__(self) -> str:
103
+ return f"AyaPayConfig(app_key={self.app_key!r}, sandbox={self.sandbox!r})"
104
+
105
+
106
+ class AyaPayMethod(_StrEnum):
107
+ """How the customer pays through the chosen channel.
108
+
109
+ :meth:`AyaPay.services` lists the methods each channel supports.
110
+ """
111
+
112
+ WEB = "WEB"
113
+ """Pay on a hosted web page (cards, web checkout)."""
114
+ QR = "QR"
115
+ """Scan a QR with the wallet app."""
116
+ NOTI = "NOTI"
117
+ """Approve a push notification in the wallet app."""
118
+
119
+
120
+ _METHODS = frozenset(method.value for method in AyaPayMethod)
121
+
122
+
123
+ @dataclass(kw_only=True)
124
+ class AyaPayPaymentData:
125
+ """An AYA Payment Gateway order.
126
+
127
+ AYA only accepts MMK (currency code 104) and documents no decimals, so
128
+ amounts are whole kyat.
129
+ """
130
+
131
+ order_id: str
132
+ """Your unique order ID (``merchOrderId``), 6 to 40 characters."""
133
+ amount: AmountInput
134
+ """The amount in whole kyat, e.g. ``Amount.kyat(1000)`` or ``1000``."""
135
+ channel: str
136
+ """The channel key from ``services()``, e.g. ``aya_pay``, ``kbz_pay``, ``visa``."""
137
+ method: AyaPayMethod
138
+ """How the customer pays through that channel."""
139
+ return_url: str | None = None
140
+ """Where AYA sends the customer afterwards. Defaults to the URL registered with
141
+ AYA."""
142
+ description: str | None = None
143
+ """Shown to the customer."""
144
+ user_refs: Sequence[str] | None = None
145
+ """Up to five of your own reference values, echoed back in the callback."""
146
+
147
+
148
+ @dataclass(frozen=True, kw_only=True)
149
+ class AyaPayService:
150
+ """A payment channel enabled for your merchant account."""
151
+
152
+ name: str
153
+ """The display name, e.g. ``AYA Pay``."""
154
+ key: str
155
+ """The ``channel`` value to send when initiating, e.g. ``aya_pay`` or ``visa``."""
156
+ image_url: str | None = None
157
+ """The channel's logo."""
158
+ methods: tuple[AyaPayMethod, ...] = ()
159
+ """The methods this channel supports."""
160
+ unknown_methods: tuple[str, ...] = ()
161
+ """Methods the gateway listed that this package does not know yet."""
162
+
163
+ def supports(self, method: AyaPayMethod | str) -> bool:
164
+ """Whether the channel supports ``method``."""
165
+ return method in self.methods
166
+
167
+
168
+ _CURRENCY_MMK = "104"
169
+
170
+ _STATUSES: Mapping[str, PaymentStatus] = {
171
+ "00": PaymentStatus.SUCCESSFUL,
172
+ "01": PaymentStatus.PENDING,
173
+ "02": PaymentStatus.FAILED,
174
+ "03": PaymentStatus.FAILED,
175
+ "04": PaymentStatus.EXPIRED,
176
+ }
177
+
178
+ # The documented field order of the decoded callback / enquiry payload. AYA leaves
179
+ # out fields that do not apply (e.g. card fields for wallet payments) and signs only
180
+ # the ones present. The payload spells `currencyCode` as `currenyCode`.
181
+ _PAYLOAD_FIELDS = (
182
+ "merchOrderId",
183
+ "tranId",
184
+ "amount",
185
+ "currencyCode",
186
+ "statusCode",
187
+ "paymentCardNumber",
188
+ "paymentMobileNumber",
189
+ "cardTypeName",
190
+ "cardExpiryDate",
191
+ "nameOnCard",
192
+ "approvalCode",
193
+ "tranRef",
194
+ "userRef1",
195
+ "userRef2",
196
+ "userRef3",
197
+ "userRef4",
198
+ "userRef5",
199
+ "description",
200
+ "dateTime",
201
+ )
202
+
203
+
204
+ @dataclass(frozen=True)
205
+ class _Call:
206
+ endpoint: str
207
+ request: HttpRequest
208
+
209
+
210
+ class _AyaPayBase:
211
+ """What the sync and async AYA clients share: signing, validation, parsing."""
212
+
213
+ config: AyaPayConfig
214
+ """The configuration in use."""
215
+
216
+ def __init__(self, config: AyaPayConfig | Mapping[str, Any]) -> None:
217
+ self.config = config_of(AyaPayConfig, config)
218
+
219
+ @staticmethod
220
+ def validate(data: AyaPayPaymentData) -> None:
221
+ """Checks the order against AYA's documented rules.
222
+
223
+ Raises an :class:`~python_myanmar_payments.InvalidPaymentDataError`.
224
+ """
225
+ user_refs: object = data.user_refs
226
+ refs_are_list = isinstance(user_refs, (list, tuple))
227
+ (
228
+ Validator()
229
+ .required("order_id", data.order_id)
230
+ .length("order_id", data.order_id, 6, 40)
231
+ .amount("amount", data.amount, AmountRule("AYA Payment Gateway", 0))
232
+ .required("channel", data.channel)
233
+ .when(
234
+ not isinstance(data.method, str) or data.method not in _METHODS,
235
+ "method",
236
+ "The method field must be one of WEB, QR or NOTI.",
237
+ )
238
+ .string("return_url", data.return_url)
239
+ .url("return_url", data.return_url)
240
+ .string("description", data.description)
241
+ .when(
242
+ user_refs is not None
243
+ and (
244
+ not refs_are_list or any(not isinstance(ref, str) for ref in user_refs) # type: ignore[attr-defined]
245
+ ),
246
+ "user_refs",
247
+ "The user_refs field must be a list of strings.",
248
+ )
249
+ .when(
250
+ refs_are_list and len(user_refs) > 5, # type: ignore[arg-type]
251
+ "user_refs",
252
+ "The user_refs field must not have more than 5 items.",
253
+ )
254
+ .validate()
255
+ )
256
+
257
+ def initiate(self, data: AyaPayPaymentData) -> FormPayment:
258
+ """Signs the order. Makes no network call.
259
+
260
+ The customer's browser must POST the returned form to AYA's hosted
261
+ checkout; ``to_html()`` renders a page that does it.
262
+ """
263
+ self.validate(data)
264
+
265
+ refs = [*(data.user_refs or []), "", "", "", "", ""][:5]
266
+ values = [
267
+ ("merchOrderId", data.order_id),
268
+ ("amount", str(to_amount(data.amount))),
269
+ ("appKey", self.config.app_key),
270
+ ("timestamp", str(unix_time())),
271
+ ("userRef1", refs[0]),
272
+ ("userRef2", refs[1]),
273
+ ("userRef3", refs[2]),
274
+ ("userRef4", refs[3]),
275
+ ("userRef5", refs[4]),
276
+ ("description", data.description or ""),
277
+ ("currencyCode", _CURRENCY_MMK),
278
+ ("channel", data.channel),
279
+ ("method", str(data.method)),
280
+ ("overrideFrontendRedirectUrl", data.return_url or ""),
281
+ ]
282
+ values.append(("checkSum", self._checksum([value for _, value in values])))
283
+
284
+ return FormPayment(
285
+ order_id=data.order_id,
286
+ action=f"{self.config.base_url}/v1/payment/request",
287
+ fields=tuple(FormField(name, value) for name, value in values),
288
+ enctype="multipart/form-data",
289
+ )
290
+
291
+ def handle_callback(self, request: CallbackRequest) -> PaymentCallback:
292
+ """Verifies AYA's backend callback."""
293
+ return _to_callback(self._verified_payload(lossless_input(request), "callback"))
294
+
295
+ def verify_redirect(self, request: CallbackRequest) -> PaymentCallback:
296
+ """Verifies the signed query string AYA adds when it sends the customer back.
297
+
298
+ Use it to show the right page; fulfill orders from the backend callback.
299
+ """
300
+ return _to_callback(self._verified_payload(lossless_query_input(request), "redirect"))
301
+
302
+ # --- Requests and results shared by both clients ------------------------------
303
+
304
+ def _services_call(self) -> _Call:
305
+ timestamp = unix_time()
306
+ return self._call(
307
+ "services",
308
+ {
309
+ "appKey": self.config.app_key,
310
+ "timestamp": timestamp,
311
+ "checkSum": self._checksum(
312
+ [self.config.app_key, self.config.app_secret, str(timestamp)]
313
+ ),
314
+ },
315
+ )
316
+
317
+ def _status_call(self, order_id: str) -> _Call:
318
+ timestamp = unix_time()
319
+ return self._call(
320
+ "enquiry",
321
+ {
322
+ "merchOrderId": order_id,
323
+ "appKey": self.config.app_key,
324
+ "timestamp": timestamp,
325
+ "checkSum": self._checksum([order_id, str(timestamp), self.config.app_key]),
326
+ },
327
+ )
328
+
329
+ def _call(self, endpoint: str, data: Mapping[str, Any]) -> _Call:
330
+ return _Call(endpoint, json_request(f"{self.config.base_url}/v1/payment/{endpoint}", data))
331
+
332
+ @staticmethod
333
+ def _body(call: _Call, response: GatewayResponse) -> LosslessObject:
334
+ body = response.json()
335
+ status = get(body, "status")
336
+ if not response.successful() or status != "00":
337
+ message = optional(body, "message")
338
+ raise ApiError(
339
+ f"AYA Pay {call.endpoint} failed: [{status}] {message or ''}".rstrip()
340
+ if status
341
+ else f"AYA Pay {call.endpoint} failed with HTTP {response.status}.",
342
+ gateway_code=status or None,
343
+ gateway_message=message,
344
+ http_status=response.status,
345
+ raw=to_plain_object(body),
346
+ )
347
+ return body
348
+
349
+ def _services(self, call: _Call, response: GatewayResponse) -> list[AyaPayService]:
350
+ body = self._body(call, response)
351
+ entries = body.get("data")
352
+ services: list[AyaPayService] = []
353
+ for entry in entries if isinstance(entries, list) else []:
354
+ if not isinstance(entry, dict) or get(entry, "key") == "":
355
+ continue
356
+ methods: list[AyaPayMethod] = []
357
+ unknown: list[str] = []
358
+ listed = entry.get("methods")
359
+ for method in listed if isinstance(listed, list) else []:
360
+ name = scalar_string(method) or ""
361
+ if name in _METHODS:
362
+ methods.append(AyaPayMethod(name))
363
+ else:
364
+ unknown.append(name)
365
+ services.append(
366
+ AyaPayService(
367
+ name=get(entry, "name") or get(entry, "key"),
368
+ key=get(entry, "key"),
369
+ image_url=optional(entry, "image_url"),
370
+ methods=tuple(methods),
371
+ unknown_methods=tuple(unknown),
372
+ )
373
+ )
374
+ return services
375
+
376
+ def _status_result(
377
+ self, call: _Call, response: GatewayResponse, order_id: str
378
+ ) -> PaymentStatusResult:
379
+ body = self._body(call, response)
380
+ payload = self._verified_payload(object_at(body, "data") or {}, "enquiry response")
381
+ status_code = trimmed(payload, "statusCode")
382
+ return PaymentStatusResult(
383
+ order_id=optional(payload, "merchOrderId") or order_id,
384
+ status=resolve_status(_STATUSES, status_code),
385
+ gateway_status=status_code,
386
+ gateway_reference=optional(payload, "tranId"),
387
+ amount=optional(payload, "amount"),
388
+ raw=to_plain_object(payload),
389
+ )
390
+
391
+ def _verified_payload(self, data: LosslessObject, context: str) -> LosslessObject:
392
+ """Decodes a ``payload`` + ``checkSum`` pair; returns the payload if it matches."""
393
+
394
+ def fail() -> SignatureVerificationError:
395
+ return SignatureVerificationError(
396
+ f"AYA Pay {context} checksum verification failed.", to_plain_object(data)
397
+ )
398
+
399
+ # Base64 never contains spaces: a space is a `+` that an unencoded query
400
+ # string turned into one.
401
+ text = decode_base64(get(data, "payload").replace(" ", "+"))
402
+ payload = None if text is None else parse_object(text)
403
+ if payload is None:
404
+ raise fail()
405
+
406
+ parts: list[str] = []
407
+ for name in _PAYLOAD_FIELDS:
408
+ key = "currenyCode" if name == "currencyCode" and "currenyCode" in payload else name
409
+ if key in payload:
410
+ if is_nested(payload[key]):
411
+ raise fail()
412
+ parts.append(scalar_string(payload[key]) or "")
413
+
414
+ if not safe_equal(self._checksum(parts), get(data, "checkSum").lower()):
415
+ raise fail()
416
+ return payload
417
+
418
+ def _checksum(self, parts: Sequence[str]) -> str:
419
+ return hmac_sha256_hex(self.config.app_secret, ":".join(parts))
420
+
421
+
422
+ def _to_callback(payload: LosslessObject) -> PaymentCallback:
423
+ status_code = trimmed(payload, "statusCode")
424
+ return PaymentCallback(
425
+ order_id=get(payload, "merchOrderId"),
426
+ status=resolve_status(_STATUSES, status_code),
427
+ gateway_status=status_code,
428
+ gateway_reference=optional(payload, "tranId"),
429
+ amount=optional(payload, "amount"),
430
+ raw=to_plain_object(payload),
431
+ )
432
+
433
+
434
+ class AyaPay(_AyaPayBase, SyncGateway):
435
+ """The AYA Payment Gateway with a synchronous HTTP client.
436
+
437
+ One hosted checkout for AYA Pay, other wallets and cards, with channel
438
+ listing, status enquiry and verified callbacks. Use :class:`AsyncAyaPay` in
439
+ async code.
440
+ """
441
+
442
+ def __init__(
443
+ self,
444
+ config: AyaPayConfig | Mapping[str, Any],
445
+ *,
446
+ http_client: httpx.Client | None = None,
447
+ timeout: float | None = DEFAULT_TIMEOUT,
448
+ ) -> None:
449
+ super().__init__(config)
450
+ self._init_transport(http_client, timeout)
451
+
452
+ @classmethod
453
+ def from_env(
454
+ cls,
455
+ env: EnvSource | None = None,
456
+ *,
457
+ http_client: httpx.Client | None = None,
458
+ timeout: float | None = DEFAULT_TIMEOUT,
459
+ ) -> AyaPay:
460
+ """A gateway configured from the ``AYA_PAY_*`` (or ``AYA_PGW_*``) variables."""
461
+ return cls(AyaPayConfig.from_env(env), http_client=http_client, timeout=timeout)
462
+
463
+ def services(self) -> list[AyaPayService]:
464
+ """Lists the payment channels enabled for your merchant account."""
465
+ call = self._services_call()
466
+ return self._services(call, self._transport.send(call.request))
467
+
468
+ def status(self, order_id: str) -> PaymentStatusResult:
469
+ """Asks AYA for the current state of an order (enquiry)."""
470
+ call = self._status_call(order_id)
471
+ return self._status_result(call, self._transport.send(call.request), order_id)
472
+
473
+
474
+ class AsyncAyaPay(_AyaPayBase, AsyncGateway):
475
+ """The AYA Payment Gateway with an async HTTP client.
476
+
477
+ The same API as :class:`AyaPay`; ``services()`` and ``status()`` are awaited.
478
+ """
479
+
480
+ def __init__(
481
+ self,
482
+ config: AyaPayConfig | Mapping[str, Any],
483
+ *,
484
+ http_client: httpx.AsyncClient | None = None,
485
+ timeout: float | None = DEFAULT_TIMEOUT,
486
+ ) -> None:
487
+ super().__init__(config)
488
+ self._init_transport(http_client, timeout)
489
+
490
+ @classmethod
491
+ def from_env(
492
+ cls,
493
+ env: EnvSource | None = None,
494
+ *,
495
+ http_client: httpx.AsyncClient | None = None,
496
+ timeout: float | None = DEFAULT_TIMEOUT,
497
+ ) -> AsyncAyaPay:
498
+ """A gateway configured from the ``AYA_PAY_*`` (or ``AYA_PGW_*``) variables."""
499
+ return cls(AyaPayConfig.from_env(env), http_client=http_client, timeout=timeout)
500
+
501
+ async def services(self) -> list[AyaPayService]:
502
+ """Lists the payment channels enabled for your merchant account."""
503
+ call = self._services_call()
504
+ return self._services(call, await self._transport.send(call.request))
505
+
506
+ async def status(self, order_id: str) -> PaymentStatusResult:
507
+ """Asks AYA for the current state of an order (enquiry)."""
508
+ call = self._status_call(order_id)
509
+ return self._status_result(call, await self._transport.send(call.request), order_id)