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,310 @@
|
|
|
1
|
+
"""CyberSource Secure Acceptance hosted checkout for card payments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, ClassVar
|
|
9
|
+
|
|
10
|
+
from ._amount import AmountInput, to_amount
|
|
11
|
+
from ._callback import CallbackRequest, lossless_input
|
|
12
|
+
from ._errors import SignatureVerificationError
|
|
13
|
+
from ._json import LosslessObject, to_plain_object
|
|
14
|
+
from ._results import FormField, FormPayment, PaymentCallback
|
|
15
|
+
from ._status import PaymentStatus, _StrEnum, resolve_status
|
|
16
|
+
from ._support import (
|
|
17
|
+
EnvSource,
|
|
18
|
+
config_of,
|
|
19
|
+
default_env,
|
|
20
|
+
env_first,
|
|
21
|
+
env_sandbox,
|
|
22
|
+
hmac_sha256_base64,
|
|
23
|
+
optional_setting,
|
|
24
|
+
random_hex,
|
|
25
|
+
require_setting,
|
|
26
|
+
safe_equal,
|
|
27
|
+
sandbox_flag,
|
|
28
|
+
trim_url,
|
|
29
|
+
utc_now,
|
|
30
|
+
)
|
|
31
|
+
from ._validate import AmountRule, Validator
|
|
32
|
+
from ._values import get, optional, scalar_string, trimmed
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"CyberSource",
|
|
36
|
+
"CyberSourceConfig",
|
|
37
|
+
"CyberSourcePaymentData",
|
|
38
|
+
"CyberSourceTransactionType",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CyberSourceConfig:
|
|
43
|
+
"""A CyberSource Secure Acceptance profile.
|
|
44
|
+
|
|
45
|
+
A missing credential raises a :class:`~python_myanmar_payments.ConfigurationError`.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
SANDBOX_URL: ClassVar[str] = "https://testsecureacceptance.cybersource.com"
|
|
49
|
+
PRODUCTION_URL: ClassVar[str] = "https://secureacceptance.cybersource.com"
|
|
50
|
+
|
|
51
|
+
profile_id: str
|
|
52
|
+
"""The Secure Acceptance profile ID."""
|
|
53
|
+
access_key: str
|
|
54
|
+
"""The profile's access key."""
|
|
55
|
+
secret_key: str
|
|
56
|
+
"""The profile's secret key, which signs the fields."""
|
|
57
|
+
sandbox: bool
|
|
58
|
+
"""Whether the test environment is used."""
|
|
59
|
+
base_url: str
|
|
60
|
+
"""The base URL in use."""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
profile_id: str = "",
|
|
66
|
+
access_key: str = "",
|
|
67
|
+
secret_key: str = "",
|
|
68
|
+
sandbox: bool | str = True,
|
|
69
|
+
base_url: str | None = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
self.profile_id = require_setting("cyber_source", "profile_id", profile_id)
|
|
72
|
+
self.access_key = require_setting("cyber_source", "access_key", access_key)
|
|
73
|
+
self.secret_key = require_setting("cyber_source", "secret_key", secret_key)
|
|
74
|
+
self.sandbox = sandbox = sandbox_flag(sandbox)
|
|
75
|
+
self.base_url = trim_url(
|
|
76
|
+
optional_setting(base_url) or (self.SANDBOX_URL if sandbox else self.PRODUCTION_URL)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_env(cls, env: EnvSource | None = None) -> CyberSourceConfig:
|
|
81
|
+
"""Reads the ``CYBER_SOURCE_*`` environment variables.
|
|
82
|
+
|
|
83
|
+
``CYBER_SOURCE_PROFILE_ID``, ``CYBER_SOURCE_ACCESS_KEY``,
|
|
84
|
+
``CYBER_SOURCE_SECRET_KEY``, ``CYBER_SOURCE_SANDBOX`` and
|
|
85
|
+
``CYBER_SOURCE_BASE_URL``. Defaults to ``os.environ``.
|
|
86
|
+
"""
|
|
87
|
+
env = default_env() if env is None else env
|
|
88
|
+
return cls(
|
|
89
|
+
profile_id=env_first(env, "CYBER_SOURCE_PROFILE_ID"),
|
|
90
|
+
access_key=env_first(env, "CYBER_SOURCE_ACCESS_KEY"),
|
|
91
|
+
secret_key=env_first(env, "CYBER_SOURCE_SECRET_KEY"),
|
|
92
|
+
sandbox=env_sandbox(env, "CYBER_SOURCE_SANDBOX"),
|
|
93
|
+
base_url=env_first(env, "CYBER_SOURCE_BASE_URL"),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def __repr__(self) -> str:
|
|
97
|
+
return f"CyberSourceConfig(profile_id={self.profile_id!r}, sandbox={self.sandbox!r})"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class CyberSourceTransactionType(_StrEnum):
|
|
101
|
+
"""What CyberSource does with the card."""
|
|
102
|
+
|
|
103
|
+
SALE = "sale"
|
|
104
|
+
"""Authorize and capture in one step."""
|
|
105
|
+
AUTHORIZATION = "authorization"
|
|
106
|
+
"""Authorize only; capture later."""
|
|
107
|
+
SALE_AND_CREATE_TOKEN = "sale,create_payment_token"
|
|
108
|
+
"""A sale that also saves the card as a payment token."""
|
|
109
|
+
AUTHORIZATION_AND_CREATE_TOKEN = "authorization,create_payment_token"
|
|
110
|
+
"""An authorization that also saves the card as a payment token."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
_TRANSACTION_TYPES = frozenset(kind.value for kind in CyberSourceTransactionType)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(kw_only=True)
|
|
117
|
+
class CyberSourcePaymentData:
|
|
118
|
+
"""A Secure Acceptance card payment. CyberSource is multi-currency."""
|
|
119
|
+
|
|
120
|
+
order_id: str
|
|
121
|
+
"""Your order ID (``reference_number``), at most 50 characters. Echoed back as
|
|
122
|
+
``req_reference_number``."""
|
|
123
|
+
amount: AmountInput
|
|
124
|
+
"""The total in ``currency``: 0 or more, any decimals, at most 15 characters, e.g.
|
|
125
|
+
``Amount.parse("10.50")``."""
|
|
126
|
+
callback_url: str
|
|
127
|
+
"""The URL CyberSource posts the result to (``override_backoffice_post_url``), at
|
|
128
|
+
most 255 characters."""
|
|
129
|
+
return_url: str | None = None
|
|
130
|
+
"""The receipt page (``override_custom_receipt_page``), at most 255 characters."""
|
|
131
|
+
cancel_url: str | None = None
|
|
132
|
+
"""The page shown on cancel (``override_custom_cancel_page``), at most 255
|
|
133
|
+
characters."""
|
|
134
|
+
currency: str | None = None
|
|
135
|
+
"""An ISO 4217 code (default ``MMK``)."""
|
|
136
|
+
transaction_type: CyberSourceTransactionType | None = None
|
|
137
|
+
"""What to do with the card (default ``sale``)."""
|
|
138
|
+
locale: str | None = None
|
|
139
|
+
"""The hosted page language, e.g. ``en-us`` (the default)."""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
_SIGNED_FIELDS = (
|
|
143
|
+
"access_key",
|
|
144
|
+
"profile_id",
|
|
145
|
+
"transaction_uuid",
|
|
146
|
+
"signed_field_names",
|
|
147
|
+
"signed_date_time",
|
|
148
|
+
"locale",
|
|
149
|
+
"transaction_type",
|
|
150
|
+
"reference_number",
|
|
151
|
+
"amount",
|
|
152
|
+
"currency",
|
|
153
|
+
"override_custom_receipt_page",
|
|
154
|
+
"override_backoffice_post_url",
|
|
155
|
+
"override_custom_cancel_page",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
_STATUSES: Mapping[str, PaymentStatus] = {
|
|
159
|
+
"ACCEPT": PaymentStatus.SUCCESSFUL,
|
|
160
|
+
"REVIEW": PaymentStatus.PENDING,
|
|
161
|
+
"DECLINE": PaymentStatus.FAILED,
|
|
162
|
+
"ERROR": PaymentStatus.FAILED,
|
|
163
|
+
"CANCEL": PaymentStatus.CANCELED,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
_CURRENCY = re.compile(r"[A-Z]{3}")
|
|
167
|
+
_LOCALE = re.compile(r"[a-z]{2}-[a-z]{2}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class CyberSource:
|
|
171
|
+
"""CyberSource Secure Acceptance hosted checkout for card payments.
|
|
172
|
+
|
|
173
|
+
Signs the form and verifies the result posts. Makes no network calls, so the
|
|
174
|
+
same class serves sync and async code.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
config: CyberSourceConfig
|
|
178
|
+
"""The configuration in use."""
|
|
179
|
+
|
|
180
|
+
def __init__(self, config: CyberSourceConfig | Mapping[str, Any]) -> None:
|
|
181
|
+
self.config = config_of(CyberSourceConfig, config)
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def from_env(cls, env: EnvSource | None = None) -> CyberSource:
|
|
185
|
+
"""A gateway configured from the ``CYBER_SOURCE_*`` environment variables."""
|
|
186
|
+
return cls(CyberSourceConfig.from_env(env))
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
def validate(data: CyberSourcePaymentData) -> None:
|
|
190
|
+
"""Checks the payment against the Secure Acceptance field reference.
|
|
191
|
+
|
|
192
|
+
Raises an :class:`~python_myanmar_payments.InvalidPaymentDataError`.
|
|
193
|
+
"""
|
|
194
|
+
(
|
|
195
|
+
Validator()
|
|
196
|
+
.required("order_id", data.order_id)
|
|
197
|
+
.max("order_id", data.order_id, 50)
|
|
198
|
+
.amount(
|
|
199
|
+
"amount",
|
|
200
|
+
data.amount,
|
|
201
|
+
AmountRule("CyberSource", None, max_length=15, allow_zero=True),
|
|
202
|
+
)
|
|
203
|
+
.required("callback_url", data.callback_url)
|
|
204
|
+
.url("callback_url", data.callback_url)
|
|
205
|
+
.max("callback_url", data.callback_url, 255)
|
|
206
|
+
.string("return_url", data.return_url)
|
|
207
|
+
.url("return_url", data.return_url)
|
|
208
|
+
.max("return_url", data.return_url, 255)
|
|
209
|
+
.string("cancel_url", data.cancel_url)
|
|
210
|
+
.url("cancel_url", data.cancel_url)
|
|
211
|
+
.max("cancel_url", data.cancel_url, 255)
|
|
212
|
+
.pattern(
|
|
213
|
+
"currency",
|
|
214
|
+
str(data.currency or "MMK"),
|
|
215
|
+
_CURRENCY,
|
|
216
|
+
"a three letter ISO 4217 code",
|
|
217
|
+
)
|
|
218
|
+
.pattern("locale", str(data.locale or "en-us"), _LOCALE, "a locale code such as en-us")
|
|
219
|
+
.when(
|
|
220
|
+
str(data.transaction_type or "sale") not in _TRANSACTION_TYPES,
|
|
221
|
+
"transaction_type",
|
|
222
|
+
"The transaction_type field is not a supported transaction type.",
|
|
223
|
+
)
|
|
224
|
+
.validate()
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def initiate(self, data: CyberSourcePaymentData) -> FormPayment:
|
|
228
|
+
"""Signs the payment fields.
|
|
229
|
+
|
|
230
|
+
The customer's browser must POST the returned form to CyberSource;
|
|
231
|
+
``to_html()`` renders a page that does it.
|
|
232
|
+
"""
|
|
233
|
+
self.validate(data)
|
|
234
|
+
|
|
235
|
+
values = {
|
|
236
|
+
"access_key": self.config.access_key,
|
|
237
|
+
"profile_id": self.config.profile_id,
|
|
238
|
+
"transaction_uuid": random_hex(),
|
|
239
|
+
"signed_field_names": ",".join(_SIGNED_FIELDS),
|
|
240
|
+
"signed_date_time": utc_now().strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
241
|
+
"locale": data.locale or "en-us",
|
|
242
|
+
"transaction_type": str(data.transaction_type or "sale"),
|
|
243
|
+
"reference_number": data.order_id,
|
|
244
|
+
"amount": str(to_amount(data.amount)),
|
|
245
|
+
"currency": data.currency or "MMK",
|
|
246
|
+
"override_custom_receipt_page": data.return_url or "",
|
|
247
|
+
"override_backoffice_post_url": data.callback_url,
|
|
248
|
+
"override_custom_cancel_page": data.cancel_url or "",
|
|
249
|
+
}
|
|
250
|
+
fields = [FormField(name, values[name]) for name in _SIGNED_FIELDS]
|
|
251
|
+
fields.append(FormField("signature", self._sign(values) or ""))
|
|
252
|
+
|
|
253
|
+
return FormPayment(
|
|
254
|
+
order_id=data.order_id,
|
|
255
|
+
action=f"{self.config.base_url}/pay",
|
|
256
|
+
fields=tuple(fields),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def handle_callback(self, request: CallbackRequest) -> PaymentCallback:
|
|
260
|
+
"""Verifies CyberSource's result post.
|
|
261
|
+
|
|
262
|
+
The same check works for the browser post to your receipt page. Only the
|
|
263
|
+
fields listed in ``signed_field_names`` are trusted: ``decision`` and
|
|
264
|
+
``req_reference_number`` must be signed, and unsigned fields are left out
|
|
265
|
+
of the result and ``raw``.
|
|
266
|
+
"""
|
|
267
|
+
payload = lossless_input(request)
|
|
268
|
+
expected = self._sign(payload)
|
|
269
|
+
names = _signed_names(payload)
|
|
270
|
+
if (
|
|
271
|
+
expected is None
|
|
272
|
+
or not safe_equal(expected, get(payload, "signature"))
|
|
273
|
+
or "decision" not in names
|
|
274
|
+
or "req_reference_number" not in names
|
|
275
|
+
):
|
|
276
|
+
raise SignatureVerificationError(
|
|
277
|
+
"CyberSource callback signature verification failed.", to_plain_object(payload)
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# A signature only covers the listed fields, so anything else in the post
|
|
281
|
+
# could have been added by the sender (e.g. a `decision` next to the signed
|
|
282
|
+
# request fields).
|
|
283
|
+
signed: LosslessObject = {name: payload.get(name) for name in [*names, "signature"]}
|
|
284
|
+
|
|
285
|
+
decision = trimmed(signed, "decision").upper()
|
|
286
|
+
return PaymentCallback(
|
|
287
|
+
order_id=get(signed, "req_reference_number"),
|
|
288
|
+
status=resolve_status(_STATUSES, decision),
|
|
289
|
+
gateway_status=decision,
|
|
290
|
+
gateway_reference=optional(signed, "transaction_id"),
|
|
291
|
+
amount=get(signed, "auth_amount") or optional(signed, "req_amount"),
|
|
292
|
+
raw=to_plain_object(signed),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def _sign(self, fields: Mapping[str, object]) -> str | None:
|
|
296
|
+
"""Signs the fields listed in ``signed_field_names``; ``None`` when one is missing."""
|
|
297
|
+
pairs = []
|
|
298
|
+
for name in _signed_names(fields):
|
|
299
|
+
value = scalar_string(fields.get(name))
|
|
300
|
+
if value is None:
|
|
301
|
+
return None
|
|
302
|
+
pairs.append(f"{name}={value}")
|
|
303
|
+
if not pairs:
|
|
304
|
+
return None
|
|
305
|
+
return hmac_sha256_base64(self.config.secret_key, ",".join(pairs))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _signed_names(fields: Mapping[str, object]) -> list[str]:
|
|
309
|
+
"""The names listed in ``signed_field_names``, in order."""
|
|
310
|
+
return [name for name in get(fields, "signed_field_names").split(",") if name != ""]
|