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