python-redsys 2.1.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 AliasChoices, 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
 
@@ -56,26 +70,22 @@ def minor_units_factor(currency: int | None) -> int:
56
70
  class BaseRequest(BaseModel):
57
71
  """Fields common to every operation message, with validation and wire serialization."""
58
72
 
59
- # 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.
60
74
  model_config = ConfigDict(populate_by_name=True, extra='allow')
61
75
 
62
76
  merchant_code: str = Field(alias='Ds_Merchant_MerchantCode', max_length=9)
63
77
  terminal: int = Field(alias='Ds_Merchant_Terminal', ge=1, le=999)
64
78
  transaction_type: int = Field(0, alias='Ds_Merchant_TransactionType')
65
79
  amount: Decimal = Field(alias='Ds_Merchant_Amount', description='Major units; scaled to minor units on the wire.')
66
- currency: int = Field(alias='Ds_Merchant_Currency')
80
+ currency: int = Field(EUR, alias='Ds_Merchant_Currency')
67
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}$')
68
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.')
69
84
 
70
85
  @model_validator(mode='before')
71
86
  @classmethod
72
87
  def _screen_unknown_fields(cls, data: Any) -> Any:
73
- """Reject typos and 1.x names; let unmodeled RedSys wire names through with a warning.
74
-
75
- A parameter the library does not model yet is accepted under its wire
76
- name (``Ds_Merchant_*``) and sent as given, so a RedSys addition does not
77
- wait for a library release. Any other unknown name is a mistake and fails.
78
- """
88
+ """Reject unknown and 1.x names; let unmodeled wire names (``Ds_Merchant_*``) through with a warning."""
79
89
  if not isinstance(data, dict):
80
90
  return data
81
91
  known = {name for name in cls.model_fields} | {f.alias for f in cls.model_fields.values() if f.alias}
@@ -83,9 +93,7 @@ class BaseRequest(BaseModel):
83
93
  if key in known:
84
94
  continue
85
95
  if key in _LEGACY_FIELDS:
86
- replacement = _LEGACY_FIELDS[key]
87
- hint = f'use {replacement!r}' if replacement else 'it has no 2.x equivalent'
88
- 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}.')
89
97
  if key.lower().startswith('ds_merchant_'):
90
98
  warn(f'{key!r} is not modeled by this library; sent as given.', UnknownParameterWarning, stacklevel=2)
91
99
  continue
@@ -127,13 +135,40 @@ class _StoredCredentialFields(BaseModel):
127
135
  identifier: str | None = Field(
128
136
  None, alias='Ds_Merchant_Identifier', max_length=40, description='Stored-card token; see redsys.api.cof.'
129
137
  )
130
- direct_payment: str | None = Field(None, alias='Ds_Merchant_DirectPayment', description='"true" or "moto".')
138
+ direct_payment: str | None = Field(
139
+ None, alias='Ds_Merchant_DirectPayment', description='options.DIRECT_PAYMENT or options.MOTO.'
140
+ )
131
141
  cof_ini: str | None = Field(None, alias='Ds_Merchant_Cof_Ini', max_length=1, description='See redsys.api.cof.')
132
142
  cof_type: str | None = Field(None, alias='Ds_Merchant_Cof_Type', max_length=1, description='See redsys.api.cof.')
133
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.')
134
169
 
135
170
 
136
- class _RedirectFields(_StoredCredentialFields):
171
+ class _RedirectFields(_StoredCredentialFields, _CardFields):
137
172
  """Fields of the redirect connection (browser form)."""
138
173
 
139
174
  merchant_url: str | None = Field(None, alias='Ds_Merchant_MerchantURL', max_length=250)
@@ -143,7 +178,7 @@ class _RedirectFields(_StoredCredentialFields):
143
178
  product_description: str | None = Field(None, alias='Ds_Merchant_ProductDescription', max_length=125)
144
179
  titular: str | None = Field(None, alias='Ds_Merchant_Titular', max_length=60)
145
180
  consumer_language: int | None = Field(None, alias='Ds_Merchant_ConsumerLanguage')
146
- 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.')
147
182
  bizum_mobile_number: str | None = Field(None, alias='Ds_Merchant_Bizum_MobileNumber', max_length=19)
148
183
  excep_sca: str | None = Field(
149
184
  None,
@@ -151,32 +186,105 @@ class _RedirectFields(_StoredCredentialFields):
151
186
  max_length=3,
152
187
  description='PSD2 SCA exemption requested; codes in redsys.api.sca.',
153
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
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
154
235
 
155
236
 
156
- class _DirectFields(_StoredCredentialFields):
237
+ class _DirectFields(_StoredCredentialFields, _CardFields, _DirectOperationFields, _XPayFields):
157
238
  """Fields of the direct connection (server-to-server card data, 3DS, stored credentials)."""
158
239
 
159
- pan: str | None = Field(None, alias='Ds_Merchant_Pan', max_length=19, pattern=r'^[0-9]{12,19}$')
160
- expiry_date: str | None = Field(None, alias='Ds_Merchant_ExpiryDate', pattern=r'^[0-9]{4}$', description='YYMM.')
161
- cvv2: str | None = Field(None, alias='Ds_Merchant_Cvv2', pattern=r'^[0-9]{3,4}$')
162
- idoper: str | None = Field(None, alias='Ds_Merchant_Idoper', max_length=40, description='inSite operation token.')
163
- emv3ds: dict[str, Any] | None = Field(None, alias='Ds_Merchant_EMV3DS', description='Build with redsys.threeds.')
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.'
245
+ )
164
246
  # RedSys spells the wire name "Frecuency".
165
247
  date_frequency: str | None = Field(None, alias='Ds_Merchant_DateFrecuency', description='Days between charges.')
166
248
  charge_expiry_date: str | None = Field(None, alias='Ds_Merchant_ChargeExpiryDate', description='yyyy-MM-dd.')
167
249
  dcc: str | dict[str, Any] | None = Field(
168
- None, alias='Ds_Merchant_DCC', description='"Y" on the 3DS initialization; then the accepted offer.'
250
+ None,
251
+ alias='Ds_Merchant_DCC',
252
+ description='options.DCC_QUERY on the 3DS initialization; then the accepted offer.',
169
253
  )
170
254
  excep_sca: str | None = Field(
171
255
  None,
172
256
  alias='Ds_Merchant_Excep_SCA',
173
257
  max_length=3,
174
- 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.',
259
+ )
260
+
261
+
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.'
175
267
  )
268
+ ota: str | None = Field(None, alias='Ds_Merchant_OTA', pattern=r'^S$', description='See redsys.api.options.')
269
+
176
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
+ )
177
280
 
178
- class RedirectPaymentMessage(BaseRequest, _RedirectFields):
179
- """Redirect-connection payment (transaction type 0)."""
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
+ )
180
288
 
181
289
 
182
290
  class RedirectPreauthorizationMessage(BaseRequest, _RedirectFields):
@@ -185,12 +293,90 @@ class RedirectPreauthorizationMessage(BaseRequest, _RedirectFields):
185
293
  transaction_type: int = Field(1, alias='Ds_Merchant_TransactionType')
186
294
 
187
295
 
188
- class DirectPaymentMessage(BaseRequest, _DirectFields):
189
- """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."""
190
343
 
191
344
  sum_total: Decimal | None = Field(
192
345
  None, alias='Ds_Merchant_SumTotal', description='Recurring series total; scaled like amount.'
193
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
194
380
 
195
381
  @field_serializer('sum_total')
196
382
  def _serialize_sum_total(self, value: Decimal | None) -> int | None:
@@ -203,21 +389,30 @@ class DirectPreauthorizationMessage(BaseRequest, _DirectFields):
203
389
  transaction_type: int = Field(1, alias='Ds_Merchant_TransactionType')
204
390
 
205
391
 
206
- class ConfirmationMessage(BaseRequest):
392
+ class ConfirmationMessage(BaseRequest, _DirectOperationFields):
207
393
  """Capture of a previous preauthorization (transaction type 2)."""
208
394
 
209
395
  transaction_type: int = Field(2, alias='Ds_Merchant_TransactionType')
210
396
 
211
397
 
212
- class RefundMessage(BaseRequest):
398
+ class RefundMessage(BaseRequest, _DirectOperationFields):
213
399
  """Refund of a previous payment (transaction type 3)."""
214
400
 
215
401
  transaction_type: int = Field(3, alias='Ds_Merchant_TransactionType')
216
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
+ )
217
412
 
218
413
 
219
- class CancellationMessage(BaseRequest):
220
- """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)."""
221
416
 
222
417
  transaction_type: int = Field(9, alias='Ds_Merchant_TransactionType')
223
418
 
@@ -247,8 +442,7 @@ class Response(BaseModel):
247
442
  card_country: str | None = Field(None, alias='Ds_Card_Country')
248
443
  card_type: str | None = Field(None, alias='Ds_Card_Type')
249
444
  card_brand: str | None = Field(None, alias='Ds_Card_Brand')
250
- # Not in the integration guides: returned by the test environment (verified 2026-10-25)
251
- # and modeled by the official PHP library 2.0.1.
445
+ # Not in the integration guides; returned by the test environment.
252
446
  card_typology: str | None = Field(None, alias='Ds_Card_Typology', description='For example CONSUMO.')
253
447
  eci: str | None = Field(None, alias='Ds_ECI', description='E-commerce indicator of the authentication.')
254
448
  response_description: str | None = Field(None, alias='Ds_Response_Description')
@@ -268,11 +462,26 @@ class Response(BaseModel):
268
462
  dcc: dict[str, Any] | None = Field(None, alias='Ds_DCC', description='Offer from the 3DS initialization.')
269
463
  identifier: str | None = Field(None, alias='Ds_Merchant_Identifier', description='Token returned on request.')
270
464
  cof_txnid: str | None = Field(None, alias='Ds_Merchant_Cof_Txnid')
271
- 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
+ )
272
468
  bizum_id_oper: str | None = Field(None, alias='Ds_Bizum_IdOper')
273
469
  bizum_cuenta_truncada: str | None = Field(None, alias='Ds_Bizum_CuentaTruncada')
274
470
  currency_dcc: str | None = Field(None, alias='Ds_Currency_DCC', description='Dynamic currency conversion.')
275
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)
276
485
 
277
486
  @field_validator('amount', mode='before')
278
487
  @classmethod
@@ -292,25 +501,50 @@ class Response(BaseModel):
292
501
  def get_order(self) -> str | None:
293
502
  return self.order
294
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
+
295
523
  @property
296
524
  def response_code(self):
297
- # A 3DS challenge response may carry no Ds_Response. The code is returned
298
- # as-is; see docs/response-codes.md for the English meanings.
525
+ # A 3DS challenge response may carry no Ds_Response.
299
526
  return int(self.response) if self.response not in (None, '') else None
300
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
+
301
533
  def is_paid(self) -> bool:
302
- 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
+ )
303
537
 
304
538
  def is_refunded(self) -> bool:
305
- return self.response_code == 900
539
+ return self.response_code == responses.REFUND_OR_CONFIRMATION_AUTHORIZED
306
540
 
307
541
  def is_canceled(self) -> bool:
308
- return self.response_code == 400
542
+ return self.response_code == responses.CANCELLATION_AUTHORIZED
309
543
 
310
544
  def is_authorized(self) -> bool:
311
545
  return self.is_paid() or self.is_refunded() or self.is_canceled()
312
546
 
313
- # 3-D Secure (EMV 3DS) accessors over the Ds_EMV3DS response object.
547
+ # == EMV 3DS accessors over Ds_EMV3DS ==
314
548
  @property
315
549
  def _emv3ds(self) -> dict:
316
550
  return self.emv3ds or {}
@@ -336,10 +570,10 @@ class Response(BaseModel):
336
570
  return self._emv3ds.get('creq')
337
571
 
338
572
  def is_no_3ds(self) -> bool:
339
- return self.protocol_version == 'NO_3DS_V2'
573
+ return self.protocol_version == emv3ds.NO_3DS_V2
340
574
 
341
575
  def requires_challenge(self) -> bool:
342
- return self._emv3ds.get('threeDSInfo') == 'ChallengeRequest'
576
+ return self._emv3ds.get('threeDSInfo') == emv3ds.CHALLENGE_REQUEST
343
577
 
344
578
 
345
579
  __all__ = [
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:
redsys/threeds.py CHANGED
@@ -1,20 +1,25 @@
1
- """Builders for the Ds_Merchant_EMV3DS object used in 3-D Secure v2 (REST direct connection).
1
+ """Builders for the ``Ds_Merchant_EMV3DS`` object of 3-D Secure v2 on the direct connection.
2
2
 
3
- Flow: inicia (`card_data`) -> authorize (`authentication_data`) -> [challenge] ->
4
- finalize (`challenge_response`). See docs/direct-connection.md. The
5
- EMV3DS sub-field names are case-sensitive and passed through verbatim.
3
+ The step numbers follow docs/direct-connection.md. The sub-field names are case-sensitive.
6
4
 
7
- Each function returns the dict to assign to a request's `emv3ds` field, e.g.
5
+ client.payment(emv3ds=threeds.authentication_data(..., three_ds_comp_ind=emv3ds.METHOD_COMPLETED), ...)
6
+ """
8
7
 
9
- from redsys import threeds
8
+ from base64 import urlsafe_b64encode
9
+ from json import dumps
10
10
 
11
- response = client.inicia(..., emv3ds=threeds.card_data()).send()
12
- """
11
+ from redsys.api import emv3ds
13
12
 
14
13
 
15
14
  def card_data() -> dict:
16
- """EMV3DS payload for the iniciaPeticionREST call (step 1)."""
17
- return {'threeDSInfo': 'CardData'}
15
+ """``emv3ds`` of the 3-D Secure initialization, iniciaPeticionREST (step 1)."""
16
+ return {'threeDSInfo': emv3ds.CARD_DATA}
17
+
18
+
19
+ def method_data(*, three_ds_server_trans_id, notification_url) -> str:
20
+ """Value of the ``threeDSMethodData`` form field the browser posts to ``threeDSMethodURL`` (step 2)."""
21
+ payload = {'threeDSServerTransID': three_ds_server_trans_id, 'threeDSMethodNotificationURL': notification_url}
22
+ return urlsafe_b64encode(dumps(payload, separators=(',', ':')).encode('utf-8')).decode('ascii').rstrip('=')
18
23
 
19
24
 
20
25
  def browser_info(
@@ -30,7 +35,7 @@ def browser_info(
30
35
  javascript_enabled=True,
31
36
  ip=None,
32
37
  ) -> dict:
33
- """Cardholder browser data the merchant page collects for the authorization."""
38
+ """Cardholder browser data, collected by the merchant page, for ``authentication_data()``."""
34
39
  data = {
35
40
  'browserAcceptHeader': accept_header,
36
41
  'browserUserAgent': user_agent,
@@ -48,11 +53,11 @@ def browser_info(
48
53
 
49
54
 
50
55
  def authentication_data(
51
- *, three_ds_server_trans_id, notification_url, protocol_version, browser, three_ds_comp_ind='Y'
56
+ *, three_ds_server_trans_id, notification_url, protocol_version, browser, three_ds_comp_ind=emv3ds.METHOD_COMPLETED
52
57
  ) -> dict:
53
- """EMV3DS payload for the authorization call (step 3). `browser` is `browser_info(...)`."""
58
+ """``emv3ds`` of the authentication (step 3); ``browser`` is ``browser_info(...)``."""
54
59
  data = {
55
- 'threeDSInfo': 'AuthenticationData',
60
+ 'threeDSInfo': emv3ds.AUTHENTICATION_DATA,
56
61
  'protocolVersion': protocol_version,
57
62
  'threeDSServerTransID': three_ds_server_trans_id,
58
63
  'notificationURL': notification_url,
@@ -63,5 +68,5 @@ def authentication_data(
63
68
 
64
69
 
65
70
  def challenge_response(*, cres, protocol_version) -> dict:
66
- """EMV3DS payload to finalize the authorization after the challenge (step 5)."""
67
- return {'threeDSInfo': 'ChallengeResponse', 'protocolVersion': protocol_version, 'cres': cres}
71
+ """``emv3ds`` of the operation after the challenge (step 5)."""
72
+ return {'threeDSInfo': emv3ds.CHALLENGE_RESPONSE, 'protocolVersion': protocol_version, 'cres': cres}