python-redsys 2.0.0__py3-none-any.whl → 2.2.0__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_redsys-2.2.0.dist-info/METADATA +187 -0
- python_redsys-2.2.0.dist-info/RECORD +34 -0
- redsys/__init__.py +38 -2
- redsys/_code_texts.py +4673 -0
- redsys/api/__init__.py +1 -1
- redsys/api/bizum.py +71 -0
- redsys/api/cards.py +50 -0
- redsys/api/currencies.py +18 -18
- redsys/api/emv3ds.py +99 -0
- redsys/api/environments.py +41 -0
- redsys/api/languages.py +9 -1
- redsys/api/options.py +31 -0
- redsys/api/pay_methods.py +25 -0
- redsys/api/paygold.py +33 -0
- redsys/api/processed_pay_methods.py +207 -0
- redsys/api/responses.py +290 -0
- redsys/api/sca.py +11 -3
- redsys/api/secure_payment.py +12 -0
- redsys/api/transactions.py +53 -8
- redsys/api/xpay.py +25 -0
- redsys/clients.py +270 -85
- redsys/codes.py +76 -0
- redsys/crypto.py +8 -8
- redsys/exceptions.py +5 -2
- redsys/models.py +309 -57
- redsys/orders.py +20 -0
- redsys/request.py +19 -13
- redsys/threeds.py +21 -16
- python_redsys-2.0.0.dist-info/METADATA +0 -214
- python_redsys-2.0.0.dist-info/RECORD +0 -20
- {python_redsys-2.0.0.dist-info → python_redsys-2.2.0.dist-info}/WHEEL +0 -0
- {python_redsys-2.0.0.dist-info → python_redsys-2.2.0.dist-info}/licenses/LICENSE +0 -0
- {python_redsys-2.0.0.dist-info → python_redsys-2.2.0.dist-info}/licenses/NOTICE +0 -0
- {python_redsys-2.0.0.dist-info → python_redsys-2.2.0.dist-info}/top_level.txt +0 -0
redsys/clients.py
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Clients for the RedSys redirect and direct connections.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
the factory for every operation. You never build or import a request model --
|
|
6
|
-
call an operation method (``payment``, ``refund``, ...) or the low-level
|
|
7
|
-
``request()`` they all delegate to, and you get back a ``Request``:
|
|
3
|
+
A client holds the credentials and the account (secret key, merchant code, terminal,
|
|
4
|
+
environment, signature version) and builds every operation as a signed ``Request``:
|
|
8
5
|
|
|
9
|
-
client = DirectClient(secret_key='...', merchant_code='
|
|
10
|
-
response = client.payment(order=
|
|
6
|
+
client = DirectClient(secret_key='...', merchant_code='999008881', terminal=1, sandbox=True)
|
|
7
|
+
response = client.payment(order=generate_order(), amount=Decimal('10.56')).send()
|
|
11
8
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
and parse incoming results through ``client.parse_response(payload)``.
|
|
9
|
+
A redirect request ends in ``to_dict()``, the form the browser posts; a direct request
|
|
10
|
+
ends in ``send()``. ``parse_response()`` verifies and parses a received result.
|
|
15
11
|
"""
|
|
16
12
|
|
|
13
|
+
from base64 import b64decode
|
|
17
14
|
from hmac import compare_digest
|
|
15
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
18
16
|
from logging import getLogger
|
|
19
|
-
from re import sub
|
|
17
|
+
from re import fullmatch, sub
|
|
18
|
+
from urllib.parse import urlsplit
|
|
19
|
+
from warnings import warn
|
|
20
20
|
|
|
21
|
-
from redsys
|
|
21
|
+
from redsys import threeds
|
|
22
|
+
from redsys.api import environments, transactions
|
|
22
23
|
from redsys.crypto import (
|
|
23
24
|
DEFAULT_SIGNATURE_VERSION,
|
|
24
25
|
HMAC_SHA256_V1,
|
|
@@ -27,8 +28,9 @@ from redsys.crypto import (
|
|
|
27
28
|
SIGNATURE_VERSIONS,
|
|
28
29
|
get_signer,
|
|
29
30
|
)
|
|
30
|
-
from redsys.exceptions import ResponseError
|
|
31
|
+
from redsys.exceptions import ResponseError, ValidationError
|
|
31
32
|
from redsys.models import (
|
|
33
|
+
BaseRequest,
|
|
32
34
|
CancellationMessage,
|
|
33
35
|
ConfirmationMessage,
|
|
34
36
|
DirectPaymentMessage,
|
|
@@ -47,7 +49,18 @@ from redsys.request import (
|
|
|
47
49
|
|
|
48
50
|
logger = getLogger(__name__)
|
|
49
51
|
|
|
50
|
-
|
|
52
|
+
|
|
53
|
+
def _default_module() -> str:
|
|
54
|
+
try:
|
|
55
|
+
return f'python-redsys/{version("python-redsys")}'
|
|
56
|
+
except PackageNotFoundError:
|
|
57
|
+
return 'python-redsys'
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
#: Ds_Merchant_Module sent by default: this library and its installed version.
|
|
61
|
+
DEFAULT_MODULE = _default_module()
|
|
62
|
+
|
|
63
|
+
# Seconds; ``send(timeout=...)`` overrides it.
|
|
51
64
|
DEFAULT_TIMEOUT = 30
|
|
52
65
|
|
|
53
66
|
__all__ = [
|
|
@@ -64,6 +77,7 @@ __all__ = [
|
|
|
64
77
|
'HMAC_SHA512_V2',
|
|
65
78
|
'SIGNATURE_VERSIONS',
|
|
66
79
|
'DEFAULT_SIGNATURE_VERSION',
|
|
80
|
+
'DEFAULT_MODULE',
|
|
67
81
|
]
|
|
68
82
|
|
|
69
83
|
|
|
@@ -79,15 +93,61 @@ def _normalize_signature(signature) -> str:
|
|
|
79
93
|
return sub(r'[^a-zA-Z0-9]', '', signature)
|
|
80
94
|
|
|
81
95
|
|
|
96
|
+
def _resolve_environment(environment: str | None, sandbox: bool | None) -> str:
|
|
97
|
+
"""The environment a client targets; ``sandbox`` means TEST when true and PROD when false."""
|
|
98
|
+
implied = None if sandbox is None else environments.TEST if sandbox else environments.PROD
|
|
99
|
+
if environment is None:
|
|
100
|
+
return implied or environments.PROD
|
|
101
|
+
if environment not in environments.HOSTS:
|
|
102
|
+
raise ValidationError('environment is not valid.')
|
|
103
|
+
if implied not in (None, environment):
|
|
104
|
+
raise ValidationError('environment contradicts sandbox.')
|
|
105
|
+
return environment
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _endpoint_option(fields: dict) -> str | None:
|
|
109
|
+
"""Pop a call's ``endpoint``, which is never signed; it must be an HTTPS URL."""
|
|
110
|
+
endpoint = fields.pop('endpoint', None)
|
|
111
|
+
if endpoint is None:
|
|
112
|
+
return None
|
|
113
|
+
parts = urlsplit(endpoint) if isinstance(endpoint, str) else None
|
|
114
|
+
if parts is None or parts.scheme != 'https' or not parts.netloc:
|
|
115
|
+
raise ValidationError('endpoint must be an https:// URL.')
|
|
116
|
+
return endpoint
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _parse_api_key(api_key: str) -> tuple[str, str, int, str]:
|
|
120
|
+
"""Split an API key into (environment, merchant code, terminal, secret key).
|
|
121
|
+
|
|
122
|
+
The format is ``<environment>_<Base64URL of merchant_terminal_secret>``, with
|
|
123
|
+
or without padding. Failures raise outside any ``except`` block and name no
|
|
124
|
+
detail, so neither the key nor its decoded secret reaches the traceback.
|
|
125
|
+
"""
|
|
126
|
+
environment, _, payload = api_key.partition('_') if isinstance(api_key, str) else ('', '', '')
|
|
127
|
+
payload = payload.rstrip('=')
|
|
128
|
+
try:
|
|
129
|
+
decoded = b64decode(payload + '=' * (-len(payload) % 4), b'-_', validate=True).decode('utf-8')
|
|
130
|
+
except ValueError:
|
|
131
|
+
decoded = ''
|
|
132
|
+
fields = fullmatch(r'([0-9]{1,9})_([0-9]{1,3})_(.+)', decoded)
|
|
133
|
+
if environment not in environments.HOSTS or fields is None:
|
|
134
|
+
raise ValidationError('API key is not valid.')
|
|
135
|
+
merchant_code, terminal, secret_key = fields.groups()
|
|
136
|
+
return environment, merchant_code, int(terminal), secret_key
|
|
137
|
+
|
|
138
|
+
|
|
82
139
|
class Client:
|
|
83
140
|
"""Base client: credentials, account identity, request factory, response parsing."""
|
|
84
141
|
|
|
85
142
|
REAL_ENDPOINT: str | None = None
|
|
86
143
|
TEST_ENDPOINT: str | None = None
|
|
144
|
+
_PATH: str | None = None
|
|
87
145
|
|
|
88
|
-
#:
|
|
146
|
+
#: Request model per transaction type; other types use ``_DEFAULT_MODEL``.
|
|
89
147
|
_DEFAULT_MODEL: type | None = None
|
|
90
148
|
_MODELS: dict[int, type] = {}
|
|
149
|
+
#: The transaction types the connection accepts; ``None`` leaves the check to the model.
|
|
150
|
+
_TRANSACTION_TYPES: tuple[int, ...] | None = None
|
|
91
151
|
|
|
92
152
|
def __init__(
|
|
93
153
|
self,
|
|
@@ -95,25 +155,52 @@ class Client:
|
|
|
95
155
|
*,
|
|
96
156
|
merchant_code: str | None = None,
|
|
97
157
|
terminal: int | None = None,
|
|
98
|
-
|
|
158
|
+
currency: int | None = None,
|
|
159
|
+
sandbox: bool | None = None,
|
|
160
|
+
environment: str | None = None,
|
|
99
161
|
signature_version: str = DEFAULT_SIGNATURE_VERSION,
|
|
162
|
+
module: str | None = DEFAULT_MODULE,
|
|
100
163
|
):
|
|
101
164
|
self._secret_key = secret_key
|
|
165
|
+
self.module = module
|
|
102
166
|
self.merchant_code = merchant_code
|
|
103
167
|
self.terminal = terminal
|
|
104
|
-
self.
|
|
168
|
+
self.currency = currency
|
|
169
|
+
self.environment = _resolve_environment(environment, sandbox)
|
|
170
|
+
self.sandbox = self.environment != environments.PROD
|
|
105
171
|
self.signature_version = signature_version
|
|
106
|
-
self.endpoint = self.
|
|
172
|
+
self.endpoint = self.url(self._PATH) if self._PATH else None
|
|
173
|
+
|
|
174
|
+
@classmethod
|
|
175
|
+
def from_api_key(cls, api_key: str, **options):
|
|
176
|
+
"""Build a client from a RedSys API key, which carries the environment and the credentials.
|
|
107
177
|
|
|
108
|
-
|
|
178
|
+
``options`` are the remaining constructor options (``signature_version``, ``currency``, ``module``).
|
|
179
|
+
Raises ``ValidationError`` if the key is not valid.
|
|
180
|
+
"""
|
|
181
|
+
environment, merchant_code, terminal, secret_key = _parse_api_key(api_key)
|
|
182
|
+
return cls(secret_key, merchant_code=merchant_code, terminal=terminal, environment=environment, **options)
|
|
183
|
+
|
|
184
|
+
def __repr__(self) -> str:
|
|
185
|
+
return (
|
|
186
|
+
f'{type(self).__name__}(merchant_code={self.merchant_code!r}, terminal={self.terminal!r}, '
|
|
187
|
+
f'environment={self.environment!r}, signature_version={self.signature_version!r})'
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def url(self, path: str) -> str:
|
|
191
|
+
"""The client's environment host followed by ``path``, e.g. ``environments.RTP_TRATA_PATH``."""
|
|
192
|
+
if not isinstance(path, str) or not path.startswith('/'):
|
|
193
|
+
raise ValidationError('path must start with "/".')
|
|
194
|
+
return f'{environments.HOSTS[self.environment]}{path}'
|
|
195
|
+
|
|
196
|
+
# == Request building ==
|
|
109
197
|
def _inject(self, fields: dict) -> dict:
|
|
110
|
-
"""Merge the client's account
|
|
111
|
-
data
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
data.update(fields)
|
|
198
|
+
"""Merge the client's account defaults into a call's fields (call wins, by either name)."""
|
|
199
|
+
data = dict(fields)
|
|
200
|
+
for name in ('merchant_code', 'terminal', 'currency', 'module'):
|
|
201
|
+
value = getattr(self, name)
|
|
202
|
+
if value is not None and name not in data and BaseRequest.model_fields[name].alias not in data:
|
|
203
|
+
data[name] = value
|
|
117
204
|
return data
|
|
118
205
|
|
|
119
206
|
def _model_for(self, transaction_type: int) -> type:
|
|
@@ -125,25 +212,41 @@ class Client:
|
|
|
125
212
|
def request(self, transaction_type: int, **fields) -> Request:
|
|
126
213
|
"""Build, validate and sign any operation by its transaction type.
|
|
127
214
|
|
|
128
|
-
|
|
129
|
-
|
|
215
|
+
For the types without a method, such as ``transactions.REFUND_CANCELLATION``.
|
|
216
|
+
Raises ``ValidationError`` if ``fields`` name a different transaction type, or if
|
|
217
|
+
the connection does not accept it (the redirect connection takes
|
|
218
|
+
``transactions.REDIRECT_TRANSACTION_TYPES``). Every operation takes ``endpoint=``,
|
|
219
|
+
an HTTPS URL that replaces the client's for that request only; it is not signed.
|
|
130
220
|
"""
|
|
221
|
+
return self._operation(transaction_type, fields)
|
|
222
|
+
|
|
223
|
+
def _operation(self, transaction_type: int, fields: dict, endpoint: str | None = None) -> Request:
|
|
224
|
+
fields = dict(fields)
|
|
225
|
+
override = _endpoint_option(fields)
|
|
226
|
+
return self._prepare(self._message(transaction_type, fields), endpoint=override or endpoint)
|
|
227
|
+
|
|
228
|
+
def _message(self, transaction_type: int, fields: dict):
|
|
229
|
+
if self._TRANSACTION_TYPES is not None and transaction_type not in self._TRANSACTION_TYPES:
|
|
230
|
+
raise ValidationError(f'transaction type {transaction_type} is not accepted by {type(self).__name__}.')
|
|
131
231
|
data = self._inject(fields)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
232
|
+
for name in ('transaction_type', BaseRequest.model_fields['transaction_type'].alias):
|
|
233
|
+
if str(data.pop(name, transaction_type)) != str(transaction_type):
|
|
234
|
+
raise ValidationError(f'{name} contradicts the operation, transaction type {transaction_type}.')
|
|
235
|
+
return self._model_for(transaction_type)(**data, transaction_type=transaction_type)
|
|
135
236
|
|
|
136
|
-
def _prepare(self, model) -> Request:
|
|
137
|
-
return self._build_request(model)
|
|
237
|
+
def _prepare(self, model, endpoint: str | None = None) -> Request:
|
|
238
|
+
return self._build_request(model, endpoint=endpoint or self.endpoint)
|
|
138
239
|
|
|
139
|
-
def _build_request(
|
|
240
|
+
def _build_request(
|
|
241
|
+
self, model, signature_version: str | None = None, sender=None, endpoint: str | None = None
|
|
242
|
+
) -> Request:
|
|
140
243
|
version = signature_version or self.signature_version
|
|
141
244
|
signer = get_signer(self._secret_key, version)
|
|
142
245
|
merchant_parameters = signer.encode_parameters(model.prepare_parameters())
|
|
143
246
|
signature = signer.sign(model.get_order(), merchant_parameters)
|
|
144
|
-
return Request(merchant_parameters, signature, version, sender=sender)
|
|
247
|
+
return Request(merchant_parameters, signature, version, sender=sender, endpoint=endpoint)
|
|
145
248
|
|
|
146
|
-
#
|
|
249
|
+
# == Response parsing ==
|
|
147
250
|
def parse_response(self, payload: dict) -> Response:
|
|
148
251
|
"""Verify the signature and parse a received payload into a ``Response``.
|
|
149
252
|
|
|
@@ -160,30 +263,37 @@ class Client:
|
|
|
160
263
|
merchant_parameters = merchant_parameters.encode('utf-8')
|
|
161
264
|
version = payload.get(SIGNATURE_VERSION, DEFAULT_SIGNATURE_VERSION)
|
|
162
265
|
response = Response.from_merchant_parameters(merchant_parameters)
|
|
163
|
-
# A signed result always carries Ds_Order; ''
|
|
164
|
-
# missing order simply fails verification below.
|
|
266
|
+
# A signed result always carries Ds_Order; without it, '' makes verification fail.
|
|
165
267
|
expected = get_signer(self._secret_key, version).sign(response.get_order() or '', merchant_parameters)
|
|
166
|
-
# Constant-time comparison to avoid leaking the signature via timing.
|
|
167
268
|
if not compare_digest(_normalize_signature(payload[SIGNATURE]), _normalize_signature(expected)):
|
|
168
269
|
raise ValueError('Signature is not valid.')
|
|
169
270
|
return response
|
|
170
271
|
|
|
171
272
|
|
|
172
273
|
class RedirectClient(Client):
|
|
173
|
-
"""Redirect connection
|
|
174
|
-
browser submits to RedSys. The library performs no HTTP and no redirect."""
|
|
274
|
+
"""Redirect connection: builds the signed form fields the browser posts to RedSys; performs no HTTP."""
|
|
175
275
|
|
|
176
276
|
# Endpoints per the redirection guide 4.2 (RS.TE.CEL.MAN.0039).
|
|
177
|
-
|
|
178
|
-
|
|
277
|
+
_PATH = environments.REDIRECT_PATH
|
|
278
|
+
REAL_ENDPOINT = environments.HOSTS[environments.PROD] + _PATH
|
|
279
|
+
TEST_ENDPOINT = environments.HOSTS[environments.TEST] + _PATH
|
|
179
280
|
_DEFAULT_MODEL = RedirectPaymentMessage
|
|
180
281
|
_MODELS = {transactions.PREAUTHORIZATION: RedirectPreauthorizationMessage}
|
|
282
|
+
_TRANSACTION_TYPES = transactions.REDIRECT_TRANSACTION_TYPES
|
|
181
283
|
|
|
182
284
|
def payment(self, **fields) -> Request:
|
|
183
|
-
return self.
|
|
285
|
+
return self._operation(transactions.AUTHORIZATION, fields)
|
|
184
286
|
|
|
185
287
|
def preauthorization(self, **fields) -> Request:
|
|
186
|
-
return self.
|
|
288
|
+
return self._operation(transactions.PREAUTHORIZATION, fields)
|
|
289
|
+
|
|
290
|
+
def authentication(self, **fields) -> Request:
|
|
291
|
+
"""Authenticate the cardholder and validate the card, with no hold (transaction type 7)."""
|
|
292
|
+
return self._operation(transactions.AUTHENTICATION, fields)
|
|
293
|
+
|
|
294
|
+
def refund_without_original(self, **fields) -> Request:
|
|
295
|
+
"""Credit the cardholder with no previous sale (transaction type 34)."""
|
|
296
|
+
return self._operation(transactions.REFUND_WITHOUT_ORIGINAL, fields)
|
|
187
297
|
|
|
188
298
|
|
|
189
299
|
def _require_httpx():
|
|
@@ -194,79 +304,154 @@ def _require_httpx():
|
|
|
194
304
|
return httpx
|
|
195
305
|
|
|
196
306
|
|
|
307
|
+
_EMV3DS_WIRE_NAME = 'Ds_Merchant_EMV3DS'
|
|
308
|
+
|
|
309
|
+
|
|
197
310
|
class _DirectBase(Client):
|
|
198
|
-
"""
|
|
311
|
+
"""Operations of the REST direct connection, independent of the HTTP transport."""
|
|
199
312
|
|
|
200
313
|
# Endpoints per the REST guide 4.0.1 (RS.TE.CEL.MAN.0037).
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
314
|
+
_PATH = environments.TRATA_PATH
|
|
315
|
+
REAL_ENDPOINT = environments.HOSTS[environments.PROD] + _PATH
|
|
316
|
+
TEST_ENDPOINT = environments.HOSTS[environments.TEST] + _PATH
|
|
317
|
+
INICIA_REAL_ENDPOINT = environments.HOSTS[environments.PROD] + environments.INICIA_PATH
|
|
318
|
+
INICIA_TEST_ENDPOINT = environments.HOSTS[environments.TEST] + environments.INICIA_PATH
|
|
205
319
|
|
|
206
320
|
_DEFAULT_MODEL = DirectPaymentMessage
|
|
207
321
|
_MODELS = {
|
|
208
322
|
transactions.PREAUTHORIZATION: DirectPreauthorizationMessage,
|
|
209
323
|
transactions.CONFIRMATION: ConfirmationMessage,
|
|
210
324
|
transactions.REFUND: RefundMessage,
|
|
211
|
-
transactions.
|
|
325
|
+
transactions.VOID: CancellationMessage,
|
|
326
|
+
transactions.REPLACEMENT_PREAUTHORIZATION: DirectPreauthorizationMessage,
|
|
327
|
+
transactions.PARTIAL_CONFIRMATION: ConfirmationMessage,
|
|
328
|
+
transactions.PAYMENT_CANCELLATION: CancellationMessage,
|
|
329
|
+
transactions.REFUND_CANCELLATION: CancellationMessage,
|
|
330
|
+
transactions.AUTHENTICATION_CONFIRMATION_CANCELLATION: CancellationMessage,
|
|
212
331
|
}
|
|
213
332
|
|
|
214
333
|
@property
|
|
215
334
|
def inicia_endpoint(self) -> str:
|
|
216
|
-
return self.
|
|
335
|
+
return self.url(environments.INICIA_PATH)
|
|
217
336
|
|
|
218
337
|
def payment(self, **fields) -> Request:
|
|
219
|
-
return self.
|
|
338
|
+
return self._operation(transactions.AUTHORIZATION, fields)
|
|
220
339
|
|
|
221
340
|
def preauthorization(self, **fields) -> Request:
|
|
222
|
-
return self.
|
|
341
|
+
return self._operation(transactions.PREAUTHORIZATION, fields)
|
|
223
342
|
|
|
224
343
|
def confirmation(self, **fields) -> Request:
|
|
225
|
-
return self.
|
|
344
|
+
return self._operation(transactions.CONFIRMATION, fields)
|
|
345
|
+
|
|
346
|
+
def partial_confirmation(self, **fields) -> Request:
|
|
347
|
+
return self._operation(transactions.PARTIAL_CONFIRMATION, fields)
|
|
348
|
+
|
|
349
|
+
def authentication(self, **fields) -> Request:
|
|
350
|
+
return self._operation(transactions.AUTHENTICATION, fields)
|
|
351
|
+
|
|
352
|
+
def authentication_confirmation(self, **fields) -> Request:
|
|
353
|
+
return self._operation(transactions.AUTHENTICATION_CONFIRMATION, fields)
|
|
354
|
+
|
|
355
|
+
def authentication_confirmation_cancellation(self, **fields) -> Request:
|
|
356
|
+
return self._operation(transactions.AUTHENTICATION_CONFIRMATION_CANCELLATION, fields)
|
|
357
|
+
|
|
358
|
+
def pay_gold(self, **fields) -> Request:
|
|
359
|
+
return self._operation(transactions.PAY_GOLD, fields)
|
|
360
|
+
|
|
361
|
+
def pay_gold_expiry_change(self, **fields) -> Request:
|
|
362
|
+
"""Change the expiry of a PayGold link (transaction type 51); RedSys requires ``amount``."""
|
|
363
|
+
return self._operation(transactions.PAY_GOLD_EXPIRY_CHANGE, fields)
|
|
364
|
+
|
|
365
|
+
def refund_without_original(self, **fields) -> Request:
|
|
366
|
+
"""Credit the cardholder with no previous sale (transaction type 34)."""
|
|
367
|
+
return self._operation(transactions.REFUND_WITHOUT_ORIGINAL, fields)
|
|
226
368
|
|
|
227
369
|
def refund(self, **fields) -> Request:
|
|
228
|
-
return self.
|
|
370
|
+
return self._operation(transactions.REFUND, fields)
|
|
371
|
+
|
|
372
|
+
def void(self, **fields) -> Request:
|
|
373
|
+
"""Void a preauthorization (transaction type 9)."""
|
|
374
|
+
return self._operation(transactions.VOID, fields)
|
|
375
|
+
|
|
376
|
+
def payment_cancellation(self, **fields) -> Request:
|
|
377
|
+
"""Cancel a payment after a merchant time-out or technical error (transaction type 45)."""
|
|
378
|
+
return self._operation(transactions.PAYMENT_CANCELLATION, fields)
|
|
229
379
|
|
|
230
380
|
def cancellation(self, **fields) -> Request:
|
|
231
|
-
|
|
381
|
+
"""Deprecated: sends type 9; use ``void()``, or ``payment_cancellation()`` for type 45."""
|
|
382
|
+
warn(
|
|
383
|
+
'cancellation() is deprecated and goes away in 3.0: use void() for type 9, the type it sends, '
|
|
384
|
+
'or payment_cancellation() for type 45.',
|
|
385
|
+
DeprecationWarning,
|
|
386
|
+
stacklevel=2,
|
|
387
|
+
)
|
|
388
|
+
return self.void(**fields)
|
|
232
389
|
|
|
233
390
|
def inicia(self, **fields) -> Request:
|
|
234
|
-
"""3-D Secure
|
|
391
|
+
"""3-D Secure initialization (iniciaPeticionREST) from the given fields, ``emv3ds`` included."""
|
|
392
|
+
fields = dict(fields)
|
|
393
|
+
endpoint = _endpoint_option(fields)
|
|
235
394
|
model = self._DEFAULT_MODEL(**self._inject(fields))
|
|
236
|
-
return self._prepare(model, endpoint=self.inicia_endpoint)
|
|
395
|
+
return self._prepare(model, endpoint=endpoint or self.inicia_endpoint)
|
|
396
|
+
|
|
397
|
+
def inicia_payment(self, **fields) -> Request:
|
|
398
|
+
"""3-D Secure step 1 for a payment; ``emv3ds`` defaults to ``threeds.card_data()``."""
|
|
399
|
+
return self._inicia(transactions.AUTHORIZATION, fields)
|
|
400
|
+
|
|
401
|
+
def inicia_preauthorization(self, **fields) -> Request:
|
|
402
|
+
"""3-D Secure step 1 for a preauthorization; ``emv3ds`` defaults to ``threeds.card_data()``."""
|
|
403
|
+
return self._inicia(transactions.PREAUTHORIZATION, fields)
|
|
404
|
+
|
|
405
|
+
def inicia_authentication(self, **fields) -> Request:
|
|
406
|
+
"""3-D Secure step 1 for an authentication; ``emv3ds`` defaults to ``threeds.card_data()``."""
|
|
407
|
+
return self._inicia(transactions.AUTHENTICATION, fields)
|
|
408
|
+
|
|
409
|
+
def _inicia(self, transaction_type: int, fields: dict) -> Request:
|
|
410
|
+
if 'emv3ds' not in fields and _EMV3DS_WIRE_NAME not in fields:
|
|
411
|
+
fields['emv3ds'] = threeds.card_data()
|
|
412
|
+
return self._operation(transaction_type, fields, endpoint=self.inicia_endpoint)
|
|
413
|
+
|
|
414
|
+
def challenge_response(
|
|
415
|
+
self, *, cres: str, protocol_version: str, transaction_type: int = transactions.AUTHORIZATION, **fields
|
|
416
|
+
) -> Request:
|
|
417
|
+
"""3-D Secure final step after the challenge; ``transaction_type`` is the one authenticated."""
|
|
418
|
+
if 'emv3ds' in fields or _EMV3DS_WIRE_NAME in fields:
|
|
419
|
+
raise ValidationError('challenge_response() builds emv3ds from cres and protocol_version.')
|
|
420
|
+
emv3ds = threeds.challenge_response(cres=cres, protocol_version=protocol_version)
|
|
421
|
+
return self._operation(transaction_type, {**fields, 'emv3ds': emv3ds})
|
|
237
422
|
|
|
238
423
|
def _prepare(self, model, endpoint: str | None = None) -> Request:
|
|
239
|
-
|
|
240
|
-
return self._build_request(model, sender=self._make_sender(endpoint))
|
|
424
|
+
return self._build_request(model, sender=self._send, endpoint=endpoint or self.endpoint)
|
|
241
425
|
|
|
242
|
-
def
|
|
426
|
+
def _send(self, request, **httpx_kwargs):
|
|
243
427
|
raise NotImplementedError
|
|
244
428
|
|
|
429
|
+
def _parse_http_response(self, http_response) -> Response:
|
|
430
|
+
http_response.raise_for_status()
|
|
431
|
+
transport = (http_response.status_code, dict(http_response.headers), http_response.text)
|
|
432
|
+
try:
|
|
433
|
+
response = self.parse_response(http_response.json())
|
|
434
|
+
except ResponseError as error:
|
|
435
|
+
error.http_status, error.http_headers, error.raw_body = transport
|
|
436
|
+
raise
|
|
437
|
+
return response._with_transport(*transport)
|
|
245
438
|
|
|
246
|
-
class DirectClient(_DirectBase):
|
|
247
|
-
"""Synchronous REST direct-connection client. Requires the httpx extra."""
|
|
248
439
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
httpx = _require_httpx()
|
|
252
|
-
httpx_kwargs.setdefault('timeout', DEFAULT_TIMEOUT)
|
|
253
|
-
http_response = httpx.post(endpoint, json=request.to_dict(), **httpx_kwargs)
|
|
254
|
-
http_response.raise_for_status()
|
|
255
|
-
return self.parse_response(http_response.json())
|
|
440
|
+
class DirectClient(_DirectBase):
|
|
441
|
+
"""Synchronous REST direct-connection client; ``send()`` requires the ``http`` extra (httpx)."""
|
|
256
442
|
|
|
257
|
-
|
|
443
|
+
def _send(self, request, **httpx_kwargs):
|
|
444
|
+
httpx = _require_httpx()
|
|
445
|
+
httpx_kwargs.setdefault('timeout', DEFAULT_TIMEOUT)
|
|
446
|
+
return self._parse_http_response(httpx.post(request.endpoint, json=request.to_dict(), **httpx_kwargs))
|
|
258
447
|
|
|
259
448
|
|
|
260
449
|
class AsyncDirectClient(_DirectBase):
|
|
261
|
-
"""Asynchronous REST direct-connection client (
|
|
262
|
-
|
|
263
|
-
def
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
http_response.raise_for_status()
|
|
270
|
-
return self.parse_response(http_response.json())
|
|
271
|
-
|
|
272
|
-
return sender
|
|
450
|
+
"""Asynchronous REST direct-connection client; ``await send()`` requires the ``http`` extra (httpx)."""
|
|
451
|
+
|
|
452
|
+
async def _send(self, request, **httpx_kwargs):
|
|
453
|
+
httpx = _require_httpx()
|
|
454
|
+
httpx_kwargs.setdefault('timeout', DEFAULT_TIMEOUT)
|
|
455
|
+
async with httpx.AsyncClient() as client:
|
|
456
|
+
http_response = await client.post(request.endpoint, json=request.to_dict(), **httpx_kwargs)
|
|
457
|
+
return self._parse_http_response(http_response)
|
redsys/codes.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Meanings of RedSys result and error codes, in Spanish, Catalan, Galician, Basque or English.
|
|
2
|
+
|
|
3
|
+
from redsys.codes import describe
|
|
4
|
+
|
|
5
|
+
describe('0195', 'en') # Ds_Response
|
|
6
|
+
describe('SIS0051') # SIS error code
|
|
7
|
+
describe('BIZ00009') # Bizum Request-to-Pay code
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from re import fullmatch
|
|
11
|
+
|
|
12
|
+
from redsys import _code_texts
|
|
13
|
+
from redsys.api import languages, responses
|
|
14
|
+
|
|
15
|
+
# Position of each language in the (Spanish, Catalan, Galician, Basque, English) texts; Valencian reads the Catalan.
|
|
16
|
+
_TEXT_INDEX = {
|
|
17
|
+
'es': 0,
|
|
18
|
+
'ca': 1,
|
|
19
|
+
'va': 1,
|
|
20
|
+
'gl': 2,
|
|
21
|
+
'eu': 3,
|
|
22
|
+
'en': 4,
|
|
23
|
+
languages.SPANISH: 0,
|
|
24
|
+
languages.CATALAN: 1,
|
|
25
|
+
languages.VALENCIAN: 1,
|
|
26
|
+
languages.GALICIAN: 2,
|
|
27
|
+
languages.EUSKERA: 3,
|
|
28
|
+
languages.ENGLISH: 4,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _language_index(language) -> int | None:
|
|
33
|
+
if isinstance(language, str):
|
|
34
|
+
language = int(language) if language.isdigit() else language.lower()
|
|
35
|
+
return _TEXT_INDEX.get(language)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _texts(code) -> tuple[str, ...] | None:
|
|
39
|
+
if isinstance(code, bool):
|
|
40
|
+
return None
|
|
41
|
+
if isinstance(code, int):
|
|
42
|
+
code = str(code)
|
|
43
|
+
if not isinstance(code, str):
|
|
44
|
+
return None
|
|
45
|
+
code = code.strip().upper()
|
|
46
|
+
if match := fullmatch(r'SIS([0-9]{4})', code):
|
|
47
|
+
return _code_texts.SIS.get(int(match.group(1)))
|
|
48
|
+
if match := fullmatch(r'BIZ([0-9]{5})', code):
|
|
49
|
+
return _code_texts.BIZ.get(int(match.group(1)))
|
|
50
|
+
if not fullmatch(r'[0-9]{1,4}', code):
|
|
51
|
+
return None
|
|
52
|
+
number = int(code)
|
|
53
|
+
if number <= responses.AUTHORIZED_LAST:
|
|
54
|
+
return _code_texts.AUTHORIZED
|
|
55
|
+
if number in _code_texts.DS_RESPONSE:
|
|
56
|
+
return _code_texts.DS_RESPONSE[number]
|
|
57
|
+
# Every SIS0nnn error has a numeric twin 9nnn that can arrive in Ds_Response.
|
|
58
|
+
return _code_texts.SIS.get(number - 9000) if number >= 9000 else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def describe(code, language='es') -> str:
|
|
62
|
+
"""The meaning of a ``Ds_Response`` code, an ``SISnnnn`` error code or a Bizum ``BIZnnnnn`` code.
|
|
63
|
+
|
|
64
|
+
``code`` is an ``int`` or a ``str`` (``0``, ``'0000'``, ``'SIS0051'``, ``'BIZ00009'``). ``language``
|
|
65
|
+
is an ISO code (``'es'``, ``'en'``, ``'ca'``, ``'gl'``, ``'eu'``; ``'va'`` reads the Catalan) or RedSys's numeric
|
|
66
|
+
one (``languages.SPANISH``, ``ENGLISH``, ``CATALAN``, ``VALENCIAN``, ``GALICIAN``, ``EUSKERA``).
|
|
67
|
+
Returns ``''`` for an unknown code or language.
|
|
68
|
+
"""
|
|
69
|
+
texts = _texts(code)
|
|
70
|
+
index = _language_index(language)
|
|
71
|
+
if texts is None or index is None:
|
|
72
|
+
return ''
|
|
73
|
+
return texts[index]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
__all__ = ['describe']
|
redsys/crypto.py
CHANGED
|
@@ -14,13 +14,13 @@ HMAC_SHA512_V2 also sends Ds_MerchantParameters as unpadded Base64URL; the other
|
|
|
14
14
|
versions send standard Base64. Responses are decoded tolerantly in either form.
|
|
15
15
|
|
|
16
16
|
The SHA-512 versions key their HMAC with the Base64 text of the derived key and differ
|
|
17
|
-
only in the padding of the signature (
|
|
18
|
-
HMAC_SHA256_V1 uses the raw derived bytes.
|
|
17
|
+
only in the padding of the signature (verified against signed responses of the RedSys
|
|
18
|
+
test environment); HMAC_SHA256_V1 uses the raw derived bytes.
|
|
19
19
|
|
|
20
20
|
HMAC_SHA512_V1 is the REST guide 4.0.1 scheme; HMAC_SHA512_V2 (the default) is the one the
|
|
21
|
-
redirection guide 4.1 and later
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
redirection guide 4.1 and later uses; HMAC_SHA256_V1 is still accepted (REST guide 4.0.1,
|
|
22
|
+
Anexo 2). The cipher parameters are pinned by a known-answer test against RedSys's official
|
|
23
|
+
worked example (see tests/test_crypto.py).
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
26
|
from base64 import b64decode, b64encode, urlsafe_b64encode
|
|
@@ -84,7 +84,7 @@ class Sha512V1Signer(Signer):
|
|
|
84
84
|
|
|
85
85
|
def sign(self, order: str, merchant_parameters: bytes) -> bytes:
|
|
86
86
|
# The HMAC key is the Base64 text of the derived key, not its raw bytes
|
|
87
|
-
# (confirmed
|
|
87
|
+
# (confirmed against the RedSys test environment, where raw bytes give SIS0042).
|
|
88
88
|
derived = b64encode(self._derive_key(order))
|
|
89
89
|
# REST manual: Base64URL with the '=' padding kept.
|
|
90
90
|
return urlsafe_b64encode(HMAC(derived, merchant_parameters, sha512).digest())
|
|
@@ -96,12 +96,12 @@ class Sha512V2Signer(Sha512V1Signer):
|
|
|
96
96
|
def encode_parameters(self, parameters: dict) -> bytes:
|
|
97
97
|
# V2 endpoints decode Ds_MerchantParameters as unpadded Base64URL: the 3DS
|
|
98
98
|
# initialization rejects standard or padded Base64 with SIS0430 (verified
|
|
99
|
-
#
|
|
99
|
+
# against the test environment).
|
|
100
100
|
return urlsafe_b64encode(dumps(parameters, sort_keys=True).encode('utf-8')).rstrip(b'=')
|
|
101
101
|
|
|
102
102
|
def sign(self, order: str, merchant_parameters: bytes) -> bytes:
|
|
103
103
|
# Same derived key and HMAC as HMAC_SHA512_V1; only the '=' padding goes
|
|
104
|
-
# (
|
|
104
|
+
# (verified against the test environment).
|
|
105
105
|
return super().sign(order, merchant_parameters).rstrip(b'=')
|
|
106
106
|
|
|
107
107
|
|
redsys/exceptions.py
CHANGED
|
@@ -8,11 +8,14 @@ class ResponseError(RedsysError):
|
|
|
8
8
|
def __init__(self, error_code, payload=None):
|
|
9
9
|
self.error_code = error_code
|
|
10
10
|
self.payload = payload
|
|
11
|
+
self.http_status = None
|
|
12
|
+
self.http_headers = None
|
|
13
|
+
self.raw_body = None
|
|
11
14
|
super().__init__(f'RedSys returned error: {error_code}')
|
|
12
15
|
|
|
13
16
|
|
|
14
|
-
class ValidationError(RedsysError):
|
|
15
|
-
"""A
|
|
17
|
+
class ValidationError(RedsysError, ValueError):
|
|
18
|
+
"""A client option or an operation's fields contradict each other or what RedSys accepts."""
|
|
16
19
|
|
|
17
20
|
|
|
18
21
|
class UnknownParameterWarning(UserWarning):
|