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,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 &#34;) and ' (as &#39;).
265
+ return escape(value, quote=True).replace("&quot;", "&#34;").replace("&#x27;", "&#39;")
@@ -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)