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/models.py CHANGED
@@ -9,9 +9,13 @@ dict with ``model_validate``. Field constraints follow these RedSys documents:
9
9
  - TPV Virtual, Manual de Integración REST, RS.TE.CEL.MAN.0037, version 4.0.1 (2025-10-17),
10
10
  https://pagosonline.redsys.es/download/1738/
11
11
  - Guía de integración de Bizum, version 2.6 (2026-08-25), https://pagosonline.redsys.es/download/2053/
12
+ - inSite guide, version 4.1 (2026-01-05), https://pagosonline.redsys.es/download/1751/
13
+ - Pago por e-mail/SMS (PayGold), RS.TE.CEL.MAN.0026, version 1.8.2 (2026-04-20),
14
+ https://pagosonline.redsys.es/download/2056/
15
+ - Integración de Google Pay, version 1.9 (2025-05-21), https://pagosonline.redsys.es/download/2045/
16
+ - Apple Pay, guía de instalación, version 3.2 (2023-03-28), https://pagosonline.redsys.es/download/2050/
12
17
  - Developer portal pages "Firmar una operación", "Parámetros de entrada y salida" and "Operativa DCC".
13
- - Librería de integración en PHP 2.0.1 (https://pagosonline.redsys.es/download/3214/), the reference
14
- for the response fields the guides omit.
18
+ - Responses of the RedSys test environment, for the response fields the guides omit.
15
19
  """
16
20
 
17
21
  from base64 import b64decode, b64encode
@@ -20,17 +24,27 @@ from json import dumps, loads
20
24
  from typing import Any
21
25
  from warnings import warn
22
26
 
23
- from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator, model_validator
24
-
25
- from redsys.api.currencies import CURRENCIES, CURRENCY_DECIMALS
27
+ from pydantic import (
28
+ AliasChoices,
29
+ BaseModel,
30
+ ConfigDict,
31
+ Field,
32
+ PrivateAttr,
33
+ field_serializer,
34
+ field_validator,
35
+ model_validator,
36
+ )
37
+
38
+ from redsys.api import emv3ds, options, paygold, responses, transactions, xpay
39
+ from redsys.api.currencies import CURRENCIES, CURRENCY_DECIMALS, EUR
26
40
  from redsys.api.transactions import TRANSACTION_TYPES
41
+ from redsys.codes import describe
27
42
  from redsys.exceptions import UnknownParameterWarning
28
43
 
29
- # 1.x request names that changed or went away in 2.0; the error names the replacement.
30
- _LEGACY_FIELDS: dict[str, str | None] = {
44
+ # 1.x request names that changed in 2.0; the error names the replacement.
45
+ _LEGACY_FIELDS: dict[str, str] = {
31
46
  'payment_method': 'pay_methods',
32
47
  'mobile_number': 'bizum_mobile_number',
33
- 'transaction_date': None,
34
48
  }
35
49
 
36
50
 
@@ -43,35 +57,35 @@ def decode_merchant_parameters(data: bytes) -> dict:
43
57
  return loads(b64decode(data + b'=' * (-len(data) % 4)).decode('utf-8'))
44
58
 
45
59
 
60
+ def currency_decimals(currency: int | None) -> int:
61
+ """ISO-4217 minor-unit exponent of a currency; 2 when unknown."""
62
+ return CURRENCY_DECIMALS.get(currency, 2) if currency is not None else 2
63
+
64
+
46
65
  def minor_units_factor(currency: int | None) -> int:
47
66
  """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
67
+ return 10 ** currency_decimals(currency)
50
68
 
51
69
 
52
70
  class BaseRequest(BaseModel):
53
71
  """Fields common to every operation message, with validation and wire serialization."""
54
72
 
55
- # Construct with the snake_case names or the wire names; unknown names are screened below.
73
+ # extra='allow' keeps the unmodeled wire names that _screen_unknown_fields lets through.
56
74
  model_config = ConfigDict(populate_by_name=True, extra='allow')
57
75
 
58
76
  merchant_code: str = Field(alias='Ds_Merchant_MerchantCode', max_length=9)
59
77
  terminal: int = Field(alias='Ds_Merchant_Terminal', ge=1, le=999)
60
78
  transaction_type: int = Field(0, alias='Ds_Merchant_TransactionType')
61
79
  amount: Decimal = Field(alias='Ds_Merchant_Amount', description='Major units; scaled to minor units on the wire.')
62
- currency: int = Field(alias='Ds_Merchant_Currency')
80
+ currency: int = Field(EUR, alias='Ds_Merchant_Currency')
63
81
  order: str = Field(alias='Ds_Merchant_Order', min_length=4, max_length=12, pattern=r'^[0-9]{4}[a-zA-Z0-9]{0,8}$')
64
82
  merchant_data: str | None = Field(None, alias='Ds_Merchant_MerchantData', max_length=1024)
83
+ module: str | None = Field(None, alias='Ds_Merchant_Module', description='Integration that sends the request.')
65
84
 
66
85
  @model_validator(mode='before')
67
86
  @classmethod
68
87
  def _screen_unknown_fields(cls, data: Any) -> Any:
69
- """Reject typos and 1.x names; let unmodeled RedSys wire names through with a warning.
70
-
71
- A parameter the library does not model yet is accepted under its wire
72
- name (``Ds_Merchant_*``) and sent as given, so a RedSys addition does not
73
- wait for a library release. Any other unknown name is a mistake and fails.
74
- """
88
+ """Reject unknown and 1.x names; let unmodeled wire names (``Ds_Merchant_*``) through with a warning."""
75
89
  if not isinstance(data, dict):
76
90
  return data
77
91
  known = {name for name in cls.model_fields} | {f.alias for f in cls.model_fields.values() if f.alias}
@@ -79,9 +93,7 @@ class BaseRequest(BaseModel):
79
93
  if key in known:
80
94
  continue
81
95
  if key in _LEGACY_FIELDS:
82
- replacement = _LEGACY_FIELDS[key]
83
- hint = f'use {replacement!r}' if replacement else 'it has no 2.x equivalent'
84
- raise ValueError(f'{key!r} is a 1.x field name; {hint}.')
96
+ raise ValueError(f'{key!r} is a 1.x field name; use {_LEGACY_FIELDS[key]!r}.')
85
97
  if key.lower().startswith('ds_merchant_'):
86
98
  warn(f'{key!r} is not modeled by this library; sent as given.', UnknownParameterWarning, stacklevel=2)
87
99
  continue
@@ -117,7 +129,46 @@ class BaseRequest(BaseModel):
117
129
  return b64encode(dumps(self.prepare_parameters(), sort_keys=True).encode('utf-8'))
118
130
 
119
131
 
120
- class _RedirectFields(BaseModel):
132
+ class _StoredCredentialFields(BaseModel):
133
+ """Tokenization and credential-on-file fields, accepted by both connections."""
134
+
135
+ identifier: str | None = Field(
136
+ None, alias='Ds_Merchant_Identifier', max_length=40, description='Stored-card token; see redsys.api.cof.'
137
+ )
138
+ direct_payment: str | None = Field(
139
+ None, alias='Ds_Merchant_DirectPayment', description='options.DIRECT_PAYMENT or options.MOTO.'
140
+ )
141
+ cof_ini: str | None = Field(None, alias='Ds_Merchant_Cof_Ini', max_length=1, description='See redsys.api.cof.')
142
+ cof_type: str | None = Field(None, alias='Ds_Merchant_Cof_Type', max_length=1, description='See redsys.api.cof.')
143
+ cof_txnid: str | None = Field(None, alias='Ds_Merchant_Cof_Txnid', max_length=15)
144
+ group: str | None = Field(
145
+ None, alias='Ds_Merchant_Group', pattern=r'^[0-9]{1,9}$', description='Group that shares card references.'
146
+ )
147
+ email_titular: str | None = Field(
148
+ None, alias='Ds_Merchant_Email_Titular', max_length=100, description='Cardholder e-mail; gets a summary.'
149
+ )
150
+
151
+
152
+ class _CardFields(BaseModel):
153
+ """Card data, inSite token and 3-D Secure data, accepted by both connections."""
154
+
155
+ pan: str | None = Field(None, alias='Ds_Merchant_Pan', max_length=19, pattern=r'^[0-9]{12,19}$')
156
+ expiry_date: str | None = Field(None, alias='Ds_Merchant_ExpiryDate', pattern=r'^[0-9]{4}$', description='YYMM.')
157
+ cvv2: str | None = Field(None, alias='Ds_Merchant_Cvv2', pattern=r'^[0-9]{3,4}$')
158
+ idoper: str | None = Field(None, alias='Ds_Merchant_Idoper', max_length=40, description='inSite operation token.')
159
+ emv3ds: dict[str, Any] | None = Field(None, alias='Ds_Merchant_EMV3DS', description='Build with redsys.threeds.')
160
+
161
+
162
+ class _DirectOperationFields(BaseModel):
163
+ """Fields of every direct-connection operation."""
164
+
165
+ merchant_descriptor: str | None = Field(
166
+ None, alias='Ds_Merchant_MerchantDescriptor', max_length=25, description='Soft descriptor; bank activation.'
167
+ )
168
+ client_ip: str | None = Field(None, alias='Ds_Merchant_ClientIP', max_length=15, description='Customer IP.')
169
+
170
+
171
+ class _RedirectFields(_StoredCredentialFields, _CardFields):
121
172
  """Fields of the redirect connection (browser form)."""
122
173
 
123
174
  merchant_url: str | None = Field(None, alias='Ds_Merchant_MerchantURL', max_length=250)
@@ -127,7 +178,7 @@ class _RedirectFields(BaseModel):
127
178
  product_description: str | None = Field(None, alias='Ds_Merchant_ProductDescription', max_length=125)
128
179
  titular: str | None = Field(None, alias='Ds_Merchant_Titular', max_length=60)
129
180
  consumer_language: int | None = Field(None, alias='Ds_Merchant_ConsumerLanguage')
130
- pay_methods: str | None = Field(None, alias='Ds_Merchant_PayMethods', description='"z" selects Bizum.')
181
+ pay_methods: str | None = Field(None, alias='Ds_Merchant_PayMethods', description='See redsys.api.pay_methods.')
131
182
  bizum_mobile_number: str | None = Field(None, alias='Ds_Merchant_Bizum_MobileNumber', max_length=19)
132
183
  excep_sca: str | None = Field(
133
184
  None,
@@ -135,36 +186,105 @@ class _RedirectFields(BaseModel):
135
186
  max_length=3,
136
187
  description='PSD2 SCA exemption requested; codes in redsys.api.sca.',
137
188
  )
189
+ merchant_descriptor: str | None = Field(
190
+ None, alias='Ds_Merchant_MerchantDescriptor', max_length=25, description='Soft descriptor; bank activation.'
191
+ )
192
+ shipping_address_pyp: str | None = Field(
193
+ None, alias='Ds_Merchant_ShippingAddressPyP', pattern=r'^[SN]$', description='See redsys.api.options.'
194
+ )
195
+ perso_code: int | None = Field(
196
+ None, alias='Ds_Merchant_PersoCode', ge=0, le=9, description='Payment page personalization.'
197
+ )
198
+
199
+
200
+ class _XPayFields(BaseModel):
201
+ """Google Pay and Apple Pay data; the wire names have no MERCHANT infix."""
202
+
203
+ xpay_data: str | None = Field(
204
+ None, alias='DS_XPAYDATA', max_length=10000, description='Encrypted wallet payload, as the wallet guide says.'
205
+ )
206
+ xpay_decoded_data: dict[str, Any] | None = Field(
207
+ None, alias='DS_XPAYDECODEDDATA', description='Wallet payload decrypted by the merchant.'
208
+ )
209
+ xpay_type: str | None = Field(None, alias='DS_XPAYTYPE', description='See redsys.api.xpay.')
210
+ xpay_origen: str | None = Field(None, alias='DS_XPAYORIGEN', description='See redsys.api.xpay.')
211
+
212
+ @field_validator('xpay_type')
213
+ @classmethod
214
+ def _validate_xpay_type(cls, value: str | None) -> str | None:
215
+ if value is not None and value not in xpay.TYPES:
216
+ raise ValueError('xpay_type is not valid.')
217
+ return value
218
+
219
+ @field_validator('xpay_origen')
220
+ @classmethod
221
+ def _validate_xpay_origen(cls, value: str | None) -> str | None:
222
+ if value is not None and value not in xpay.ORIGINS:
223
+ raise ValueError('xpay_origen is not valid.')
224
+ return value
138
225
 
226
+ @model_validator(mode='after')
227
+ def _one_wallet_payload_with_its_type_and_origin(self):
228
+ payloads = (self.xpay_data is not None) + (self.xpay_decoded_data is not None)
229
+ described = (self.xpay_type is not None) + (self.xpay_origen is not None)
230
+ if payloads > 1:
231
+ raise ValueError('xpay_data and xpay_decoded_data are mutually exclusive.')
232
+ if (payloads, described) not in ((0, 0), (1, 2)):
233
+ raise ValueError('a wallet payload goes with xpay_type and xpay_origen.')
234
+ return self
139
235
 
140
- class _DirectFields(BaseModel):
236
+
237
+ class _DirectFields(_StoredCredentialFields, _CardFields, _DirectOperationFields, _XPayFields):
141
238
  """Fields of the direct connection (server-to-server card data, 3DS, stored credentials)."""
142
239
 
143
- pan: str | None = Field(None, alias='Ds_Merchant_Pan', max_length=19, pattern=r'^[0-9]{12,19}$')
144
- expiry_date: str | None = Field(None, alias='Ds_Merchant_ExpiryDate', pattern=r'^[0-9]{4}$', description='YYMM.')
145
- 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.'
240
+ mpi_external: dict[str, Any] | None = Field(
241
+ None, alias='Ds_Merchant_MpiExternal', description='Results of an external 3DS server.'
242
+ )
243
+ token_data: dict[str, Any] | None = Field(
244
+ None, alias='Ds_Merchant_TokenData', description='Network token cryptogram data.'
148
245
  )
149
- 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
- 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
246
  # RedSys spells the wire name "Frecuency".
156
247
  date_frequency: str | None = Field(None, alias='Ds_Merchant_DateFrecuency', description='Days between charges.')
157
248
  charge_expiry_date: str | None = Field(None, alias='Ds_Merchant_ChargeExpiryDate', description='yyyy-MM-dd.')
249
+ dcc: str | dict[str, Any] | None = Field(
250
+ None,
251
+ alias='Ds_Merchant_DCC',
252
+ description='options.DCC_QUERY on the 3DS initialization; then the accepted offer.',
253
+ )
158
254
  excep_sca: str | None = Field(
159
255
  None,
160
256
  alias='Ds_Merchant_Excep_SCA',
161
257
  max_length=3,
162
- description='PSD2 SCA exemption requested; codes in redsys.api.sca. "Y" on the 3DS initialization asks.',
258
+ description='PSD2 SCA exemption requested; codes in redsys.api.sca. sca.QUERY on the 3DS initialization asks.',
163
259
  )
164
260
 
165
261
 
166
- class RedirectPaymentMessage(BaseRequest, _RedirectFields):
167
- """Redirect-connection payment (transaction type 0)."""
262
+ class _PaymentFields(BaseModel):
263
+ """Fields of payments (type 0) and authentications (type 7) on both connections."""
264
+
265
+ tax_reference: str | None = Field(
266
+ None, alias='Ds_Merchant_Tax_Reference', max_length=100, description='Tax payment barcode; bank activation.'
267
+ )
268
+ ota: str | None = Field(None, alias='Ds_Merchant_OTA', pattern=r'^S$', description='See redsys.api.options.')
269
+
270
+
271
+ class _BizumFields(BaseModel):
272
+ """Bizum fields of payments on both connections."""
273
+
274
+ bizum_mrt_amount_variation: str | None = Field(
275
+ None,
276
+ alias='Ds_Merchant_Bizum_MrtAmountVariation',
277
+ pattern=r'^[0-9]+$',
278
+ description='Ceiling for later MIT amounts, in minor units; on the operation that creates the TID.',
279
+ )
280
+
281
+
282
+ class RedirectPaymentMessage(BaseRequest, _RedirectFields, _PaymentFields, _BizumFields):
283
+ """Redirect-connection payment (transaction type 0), authentication (7) or refund without original (34)."""
284
+
285
+ bizum_id_oper: str | None = Field(
286
+ None, alias='Ds_Merchant_Bizum_IdOper', description='Response.bizum_id_oper; asks RedSys for the backup.'
287
+ )
168
288
 
169
289
 
170
290
  class RedirectPreauthorizationMessage(BaseRequest, _RedirectFields):
@@ -173,12 +293,90 @@ class RedirectPreauthorizationMessage(BaseRequest, _RedirectFields):
173
293
  transaction_type: int = Field(1, alias='Ds_Merchant_TransactionType')
174
294
 
175
295
 
176
- class DirectPaymentMessage(BaseRequest, _DirectFields):
177
- """Direct-connection card payment (transaction type 0)."""
296
+ class _PayGoldFields(BaseModel):
297
+ """Fields of a PayGold link (types 15 and 51)."""
298
+
299
+ customer_mobile: str | None = Field(None, alias='Ds_Merchant_Customer_Mobile', max_length=19)
300
+ customer_mail: str | None = Field(None, alias='Ds_Merchant_Customer_Mail', max_length=100)
301
+ customer_sms_text: str | None = Field(
302
+ None, alias='Ds_Merchant_Customer_Sms_Text', max_length=160, description='Must contain paygold.SMS_URL.'
303
+ )
304
+ p2f_expiry_date: str | None = Field(
305
+ None,
306
+ alias='Ds_Merchant_P2f_ExpiryDate',
307
+ pattern=r'^([0-9]{1,23}|[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{2}\.[0-9]{2}\.[0-9]{2}\.[0-9]{3})$',
308
+ description='Minutes from now, or yyyy-mm-dd-HH.MM.ss.sss.',
309
+ )
310
+ p2f_xml_data: str | None = Field(
311
+ None, alias='Ds_Merchant_P2f_XmlData', max_length=1000, description='E-mail tags; see redsys.api.paygold.'
312
+ )
313
+ alt_transaction_type: int | None = Field(
314
+ None,
315
+ alias='Ds_Merchant_AltTransType',
316
+ description='transactions.REFUND_WITHOUT_ORIGINAL turns the link into a refund without original.',
317
+ )
318
+ pay_methods: str | None = Field(None, alias='Ds_Merchant_PayMethods', description='See redsys.api.pay_methods.')
319
+ merchant_url: str | None = Field(None, alias='Ds_Merchant_MerchantURL', max_length=250)
320
+
321
+ @field_validator('customer_sms_text')
322
+ @classmethod
323
+ def _sms_text_holds_the_link(cls, value: str | None) -> str | None:
324
+ if value is not None and paygold.SMS_URL not in value:
325
+ raise ValueError(f'customer_sms_text must contain {paygold.SMS_URL}.')
326
+ return value
327
+
328
+ @field_validator('p2f_expiry_date', mode='before')
329
+ @classmethod
330
+ def _minutes_as_text(cls, value):
331
+ return str(value) if isinstance(value, int) and not isinstance(value, bool) else value
332
+
333
+ @field_validator('alt_transaction_type')
334
+ @classmethod
335
+ def _validate_alt_transaction_type(cls, value: int | None) -> int | None:
336
+ if value is not None and value != transactions.REFUND_WITHOUT_ORIGINAL:
337
+ raise ValueError('alt_transaction_type is 34, refund without original.')
338
+ return value
339
+
340
+
341
+ class DirectPaymentMessage(BaseRequest, _DirectFields, _PaymentFields, _PayGoldFields, _BizumFields):
342
+ """Direct-connection card payment (transaction type 0), and the types without their own message."""
178
343
 
179
344
  sum_total: Decimal | None = Field(
180
345
  None, alias='Ds_Merchant_SumTotal', description='Recurring series total; scaled like amount.'
181
346
  )
347
+ financing: str | dict[str, Any] | None = Field(
348
+ None,
349
+ alias='Ds_Merchant_Financing',
350
+ description='options.FINANCING_QUERY on the 3DS initialization; then the chosen option.',
351
+ )
352
+ matching_data: str | None = Field(
353
+ None, alias='Ds_Merchant_MatchingData', max_length=21, description='Unique; type 17, with acquirer_identifier.'
354
+ )
355
+ acquirer_identifier: str | None = Field(
356
+ None, alias='Ds_Acquirer_Identifier', pattern=r'^[0-9]{1,6}$', description='Type 17, with matching_data.'
357
+ )
358
+ bizum_mobile_number: str | None = Field(None, alias='Ds_Merchant_Bizum_MobileNumber', max_length=19)
359
+ otp: str | None = Field(
360
+ None, alias='Ds_Merchant_Otp', pattern=r'^[0-9]+$', description='Bizum backup one-time password.'
361
+ )
362
+
363
+ @field_validator('otp', mode='before')
364
+ @classmethod
365
+ def _otp_as_text(cls, value):
366
+ return str(value) if isinstance(value, int) and not isinstance(value, bool) else value
367
+
368
+ @field_validator('financing')
369
+ @classmethod
370
+ def _validate_financing(cls, value):
371
+ if isinstance(value, str) and value != options.FINANCING_QUERY:
372
+ raise ValueError('financing is "Y" or the chosen option.')
373
+ return value
374
+
375
+ @model_validator(mode='after')
376
+ def _matching_data_with_acquirer(self):
377
+ if (self.matching_data is None) != (self.acquirer_identifier is None):
378
+ raise ValueError('matching_data and acquirer_identifier go together.')
379
+ return self
182
380
 
183
381
  @field_serializer('sum_total')
184
382
  def _serialize_sum_total(self, value: Decimal | None) -> int | None:
@@ -191,21 +389,30 @@ class DirectPreauthorizationMessage(BaseRequest, _DirectFields):
191
389
  transaction_type: int = Field(1, alias='Ds_Merchant_TransactionType')
192
390
 
193
391
 
194
- class ConfirmationMessage(BaseRequest):
392
+ class ConfirmationMessage(BaseRequest, _DirectOperationFields):
195
393
  """Capture of a previous preauthorization (transaction type 2)."""
196
394
 
197
395
  transaction_type: int = Field(2, alias='Ds_Merchant_TransactionType')
198
396
 
199
397
 
200
- class RefundMessage(BaseRequest):
398
+ class RefundMessage(BaseRequest, _DirectOperationFields):
201
399
  """Refund of a previous payment (transaction type 3)."""
202
400
 
203
401
  transaction_type: int = Field(3, alias='Ds_Merchant_TransactionType')
204
402
  authorization_code: str | None = Field(None, alias='Ds_Merchant_AuthorisationCode', max_length=6)
403
+ transaction_date: str | None = Field(
404
+ None,
405
+ alias='Ds_Merchant_TransactionDate',
406
+ pattern=r'^[0-9]{4}-[0-9]{2}-[0-9]{2}$',
407
+ description='yyyy-mm-dd of the original operation.',
408
+ )
409
+ rts: str | None = Field(
410
+ None, alias='Ds_Merchant_RTS', pattern=r'^[0-9]{1,24}$', description='Response.rts of a partial confirmation.'
411
+ )
205
412
 
206
413
 
207
- class CancellationMessage(BaseRequest):
208
- """Cancellation of a preauthorization (transaction type 9)."""
414
+ class CancellationMessage(BaseRequest, _DirectOperationFields):
415
+ """Void of a preauthorization (transaction type 9) or another cancellation (types 45 to 47)."""
209
416
 
210
417
  transaction_type: int = Field(9, alias='Ds_Merchant_TransactionType')
211
418
 
@@ -235,28 +442,46 @@ class Response(BaseModel):
235
442
  card_country: str | None = Field(None, alias='Ds_Card_Country')
236
443
  card_type: str | None = Field(None, alias='Ds_Card_Type')
237
444
  card_brand: str | None = Field(None, alias='Ds_Card_Brand')
238
- # Not in the integration guides: returned by the test environment (verified 2026-10-25)
239
- # and modeled by the official PHP library 2.0.1.
445
+ # Not in the integration guides; returned by the test environment.
240
446
  card_typology: str | None = Field(None, alias='Ds_Card_Typology', description='For example CONSUMO.')
241
447
  eci: str | None = Field(None, alias='Ds_ECI', description='E-commerce indicator of the authentication.')
242
448
  response_description: str | None = Field(None, alias='Ds_Response_Description')
243
449
  secure_payment: str | None = Field(None, alias='Ds_SecurePayment')
244
450
  authorization_code: str | None = Field(None, alias='Ds_AuthorisationCode')
245
451
  error_code: str | None = Field(None, alias='Ds_ErrorCode')
246
- card_number: str | None = Field(None, alias='Ds_CardNumber')
452
+ # REST responses send Ds_CardNumber; redirect notifications send Ds_Card_Number.
453
+ card_number: str | None = Field(
454
+ None, alias='Ds_CardNumber', validation_alias=AliasChoices('Ds_CardNumber', 'Ds_Card_Number', 'card_number')
455
+ )
247
456
  expiry_date: str | None = Field(None, alias='Ds_ExpiryDate')
248
457
  card_psd2: str | None = Field(None, alias='Ds_Card_PSD2')
249
458
  excep_sca: str | None = Field(
250
459
  None, alias='Ds_Excep_SCA', description='Exemptions the card allows, from the 3DS initialization.'
251
460
  )
252
461
  emv3ds: dict[str, Any] | None = Field(None, alias='Ds_EMV3DS')
462
+ dcc: dict[str, Any] | None = Field(None, alias='Ds_DCC', description='Offer from the 3DS initialization.')
253
463
  identifier: str | None = Field(None, alias='Ds_Merchant_Identifier', description='Token returned on request.')
254
464
  cof_txnid: str | None = Field(None, alias='Ds_Merchant_Cof_Txnid')
255
- processed_pay_method: str | None = Field(None, alias='Ds_ProcessedPayMethod', description='68 = Bizum.')
465
+ processed_pay_method: str | None = Field(
466
+ None, alias='Ds_ProcessedPayMethod', description='See redsys.api.processed_pay_methods.'
467
+ )
256
468
  bizum_id_oper: str | None = Field(None, alias='Ds_Bizum_IdOper')
257
469
  bizum_cuenta_truncada: str | None = Field(None, alias='Ds_Bizum_CuentaTruncada')
258
470
  currency_dcc: str | None = Field(None, alias='Ds_Currency_DCC', description='Dynamic currency conversion.')
259
471
  amount_dcc: str | None = Field(None, alias='Ds_Amount_DCC', description='Minor units in Ds_Currency_DCC.')
472
+ rts: str | None = Field(None, alias='rts', description='Partial confirmation reference; refund it with rts=.')
473
+ rtp_status: str | None = Field(None, alias='Ds_RtpStatus', description='Bizum Request-to-Pay: OK or KO.')
474
+ rtp_response: str | None = Field(None, alias='Ds_RtpResponse', description='BIZnnnnn; see redsys.api.bizum.')
475
+ rtp_description: str | None = Field(None, alias='Ds_RtpDescription')
476
+ rtp_backup_html: str | None = Field(None, alias='Ds_RtpBackupHtml', description='Base64 HTML backup form.')
477
+ rtp_backup_template: str | None = Field(
478
+ None, alias='Ds_RtpBackupTemplate', description='Base64 JSON pieces of a backup form.'
479
+ )
480
+ url_pago_2_fases: str | None = Field(None, alias='Ds_UrlPago2Fases', description='PayGold payment link.')
481
+
482
+ _http_status: int | None = PrivateAttr(None)
483
+ _http_headers: dict[str, str] | None = PrivateAttr(None)
484
+ _raw_body: str | None = PrivateAttr(None)
260
485
 
261
486
  @field_validator('amount', mode='before')
262
487
  @classmethod
@@ -269,31 +494,57 @@ class Response(BaseModel):
269
494
  def from_merchant_parameters(cls, merchant_parameters: bytes) -> 'Response':
270
495
  response = cls.model_validate(decode_merchant_parameters(merchant_parameters))
271
496
  if response.amount is not None:
272
- response.amount = response.amount / minor_units_factor(response.currency)
497
+ # scaleb keeps the currency's decimals: 100 EUR minor units read as 1.00, not 1.
498
+ response.amount = response.amount.scaleb(-currency_decimals(response.currency))
273
499
  return response
274
500
 
275
501
  def get_order(self) -> str | None:
276
502
  return self.order
277
503
 
504
+ def _with_transport(self, http_status: int, http_headers: dict[str, str], raw_body: str) -> 'Response':
505
+ self._http_status, self._http_headers, self._raw_body = http_status, http_headers, raw_body
506
+ return self
507
+
508
+ @property
509
+ def http_status(self) -> int | None:
510
+ """HTTP status of the direct call; ``None`` when parsed from a received payload."""
511
+ return self._http_status
512
+
513
+ @property
514
+ def http_headers(self) -> dict[str, str] | None:
515
+ """HTTP headers of the direct call; ``None`` when parsed from a received payload."""
516
+ return self._http_headers
517
+
518
+ @property
519
+ def raw_body(self) -> str | None:
520
+ """Unprocessed HTTP body of the direct call; ``None`` when parsed from a received payload."""
521
+ return self._raw_body
522
+
278
523
  @property
279
524
  def response_code(self):
280
- # A 3DS challenge response may carry no Ds_Response. The code is returned
281
- # as-is; see docs/response-codes.md for the English meanings.
525
+ # A 3DS challenge response may carry no Ds_Response.
282
526
  return int(self.response) if self.response not in (None, '') else None
283
527
 
528
+ def describe(self, language='es') -> str:
529
+ """Meaning of ``Ds_Response``, or of ``Ds_ErrorCode`` without it; see ``redsys.codes.describe``."""
530
+ code = self.response if self.response not in (None, '') else self.error_code
531
+ return describe(code, language) if code else ''
532
+
284
533
  def is_paid(self) -> bool:
285
- return self.response_code is not None and 0 <= self.response_code <= 99
534
+ return (
535
+ self.response_code is not None and responses.AUTHORIZED <= self.response_code <= responses.AUTHORIZED_LAST
536
+ )
286
537
 
287
538
  def is_refunded(self) -> bool:
288
- return self.response_code == 900
539
+ return self.response_code == responses.REFUND_OR_CONFIRMATION_AUTHORIZED
289
540
 
290
541
  def is_canceled(self) -> bool:
291
- return self.response_code == 400
542
+ return self.response_code == responses.CANCELLATION_AUTHORIZED
292
543
 
293
544
  def is_authorized(self) -> bool:
294
545
  return self.is_paid() or self.is_refunded() or self.is_canceled()
295
546
 
296
- # 3-D Secure (EMV 3DS) accessors over the Ds_EMV3DS response object.
547
+ # == EMV 3DS accessors over Ds_EMV3DS ==
297
548
  @property
298
549
  def _emv3ds(self) -> dict:
299
550
  return self.emv3ds or {}
@@ -319,10 +570,10 @@ class Response(BaseModel):
319
570
  return self._emv3ds.get('creq')
320
571
 
321
572
  def is_no_3ds(self) -> bool:
322
- return self.protocol_version == 'NO_3DS_V2'
573
+ return self.protocol_version == emv3ds.NO_3DS_V2
323
574
 
324
575
  def requires_challenge(self) -> bool:
325
- return self._emv3ds.get('threeDSInfo') == 'ChallengeRequest'
576
+ return self._emv3ds.get('threeDSInfo') == emv3ds.CHALLENGE_REQUEST
326
577
 
327
578
 
328
579
  __all__ = [
@@ -336,5 +587,6 @@ __all__ = [
336
587
  'CancellationMessage',
337
588
  'Response',
338
589
  'decode_merchant_parameters',
590
+ 'currency_decimals',
339
591
  'minor_units_factor',
340
592
  ]
redsys/orders.py ADDED
@@ -0,0 +1,20 @@
1
+ """Order numbers for ``Ds_Merchant_Order``."""
2
+
3
+ from secrets import choice
4
+ from string import ascii_letters, digits
5
+
6
+ _ALPHANUMERIC = ascii_letters + digits
7
+
8
+
9
+ def generate_order(length: int = 12) -> str:
10
+ """A random order number of ``length`` characters (4 to 12): 4 digits, then letters and digits.
11
+
12
+ RedSys recommends the 4 leading digits. A random draw makes a repeated order (SIS0051)
13
+ unlikely, not impossible. Raises ``ValueError`` for a length outside 4 to 12.
14
+ """
15
+ if isinstance(length, bool) or not isinstance(length, int) or not 4 <= length <= 12:
16
+ raise ValueError('length must be an integer from 4 to 12.')
17
+ return ''.join(choice(digits) for _ in range(4)) + ''.join(choice(_ALPHANUMERIC) for _ in range(length - 4))
18
+
19
+
20
+ __all__ = ['generate_order']
redsys/request.py CHANGED
@@ -9,26 +9,32 @@ def _as_text(value):
9
9
 
10
10
 
11
11
  class Request:
12
- """A signed request, ready to serialize or send -- the return of every operation.
12
+ """A signed request, the return of every operation.
13
13
 
14
- Holds the three fields RedSys exchanges (the Base64 merchant parameters, the
15
- signature over them, and the signature version) and the two ways to hand it
16
- off:
17
-
18
- - ``to_dict()`` -- the three ``Ds_*`` fields to POST (redirect form fields or
19
- REST JSON body). Always available.
20
- - ``send()`` -- performs the REST call and returns the parsed ``Response``.
21
- Only direct-connection clients bind a sender; for redirect requests (the
22
- browser submits them) it raises ``TypeError`` -- use ``to_dict()``.
23
-
24
- Async clients bind an awaitable sender, so ``await request.send()`` works.
14
+ ``to_dict()`` gives the three ``Ds_*`` fields to post: the redirect form fields or the
15
+ REST JSON body. ``send()`` performs the REST call and returns the parsed ``Response``;
16
+ it is awaitable on ``AsyncDirectClient`` and raises ``TypeError`` on a redirect request.
25
17
  """
26
18
 
27
- def __init__(self, merchant_parameters: bytes, signature: bytes, signature_version: str, *, sender=None):
19
+ def __init__(
20
+ self,
21
+ merchant_parameters: bytes,
22
+ signature: bytes,
23
+ signature_version: str,
24
+ *,
25
+ sender=None,
26
+ endpoint: str | None = None,
27
+ ):
28
28
  self._merchant_parameters = merchant_parameters
29
29
  self._signature = signature
30
30
  self._signature_version = signature_version
31
31
  self._sender = sender
32
+ self._endpoint = endpoint
33
+
34
+ @property
35
+ def endpoint(self) -> str | None:
36
+ """The URL this request goes to: the form ``action`` on redirect, the REST URL on direct."""
37
+ return self._endpoint
32
38
 
33
39
  @property
34
40
  def merchant_parameters(self) -> bytes: