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.
redsys/clients.py CHANGED
@@ -1,24 +1,25 @@
1
- """Client entry points for the RedSys platform.
1
+ """Clients for the RedSys redirect and direct connections.
2
2
 
3
- The client is the single entry point: it holds the credentials and account
4
- identity (secret key, merchant code, terminal, signature version) and acts as
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='...', terminal=1, sandbox=True)
10
- response = client.payment(order='000000001', amount=Decimal('10.56'), currency=978).send()
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
- Redirect requests end in ``.to_dict()`` (the browser submits them); direct
13
- requests end in ``.send()`` (the library performs the REST call). Both verify
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.api import transactions
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
- # Default HTTP timeout (seconds) for direct-connection network calls; override per call.
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
- #: transaction_type -> request model. Unmapped types fall back to _DEFAULT_MODEL.
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
- sandbox: bool = False,
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.sandbox = sandbox
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.TEST_ENDPOINT if sandbox else self.REAL_ENDPOINT
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
- # -- request building -------------------------------------------------
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 identity into a call's fields (call wins)."""
111
- data: dict = {}
112
- if self.merchant_code is not None:
113
- data['merchant_code'] = self.merchant_code
114
- if self.terminal is not None:
115
- data['terminal'] = self.terminal
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
- The single funnel every named operation delegates to. Use it directly
129
- for transaction types without a dedicated method (e.g. ``PAY_GOLD``).
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
- data.setdefault('transaction_type', transaction_type)
133
- model = self._model_for(transaction_type)(**data)
134
- return self._prepare(model)
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(self, model, signature_version: str | None = None, sender=None) -> 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
- # -- response ---------------------------------------------------------
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; '' can never match, so a
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 (secure method): builds the signed POST parameters the
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
- REAL_ENDPOINT = 'https://sis.redsys.es/sis/realizarPago'
178
- TEST_ENDPOINT = 'https://sis-t.redsys.es:25443/sis/realizarPago'
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.request(transactions.AUTHORIZATION, **fields)
285
+ return self._operation(transactions.AUTHORIZATION, fields)
184
286
 
185
287
  def preauthorization(self, **fields) -> Request:
186
- return self.request(transactions.PREAUTHORIZATION, **fields)
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
- """Shared REST direct-connection logic (transport-agnostic)."""
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
- REAL_ENDPOINT = 'https://sis.redsys.es/sis/rest/trataPeticionREST'
202
- TEST_ENDPOINT = 'https://sis-t.redsys.es:25443/sis/rest/trataPeticionREST'
203
- INICIA_REAL_ENDPOINT = 'https://sis.redsys.es/sis/rest/iniciaPeticionREST'
204
- INICIA_TEST_ENDPOINT = 'https://sis-t.redsys.es:25443/sis/rest/iniciaPeticionREST'
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.CANCELLATION: CancellationMessage,
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.INICIA_TEST_ENDPOINT if self.sandbox else self.INICIA_REAL_ENDPOINT
335
+ return self.url(environments.INICIA_PATH)
217
336
 
218
337
  def payment(self, **fields) -> Request:
219
- return self.request(transactions.AUTHORIZATION, **fields)
338
+ return self._operation(transactions.AUTHORIZATION, fields)
220
339
 
221
340
  def preauthorization(self, **fields) -> Request:
222
- return self.request(transactions.PREAUTHORIZATION, **fields)
341
+ return self._operation(transactions.PREAUTHORIZATION, fields)
223
342
 
224
343
  def confirmation(self, **fields) -> Request:
225
- return self.request(transactions.CONFIRMATION, **fields)
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.request(transactions.REFUND, **fields)
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
- return self.request(transactions.CANCELLATION, **fields)
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 step 1: build a request bound to the iniciaPeticionREST endpoint."""
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
- endpoint = endpoint or self.endpoint
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 _make_sender(self, endpoint):
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
- def _make_sender(self, endpoint):
250
- def sender(request, **httpx_kwargs):
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
- return sender
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 (httpx.AsyncClient). Requires the httpx extra."""
262
-
263
- def _make_sender(self, endpoint):
264
- async def sender(request, **httpx_kwargs):
265
- httpx = _require_httpx()
266
- httpx_kwargs.setdefault('timeout', DEFAULT_TIMEOUT)
267
- async with httpx.AsyncClient() as client:
268
- http_response = await client.post(endpoint, json=request.to_dict(), **httpx_kwargs)
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 (RedSys PHP library 2.0.1, Signature.php);
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 and RedSys's own libraries use; HMAC_SHA256_V1 is
22
- still accepted (REST guide 4.0.1, Anexo 2). The cipher parameters are pinned by a
23
- known-answer test against RedSys's official worked example (see tests/test_crypto.py).
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 live against the RedSys test environment; raw bytes -> SIS0042).
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
- # live 2026-10-25), and the RedSys PHP library sends it this way.
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
- # (RedSys PHP library 2.0.1, base64_url_encode_safe).
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 parameter failed validation."""
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):