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,265 @@
|
|
|
1
|
+
"""What initiating a payment, checking a status and verifying a callback return."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from html import escape
|
|
9
|
+
from typing import Any, ClassVar, Literal
|
|
10
|
+
|
|
11
|
+
from ._status import PaymentFlow, PaymentStatus
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Acknowledgement",
|
|
15
|
+
"AppPayment",
|
|
16
|
+
"FormField",
|
|
17
|
+
"FormPayment",
|
|
18
|
+
"PaymentCallback",
|
|
19
|
+
"PaymentResult",
|
|
20
|
+
"PaymentStatusResult",
|
|
21
|
+
"QrPayment",
|
|
22
|
+
"RedirectPayment",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, kw_only=True)
|
|
27
|
+
class RedirectPayment:
|
|
28
|
+
"""Send the customer's browser to ``url`` to complete the payment."""
|
|
29
|
+
|
|
30
|
+
flow: ClassVar[Literal[PaymentFlow.REDIRECT]] = PaymentFlow.REDIRECT
|
|
31
|
+
order_id: str
|
|
32
|
+
"""Your order ID, as sent to the gateway."""
|
|
33
|
+
url: str
|
|
34
|
+
"""The gateway page to redirect the customer to."""
|
|
35
|
+
gateway_reference: str | None = None
|
|
36
|
+
"""The gateway's ID for this attempt (KBZ ``prepay_id``, Wave ``transaction_id``)."""
|
|
37
|
+
raw: Mapping[str, Any] = field(default_factory=dict)
|
|
38
|
+
"""The gateway's response, for logging."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class FormField:
|
|
43
|
+
"""One signed hidden field of a :class:`FormPayment`."""
|
|
44
|
+
|
|
45
|
+
name: str
|
|
46
|
+
value: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, kw_only=True)
|
|
50
|
+
class FormPayment:
|
|
51
|
+
"""POST ``fields`` to ``action`` from the customer's browser.
|
|
52
|
+
|
|
53
|
+
:meth:`to_html` renders a page that does this automatically.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
flow: ClassVar[Literal[PaymentFlow.FORM]] = PaymentFlow.FORM
|
|
57
|
+
order_id: str
|
|
58
|
+
"""Your order ID, as sent to the gateway."""
|
|
59
|
+
action: str
|
|
60
|
+
"""The gateway URL the form posts to."""
|
|
61
|
+
fields: tuple[FormField, ...]
|
|
62
|
+
"""The signed hidden fields, in signing order. Post them unchanged."""
|
|
63
|
+
enctype: str = "application/x-www-form-urlencoded"
|
|
64
|
+
"""The encoding the gateway expects for the form."""
|
|
65
|
+
|
|
66
|
+
def field(self, name: str) -> str | None:
|
|
67
|
+
"""The value of the named field, or ``None``."""
|
|
68
|
+
return next((item.value for item in self.fields if item.name == name), None)
|
|
69
|
+
|
|
70
|
+
def values(self) -> dict[str, str]:
|
|
71
|
+
"""The fields as a dict, e.g. to render the form with your own template."""
|
|
72
|
+
return {item.name: item.value for item in self.fields}
|
|
73
|
+
|
|
74
|
+
def to_html(self) -> str:
|
|
75
|
+
"""A complete HTML page that posts the form as soon as it loads.
|
|
76
|
+
|
|
77
|
+
Every value is escaped.
|
|
78
|
+
"""
|
|
79
|
+
inputs = "".join(
|
|
80
|
+
f'<input type="hidden" name="{_escape(item.name)}" value="{_escape(item.value)}">'
|
|
81
|
+
for item in self.fields
|
|
82
|
+
)
|
|
83
|
+
return (
|
|
84
|
+
'<!DOCTYPE html><html><head><meta charset="utf-8">'
|
|
85
|
+
"<title>Redirecting to payment</title></head><body>"
|
|
86
|
+
f'<form id="payment-form" method="POST" action="{_escape(self.action)}" '
|
|
87
|
+
f'enctype="{_escape(self.enctype)}">'
|
|
88
|
+
f"{inputs}"
|
|
89
|
+
'<noscript><button type="submit">Continue to payment</button></noscript></form>'
|
|
90
|
+
'<script>document.getElementById("payment-form").submit();</script></body></html>'
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True, kw_only=True)
|
|
95
|
+
class QrPayment:
|
|
96
|
+
"""Show a QR code for the customer to scan.
|
|
97
|
+
|
|
98
|
+
Gateways return either a payload to encode (``qr_string``) or a ready-made
|
|
99
|
+
image (``qr_image``).
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
flow: ClassVar[Literal[PaymentFlow.QR]] = PaymentFlow.QR
|
|
103
|
+
order_id: str
|
|
104
|
+
"""Your order ID, as sent to the gateway."""
|
|
105
|
+
qr_string: str | None = None
|
|
106
|
+
"""A QR payload to encode into an image yourself (KBZ Pay)."""
|
|
107
|
+
qr_image: str | None = None
|
|
108
|
+
"""A base64 encoded image to display as is (Yoma MMQR)."""
|
|
109
|
+
expires_at: datetime | None = None
|
|
110
|
+
"""When the QR stops being payable (UTC), when the gateway limits it."""
|
|
111
|
+
reference: str | None = None
|
|
112
|
+
"""The gateway's ID for this QR, used to check its status (Yoma ``refLabel``, KBZ
|
|
113
|
+
``prepay_id``)."""
|
|
114
|
+
raw: Mapping[str, Any] = field(default_factory=dict)
|
|
115
|
+
"""The gateway's response, for logging."""
|
|
116
|
+
|
|
117
|
+
def qr_image_data_uri(self, mime_type: str = "image/png") -> str | None:
|
|
118
|
+
"""The image as a data URI for an ``<img src>``, or ``None`` without an image."""
|
|
119
|
+
return None if self.qr_image is None else f"data:{mime_type};base64,{self.qr_image}"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True, kw_only=True)
|
|
123
|
+
class AppPayment:
|
|
124
|
+
"""Pass these values to your mobile app, which hands them to the wallet's SDK.
|
|
125
|
+
|
|
126
|
+
:meth:`to_dict` returns ``orderId``, ``orderInfo``, ``sign`` and ``signType``.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
flow: ClassVar[Literal[PaymentFlow.APP]] = PaymentFlow.APP
|
|
130
|
+
order_id: str
|
|
131
|
+
"""Your order ID, as sent to the gateway."""
|
|
132
|
+
order_info: str
|
|
133
|
+
"""The signed order string the SDK expects."""
|
|
134
|
+
sign: str
|
|
135
|
+
"""The signature of ``order_info``."""
|
|
136
|
+
sign_type: str
|
|
137
|
+
"""The signature algorithm, e.g. ``SHA256``."""
|
|
138
|
+
raw: Mapping[str, Any] = field(default_factory=dict)
|
|
139
|
+
"""The gateway's response, for logging. Left out of :meth:`to_dict`."""
|
|
140
|
+
|
|
141
|
+
def to_dict(self) -> dict[str, str]:
|
|
142
|
+
"""The values your app needs: ``orderId``, ``orderInfo``, ``sign`` and ``signType``."""
|
|
143
|
+
return {
|
|
144
|
+
"orderId": self.order_id,
|
|
145
|
+
"orderInfo": self.order_info,
|
|
146
|
+
"sign": self.sign,
|
|
147
|
+
"signType": self.sign_type,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
PaymentResult = RedirectPayment | FormPayment | QrPayment | AppPayment
|
|
152
|
+
"""Returned when a payment is initiated. Each flow has its own class; check ``flow``."""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True, kw_only=True)
|
|
156
|
+
class Acknowledgement:
|
|
157
|
+
"""The HTTP response a gateway expects after it delivers a callback.
|
|
158
|
+
|
|
159
|
+
Gateways retry until they receive it.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
status: int = 200
|
|
163
|
+
"""The HTTP status, 200 unless a gateway documents another."""
|
|
164
|
+
body: str = ""
|
|
165
|
+
"""The response body, e.g. KBZ Pay's plain ``success``."""
|
|
166
|
+
headers: Mapping[str, str] = field(default_factory=lambda: {"Content-Type": "text/plain"})
|
|
167
|
+
"""The response headers."""
|
|
168
|
+
|
|
169
|
+
@classmethod
|
|
170
|
+
def default(cls) -> Acknowledgement:
|
|
171
|
+
"""An empty ``200 text/plain`` response, which most gateways expect."""
|
|
172
|
+
return cls()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class PaymentCallback:
|
|
176
|
+
"""A gateway notification whose signature has been verified.
|
|
177
|
+
|
|
178
|
+
Always compare ``amount`` with your order before fulfilling it, and handle
|
|
179
|
+
duplicates: gateways retry.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
__slots__ = (
|
|
183
|
+
"acknowledgement",
|
|
184
|
+
"amount",
|
|
185
|
+
"gateway_reference",
|
|
186
|
+
"gateway_status",
|
|
187
|
+
"order_id",
|
|
188
|
+
"raw",
|
|
189
|
+
"status",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
order_id: str
|
|
193
|
+
"""Your order ID."""
|
|
194
|
+
status: PaymentStatus
|
|
195
|
+
"""The status mapped onto this package's statuses."""
|
|
196
|
+
gateway_status: str
|
|
197
|
+
"""The gateway's own status value, unmapped."""
|
|
198
|
+
gateway_reference: str | None
|
|
199
|
+
"""The gateway's ID for the payment, when it sends one."""
|
|
200
|
+
amount: str | None
|
|
201
|
+
"""The amount the gateway reports, exactly as it sent it, when it sends one."""
|
|
202
|
+
raw: Mapping[str, Any]
|
|
203
|
+
"""The verified payload."""
|
|
204
|
+
acknowledgement: Acknowledgement
|
|
205
|
+
"""The response to send so the gateway stops retrying."""
|
|
206
|
+
|
|
207
|
+
def __init__(
|
|
208
|
+
self,
|
|
209
|
+
*,
|
|
210
|
+
order_id: str,
|
|
211
|
+
status: PaymentStatus,
|
|
212
|
+
gateway_status: str,
|
|
213
|
+
gateway_reference: str | None = None,
|
|
214
|
+
amount: str | None = None,
|
|
215
|
+
raw: Mapping[str, Any] | None = None,
|
|
216
|
+
acknowledgement: Acknowledgement | None = None,
|
|
217
|
+
) -> None:
|
|
218
|
+
self.order_id = order_id
|
|
219
|
+
self.status = PaymentStatus(status)
|
|
220
|
+
self.gateway_status = gateway_status
|
|
221
|
+
self.gateway_reference = gateway_reference
|
|
222
|
+
self.amount = amount
|
|
223
|
+
self.raw = raw if raw is not None else {}
|
|
224
|
+
self.acknowledgement = acknowledgement or Acknowledgement.default()
|
|
225
|
+
|
|
226
|
+
def is_successful(self) -> bool:
|
|
227
|
+
"""Whether the customer paid."""
|
|
228
|
+
return self.status is PaymentStatus.SUCCESSFUL
|
|
229
|
+
|
|
230
|
+
def __repr__(self) -> str:
|
|
231
|
+
return (
|
|
232
|
+
f"PaymentCallback(order_id={self.order_id!r}, status={self.status.value!r}, "
|
|
233
|
+
f"gateway_status={self.gateway_status!r}, "
|
|
234
|
+
f"gateway_reference={self.gateway_reference!r}, amount={self.amount!r})"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass(frozen=True, kw_only=True)
|
|
239
|
+
class PaymentStatusResult:
|
|
240
|
+
"""The state of a payment as reported by a gateway's status API."""
|
|
241
|
+
|
|
242
|
+
status: PaymentStatus
|
|
243
|
+
"""The status mapped onto this package's statuses."""
|
|
244
|
+
gateway_status: str
|
|
245
|
+
"""The gateway's own status value, unmapped."""
|
|
246
|
+
order_id: str | None = None
|
|
247
|
+
"""Your order ID, when the gateway returns it."""
|
|
248
|
+
gateway_reference: str | None = None
|
|
249
|
+
"""The gateway's ID for the payment, when it sends one."""
|
|
250
|
+
amount: str | None = None
|
|
251
|
+
"""The amount the gateway reports, exactly as it sent it, when it sends one."""
|
|
252
|
+
raw: Mapping[str, Any] = field(default_factory=dict)
|
|
253
|
+
"""The gateway's response, for logging."""
|
|
254
|
+
|
|
255
|
+
def __post_init__(self) -> None:
|
|
256
|
+
object.__setattr__(self, "status", PaymentStatus(self.status))
|
|
257
|
+
|
|
258
|
+
def is_successful(self) -> bool:
|
|
259
|
+
"""Whether the customer paid."""
|
|
260
|
+
return self.status is PaymentStatus.SUCCESSFUL
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _escape(value: str) -> str:
|
|
264
|
+
# Matches the Node SDK: &, <, >, " (as ") and ' (as ').
|
|
265
|
+
return escape(value, quote=True).replace(""", """).replace("'", "'")
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Gateway-independent payment statuses and flows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
__all__ = ["PaymentFlow", "PaymentStatus", "resolve_status"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _StrEnum(str, Enum):
|
|
12
|
+
"""A string enum whose ``str()`` and format are its value on every Python."""
|
|
13
|
+
|
|
14
|
+
def __str__(self) -> str:
|
|
15
|
+
return str(self.value)
|
|
16
|
+
|
|
17
|
+
def __format__(self, format_spec: str) -> str:
|
|
18
|
+
return format(str(self.value), format_spec)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PaymentStatus(_StrEnum):
|
|
22
|
+
"""A gateway-independent payment status.
|
|
23
|
+
|
|
24
|
+
Every gateway's own status values are mapped onto these. Members are strings,
|
|
25
|
+
so ``callback.status == "successful"`` works too.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
SUCCESSFUL = "successful"
|
|
29
|
+
"""The customer paid. The only status that means money was collected."""
|
|
30
|
+
PENDING = "pending"
|
|
31
|
+
"""The payment is still in progress or waiting on the customer."""
|
|
32
|
+
FAILED = "failed"
|
|
33
|
+
"""The payment was attempted and failed or was rejected."""
|
|
34
|
+
CANCELED = "canceled"
|
|
35
|
+
"""The payment or order was canceled or closed before completing."""
|
|
36
|
+
EXPIRED = "expired"
|
|
37
|
+
"""The payment window ran out before the customer paid."""
|
|
38
|
+
UNKNOWN = "unknown"
|
|
39
|
+
"""The gateway sent a status this package does not recognize. Inspect ``gateway_status``."""
|
|
40
|
+
|
|
41
|
+
def is_final(self) -> bool:
|
|
42
|
+
"""Whether the status will not change any more: ``False`` for pending and unknown."""
|
|
43
|
+
return self not in (PaymentStatus.PENDING, PaymentStatus.UNKNOWN)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PaymentFlow(_StrEnum):
|
|
47
|
+
"""How the customer completes a payment after it has been initiated."""
|
|
48
|
+
|
|
49
|
+
REDIRECT = "redirect"
|
|
50
|
+
"""Send the customer's browser to a gateway-hosted URL."""
|
|
51
|
+
FORM = "form"
|
|
52
|
+
"""POST a signed form from the customer's browser to the gateway."""
|
|
53
|
+
QR = "qr"
|
|
54
|
+
"""Show a QR code the customer scans with their wallet app."""
|
|
55
|
+
APP = "app"
|
|
56
|
+
"""Hand a signed payload to your mobile app, which opens the wallet SDK."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def resolve_status(
|
|
60
|
+
statuses: Mapping[str, PaymentStatus], gateway_status: str | None
|
|
61
|
+
) -> PaymentStatus:
|
|
62
|
+
"""Maps a gateway status onto a :class:`PaymentStatus`, trimming whitespace.
|
|
63
|
+
|
|
64
|
+
Statuses missing from the map resolve to ``PaymentStatus.UNKNOWN``.
|
|
65
|
+
"""
|
|
66
|
+
return statuses.get((gateway_status or "").strip(), PaymentStatus.UNKNOWN)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Shared internals: hashing, time, environment variables and settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import secrets
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Any, TypeVar
|
|
15
|
+
from urllib.parse import quote_plus
|
|
16
|
+
|
|
17
|
+
from ._errors import ConfigurationError
|
|
18
|
+
|
|
19
|
+
EnvSource = Mapping[str, str]
|
|
20
|
+
"""Environment variables, e.g. ``os.environ``."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# --- Hashing -------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def hmac_sha256_hex(key: str, message: str) -> str:
|
|
27
|
+
"""HMAC-SHA256 of ``message``, hex encoded."""
|
|
28
|
+
return hmac.new(key.encode(), message.encode(), hashlib.sha256).hexdigest()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def hmac_sha256_base64(key: str, message: str) -> str:
|
|
32
|
+
"""HMAC-SHA256 of ``message``, base64 encoded."""
|
|
33
|
+
digest = hmac.new(key.encode(), message.encode(), hashlib.sha256).digest()
|
|
34
|
+
return base64.b64encode(digest).decode()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def sha256_hex(message: str) -> str:
|
|
38
|
+
"""SHA-256 of ``message``, hex encoded."""
|
|
39
|
+
return hashlib.sha256(message.encode()).hexdigest()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def safe_equal(expected: str, actual: str) -> bool:
|
|
43
|
+
"""Compares two strings in constant time (for equal lengths)."""
|
|
44
|
+
return hmac.compare_digest(expected.encode(), actual.encode())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def random_hex(size: int = 16) -> str:
|
|
48
|
+
"""``size`` random bytes, hex encoded."""
|
|
49
|
+
return secrets.token_hex(size)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def query_escape(value: str) -> str:
|
|
53
|
+
"""Escapes text like Go's ``url.QueryEscape``.
|
|
54
|
+
|
|
55
|
+
Everything except letters, digits and ``-_.~`` is percent-encoded and spaces
|
|
56
|
+
become ``+``.
|
|
57
|
+
"""
|
|
58
|
+
return quote_plus(value, safe="")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_BASE64 = re.compile(r"[A-Za-z0-9+/]+={0,2}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def decode_base64(value: str) -> str | None:
|
|
65
|
+
"""Decodes standard base64 as UTF-8 text, or ``None``.
|
|
66
|
+
|
|
67
|
+
The padding is either complete or left out entirely; partial padding, other
|
|
68
|
+
alphabets, whitespace and bytes that are not UTF-8 are rejected.
|
|
69
|
+
"""
|
|
70
|
+
if _BASE64.fullmatch(value) is None:
|
|
71
|
+
return None
|
|
72
|
+
if "=" in value:
|
|
73
|
+
if len(value) % 4 != 0:
|
|
74
|
+
return None
|
|
75
|
+
elif len(value) % 4 == 1:
|
|
76
|
+
return None
|
|
77
|
+
else:
|
|
78
|
+
value += "=" * (-len(value) % 4)
|
|
79
|
+
try:
|
|
80
|
+
return base64.b64decode(value, validate=True).decode("utf-8")
|
|
81
|
+
except ValueError: # Invalid UTF-8 (UnicodeDecodeError is a ValueError).
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --- Time ----------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def current_time() -> float:
|
|
89
|
+
"""Seconds since the epoch. Tests replace it to freeze the clock."""
|
|
90
|
+
return time.time()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def unix_time() -> int:
|
|
94
|
+
"""Whole seconds since the epoch."""
|
|
95
|
+
return int(current_time())
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def utc_now() -> datetime:
|
|
99
|
+
"""The current time as an aware UTC datetime."""
|
|
100
|
+
return datetime.fromtimestamp(current_time(), tz=timezone.utc)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --- Settings ------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def default_env() -> EnvSource:
|
|
107
|
+
"""The default environment: ``os.environ``."""
|
|
108
|
+
return os.environ
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def env_first(env: EnvSource, *keys: str) -> str:
|
|
112
|
+
"""The first non-empty value among ``keys``, trimmed, or ``""``."""
|
|
113
|
+
for key in keys:
|
|
114
|
+
value = (env.get(key) or "").strip()
|
|
115
|
+
if value:
|
|
116
|
+
return value
|
|
117
|
+
return ""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_FALSE = frozenset({"false", "0", "f", "no", "off"})
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def env_sandbox(env: EnvSource, key: str) -> bool:
|
|
124
|
+
"""Reads a ``*_SANDBOX`` variable.
|
|
125
|
+
|
|
126
|
+
Only ``false``, ``0``, ``f``, ``no`` and ``off`` (any case) select production;
|
|
127
|
+
unset or unrecognized values mean sandbox.
|
|
128
|
+
"""
|
|
129
|
+
return sandbox_flag(env.get(key) or "")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def sandbox_flag(value: bool | str) -> bool:
|
|
133
|
+
"""A ``sandbox`` setting: a bool, or text read like a ``*_SANDBOX`` variable."""
|
|
134
|
+
if isinstance(value, str):
|
|
135
|
+
return value.strip().lower() not in _FALSE
|
|
136
|
+
return bool(value)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
_C = TypeVar("_C")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def config_of(config_class: type[_C], config: _C | Mapping[str, Any]) -> _C:
|
|
143
|
+
"""``config`` itself, or a ``config_class`` built from a mapping of its arguments."""
|
|
144
|
+
if isinstance(config, config_class):
|
|
145
|
+
return config
|
|
146
|
+
return config_class(**config) # type: ignore[arg-type]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
_INT = re.compile(r"[+-]?[0-9]+")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def env_int(env: EnvSource, key: str) -> int | None:
|
|
153
|
+
"""An integer variable, or ``None`` when unset or not an integer."""
|
|
154
|
+
value = (env.get(key) or "").strip()
|
|
155
|
+
return int(value) if _INT.fullmatch(value) else None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def require_setting(gateway: str, key: str, value: object) -> str:
|
|
159
|
+
"""The value, or raises a :class:`ConfigurationError` naming ``key``."""
|
|
160
|
+
if not isinstance(value, str) or value.strip() == "":
|
|
161
|
+
raise ConfigurationError(gateway, key)
|
|
162
|
+
return value
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def optional_setting(value: object) -> str | None:
|
|
166
|
+
"""An optional string, ``None`` when blank."""
|
|
167
|
+
return value if isinstance(value, str) and value.strip() != "" else None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def trim_url(url: str) -> str:
|
|
171
|
+
"""Removes trailing slashes."""
|
|
172
|
+
return url.rstrip("/")
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Validates payment data against each gateway's documented limits."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
from ._amount import Amount, to_amount
|
|
11
|
+
from ._errors import InvalidPaymentDataError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class AmountRule:
|
|
16
|
+
"""What a gateway's documentation allows for an amount."""
|
|
17
|
+
|
|
18
|
+
gateway: str
|
|
19
|
+
"""The gateway's display name, used in error messages."""
|
|
20
|
+
max_decimals: int | None
|
|
21
|
+
"""The most fractional digits allowed: 0 means whole amounts only, None no limit."""
|
|
22
|
+
max_length: int | None = None
|
|
23
|
+
"""Limits the amount's text length."""
|
|
24
|
+
allow_zero: bool = False
|
|
25
|
+
"""Accepts an amount of 0."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def byte_length(value: str) -> int:
|
|
29
|
+
"""UTF-8 byte length, matching the byte limits in the gateway documentation."""
|
|
30
|
+
return len(value.encode("utf-8", errors="surrogatepass"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_int(value: object) -> bool:
|
|
34
|
+
"""Whether ``value`` is an ``int`` and not a ``bool``."""
|
|
35
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Validator:
|
|
39
|
+
"""Collects one error per field (the first one wins) and raises them together."""
|
|
40
|
+
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
self._errors: dict[str, str] = {}
|
|
43
|
+
|
|
44
|
+
def required(self, field: str, value: object) -> Validator:
|
|
45
|
+
if not isinstance(value, str) or value.strip() == "":
|
|
46
|
+
self._fail(field, f"The {field} field is required.")
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
def string(self, field: str, value: object) -> Validator:
|
|
50
|
+
"""Fails when the value is set but not a string."""
|
|
51
|
+
if value is not None and not isinstance(value, str):
|
|
52
|
+
self._fail(field, f"The {field} field must be a string.")
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def length(self, field: str, value: object, minimum: int, maximum: int) -> Validator:
|
|
56
|
+
if isinstance(value, str) and value != "":
|
|
57
|
+
size = byte_length(value)
|
|
58
|
+
if size < minimum or size > maximum:
|
|
59
|
+
self._fail(
|
|
60
|
+
field,
|
|
61
|
+
f"The {field} field must be between {minimum} and {maximum} characters.",
|
|
62
|
+
)
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def max(self, field: str, value: object, maximum: int) -> Validator:
|
|
66
|
+
if isinstance(value, str) and byte_length(value) > maximum:
|
|
67
|
+
self._fail(field, f"The {field} field must not be greater than {maximum} characters.")
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
def pattern(
|
|
71
|
+
self, field: str, value: object, pattern: re.Pattern[str], description: str
|
|
72
|
+
) -> Validator:
|
|
73
|
+
if isinstance(value, str) and value != "" and pattern.fullmatch(value) is None:
|
|
74
|
+
self._fail(field, f"The {field} field may only contain {description}.")
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
def between(self, field: str, value: object, minimum: int, maximum: int) -> Validator:
|
|
78
|
+
"""Fails when a set value is not an integer within [minimum, maximum]."""
|
|
79
|
+
if value is not None and (
|
|
80
|
+
not is_int(value) or not minimum <= value <= maximum # type: ignore[operator]
|
|
81
|
+
):
|
|
82
|
+
self._fail(field, f"The {field} field must be between {minimum} and {maximum}.")
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
def amount(self, field: str, value: object, rule: AmountRule) -> Validator:
|
|
86
|
+
if value is None:
|
|
87
|
+
return self._fail(field, f"The {field} field is required.")
|
|
88
|
+
if isinstance(value, float):
|
|
89
|
+
return self._fail(
|
|
90
|
+
field,
|
|
91
|
+
f"The {field} field must not be a float; use Amount.kyat(1000) or "
|
|
92
|
+
"Amount.parse('10.50').",
|
|
93
|
+
)
|
|
94
|
+
if not isinstance(value, (Amount, str, Decimal)) and not is_int(value):
|
|
95
|
+
return self._fail(
|
|
96
|
+
field,
|
|
97
|
+
f"The {field} field must be an Amount, e.g. Amount.kyat(1000) or "
|
|
98
|
+
"Amount.parse('1000.50').",
|
|
99
|
+
)
|
|
100
|
+
amount = to_amount(value)
|
|
101
|
+
if amount is None:
|
|
102
|
+
return self._fail(
|
|
103
|
+
field, f"The {field} field must be a non-negative number such as 1000 or 1000.50."
|
|
104
|
+
)
|
|
105
|
+
places = amount.decimal_places()
|
|
106
|
+
if rule.max_decimals == 0 and places > 0:
|
|
107
|
+
return self._fail(
|
|
108
|
+
field,
|
|
109
|
+
f"{rule.gateway} does not accept decimal amounts; "
|
|
110
|
+
f"the {field} field must be a whole number.",
|
|
111
|
+
)
|
|
112
|
+
if rule.max_decimals is not None and rule.max_decimals > 0 and places > rule.max_decimals:
|
|
113
|
+
return self._fail(
|
|
114
|
+
field,
|
|
115
|
+
f"{rule.gateway} accepts at most {rule.max_decimals} decimal places; "
|
|
116
|
+
f"the {field} field has {places}.",
|
|
117
|
+
)
|
|
118
|
+
if not rule.allow_zero and amount.is_zero():
|
|
119
|
+
return self._fail(field, f"The {field} field must be greater than 0.")
|
|
120
|
+
if rule.max_length is not None and len(str(amount)) > rule.max_length:
|
|
121
|
+
return self._fail(
|
|
122
|
+
field, f"The {field} field must not be greater than {rule.max_length} characters."
|
|
123
|
+
)
|
|
124
|
+
return self
|
|
125
|
+
|
|
126
|
+
def url(self, field: str, value: object) -> Validator:
|
|
127
|
+
"""Fails when a set value is not an absolute http or https URL."""
|
|
128
|
+
if isinstance(value, str) and value != "" and not _is_http_url(value):
|
|
129
|
+
self._fail(field, f"The {field} field must be a valid http or https URL.")
|
|
130
|
+
return self
|
|
131
|
+
|
|
132
|
+
def when(self, condition: bool, field: str, message: str) -> Validator:
|
|
133
|
+
if condition:
|
|
134
|
+
self._fail(field, message)
|
|
135
|
+
return self
|
|
136
|
+
|
|
137
|
+
def failed(self) -> bool:
|
|
138
|
+
"""Whether any check failed so far."""
|
|
139
|
+
return bool(self._errors)
|
|
140
|
+
|
|
141
|
+
def validate(self) -> None:
|
|
142
|
+
"""Raises an :class:`InvalidPaymentDataError` when any check failed."""
|
|
143
|
+
if self._errors:
|
|
144
|
+
raise InvalidPaymentDataError(self._errors)
|
|
145
|
+
|
|
146
|
+
def _fail(self, field: str, message: str) -> Validator:
|
|
147
|
+
self._errors.setdefault(field, message)
|
|
148
|
+
return self
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _is_http_url(value: str) -> bool:
|
|
152
|
+
try:
|
|
153
|
+
parts = urlsplit(value)
|
|
154
|
+
_ = parts.port # Raises for an invalid port.
|
|
155
|
+
except ValueError:
|
|
156
|
+
return False
|
|
157
|
+
return parts.scheme.lower() in ("http", "https") and bool(parts.hostname)
|