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,431 @@
1
+ """Wave Money (WavePay payment gateway): redirect payments and callbacks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, ClassVar
8
+
9
+ import httpx
10
+
11
+ from ._amount import Amount, AmountInput, to_amount
12
+ from ._callback import CallbackRequest, lossless_body
13
+ from ._errors import ApiError, SignatureVerificationError
14
+ from ._http import (
15
+ DEFAULT_TIMEOUT,
16
+ AsyncGateway,
17
+ GatewayResponse,
18
+ HttpRequest,
19
+ SyncGateway,
20
+ form_request,
21
+ )
22
+ from ._json import LosslessObject, dumps, to_plain_object
23
+ from ._results import PaymentCallback, RedirectPayment
24
+ from ._status import PaymentStatus, resolve_status
25
+ from ._support import (
26
+ EnvSource,
27
+ config_of,
28
+ default_env,
29
+ env_first,
30
+ env_int,
31
+ env_sandbox,
32
+ hmac_sha256_hex,
33
+ optional_setting,
34
+ query_escape,
35
+ random_hex,
36
+ require_setting,
37
+ safe_equal,
38
+ sandbox_flag,
39
+ trim_url,
40
+ )
41
+ from ._validate import AmountRule, Validator
42
+ from ._values import get, is_nested, optional, scalar_string, trimmed
43
+
44
+ __all__ = [
45
+ "AsyncWaveMoney",
46
+ "WaveMoney",
47
+ "WaveMoneyConfig",
48
+ "WaveMoneyItem",
49
+ "WaveMoneyPaymentData",
50
+ ]
51
+
52
+
53
+ class WaveMoneyConfig:
54
+ """Wave Money credentials and endpoints.
55
+
56
+ A missing credential raises a :class:`~python_myanmar_payments.ConfigurationError`.
57
+ """
58
+
59
+ SANDBOX_URL: ClassVar[str] = "https://preprodpayments.wavemoney.io:8107"
60
+ PRODUCTION_URL: ClassVar[str] = "https://payments.wavemoney.io"
61
+ SANDBOX_AUTHENTICATE_URL: ClassVar[str] = "https://preprodpayments.wavemoney.io"
62
+ PRODUCTION_AUTHENTICATE_URL: ClassVar[str] = "https://payments.wavemoney.io"
63
+ DEFAULT_TIME_TO_LIVE_SECONDS: ClassVar[int] = 300
64
+
65
+ merchant_id: str
66
+ """The merchant ID Wave issued."""
67
+ secret_key: str
68
+ """The hash secret key Wave issued."""
69
+ merchant_name: str
70
+ """Your business name, shown on Wave's payment page."""
71
+ time_to_live_seconds: int
72
+ """Seconds the customer has to pay."""
73
+ sandbox: bool
74
+ """Whether the test environment is used."""
75
+ base_url: str
76
+ """The API base URL in use."""
77
+ authenticate_url: str
78
+ """The host the customer is redirected to. Wave serves it without the API port."""
79
+
80
+ def __init__(
81
+ self,
82
+ *,
83
+ merchant_id: str = "",
84
+ secret_key: str = "",
85
+ merchant_name: str = "",
86
+ time_to_live_seconds: int | None = None,
87
+ sandbox: bool | str = True,
88
+ base_url: str | None = None,
89
+ authenticate_url: str | None = None,
90
+ ) -> None:
91
+ self.merchant_id = require_setting("wave_money", "merchant_id", merchant_id)
92
+ self.secret_key = require_setting("wave_money", "secret_key", secret_key)
93
+ self.merchant_name = require_setting("wave_money", "merchant_name", merchant_name)
94
+ ttl = time_to_live_seconds
95
+ valid = isinstance(ttl, int) and not isinstance(ttl, bool) and ttl > 0
96
+ self.time_to_live_seconds = (
97
+ ttl if valid and ttl is not None else self.DEFAULT_TIME_TO_LIVE_SECONDS
98
+ )
99
+ self.sandbox = sandbox = sandbox_flag(sandbox)
100
+ self.base_url = trim_url(
101
+ optional_setting(base_url) or (self.SANDBOX_URL if sandbox else self.PRODUCTION_URL)
102
+ )
103
+ self.authenticate_url = trim_url(
104
+ optional_setting(authenticate_url)
105
+ or (self.SANDBOX_AUTHENTICATE_URL if sandbox else self.PRODUCTION_AUTHENTICATE_URL)
106
+ )
107
+
108
+ @classmethod
109
+ def from_env(cls, env: EnvSource | None = None) -> WaveMoneyConfig:
110
+ """Reads the ``WAVE_MONEY_*`` environment variables.
111
+
112
+ ``WAVE_MONEY_MERCHANT_ID``, ``WAVE_MONEY_SECRET_KEY``,
113
+ ``WAVE_MONEY_MERCHANT_NAME`` (falling back to ``APP_NAME``),
114
+ ``WAVE_MONEY_TIME_TO_LIVE_IN_SECONDS``, ``WAVE_MONEY_SANDBOX``,
115
+ ``WAVE_MONEY_BASE_URL`` and ``WAVE_MONEY_AUTHENTICATE_URL``. Defaults to
116
+ ``os.environ``.
117
+ """
118
+ env = default_env() if env is None else env
119
+ return cls(
120
+ merchant_id=env_first(env, "WAVE_MONEY_MERCHANT_ID"),
121
+ secret_key=env_first(env, "WAVE_MONEY_SECRET_KEY"),
122
+ merchant_name=env_first(env, "WAVE_MONEY_MERCHANT_NAME", "APP_NAME"),
123
+ time_to_live_seconds=env_int(env, "WAVE_MONEY_TIME_TO_LIVE_IN_SECONDS"),
124
+ sandbox=env_sandbox(env, "WAVE_MONEY_SANDBOX"),
125
+ base_url=env_first(env, "WAVE_MONEY_BASE_URL"),
126
+ authenticate_url=env_first(env, "WAVE_MONEY_AUTHENTICATE_URL"),
127
+ )
128
+
129
+ def __repr__(self) -> str:
130
+ return f"WaveMoneyConfig(merchant_id={self.merchant_id!r}, sandbox={self.sandbox!r})"
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class WaveMoneyItem:
135
+ """A line item shown on Wave's payment page."""
136
+
137
+ name: str
138
+ """The item name."""
139
+ amount: AmountInput
140
+ """The item amount in whole kyat, e.g. ``Amount.kyat(1000)`` or ``1000``."""
141
+
142
+
143
+ @dataclass(kw_only=True)
144
+ class WaveMoneyPaymentData:
145
+ """A Wave Money payment request. Wave only accepts whole kyat (MMK)."""
146
+
147
+ order_id: str
148
+ """Your order ID. One order can have several payment attempts."""
149
+ callback_url: str
150
+ """The URL Wave posts the result to (``backend_result_url``)."""
151
+ return_url: str
152
+ """Where Wave sends the customer back (``frontend_result_url``). Not proof of
153
+ payment."""
154
+ description: str
155
+ """Shown to the customer."""
156
+ items: Sequence[WaveMoneyItem] = field(default_factory=list)
157
+ """The line items shown on Wave's page; at least one."""
158
+ amount: AmountInput | None = None
159
+ """The total in whole kyat. Leave it unset to charge the sum of the items."""
160
+ merchant_reference_id: str | None = None
161
+ """The unique ID of this attempt; Wave rejects a reused one.
162
+
163
+ ``initiate()`` fills it with a random ID when empty, so store it afterwards:
164
+ Wave's callback may omit ``orderId`` but always carries this."""
165
+
166
+
167
+ _STATUSES: Mapping[str, PaymentStatus] = {
168
+ "PAYMENT_CONFIRMED": PaymentStatus.SUCCESSFUL,
169
+ "INSUFFICIENT_BALANCE": PaymentStatus.PENDING,
170
+ "ACCOUNT_LOCKED": PaymentStatus.FAILED,
171
+ "BILL_COLLECTION_FAILED": PaymentStatus.FAILED,
172
+ "PAYMENT_REQUEST_CANCELLED": PaymentStatus.CANCELED,
173
+ "TRANSACTION_TIMED_OUT": PaymentStatus.EXPIRED,
174
+ "SCHEDULER_TRANSACTION_TIMED_OUT": PaymentStatus.EXPIRED,
175
+ }
176
+
177
+ _CALLBACK_FIELDS = (
178
+ "status",
179
+ "timeToLiveSeconds",
180
+ "merchantId",
181
+ "orderId",
182
+ "amount",
183
+ "backendResultUrl",
184
+ "merchantReferenceId",
185
+ "initiatorMsisdn",
186
+ "transactionId",
187
+ "paymentRequestId",
188
+ "requestTime",
189
+ )
190
+
191
+ _RULE = AmountRule("Wave Money", 0)
192
+
193
+
194
+ class _WaveMoneyBase:
195
+ """What the sync and async Wave Money clients share."""
196
+
197
+ config: WaveMoneyConfig
198
+ """The configuration in use."""
199
+
200
+ def __init__(self, config: WaveMoneyConfig | Mapping[str, Any]) -> None:
201
+ self.config = config_of(WaveMoneyConfig, config)
202
+
203
+ @staticmethod
204
+ def resolved_amount(data: WaveMoneyPaymentData) -> Amount | None:
205
+ """The total that will be charged: ``amount``, or the exact sum of the items.
206
+
207
+ ``None`` when an item amount is missing, malformed or has decimals.
208
+ """
209
+ if data.amount is not None:
210
+ return to_amount(data.amount)
211
+ items = data.items
212
+ if not isinstance(items, (list, tuple)) or len(items) == 0:
213
+ return None
214
+ total = 0
215
+ for item in items:
216
+ amount = to_amount(getattr(item, "amount", None))
217
+ if amount is None or amount.decimal_places() > 0:
218
+ return None
219
+ total += int(str(amount))
220
+ return Amount.kyat(total)
221
+
222
+ @classmethod
223
+ def validate(cls, data: WaveMoneyPaymentData) -> None:
224
+ """Checks the request against Wave's documented rules.
225
+
226
+ Raises an :class:`~python_myanmar_payments.InvalidPaymentDataError`.
227
+ """
228
+ items = list(data.items) if isinstance(data.items, (list, tuple)) else []
229
+ validator = (
230
+ Validator()
231
+ .required("order_id", data.order_id)
232
+ .required("callback_url", data.callback_url)
233
+ .url("callback_url", data.callback_url)
234
+ .required("return_url", data.return_url)
235
+ .url("return_url", data.return_url)
236
+ .required("description", data.description)
237
+ .when(len(items) == 0, "items", "The items field must have at least one item.")
238
+ .string("merchant_reference_id", data.merchant_reference_id)
239
+ )
240
+ for index, item in enumerate(items):
241
+ validator.required(f"items.{index}.name", getattr(item, "name", None)).amount(
242
+ f"items.{index}.amount", getattr(item, "amount", None), _RULE
243
+ )
244
+ resolved = cls.resolved_amount(data)
245
+ if not (len(items) > 0 and data.amount is None and resolved is None):
246
+ validator.amount("amount", data.amount if data.amount is not None else resolved, _RULE)
247
+ validator.validate()
248
+
249
+ def handle_callback(self, request: CallbackRequest) -> PaymentCallback:
250
+ """Verifies Wave's callback. Only ``PAYMENT_CONFIRMED`` means the customer paid.
251
+
252
+ ``order_id`` falls back to ``merchantReferenceId`` when ``orderId`` is
253
+ missing, null or empty, because Wave marks ``orderId`` as optional.
254
+ """
255
+ payload = lossless_body(request)
256
+ nested = any(is_nested(payload.get(name)) for name in _CALLBACK_FIELDS)
257
+ parts = []
258
+ for name in _CALLBACK_FIELDS:
259
+ text = scalar_string(payload.get(name))
260
+ parts.append("null" if text is None else text)
261
+ expected = self._hash(parts)
262
+ hash_value = payload.get("hashValue")
263
+
264
+ if (
265
+ nested
266
+ or not isinstance(hash_value, str)
267
+ or not safe_equal(expected, hash_value.lower())
268
+ ):
269
+ raise SignatureVerificationError(
270
+ "Wave Money callback hash verification failed.", to_plain_object(payload)
271
+ )
272
+
273
+ gateway_status = trimmed(payload, "status")
274
+ return PaymentCallback(
275
+ order_id=get(payload, "orderId") or get(payload, "merchantReferenceId"),
276
+ status=resolve_status(_STATUSES, gateway_status),
277
+ gateway_status=gateway_status,
278
+ gateway_reference=optional(payload, "transactionId"),
279
+ amount=optional(payload, "amount"),
280
+ raw=to_plain_object(payload),
281
+ )
282
+
283
+ def _initiate_request(self, data: WaveMoneyPaymentData) -> HttpRequest:
284
+ self.validate(data)
285
+ if not data.merchant_reference_id:
286
+ data.merchant_reference_id = random_hex()
287
+
288
+ amount = str(self.resolved_amount(data))
289
+ ttl = str(self.config.time_to_live_seconds)
290
+ # Wave documents items as [{"name": "...", "amount": 1000}] with a numeric
291
+ # amount; the amount text is written as a JSON number without a float.
292
+ items = ",".join(
293
+ f'{{"name":{dumps(item.name)},"amount":{to_amount(item.amount)}}}'
294
+ for item in data.items
295
+ )
296
+ return form_request(
297
+ f"{self.config.base_url}/payment",
298
+ {
299
+ "time_to_live_in_seconds": ttl,
300
+ "merchant_id": self.config.merchant_id,
301
+ "order_id": data.order_id,
302
+ "merchant_reference_id": data.merchant_reference_id,
303
+ "frontend_result_url": data.return_url,
304
+ "backend_result_url": data.callback_url,
305
+ "amount": amount,
306
+ "payment_description": data.description,
307
+ "merchant_name": self.config.merchant_name,
308
+ "items": f"[{items}]",
309
+ "hash": self._hash(
310
+ [
311
+ ttl,
312
+ self.config.merchant_id,
313
+ data.order_id,
314
+ amount,
315
+ data.callback_url,
316
+ data.merchant_reference_id,
317
+ ]
318
+ ),
319
+ },
320
+ )
321
+
322
+ def _redirect(self, data: WaveMoneyPaymentData, response: GatewayResponse) -> RedirectPayment:
323
+ body = response.json()
324
+ transaction_id = get(body, "transaction_id")
325
+ if not response.successful() or get(body, "message") != "success" or transaction_id == "":
326
+ message = _error_message(body)
327
+ raise ApiError(
328
+ f"Wave Money payment request failed with HTTP {response.status}: {message}",
329
+ gateway_code="VALIDATION_ERROR" if "errors" in body else optional(body, "message"),
330
+ gateway_message=message,
331
+ http_status=response.status,
332
+ raw=to_plain_object(body),
333
+ )
334
+ return RedirectPayment(
335
+ order_id=data.order_id,
336
+ url=f"{self.config.authenticate_url}/authenticate?transaction_id="
337
+ f"{query_escape(transaction_id)}",
338
+ gateway_reference=transaction_id,
339
+ raw=to_plain_object(body),
340
+ )
341
+
342
+ def _hash(self, parts: Sequence[str]) -> str:
343
+ return hmac_sha256_hex(self.config.secret_key, "".join(parts))
344
+
345
+
346
+ def _error_message(body: LosslessObject) -> str:
347
+ errors = body.get("errors")
348
+ if isinstance(errors, dict):
349
+ lines = []
350
+ for name in sorted(errors):
351
+ entries = errors[name]
352
+ texts = [
353
+ text
354
+ for text in (
355
+ scalar_string(entry)
356
+ for entry in (entries if isinstance(entries, list) else [entries])
357
+ )
358
+ if text is not None
359
+ ]
360
+ lines.append(f"{name}: {' '.join(texts)}")
361
+ return "; ".join(lines)
362
+ return get(body, "message") or "unexpected response"
363
+
364
+
365
+ class WaveMoney(_WaveMoneyBase, SyncGateway):
366
+ """Wave Money with a synchronous HTTP client.
367
+
368
+ Redirect payments and callbacks. Wave has no status API, so the callback is
369
+ the only result. Use :class:`AsyncWaveMoney` in async code.
370
+ """
371
+
372
+ def __init__(
373
+ self,
374
+ config: WaveMoneyConfig | Mapping[str, Any],
375
+ *,
376
+ http_client: httpx.Client | None = None,
377
+ timeout: float | None = DEFAULT_TIMEOUT,
378
+ ) -> None:
379
+ super().__init__(config)
380
+ self._init_transport(http_client, timeout)
381
+
382
+ @classmethod
383
+ def from_env(
384
+ cls,
385
+ env: EnvSource | None = None,
386
+ *,
387
+ http_client: httpx.Client | None = None,
388
+ timeout: float | None = DEFAULT_TIMEOUT,
389
+ ) -> WaveMoney:
390
+ """A gateway configured from the ``WAVE_MONEY_*`` environment variables."""
391
+ return cls(WaveMoneyConfig.from_env(env), http_client=http_client, timeout=timeout)
392
+
393
+ def initiate(self, data: WaveMoneyPaymentData) -> RedirectPayment:
394
+ """Creates a payment request and returns Wave's page to redirect the customer to.
395
+
396
+ Once ``data`` passes validation it fills ``data.merchant_reference_id``
397
+ with a random ID when empty, so store it afterwards. Invalid data is left
398
+ untouched.
399
+ """
400
+ request = self._initiate_request(data)
401
+ return self._redirect(data, self._transport.send(request))
402
+
403
+
404
+ class AsyncWaveMoney(_WaveMoneyBase, AsyncGateway):
405
+ """Wave Money with an async HTTP client. The same API as :class:`WaveMoney`, awaited."""
406
+
407
+ def __init__(
408
+ self,
409
+ config: WaveMoneyConfig | Mapping[str, Any],
410
+ *,
411
+ http_client: httpx.AsyncClient | None = None,
412
+ timeout: float | None = DEFAULT_TIMEOUT,
413
+ ) -> None:
414
+ super().__init__(config)
415
+ self._init_transport(http_client, timeout)
416
+
417
+ @classmethod
418
+ def from_env(
419
+ cls,
420
+ env: EnvSource | None = None,
421
+ *,
422
+ http_client: httpx.AsyncClient | None = None,
423
+ timeout: float | None = DEFAULT_TIMEOUT,
424
+ ) -> AsyncWaveMoney:
425
+ """A gateway configured from the ``WAVE_MONEY_*`` environment variables."""
426
+ return cls(WaveMoneyConfig.from_env(env), http_client=http_client, timeout=timeout)
427
+
428
+ async def initiate(self, data: WaveMoneyPaymentData) -> RedirectPayment:
429
+ """Creates a payment request and returns Wave's page to redirect the customer to."""
430
+ request = self._initiate_request(data)
431
+ return self._redirect(data, await self._transport.send(request))