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,111 @@
|
|
|
1
|
+
"""Python SDK for Myanmar payment gateways.
|
|
2
|
+
|
|
3
|
+
KBZ Pay, Wave Money, AYA Pay, Yoma MMQR and CyberSource. Typed requests and
|
|
4
|
+
results, exact amounts, sync and async clients.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ._amount import Amount, AmountInput
|
|
8
|
+
from ._cache import AsyncTokenCache, MemoryTokenCache, TokenCache
|
|
9
|
+
from ._callback import BodyInput, CallbackRequest, HeadersInput, QueryInput
|
|
10
|
+
from ._errors import (
|
|
11
|
+
ApiError,
|
|
12
|
+
ConfigurationError,
|
|
13
|
+
InvalidPaymentDataError,
|
|
14
|
+
PaymentError,
|
|
15
|
+
SignatureVerificationError,
|
|
16
|
+
)
|
|
17
|
+
from ._facade import AsyncMyanmarPayments, MyanmarPayments
|
|
18
|
+
from ._http import DEFAULT_TIMEOUT
|
|
19
|
+
from ._results import (
|
|
20
|
+
Acknowledgement,
|
|
21
|
+
AppPayment,
|
|
22
|
+
FormField,
|
|
23
|
+
FormPayment,
|
|
24
|
+
PaymentCallback,
|
|
25
|
+
PaymentResult,
|
|
26
|
+
PaymentStatusResult,
|
|
27
|
+
QrPayment,
|
|
28
|
+
RedirectPayment,
|
|
29
|
+
)
|
|
30
|
+
from ._status import PaymentFlow, PaymentStatus, resolve_status
|
|
31
|
+
from ._version import __version__
|
|
32
|
+
from .aya_pay import (
|
|
33
|
+
AsyncAyaPay,
|
|
34
|
+
AyaPay,
|
|
35
|
+
AyaPayConfig,
|
|
36
|
+
AyaPayMethod,
|
|
37
|
+
AyaPayPaymentData,
|
|
38
|
+
AyaPayService,
|
|
39
|
+
)
|
|
40
|
+
from .cyber_source import (
|
|
41
|
+
CyberSource,
|
|
42
|
+
CyberSourceConfig,
|
|
43
|
+
CyberSourcePaymentData,
|
|
44
|
+
CyberSourceTransactionType,
|
|
45
|
+
)
|
|
46
|
+
from .kbz_pay import AsyncKbzPay, KbzPay, KbzPayConfig, KbzPayPaymentData, KbzPaySigner
|
|
47
|
+
from .wave_money import (
|
|
48
|
+
AsyncWaveMoney,
|
|
49
|
+
WaveMoney,
|
|
50
|
+
WaveMoneyConfig,
|
|
51
|
+
WaveMoneyItem,
|
|
52
|
+
WaveMoneyPaymentData,
|
|
53
|
+
)
|
|
54
|
+
from .yoma_mmqr import AsyncYomaMmqr, YomaMmqr, YomaMmqrConfig, YomaMmqrPaymentData
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"DEFAULT_TIMEOUT",
|
|
58
|
+
"Acknowledgement",
|
|
59
|
+
"Amount",
|
|
60
|
+
"AmountInput",
|
|
61
|
+
"ApiError",
|
|
62
|
+
"AppPayment",
|
|
63
|
+
"AsyncAyaPay",
|
|
64
|
+
"AsyncKbzPay",
|
|
65
|
+
"AsyncMyanmarPayments",
|
|
66
|
+
"AsyncTokenCache",
|
|
67
|
+
"AsyncWaveMoney",
|
|
68
|
+
"AsyncYomaMmqr",
|
|
69
|
+
"AyaPay",
|
|
70
|
+
"AyaPayConfig",
|
|
71
|
+
"AyaPayMethod",
|
|
72
|
+
"AyaPayPaymentData",
|
|
73
|
+
"AyaPayService",
|
|
74
|
+
"BodyInput",
|
|
75
|
+
"CallbackRequest",
|
|
76
|
+
"ConfigurationError",
|
|
77
|
+
"CyberSource",
|
|
78
|
+
"CyberSourceConfig",
|
|
79
|
+
"CyberSourcePaymentData",
|
|
80
|
+
"CyberSourceTransactionType",
|
|
81
|
+
"FormField",
|
|
82
|
+
"FormPayment",
|
|
83
|
+
"HeadersInput",
|
|
84
|
+
"InvalidPaymentDataError",
|
|
85
|
+
"KbzPay",
|
|
86
|
+
"KbzPayConfig",
|
|
87
|
+
"KbzPayPaymentData",
|
|
88
|
+
"KbzPaySigner",
|
|
89
|
+
"MemoryTokenCache",
|
|
90
|
+
"MyanmarPayments",
|
|
91
|
+
"PaymentCallback",
|
|
92
|
+
"PaymentError",
|
|
93
|
+
"PaymentFlow",
|
|
94
|
+
"PaymentResult",
|
|
95
|
+
"PaymentStatus",
|
|
96
|
+
"PaymentStatusResult",
|
|
97
|
+
"QrPayment",
|
|
98
|
+
"QueryInput",
|
|
99
|
+
"RedirectPayment",
|
|
100
|
+
"SignatureVerificationError",
|
|
101
|
+
"TokenCache",
|
|
102
|
+
"WaveMoney",
|
|
103
|
+
"WaveMoneyConfig",
|
|
104
|
+
"WaveMoneyItem",
|
|
105
|
+
"WaveMoneyPaymentData",
|
|
106
|
+
"YomaMmqr",
|
|
107
|
+
"YomaMmqrConfig",
|
|
108
|
+
"YomaMmqrPaymentData",
|
|
109
|
+
"__version__",
|
|
110
|
+
"resolve_status",
|
|
111
|
+
]
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""The exact money amount type."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
|
|
9
|
+
from ._errors import InvalidPaymentDataError
|
|
10
|
+
|
|
11
|
+
__all__ = ["Amount", "AmountInput"]
|
|
12
|
+
|
|
13
|
+
_PATTERN = re.compile(r"[0-9]+(?:\.[0-9]+)?")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Amount:
|
|
17
|
+
"""An exact, non-negative money amount, kept as decimal text.
|
|
18
|
+
|
|
19
|
+
It never passes through a float, so it is never rounded. Build one with
|
|
20
|
+
:meth:`Amount.kyat` for whole amounts, :meth:`Amount.parse` for decimal text or
|
|
21
|
+
:meth:`Amount.of` for any accepted input. Each gateway then checks it against
|
|
22
|
+
its documented rules (Wave Money, AYA and Yoma MMQR only accept whole kyat,
|
|
23
|
+
KBZ Pay up to 2 decimal places, CyberSource any).
|
|
24
|
+
|
|
25
|
+
Amounts are immutable and ``str(amount)`` is the exact text sent to the
|
|
26
|
+
gateway, e.g. ``"1000.50"``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
__slots__ = ("_value",)
|
|
30
|
+
|
|
31
|
+
_value: str
|
|
32
|
+
|
|
33
|
+
def __init__(self, amount: AmountInput) -> None:
|
|
34
|
+
"""The same as :meth:`Amount.of`: ``Amount(1000)``, ``Amount("1000.50")``."""
|
|
35
|
+
object.__setattr__(self, "_value", Amount.of(amount)._value)
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def _make(cls, value: str) -> Amount:
|
|
39
|
+
instance = object.__new__(cls)
|
|
40
|
+
object.__setattr__(instance, "_value", value)
|
|
41
|
+
return instance
|
|
42
|
+
|
|
43
|
+
def __setattr__(self, name: str, value: object) -> None:
|
|
44
|
+
raise AttributeError("Amount is immutable")
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def kyat(cls, amount: int) -> Amount:
|
|
48
|
+
"""A whole amount, e.g. ``Amount.kyat(1000)`` for 1000 MMK.
|
|
49
|
+
|
|
50
|
+
Works for whole units of any currency. Floats, booleans and negative
|
|
51
|
+
values raise an :class:`InvalidPaymentDataError` for the ``amount`` field.
|
|
52
|
+
"""
|
|
53
|
+
if isinstance(amount, bool) or not isinstance(amount, int):
|
|
54
|
+
raise _invalid(
|
|
55
|
+
"The amount field must be a whole number of kyat; use "
|
|
56
|
+
"Amount.parse('10.50') for decimal amounts, "
|
|
57
|
+
f"got {amount!r}."
|
|
58
|
+
)
|
|
59
|
+
if amount < 0:
|
|
60
|
+
raise _invalid("The amount field must not be negative.")
|
|
61
|
+
return cls._make(str(amount))
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def parse(cls, amount: str) -> Amount:
|
|
65
|
+
"""A decimal amount written as plain digits, e.g. ``Amount.parse("1000.50")``.
|
|
66
|
+
|
|
67
|
+
Signs, exponents, spaces and thousands separators are rejected with an
|
|
68
|
+
:class:`InvalidPaymentDataError`. Leading zeros of the whole part are
|
|
69
|
+
removed (``"007.50"`` becomes ``"7.50"``); the fractional digits are kept
|
|
70
|
+
exactly as given.
|
|
71
|
+
"""
|
|
72
|
+
if not isinstance(amount, str) or _PATTERN.fullmatch(amount) is None:
|
|
73
|
+
raise _invalid(
|
|
74
|
+
"The amount field must be a number such as 1000 or 1000.50, "
|
|
75
|
+
f"got {json.dumps(amount, ensure_ascii=False, default=repr)}."
|
|
76
|
+
)
|
|
77
|
+
whole, dot, fraction = amount.partition(".")
|
|
78
|
+
whole = whole.lstrip("0") or "0"
|
|
79
|
+
return cls._make(f"{whole}.{fraction}" if dot else whole)
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def of(cls, amount: AmountInput) -> Amount:
|
|
83
|
+
"""Any accepted amount input as an :class:`Amount`.
|
|
84
|
+
|
|
85
|
+
Takes an ``Amount`` (returned as is), a whole ``int`` (as
|
|
86
|
+
:meth:`kyat`), decimal text (as :meth:`parse`) or a finite, non-negative
|
|
87
|
+
:class:`~decimal.Decimal` (its exact digits, e.g. ``Decimal("1000.50")``
|
|
88
|
+
is ``"1000.50"``). Floats are never accepted.
|
|
89
|
+
"""
|
|
90
|
+
if isinstance(amount, Amount):
|
|
91
|
+
return amount
|
|
92
|
+
if isinstance(amount, str):
|
|
93
|
+
return cls.parse(amount)
|
|
94
|
+
if isinstance(amount, Decimal):
|
|
95
|
+
if not amount.is_finite():
|
|
96
|
+
raise _invalid(f"The amount field must be a finite number, got {amount!r}.")
|
|
97
|
+
if amount < 0:
|
|
98
|
+
raise _invalid("The amount field must not be negative.")
|
|
99
|
+
return cls.parse(format(amount.copy_abs(), "f"))
|
|
100
|
+
return cls.kyat(amount)
|
|
101
|
+
|
|
102
|
+
def __str__(self) -> str:
|
|
103
|
+
return self._value
|
|
104
|
+
|
|
105
|
+
def __repr__(self) -> str:
|
|
106
|
+
return f"Amount('{self._value}')"
|
|
107
|
+
|
|
108
|
+
def __eq__(self, other: object) -> bool:
|
|
109
|
+
if isinstance(other, Amount):
|
|
110
|
+
return _normalize(self._value) == _normalize(other._value)
|
|
111
|
+
return NotImplemented
|
|
112
|
+
|
|
113
|
+
def __hash__(self) -> int:
|
|
114
|
+
return hash(_normalize(self._value))
|
|
115
|
+
|
|
116
|
+
def decimal_places(self) -> int:
|
|
117
|
+
"""The number of fractional digits, e.g. ``2`` for ``1000.50``."""
|
|
118
|
+
_, dot, fraction = self._value.partition(".")
|
|
119
|
+
return len(fraction) if dot else 0
|
|
120
|
+
|
|
121
|
+
def whole_part(self) -> str:
|
|
122
|
+
"""The digits before the decimal point, e.g. ``"1000"`` for ``1000.50``."""
|
|
123
|
+
return self._value.partition(".")[0]
|
|
124
|
+
|
|
125
|
+
def is_zero(self) -> bool:
|
|
126
|
+
"""Whether the amount equals zero, e.g. ``0`` or ``0.00``."""
|
|
127
|
+
return self._value.replace(".", "").strip("0") == ""
|
|
128
|
+
|
|
129
|
+
def is_positive(self) -> bool:
|
|
130
|
+
"""Whether the amount is greater than zero."""
|
|
131
|
+
return not self.is_zero()
|
|
132
|
+
|
|
133
|
+
def to_decimal(self) -> Decimal:
|
|
134
|
+
"""The amount as an exact :class:`~decimal.Decimal`, e.g. ``Decimal("1000.50")``."""
|
|
135
|
+
return Decimal(self._value)
|
|
136
|
+
|
|
137
|
+
def equals(self, other: Amount | str | None) -> bool:
|
|
138
|
+
"""Whether both amounts have the same value.
|
|
139
|
+
|
|
140
|
+
Leading zeros of the whole part and trailing zeros of the fraction are
|
|
141
|
+
ignored: ``1000``, ``01000`` and ``1000.00`` are equal. Text such as
|
|
142
|
+
``callback.amount`` must be plain digits with an optional fraction;
|
|
143
|
+
anything else, and ``None``, is never equal, so a gateway amount that was
|
|
144
|
+
not sent never matches.
|
|
145
|
+
"""
|
|
146
|
+
if isinstance(other, Amount):
|
|
147
|
+
return self == other
|
|
148
|
+
if not isinstance(other, str) or _PATTERN.fullmatch(other) is None:
|
|
149
|
+
return False
|
|
150
|
+
return _normalize(self._value) == _normalize(other)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
AmountInput = Amount | int | str | Decimal
|
|
154
|
+
"""What payment data accepts as an amount: an :class:`Amount`, a whole ``int``,
|
|
155
|
+
decimal text such as ``"1000.50"`` or a :class:`~decimal.Decimal`. Never a float."""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _normalize(value: str) -> str:
|
|
159
|
+
whole, _, fraction = value.partition(".")
|
|
160
|
+
whole = whole.lstrip("0") or "0"
|
|
161
|
+
fraction = fraction.rstrip("0")
|
|
162
|
+
return f"{whole}.{fraction}" if fraction else whole
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _invalid(message: str) -> InvalidPaymentDataError:
|
|
166
|
+
return InvalidPaymentDataError({"amount": message})
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def to_amount(value: object) -> Amount | None:
|
|
170
|
+
"""``value`` as an :class:`Amount`, or ``None`` when it is not a valid amount."""
|
|
171
|
+
if isinstance(value, (Amount, str, Decimal)) or (
|
|
172
|
+
isinstance(value, int) and not isinstance(value, bool)
|
|
173
|
+
):
|
|
174
|
+
try:
|
|
175
|
+
return Amount.of(value)
|
|
176
|
+
except InvalidPaymentDataError:
|
|
177
|
+
return None
|
|
178
|
+
return None
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Access token caches (used by Yoma MMQR)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from typing import Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
__all__ = ["AsyncTokenCache", "MemoryTokenCache", "TokenCache"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@runtime_checkable
|
|
13
|
+
class TokenCache(Protocol):
|
|
14
|
+
"""Stores access tokens between calls.
|
|
15
|
+
|
|
16
|
+
Implement it on top of Redis or similar to share tokens between processes.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def get(self, key: str) -> str | None:
|
|
20
|
+
"""The cached value, or ``None`` when it is missing or expired."""
|
|
21
|
+
|
|
22
|
+
def set(self, key: str, value: str, ttl_seconds: int) -> None:
|
|
23
|
+
"""Stores ``value`` for ``ttl_seconds``; ``0`` or less never expires."""
|
|
24
|
+
|
|
25
|
+
def delete(self, key: str) -> None:
|
|
26
|
+
"""Removes ``key``."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@runtime_checkable
|
|
30
|
+
class AsyncTokenCache(Protocol):
|
|
31
|
+
"""A :class:`TokenCache` with coroutine methods, e.g. on ``redis.asyncio``.
|
|
32
|
+
|
|
33
|
+
Only :class:`~python_myanmar_payments.AsyncYomaMmqr` accepts it.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
async def get(self, key: str) -> str | None:
|
|
37
|
+
"""The cached value, or ``None`` when it is missing or expired."""
|
|
38
|
+
|
|
39
|
+
async def set(self, key: str, value: str, ttl_seconds: int) -> None:
|
|
40
|
+
"""Stores ``value`` for ``ttl_seconds``; ``0`` or less never expires."""
|
|
41
|
+
|
|
42
|
+
async def delete(self, key: str) -> None:
|
|
43
|
+
"""Removes ``key``."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MemoryTokenCache:
|
|
47
|
+
"""The default :class:`TokenCache`: a dict that lives as long as the process.
|
|
48
|
+
|
|
49
|
+
Share one gateway (or one cache) across requests so the token is reused.
|
|
50
|
+
Thread-safe.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self) -> None:
|
|
54
|
+
self._items: dict[str, tuple[str, float | None]] = {}
|
|
55
|
+
self._lock = threading.Lock()
|
|
56
|
+
|
|
57
|
+
def get(self, key: str) -> str | None:
|
|
58
|
+
with self._lock:
|
|
59
|
+
item = self._items.get(key)
|
|
60
|
+
if item is None:
|
|
61
|
+
return None
|
|
62
|
+
value, expires_at = item
|
|
63
|
+
if expires_at is not None and time.monotonic() >= expires_at:
|
|
64
|
+
del self._items[key]
|
|
65
|
+
return None
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
def set(self, key: str, value: str, ttl_seconds: int) -> None:
|
|
69
|
+
with self._lock:
|
|
70
|
+
expires_at = time.monotonic() + ttl_seconds if ttl_seconds > 0 else None
|
|
71
|
+
self._items[key] = (value, expires_at)
|
|
72
|
+
|
|
73
|
+
def delete(self, key: str) -> None:
|
|
74
|
+
with self._lock:
|
|
75
|
+
self._items.pop(key, None)
|
|
76
|
+
|
|
77
|
+
def clear(self) -> None:
|
|
78
|
+
"""Removes every entry."""
|
|
79
|
+
with self._lock:
|
|
80
|
+
self._items.clear()
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""The incoming gateway request that callbacks are verified against."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
6
|
+
from types import MappingProxyType
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import parse_qsl
|
|
9
|
+
|
|
10
|
+
from ._json import LosslessObject, dumps, parse_object, to_plain_object
|
|
11
|
+
|
|
12
|
+
__all__ = ["BodyInput", "CallbackRequest", "HeadersInput", "QueryInput"]
|
|
13
|
+
|
|
14
|
+
HeaderValue = str | Sequence[str] | None
|
|
15
|
+
|
|
16
|
+
HeadersInput = Mapping[str, HeaderValue] | Iterable[tuple[str, str]]
|
|
17
|
+
"""Headers as a mapping (Django, Flask, Starlette and plain dicts) or ``(name, value)`` pairs."""
|
|
18
|
+
|
|
19
|
+
QueryInput = str | bytes | Mapping[str, HeaderValue] | Iterable[tuple[str, str]]
|
|
20
|
+
"""A query string (``str`` or ``bytes``), a mapping of values or ``(name, value)`` pairs."""
|
|
21
|
+
|
|
22
|
+
BodyInput = bytes | bytearray | memoryview | str
|
|
23
|
+
"""A request body: raw bytes (preferred) or text."""
|
|
24
|
+
|
|
25
|
+
# JavaScript's String.prototype.trim() whitespace, which includes the byte order mark.
|
|
26
|
+
_WHITESPACE = "".join(
|
|
27
|
+
chr(code)
|
|
28
|
+
for code in (
|
|
29
|
+
*(0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x20, 0xA0, 0x1680),
|
|
30
|
+
*range(0x2000, 0x200B),
|
|
31
|
+
*(0x2028, 0x2029, 0x202F, 0x205F, 0x3000, 0xFEFF),
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CallbackRequest:
|
|
37
|
+
"""An incoming request from a gateway (a server callback or a browser return).
|
|
38
|
+
|
|
39
|
+
Signatures are verified against what the gateway actually sent, so build it
|
|
40
|
+
from the real request: the raw body bytes, the headers and the query string.
|
|
41
|
+
|
|
42
|
+
.. code-block:: python
|
|
43
|
+
|
|
44
|
+
CallbackRequest(body=request.body, headers=request.headers,
|
|
45
|
+
query=request.META["QUERY_STRING"])
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
__slots__ = ("_headers", "_query", "raw_body")
|
|
49
|
+
|
|
50
|
+
raw_body: bytes
|
|
51
|
+
"""The raw request body exactly as received."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
body: BodyInput | None = None,
|
|
56
|
+
headers: HeadersInput | None = None,
|
|
57
|
+
query: QueryInput | None = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
if body is None:
|
|
60
|
+
self.raw_body = b""
|
|
61
|
+
elif isinstance(body, str):
|
|
62
|
+
self.raw_body = body.encode("utf-8", errors="surrogatepass")
|
|
63
|
+
else:
|
|
64
|
+
self.raw_body = bytes(body)
|
|
65
|
+
self._headers: Mapping[str, str] = MappingProxyType(_normalize_headers(headers))
|
|
66
|
+
self._query: Mapping[str, str] = MappingProxyType(_normalize_query(query))
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_json(
|
|
70
|
+
cls, payload: Mapping[str, Any], headers: HeadersInput | None = None
|
|
71
|
+
) -> CallbackRequest:
|
|
72
|
+
"""Builds a request from a decoded payload, encoded as a JSON body.
|
|
73
|
+
|
|
74
|
+
Use it to replay a callback stored as JSON. Decimals are written as
|
|
75
|
+
strings.
|
|
76
|
+
"""
|
|
77
|
+
merged = {**_normalize_headers(headers), "content-type": "application/json"}
|
|
78
|
+
return cls(body=dumps(payload), headers=merged)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def body(self) -> str:
|
|
82
|
+
"""The raw request body, decoded as UTF-8."""
|
|
83
|
+
return self.raw_body.decode("utf-8", errors="replace")
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def headers(self) -> Mapping[str, str]:
|
|
87
|
+
"""The request headers, with lowercase names. Repeated headers are joined with ``, ``."""
|
|
88
|
+
return self._headers
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def query(self) -> Mapping[str, str]:
|
|
92
|
+
"""The query string parameters (the first value of each)."""
|
|
93
|
+
return self._query
|
|
94
|
+
|
|
95
|
+
def header(self, name: str) -> str | None:
|
|
96
|
+
"""The named header, matched case-insensitively, or ``None``."""
|
|
97
|
+
return self._headers.get(name.lower())
|
|
98
|
+
|
|
99
|
+
def parsed_body(self) -> dict[str, Any]:
|
|
100
|
+
"""The body decoded as JSON or as a urlencoded form.
|
|
101
|
+
|
|
102
|
+
JSON numbers keep their exact text as strings, e.g. ``"1000.50"``.
|
|
103
|
+
"""
|
|
104
|
+
return to_plain_object(lossless_body(self))
|
|
105
|
+
|
|
106
|
+
def input(self) -> dict[str, Any]:
|
|
107
|
+
"""The parsed body merged over the query string."""
|
|
108
|
+
return to_plain_object(lossless_input(self))
|
|
109
|
+
|
|
110
|
+
def query_input(self) -> dict[str, Any]:
|
|
111
|
+
"""The query string merged over the parsed body."""
|
|
112
|
+
return to_plain_object(lossless_query_input(self))
|
|
113
|
+
|
|
114
|
+
def __repr__(self) -> str:
|
|
115
|
+
return (
|
|
116
|
+
f"CallbackRequest(body={self.body!r}, headers={dict(self._headers)!r}, "
|
|
117
|
+
f"query={dict(self._query)!r})"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def lossless_body(request: CallbackRequest) -> LosslessObject:
|
|
122
|
+
"""The body as JSON (numbers kept exact) or a form, or ``{}``."""
|
|
123
|
+
body = request.body.strip(_WHITESPACE)
|
|
124
|
+
if body == "":
|
|
125
|
+
return {}
|
|
126
|
+
json = parse_object(body)
|
|
127
|
+
if json is not None:
|
|
128
|
+
return json
|
|
129
|
+
return dict(_first_values(parse_qsl(body, keep_blank_values=True)))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def lossless_input(request: CallbackRequest) -> LosslessObject:
|
|
133
|
+
"""The parsed body merged over the query string."""
|
|
134
|
+
return {**request.query, **lossless_body(request)}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def lossless_query_input(request: CallbackRequest) -> LosslessObject:
|
|
138
|
+
"""The query string merged over the parsed body."""
|
|
139
|
+
return {**lossless_body(request), **request.query}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _first_values(pairs: Iterable[tuple[str, str]]) -> dict[str, str]:
|
|
143
|
+
result: dict[str, str] = {}
|
|
144
|
+
for key, value in pairs:
|
|
145
|
+
result.setdefault(key, value)
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _pairs(source: Any) -> Iterable[tuple[str, Any]]:
|
|
150
|
+
items = getattr(source, "items", None)
|
|
151
|
+
if callable(items):
|
|
152
|
+
return list(items())
|
|
153
|
+
return list(source)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _normalize_headers(headers: HeadersInput | None) -> dict[str, str]:
|
|
157
|
+
result: dict[str, str] = {}
|
|
158
|
+
if headers is None:
|
|
159
|
+
return result
|
|
160
|
+
for name, value in _pairs(headers):
|
|
161
|
+
if value is None:
|
|
162
|
+
continue
|
|
163
|
+
text = value if isinstance(value, str) else ", ".join(value)
|
|
164
|
+
key = str(name).lower()
|
|
165
|
+
result[key] = text if key not in result else f"{result[key]}, {text}"
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _normalize_query(query: QueryInput | None) -> dict[str, str]:
|
|
170
|
+
if query is None:
|
|
171
|
+
return {}
|
|
172
|
+
if isinstance(query, bytes):
|
|
173
|
+
query = query.decode("utf-8", errors="replace")
|
|
174
|
+
if isinstance(query, str):
|
|
175
|
+
text = query[1:] if query.startswith("?") else query
|
|
176
|
+
return _first_values(parse_qsl(text, keep_blank_values=True))
|
|
177
|
+
lists = getattr(query, "lists", None)
|
|
178
|
+
if callable(lists): # Django's QueryDict and Werkzeug's MultiDict.
|
|
179
|
+
return {key: values[0] for key, values in lists() if values}
|
|
180
|
+
multi_items = getattr(query, "multi_items", None)
|
|
181
|
+
if callable(multi_items): # Starlette's QueryParams.
|
|
182
|
+
return _first_values(multi_items())
|
|
183
|
+
result: dict[str, str] = {}
|
|
184
|
+
for key, value in _pairs(query):
|
|
185
|
+
first = value if isinstance(value, str) or value is None else next(iter(value), None)
|
|
186
|
+
if first is not None:
|
|
187
|
+
result.setdefault(key, first)
|
|
188
|
+
return result
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""The errors this package raises."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from types import MappingProxyType
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ApiError",
|
|
11
|
+
"ConfigurationError",
|
|
12
|
+
"InvalidPaymentDataError",
|
|
13
|
+
"PaymentError",
|
|
14
|
+
"SignatureVerificationError",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PaymentError(Exception):
|
|
19
|
+
"""Base class of every error raised by this package."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InvalidPaymentDataError(PaymentError):
|
|
23
|
+
"""Raised when payment data has values the gateway would reject.
|
|
24
|
+
|
|
25
|
+
Raised before any request is sent.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
errors: Mapping[str, str]
|
|
29
|
+
"""Field name to error message, e.g. ``{"amount": "The amount field is required."}``."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, errors: Mapping[str, str]) -> None:
|
|
32
|
+
messages = [errors[field] for field in sorted(errors)]
|
|
33
|
+
super().__init__(f"Invalid payment data: {' '.join(messages)}")
|
|
34
|
+
self.errors = MappingProxyType(dict(errors))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ApiError(PaymentError):
|
|
38
|
+
"""Raised when a gateway rejects a request or answers with an error.
|
|
39
|
+
|
|
40
|
+
This includes errors sent with HTTP 200, and gateways that cannot be reached
|
|
41
|
+
(the network error is chained as ``__cause__``).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
gateway_code: str | None
|
|
45
|
+
"""The gateway's own error code, e.g. ``ORDER_ID_USED`` or ``09``."""
|
|
46
|
+
gateway_message: str | None
|
|
47
|
+
"""The gateway's own error message."""
|
|
48
|
+
http_status: int
|
|
49
|
+
"""The HTTP status of the response, or ``0`` when no response was received."""
|
|
50
|
+
raw: Mapping[str, Any]
|
|
51
|
+
"""The decoded response body."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
message: str,
|
|
56
|
+
*,
|
|
57
|
+
gateway_code: str | None = None,
|
|
58
|
+
gateway_message: str | None = None,
|
|
59
|
+
http_status: int = 0,
|
|
60
|
+
raw: Mapping[str, Any] | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
super().__init__(message)
|
|
63
|
+
self.gateway_code = gateway_code
|
|
64
|
+
self.gateway_message = gateway_message
|
|
65
|
+
self.http_status = http_status
|
|
66
|
+
self.raw = raw if raw is not None else {}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SignatureVerificationError(PaymentError):
|
|
70
|
+
"""Raised when a callback, return redirect or gateway response fails verification.
|
|
71
|
+
|
|
72
|
+
Never act on its payload.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
raw: Mapping[str, Any]
|
|
76
|
+
"""The unverified payload, for logging only."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, message: str, raw: Mapping[str, Any] | None = None) -> None:
|
|
79
|
+
super().__init__(message)
|
|
80
|
+
self.raw = raw if raw is not None else {}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ConfigurationError(PaymentError):
|
|
84
|
+
"""Raised when a gateway is missing a credential or setting it needs."""
|
|
85
|
+
|
|
86
|
+
gateway: str
|
|
87
|
+
"""The gateway, e.g. ``kbz_pay``."""
|
|
88
|
+
key: str
|
|
89
|
+
"""The missing setting, e.g. ``app_key``."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, gateway: str, key: str) -> None:
|
|
92
|
+
super().__init__(f"The {gateway} configuration is missing [{key}].")
|
|
93
|
+
self.gateway = gateway
|
|
94
|
+
self.key = key
|