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,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)
|