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,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 != ""]