python-redsys 2.0.0__py3-none-any.whl → 2.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_redsys-2.2.0.dist-info/METADATA +187 -0
- python_redsys-2.2.0.dist-info/RECORD +34 -0
- redsys/__init__.py +38 -2
- redsys/_code_texts.py +4673 -0
- redsys/api/__init__.py +1 -1
- redsys/api/bizum.py +71 -0
- redsys/api/cards.py +50 -0
- redsys/api/currencies.py +18 -18
- redsys/api/emv3ds.py +99 -0
- redsys/api/environments.py +41 -0
- redsys/api/languages.py +9 -1
- redsys/api/options.py +31 -0
- redsys/api/pay_methods.py +25 -0
- redsys/api/paygold.py +33 -0
- redsys/api/processed_pay_methods.py +207 -0
- redsys/api/responses.py +290 -0
- redsys/api/sca.py +11 -3
- redsys/api/secure_payment.py +12 -0
- redsys/api/transactions.py +53 -8
- redsys/api/xpay.py +25 -0
- redsys/clients.py +270 -85
- redsys/codes.py +76 -0
- redsys/crypto.py +8 -8
- redsys/exceptions.py +5 -2
- redsys/models.py +309 -57
- redsys/orders.py +20 -0
- redsys/request.py +19 -13
- redsys/threeds.py +21 -16
- python_redsys-2.0.0.dist-info/METADATA +0 -214
- python_redsys-2.0.0.dist-info/RECORD +0 -20
- {python_redsys-2.0.0.dist-info → python_redsys-2.2.0.dist-info}/WHEEL +0 -0
- {python_redsys-2.0.0.dist-info → python_redsys-2.2.0.dist-info}/licenses/LICENSE +0 -0
- {python_redsys-2.0.0.dist-info → python_redsys-2.2.0.dist-info}/licenses/NOTICE +0 -0
- {python_redsys-2.0.0.dist-info → python_redsys-2.2.0.dist-info}/top_level.txt +0 -0
redsys/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
|
-
-
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
30
|
-
_LEGACY_FIELDS: dict[str, str
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
|
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='
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
None, alias='
|
|
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.
|
|
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
|
|
167
|
-
"""
|
|
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
|
|
177
|
-
"""
|
|
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
|
-
"""
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
|
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 ==
|
|
539
|
+
return self.response_code == responses.REFUND_OR_CONFIRMATION_AUTHORIZED
|
|
289
540
|
|
|
290
541
|
def is_canceled(self) -> bool:
|
|
291
|
-
return self.response_code ==
|
|
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
|
-
#
|
|
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 ==
|
|
573
|
+
return self.protocol_version == emv3ds.NO_3DS_V2
|
|
323
574
|
|
|
324
575
|
def requires_challenge(self) -> bool:
|
|
325
|
-
return self._emv3ds.get('threeDSInfo') ==
|
|
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,
|
|
12
|
+
"""A signed request, the return of every operation.
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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__(
|
|
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:
|