python-redsys 2.0.0__tar.gz → 2.1.0__tar.gz

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.
Files changed (32) hide show
  1. {python_redsys-2.0.0/python_redsys.egg-info → python_redsys-2.1.0}/PKG-INFO +13 -2
  2. {python_redsys-2.0.0 → python_redsys-2.1.0}/README.md +12 -1
  3. {python_redsys-2.0.0 → python_redsys-2.1.0}/pyproject.toml +6 -2
  4. {python_redsys-2.0.0 → python_redsys-2.1.0/python_redsys.egg-info}/PKG-INFO +13 -2
  5. python_redsys-2.1.0/redsys/api/transactions.py +74 -0
  6. {python_redsys-2.0.0 → python_redsys-2.1.0}/redsys/clients.py +5 -0
  7. {python_redsys-2.0.0 → python_redsys-2.1.0}/redsys/models.py +32 -14
  8. {python_redsys-2.0.0 → python_redsys-2.1.0}/tests/test_direct.py +23 -0
  9. {python_redsys-2.0.0 → python_redsys-2.1.0}/tests/test_live.py +31 -6
  10. {python_redsys-2.0.0 → python_redsys-2.1.0}/tests/test_models.py +26 -2
  11. python_redsys-2.0.0/redsys/api/transactions.py +0 -46
  12. {python_redsys-2.0.0 → python_redsys-2.1.0}/LICENSE +0 -0
  13. {python_redsys-2.0.0 → python_redsys-2.1.0}/NOTICE +0 -0
  14. {python_redsys-2.0.0 → python_redsys-2.1.0}/python_redsys.egg-info/SOURCES.txt +0 -0
  15. {python_redsys-2.0.0 → python_redsys-2.1.0}/python_redsys.egg-info/dependency_links.txt +0 -0
  16. {python_redsys-2.0.0 → python_redsys-2.1.0}/python_redsys.egg-info/requires.txt +0 -0
  17. {python_redsys-2.0.0 → python_redsys-2.1.0}/python_redsys.egg-info/top_level.txt +0 -0
  18. {python_redsys-2.0.0 → python_redsys-2.1.0}/redsys/__init__.py +0 -0
  19. {python_redsys-2.0.0 → python_redsys-2.1.0}/redsys/api/__init__.py +0 -0
  20. {python_redsys-2.0.0 → python_redsys-2.1.0}/redsys/api/cof.py +0 -0
  21. {python_redsys-2.0.0 → python_redsys-2.1.0}/redsys/api/currencies.py +0 -0
  22. {python_redsys-2.0.0 → python_redsys-2.1.0}/redsys/api/languages.py +0 -0
  23. {python_redsys-2.0.0 → python_redsys-2.1.0}/redsys/api/sca.py +0 -0
  24. {python_redsys-2.0.0 → python_redsys-2.1.0}/redsys/crypto.py +0 -0
  25. {python_redsys-2.0.0 → python_redsys-2.1.0}/redsys/exceptions.py +0 -0
  26. {python_redsys-2.0.0 → python_redsys-2.1.0}/redsys/py.typed +0 -0
  27. {python_redsys-2.0.0 → python_redsys-2.1.0}/redsys/request.py +0 -0
  28. {python_redsys-2.0.0 → python_redsys-2.1.0}/redsys/threeds.py +0 -0
  29. {python_redsys-2.0.0 → python_redsys-2.1.0}/setup.cfg +0 -0
  30. {python_redsys-2.0.0 → python_redsys-2.1.0}/tests/test_clients.py +0 -0
  31. {python_redsys-2.0.0 → python_redsys-2.1.0}/tests/test_crypto.py +0 -0
  32. {python_redsys-2.0.0 → python_redsys-2.1.0}/tests/test_threeds.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-redsys
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: A simple, lightweight client to handle payments through the RedSys platform.
5
5
  Author-email: David Díaz <d.diazp@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -48,6 +48,17 @@ pip install "python-redsys[http]"
48
48
 
49
49
  Requires Python 3.10+. Runtime dependencies: `cryptography` and `pydantic` (v2).
50
50
 
51
+ ## Status
52
+
53
+ | | |
54
+ |---|---|
55
+ | Version | 2.1.0; 1.x receives fixes until 2026-12-31 |
56
+ | Python | 3.10 to 3.15 |
57
+ | RedSys documents followed | Redirection guide 4.2 (2026-02-04), REST guide 4.0.1 (2025-10-17), Bizum guide 2.6 (2026-08-25) |
58
+ | Signature versions | `HMAC_SHA512_V2` (default), `HMAC_SHA512_V1`, `HMAC_SHA256_V1` |
59
+ | Verified against the RedSys test environment | Redirect connection in a browser, with the 3DS challenge, for all three signature versions (2026-10-25); direct connection end to end (3DS initialization, challenged payment, refund, stored-card token charged as a merchant-initiated payment) on demand by the *Live* workflow |
60
+ | Not yet verified live | Frictionless 3DS and the 3DSMethod step |
61
+
51
62
  ## Upgrading from 1.x
52
63
 
53
64
  2.0 has a new API. The [migration guide](docs/migration-1x-to-2x.md) lists every change; 1.x receives fixes until 2026-12-31.
@@ -194,7 +205,7 @@ client.payment(
194
205
  ).send()
195
206
  ```
196
207
 
197
- `redsys.cof` provides the indicator constants (`INITIAL`/`SUBSEQUENT`, `RECURRING`/`INSTALLMENT`/...) and the `REQUEST_TOKEN` sentinel. The periodic fields (`sum_total`, `date_frequency`, `charge_expiry_date`) are also accepted by `DirectClient.payment()`. `redsys.sca` holds the PSD2 exemption codes for `excep_sca` (`LWV`, `TRA`, `MIT`, `COR`, `ATD`); `sca.QUERY` on `inicia()` asks which exemptions the card allows, returned in `response.excep_sca`.
208
+ `redsys.cof` provides the indicator constants (`INITIAL`/`SUBSEQUENT`, `RECURRING`/`INSTALLMENT`/...) and the `REQUEST_TOKEN` sentinel. The token and COF fields are also accepted by `RedirectClient.payment()`, so the first payment can run on the RedSys page. The periodic fields (`sum_total`, `date_frequency`, `charge_expiry_date`) are accepted by `DirectClient.payment()`. `redsys.sca` holds the PSD2 exemption codes for `excep_sca` (`LWV`, `TRA`, `MIT`, `COR`, `ATD`); `sca.QUERY` on `inicia()` asks which exemptions the card allows, returned in `response.excep_sca`.
198
209
 
199
210
  ## Signature versions
200
211
 
@@ -19,6 +19,17 @@ pip install "python-redsys[http]"
19
19
 
20
20
  Requires Python 3.10+. Runtime dependencies: `cryptography` and `pydantic` (v2).
21
21
 
22
+ ## Status
23
+
24
+ | | |
25
+ |---|---|
26
+ | Version | 2.1.0; 1.x receives fixes until 2026-12-31 |
27
+ | Python | 3.10 to 3.15 |
28
+ | RedSys documents followed | Redirection guide 4.2 (2026-02-04), REST guide 4.0.1 (2025-10-17), Bizum guide 2.6 (2026-08-25) |
29
+ | Signature versions | `HMAC_SHA512_V2` (default), `HMAC_SHA512_V1`, `HMAC_SHA256_V1` |
30
+ | Verified against the RedSys test environment | Redirect connection in a browser, with the 3DS challenge, for all three signature versions (2026-10-25); direct connection end to end (3DS initialization, challenged payment, refund, stored-card token charged as a merchant-initiated payment) on demand by the *Live* workflow |
31
+ | Not yet verified live | Frictionless 3DS and the 3DSMethod step |
32
+
22
33
  ## Upgrading from 1.x
23
34
 
24
35
  2.0 has a new API. The [migration guide](docs/migration-1x-to-2x.md) lists every change; 1.x receives fixes until 2026-12-31.
@@ -165,7 +176,7 @@ client.payment(
165
176
  ).send()
166
177
  ```
167
178
 
168
- `redsys.cof` provides the indicator constants (`INITIAL`/`SUBSEQUENT`, `RECURRING`/`INSTALLMENT`/...) and the `REQUEST_TOKEN` sentinel. The periodic fields (`sum_total`, `date_frequency`, `charge_expiry_date`) are also accepted by `DirectClient.payment()`. `redsys.sca` holds the PSD2 exemption codes for `excep_sca` (`LWV`, `TRA`, `MIT`, `COR`, `ATD`); `sca.QUERY` on `inicia()` asks which exemptions the card allows, returned in `response.excep_sca`.
179
+ `redsys.cof` provides the indicator constants (`INITIAL`/`SUBSEQUENT`, `RECURRING`/`INSTALLMENT`/...) and the `REQUEST_TOKEN` sentinel. The token and COF fields are also accepted by `RedirectClient.payment()`, so the first payment can run on the RedSys page. The periodic fields (`sum_total`, `date_frequency`, `charge_expiry_date`) are accepted by `DirectClient.payment()`. `redsys.sca` holds the PSD2 exemption codes for `excep_sca` (`LWV`, `TRA`, `MIT`, `COR`, `ATD`); `sca.QUERY` on `inicia()` asks which exemptions the card allows, returned in `response.excep_sca`.
169
180
 
170
181
  ## Signature versions
171
182
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-redsys"
7
- version = "2.0.0"
7
+ version = "2.1.0"
8
8
  description = "A simple, lightweight client to handle payments through the RedSys platform."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -40,7 +40,8 @@ lint = ["ruff", "mypy"]
40
40
  dev = [
41
41
  { include-group = "test" },
42
42
  { include-group = "lint" },
43
- "pre-commit",
43
+ # filelock releases with the 2026 security fixes need Python 3.11; pre-commit pulls it in.
44
+ "pre-commit; python_version >= '3.11'",
44
45
  ]
45
46
 
46
47
  [tool.setuptools.packages.find]
@@ -60,6 +61,9 @@ select = ["E", "F", "I", "B", "UP"]
60
61
  [tool.ruff.format]
61
62
  quote-style = "single"
62
63
 
64
+ [tool.coverage.report]
65
+ fail_under = 95
66
+
63
67
  [tool.mypy]
64
68
  python_version = "3.10"
65
69
  ignore_missing_imports = true
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-redsys
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: A simple, lightweight client to handle payments through the RedSys platform.
5
5
  Author-email: David Díaz <d.diazp@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -48,6 +48,17 @@ pip install "python-redsys[http]"
48
48
 
49
49
  Requires Python 3.10+. Runtime dependencies: `cryptography` and `pydantic` (v2).
50
50
 
51
+ ## Status
52
+
53
+ | | |
54
+ |---|---|
55
+ | Version | 2.1.0; 1.x receives fixes until 2026-12-31 |
56
+ | Python | 3.10 to 3.15 |
57
+ | RedSys documents followed | Redirection guide 4.2 (2026-02-04), REST guide 4.0.1 (2025-10-17), Bizum guide 2.6 (2026-08-25) |
58
+ | Signature versions | `HMAC_SHA512_V2` (default), `HMAC_SHA512_V1`, `HMAC_SHA256_V1` |
59
+ | Verified against the RedSys test environment | Redirect connection in a browser, with the 3DS challenge, for all three signature versions (2026-10-25); direct connection end to end (3DS initialization, challenged payment, refund, stored-card token charged as a merchant-initiated payment) on demand by the *Live* workflow |
60
+ | Not yet verified live | Frictionless 3DS and the 3DSMethod step |
61
+
51
62
  ## Upgrading from 1.x
52
63
 
53
64
  2.0 has a new API. The [migration guide](docs/migration-1x-to-2x.md) lists every change; 1.x receives fixes until 2026-12-31.
@@ -194,7 +205,7 @@ client.payment(
194
205
  ).send()
195
206
  ```
196
207
 
197
- `redsys.cof` provides the indicator constants (`INITIAL`/`SUBSEQUENT`, `RECURRING`/`INSTALLMENT`/...) and the `REQUEST_TOKEN` sentinel. The periodic fields (`sum_total`, `date_frequency`, `charge_expiry_date`) are also accepted by `DirectClient.payment()`. `redsys.sca` holds the PSD2 exemption codes for `excep_sca` (`LWV`, `TRA`, `MIT`, `COR`, `ATD`); `sca.QUERY` on `inicia()` asks which exemptions the card allows, returned in `response.excep_sca`.
208
+ `redsys.cof` provides the indicator constants (`INITIAL`/`SUBSEQUENT`, `RECURRING`/`INSTALLMENT`/...) and the `REQUEST_TOKEN` sentinel. The token and COF fields are also accepted by `RedirectClient.payment()`, so the first payment can run on the RedSys page. The periodic fields (`sum_total`, `date_frequency`, `charge_expiry_date`) are accepted by `DirectClient.payment()`. `redsys.sca` holds the PSD2 exemption codes for `excep_sca` (`LWV`, `TRA`, `MIT`, `COR`, `ATD`); `sca.QUERY` on `inicia()` asks which exemptions the card allows, returned in `response.excep_sca`.
198
209
 
199
210
  ## Signature versions
200
211
 
@@ -0,0 +1,74 @@
1
+ AUTHORIZATION = 0
2
+ """Authorization."""
3
+
4
+ PREAUTHORIZATION = 1
5
+ """Pre-authorization."""
6
+
7
+ CONFIRMATION = 2
8
+ """Confirmation."""
9
+
10
+ REFUND = 3
11
+ """Refund."""
12
+
13
+ AUTHENTICATION = 7
14
+ """Authentication."""
15
+
16
+ AUTHENTICATION_CONFIRMATION = 8
17
+ """Authentication confirmation"""
18
+
19
+ CANCELLATION = 9
20
+ """Cancellation"""
21
+
22
+ REPLACEMENT_PREAUTHORIZATION = 11
23
+ """Replacement preauthorization: extends an existing preauthorization."""
24
+
25
+ PAY_GOLD = 15
26
+ """Pay gold"""
27
+
28
+ AUTHENTICATION_PUCE = 17
29
+ """PUCE authentication"""
30
+
31
+ REFUND_WITHOUT_ORIGINAL = 34
32
+ """Refund without original"""
33
+
34
+ BETTING_PRICE = 37
35
+ """Betting price"""
36
+
37
+ PAY_GOLD_AUTHORIZATION = 38
38
+ """PayGold authorization: the cardholder completes a PayGold request (type 15); sent by RedSys, not the merchant."""
39
+
40
+ DELETE_REFERENCE = 44
41
+ """Delete a stored card reference (token)."""
42
+
43
+ PAYMENT_CANCELLATION = 45
44
+ """Payment cancellation, for merchant time-outs or technical errors."""
45
+
46
+ REFUND_CANCELLATION = 46
47
+ """Refund cancellation, within 48 hours of the refund."""
48
+
49
+ AUTHENTICATION_CONFIRMATION_CANCELLATION = 47
50
+ """Cancellation of an authentication confirmation (type 8)."""
51
+
52
+ PARTIAL_CONFIRMATION = 48
53
+ """Partial confirmation of a preauthorization."""
54
+
55
+ TRANSACTION_TYPES = (
56
+ AUTHORIZATION,
57
+ PREAUTHORIZATION,
58
+ CONFIRMATION,
59
+ REFUND,
60
+ AUTHENTICATION,
61
+ AUTHENTICATION_CONFIRMATION,
62
+ CANCELLATION,
63
+ REPLACEMENT_PREAUTHORIZATION,
64
+ PAY_GOLD,
65
+ AUTHENTICATION_PUCE,
66
+ REFUND_WITHOUT_ORIGINAL,
67
+ BETTING_PRICE,
68
+ PAY_GOLD_AUTHORIZATION,
69
+ DELETE_REFERENCE,
70
+ PAYMENT_CANCELLATION,
71
+ REFUND_CANCELLATION,
72
+ AUTHENTICATION_CONFIRMATION_CANCELLATION,
73
+ PARTIAL_CONFIRMATION,
74
+ )
@@ -209,6 +209,11 @@ class _DirectBase(Client):
209
209
  transactions.CONFIRMATION: ConfirmationMessage,
210
210
  transactions.REFUND: RefundMessage,
211
211
  transactions.CANCELLATION: CancellationMessage,
212
+ transactions.REPLACEMENT_PREAUTHORIZATION: DirectPreauthorizationMessage,
213
+ transactions.PARTIAL_CONFIRMATION: ConfirmationMessage,
214
+ transactions.PAYMENT_CANCELLATION: CancellationMessage,
215
+ transactions.REFUND_CANCELLATION: CancellationMessage,
216
+ transactions.AUTHENTICATION_CONFIRMATION_CANCELLATION: CancellationMessage,
212
217
  }
213
218
 
214
219
  @property
@@ -20,7 +20,7 @@ from json import dumps, loads
20
20
  from typing import Any
21
21
  from warnings import warn
22
22
 
23
- from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator, model_validator
23
+ from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_serializer, field_validator, model_validator
24
24
 
25
25
  from redsys.api.currencies import CURRENCIES, CURRENCY_DECIMALS
26
26
  from redsys.api.transactions import TRANSACTION_TYPES
@@ -43,10 +43,14 @@ def decode_merchant_parameters(data: bytes) -> dict:
43
43
  return loads(b64decode(data + b'=' * (-len(data) % 4)).decode('utf-8'))
44
44
 
45
45
 
46
+ def currency_decimals(currency: int | None) -> int:
47
+ """ISO-4217 minor-unit exponent of a currency; 2 when unknown."""
48
+ return CURRENCY_DECIMALS.get(currency, 2) if currency is not None else 2
49
+
50
+
46
51
  def minor_units_factor(currency: int | None) -> int:
47
52
  """Power-of-ten factor that converts major units to the wire minor units."""
48
- decimals = CURRENCY_DECIMALS.get(currency, 2) if currency is not None else 2
49
- return 10**decimals
53
+ return 10 ** currency_decimals(currency)
50
54
 
51
55
 
52
56
  class BaseRequest(BaseModel):
@@ -117,7 +121,19 @@ class BaseRequest(BaseModel):
117
121
  return b64encode(dumps(self.prepare_parameters(), sort_keys=True).encode('utf-8'))
118
122
 
119
123
 
120
- class _RedirectFields(BaseModel):
124
+ class _StoredCredentialFields(BaseModel):
125
+ """Tokenization and credential-on-file fields, accepted by both connections."""
126
+
127
+ identifier: str | None = Field(
128
+ None, alias='Ds_Merchant_Identifier', max_length=40, description='Stored-card token; see redsys.api.cof.'
129
+ )
130
+ direct_payment: str | None = Field(None, alias='Ds_Merchant_DirectPayment', description='"true" or "moto".')
131
+ cof_ini: str | None = Field(None, alias='Ds_Merchant_Cof_Ini', max_length=1, description='See redsys.api.cof.')
132
+ cof_type: str | None = Field(None, alias='Ds_Merchant_Cof_Type', max_length=1, description='See redsys.api.cof.')
133
+ cof_txnid: str | None = Field(None, alias='Ds_Merchant_Cof_Txnid', max_length=15)
134
+
135
+
136
+ class _RedirectFields(_StoredCredentialFields):
121
137
  """Fields of the redirect connection (browser form)."""
122
138
 
123
139
  merchant_url: str | None = Field(None, alias='Ds_Merchant_MerchantURL', max_length=250)
@@ -137,24 +153,20 @@ class _RedirectFields(BaseModel):
137
153
  )
138
154
 
139
155
 
140
- class _DirectFields(BaseModel):
156
+ class _DirectFields(_StoredCredentialFields):
141
157
  """Fields of the direct connection (server-to-server card data, 3DS, stored credentials)."""
142
158
 
143
159
  pan: str | None = Field(None, alias='Ds_Merchant_Pan', max_length=19, pattern=r'^[0-9]{12,19}$')
144
160
  expiry_date: str | None = Field(None, alias='Ds_Merchant_ExpiryDate', pattern=r'^[0-9]{4}$', description='YYMM.')
145
161
  cvv2: str | None = Field(None, alias='Ds_Merchant_Cvv2', pattern=r'^[0-9]{3,4}$')
146
- identifier: str | None = Field(
147
- None, alias='Ds_Merchant_Identifier', max_length=40, description='Stored-card token; see redsys.api.cof.'
148
- )
149
162
  idoper: str | None = Field(None, alias='Ds_Merchant_Idoper', max_length=40, description='inSite operation token.')
150
- direct_payment: str | None = Field(None, alias='Ds_Merchant_DirectPayment', description='"true" or "moto".')
151
163
  emv3ds: dict[str, Any] | None = Field(None, alias='Ds_Merchant_EMV3DS', description='Build with redsys.threeds.')
152
- cof_ini: str | None = Field(None, alias='Ds_Merchant_Cof_Ini', max_length=1, description='See redsys.api.cof.')
153
- cof_type: str | None = Field(None, alias='Ds_Merchant_Cof_Type', max_length=1, description='See redsys.api.cof.')
154
- cof_txnid: str | None = Field(None, alias='Ds_Merchant_Cof_Txnid', max_length=15)
155
164
  # RedSys spells the wire name "Frecuency".
156
165
  date_frequency: str | None = Field(None, alias='Ds_Merchant_DateFrecuency', description='Days between charges.')
157
166
  charge_expiry_date: str | None = Field(None, alias='Ds_Merchant_ChargeExpiryDate', description='yyyy-MM-dd.')
167
+ dcc: str | dict[str, Any] | None = Field(
168
+ None, alias='Ds_Merchant_DCC', description='"Y" on the 3DS initialization; then the accepted offer.'
169
+ )
158
170
  excep_sca: str | None = Field(
159
171
  None,
160
172
  alias='Ds_Merchant_Excep_SCA',
@@ -243,13 +255,17 @@ class Response(BaseModel):
243
255
  secure_payment: str | None = Field(None, alias='Ds_SecurePayment')
244
256
  authorization_code: str | None = Field(None, alias='Ds_AuthorisationCode')
245
257
  error_code: str | None = Field(None, alias='Ds_ErrorCode')
246
- card_number: str | None = Field(None, alias='Ds_CardNumber')
258
+ # REST responses send Ds_CardNumber; redirect notifications send Ds_Card_Number.
259
+ card_number: str | None = Field(
260
+ None, alias='Ds_CardNumber', validation_alias=AliasChoices('Ds_CardNumber', 'Ds_Card_Number', 'card_number')
261
+ )
247
262
  expiry_date: str | None = Field(None, alias='Ds_ExpiryDate')
248
263
  card_psd2: str | None = Field(None, alias='Ds_Card_PSD2')
249
264
  excep_sca: str | None = Field(
250
265
  None, alias='Ds_Excep_SCA', description='Exemptions the card allows, from the 3DS initialization.'
251
266
  )
252
267
  emv3ds: dict[str, Any] | None = Field(None, alias='Ds_EMV3DS')
268
+ dcc: dict[str, Any] | None = Field(None, alias='Ds_DCC', description='Offer from the 3DS initialization.')
253
269
  identifier: str | None = Field(None, alias='Ds_Merchant_Identifier', description='Token returned on request.')
254
270
  cof_txnid: str | None = Field(None, alias='Ds_Merchant_Cof_Txnid')
255
271
  processed_pay_method: str | None = Field(None, alias='Ds_ProcessedPayMethod', description='68 = Bizum.')
@@ -269,7 +285,8 @@ class Response(BaseModel):
269
285
  def from_merchant_parameters(cls, merchant_parameters: bytes) -> 'Response':
270
286
  response = cls.model_validate(decode_merchant_parameters(merchant_parameters))
271
287
  if response.amount is not None:
272
- response.amount = response.amount / minor_units_factor(response.currency)
288
+ # scaleb keeps the currency's decimals: 100 EUR minor units read as 1.00, not 1.
289
+ response.amount = response.amount.scaleb(-currency_decimals(response.currency))
273
290
  return response
274
291
 
275
292
  def get_order(self) -> str | None:
@@ -336,5 +353,6 @@ __all__ = [
336
353
  'CancellationMessage',
337
354
  'Response',
338
355
  'decode_merchant_parameters',
356
+ 'currency_decimals',
339
357
  'minor_units_factor',
340
358
  ]
@@ -35,6 +35,12 @@ def _client(cls=DirectClient, **overrides):
35
35
  return cls(SECRET, **params)
36
36
 
37
37
 
38
+ def _decoded_params(request):
39
+ from redsys.models import decode_merchant_parameters
40
+
41
+ return decode_merchant_parameters(request.merchant_parameters)
42
+
43
+
38
44
  def _payment(client):
39
45
  return client.payment(
40
46
  order=ORDER,
@@ -169,3 +175,20 @@ def test_async_inicia_posts_and_parses():
169
175
  assert response.is_paid()
170
176
  assert captured['url'] == 'https://sis-t.redsys.es:25443/sis/rest/iniciaPeticionREST'
171
177
  assert set(captured['json']) == {SIGNATURE_VERSION, MERCHANT_PARAMETERS, SIGNATURE}
178
+
179
+
180
+ def test_extra_transaction_types_build_on_their_message():
181
+ from redsys import transactions
182
+
183
+ client = DirectClient('sq7HjrUOBfKmC576ILgskD5srU870gJ7', merchant_code='999008881', terminal=1, sandbox=True)
184
+ for code in (11, 44, 45, 46, 47, 48):
185
+ assert code in transactions.TRANSACTION_TYPES
186
+ request = client.request(code, order='000000000001', amount=Decimal('1.00'), currency=978)
187
+ assert _decoded_params(request)['Ds_Merchant_TransactionType'] == code
188
+
189
+
190
+ def test_dcc_passes_through_request_and_response():
191
+ client = DirectClient('sq7HjrUOBfKmC576ILgskD5srU870gJ7', merchant_code='999008881', terminal=1, sandbox=True)
192
+ offer = {'monedaDCC': '840', 'importeDCC': '117'}
193
+ request = client.payment(order='000000000001', amount=Decimal('1.00'), currency=978, dcc=offer)
194
+ assert _decoded_params(request)['Ds_Merchant_DCC'] == offer
@@ -16,7 +16,7 @@ import pytest
16
16
  pytestmark = pytest.mark.skipif(os.environ.get('REDSYS_LIVE') != '1', reason='set REDSYS_LIVE=1 to run')
17
17
  httpx = pytest.importorskip('httpx')
18
18
 
19
- from redsys import currencies, threeds # noqa: E402
19
+ from redsys import cof, currencies, sca, threeds # noqa: E402
20
20
  from redsys.clients import HMAC_SHA256_V1, HMAC_SHA512_V1, HMAC_SHA512_V2, DirectClient # noqa: E402
21
21
 
22
22
  SECRET = 'sq7HjrUOBfKmC576ILgskD5srU870gJ7'
@@ -68,9 +68,8 @@ def test_live_payment_without_authentication_requires_sca(version):
68
68
  assert response.response_code == 195
69
69
 
70
70
 
71
- @pytest.mark.parametrize('version', VERSIONS)
72
- def test_live_challenged_payment_then_refund(version):
73
- client, operation = _client(version), _operation()
71
+ def _challenged_payment(client, operation, **fields):
72
+ """Initialization, authentication with the simulator's challenge, and the final authorization."""
74
73
  card_config = client.inicia(emv3ds=threeds.card_data(), **operation, **CARD).send()
75
74
  authentication = threeds.authentication_data(
76
75
  three_ds_server_trans_id=card_config.three_ds_server_trans_id,
@@ -79,13 +78,39 @@ def test_live_challenged_payment_then_refund(version):
79
78
  browser=BROWSER,
80
79
  three_ds_comp_ind='N',
81
80
  )
82
- challenge = client.payment(emv3ds=authentication, **operation, **CARD).send()
81
+ challenge = client.payment(emv3ds=authentication, **operation, **CARD, **fields).send()
83
82
  assert challenge.requires_challenge()
84
83
 
85
84
  cres = _answer_simulated_challenge(challenge)
86
85
  final = threeds.challenge_response(cres=cres, protocol_version=challenge.protocol_version)
87
- payment = client.payment(emv3ds=final, **operation, **CARD).send()
86
+ return client.payment(emv3ds=final, **operation, **CARD, **fields).send()
87
+
88
+
89
+ @pytest.mark.parametrize('version', VERSIONS)
90
+ def test_live_challenged_payment_then_refund(version):
91
+ client, operation = _client(version), _operation()
92
+ payment = _challenged_payment(client, operation)
88
93
  assert payment.is_paid() and payment.authorization_code
89
94
 
90
95
  refund = client.refund(**operation).send()
91
96
  assert refund.is_refunded()
97
+
98
+
99
+ @pytest.mark.parametrize('version', VERSIONS)
100
+ def test_live_token_then_merchant_initiated_charge(version):
101
+ client = _client(version)
102
+ first = _challenged_payment(
103
+ client, _operation(), identifier=cof.REQUEST_TOKEN, cof_ini=cof.INITIAL, cof_type=cof.RECURRING
104
+ )
105
+ assert first.is_paid() and first.identifier and first.cof_txnid
106
+
107
+ charge = client.payment(
108
+ identifier=first.identifier,
109
+ cof_ini=cof.SUBSEQUENT,
110
+ cof_type=cof.RECURRING,
111
+ cof_txnid=first.cof_txnid,
112
+ excep_sca=sca.MIT,
113
+ direct_payment='true',
114
+ **_operation(),
115
+ ).send()
116
+ assert charge.is_paid()
@@ -158,9 +158,9 @@ def test_response_predicates_and_code():
158
158
 
159
159
 
160
160
  def test_response_keeps_unmapped_fields():
161
- resp = _response(Ds_Response='0000', Ds_Order='1', Ds_Amount='100', Ds_Card_Number='454881******0003')
161
+ resp = _response(Ds_Response='0000', Ds_Order='1', Ds_Amount='100', Ds_Control_1790968949956='1790968949956')
162
162
  assert resp.is_paid()
163
- assert resp.model_extra == {'Ds_Card_Number': '454881******0003'}
163
+ assert resp.model_extra == {'Ds_Control_1790968949956': '1790968949956'}
164
164
 
165
165
 
166
166
  def test_response_amount_euro_and_processed_pay_method():
@@ -195,6 +195,9 @@ def test_request_amount_scaled_by_currency_exponent():
195
195
  def test_response_amount_scaled_by_currency_exponent():
196
196
  assert _response(Ds_Response='0000', Ds_Order='1', Ds_Amount='1056', Ds_Currency='978').amount == Decimal('10.56')
197
197
  assert _response(Ds_Response='0000', Ds_Order='1', Ds_Amount='1000', Ds_Currency='392').amount == Decimal('1000')
198
+ # The currency's decimals are kept, as RedSys shows them: 100 EUR minor units are 1.00.
199
+ assert str(_response(Ds_Response='0000', Ds_Order='1', Ds_Amount='100', Ds_Currency='978').amount) == '1.00'
200
+ assert str(_response(Ds_Response='0000', Ds_Order='1', Ds_Amount='100', Ds_Currency='392').amount) == '100'
198
201
 
199
202
 
200
203
  def test_response_is_canceled():
@@ -333,3 +336,24 @@ def test_response_bizum_fields():
333
336
  )
334
337
  assert (resp.processed_pay_method, resp.bizum_id_oper, resp.bizum_cuenta_truncada) == ('68', '9382755', 'XXXX4000')
335
338
  assert resp.model_extra == {}
339
+
340
+
341
+ def test_response_card_number_from_redirect_and_rest_names():
342
+ # Redirect notifications send Ds_Card_Number (seen live 2026-10-25); REST responses send Ds_CardNumber.
343
+ redirect = _response(Ds_Response='0000', Ds_Order='1', Ds_Card_Number='454881******0003')
344
+ rest = _response(Ds_Response='0000', Ds_Order='1', Ds_CardNumber='454881******0003')
345
+ assert redirect.card_number == rest.card_number == '454881******0003'
346
+ assert redirect.model_extra == {}
347
+
348
+
349
+ def test_response_dcc_object():
350
+ resp = _response(Ds_Order='1', Ds_DCC={'InfoMonedaTarjeta': {'monedaDCC': '840'}})
351
+ assert resp.dcc == {'InfoMonedaTarjeta': {'monedaDCC': '840'}}
352
+ assert resp.model_extra == {}
353
+
354
+
355
+ def test_redirect_accepts_stored_credential_fields():
356
+ params = _valid(identifier='REQUIRED', cof_ini='S', cof_type='R', direct_payment='true').prepare_parameters()
357
+ assert params['Ds_Merchant_Identifier'] == 'REQUIRED'
358
+ assert (params['Ds_Merchant_Cof_Ini'], params['Ds_Merchant_Cof_Type']) == ('S', 'R')
359
+ assert params['Ds_Merchant_DirectPayment'] == 'true'
@@ -1,46 +0,0 @@
1
- AUTHORIZATION = 0
2
- """Authorization."""
3
-
4
- PREAUTHORIZATION = 1
5
- """Pre-authorization."""
6
-
7
- CONFIRMATION = 2
8
- """Confirmation."""
9
-
10
- REFUND = 3
11
- """Refund."""
12
-
13
- AUTHENTICATION = 7
14
- """Authentication."""
15
-
16
- AUTHENTICATION_CONFIRMATION = 8
17
- """Authentication confirmation"""
18
-
19
- CANCELLATION = 9
20
- """Cancellation"""
21
-
22
- PAY_GOLD = 15
23
- """Pay gold"""
24
-
25
- AUTHENTICATION_PUCE = 17
26
- """PUCE authentication"""
27
-
28
- REFUND_WITHOUT_ORIGINAL = 34
29
- """Refund without original"""
30
-
31
- BETTING_PRICE = 37
32
- """Betting price"""
33
-
34
- TRANSACTION_TYPES = (
35
- AUTHORIZATION,
36
- PREAUTHORIZATION,
37
- CONFIRMATION,
38
- REFUND,
39
- AUTHENTICATION,
40
- AUTHENTICATION_CONFIRMATION,
41
- CANCELLATION,
42
- PAY_GOLD,
43
- AUTHENTICATION_PUCE,
44
- REFUND_WITHOUT_ORIGINAL,
45
- BETTING_PRICE,
46
- )
File without changes
File without changes
File without changes