python-redsys 1.2.2__py3-none-any.whl → 2.1.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.1.0.dist-info/METADATA +225 -0
- python_redsys-2.1.0.dist-info/RECORD +20 -0
- {python_redsys-1.2.2.dist-info → python_redsys-2.1.0.dist-info}/WHEEL +2 -1
- python_redsys-2.1.0.dist-info/licenses/LICENSE +202 -0
- python_redsys-2.1.0.dist-info/licenses/NOTICE +7 -0
- python_redsys-2.1.0.dist-info/top_level.txt +1 -0
- redsys/__init__.py +3 -2
- redsys/api/__init__.py +1 -0
- redsys/api/cof.py +29 -0
- redsys/api/currencies.py +84 -0
- redsys/api/languages.py +67 -0
- redsys/api/sca.py +23 -0
- redsys/api/transactions.py +74 -0
- redsys/clients.py +277 -0
- redsys/crypto.py +123 -0
- redsys/exceptions.py +19 -0
- redsys/models.py +358 -0
- redsys/py.typed +1 -0
- redsys/request.py +54 -157
- redsys/threeds.py +67 -0
- python_redsys-1.2.2.dist-info/METADATA +0 -122
- python_redsys-1.2.2.dist-info/RECORD +0 -13
- python_redsys-1.2.2.dist-info/licenses/LICENSE +0 -25
- redsys/client.py +0 -105
- redsys/constants.py +0 -113
- redsys/response.py +0 -149
- redsys/tests/__init__.py +0 -0
- redsys/tests/test_client.py +0 -89
- redsys/tests/test_request.py +0 -74
- redsys/tests/test_response.py +0 -39
redsys/models.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Declarative request and response models.
|
|
2
|
+
|
|
3
|
+
Each field carries its RedSys wire name as a Pydantic alias, so a message serializes to the
|
|
4
|
+
``Ds_Merchant_*`` dict with ``model_dump(by_alias=True)`` and a response parses from the ``Ds_*``
|
|
5
|
+
dict with ``model_validate``. Field constraints follow these RedSys documents:
|
|
6
|
+
|
|
7
|
+
- TPV Virtual, Manual de Integración - Redirección, RS.TE.CEL.MAN.0039, version 4.2 (2026-02-04),
|
|
8
|
+
https://pagosonline.redsys.es/download/1735/
|
|
9
|
+
- TPV Virtual, Manual de Integración REST, RS.TE.CEL.MAN.0037, version 4.0.1 (2025-10-17),
|
|
10
|
+
https://pagosonline.redsys.es/download/1738/
|
|
11
|
+
- Guía de integración de Bizum, version 2.6 (2026-08-25), https://pagosonline.redsys.es/download/2053/
|
|
12
|
+
- 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.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from base64 import b64decode, b64encode
|
|
18
|
+
from decimal import Decimal
|
|
19
|
+
from json import dumps, loads
|
|
20
|
+
from typing import Any
|
|
21
|
+
from warnings import warn
|
|
22
|
+
|
|
23
|
+
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_serializer, field_validator, model_validator
|
|
24
|
+
|
|
25
|
+
from redsys.api.currencies import CURRENCIES, CURRENCY_DECIMALS
|
|
26
|
+
from redsys.api.transactions import TRANSACTION_TYPES
|
|
27
|
+
from redsys.exceptions import UnknownParameterWarning
|
|
28
|
+
|
|
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] = {
|
|
31
|
+
'payment_method': 'pay_methods',
|
|
32
|
+
'mobile_number': 'bizum_mobile_number',
|
|
33
|
+
'transaction_date': None,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def decode_merchant_parameters(data: bytes) -> dict:
|
|
38
|
+
"""Decode a Ds_MerchantParameters string in standard or URL-safe Base64, padded or not.
|
|
39
|
+
|
|
40
|
+
RedSys mixes the forms by signature version (HMAC_SHA512_V2 responses come unpadded).
|
|
41
|
+
"""
|
|
42
|
+
data = data.replace(b'-', b'+').replace(b'_', b'/')
|
|
43
|
+
return loads(b64decode(data + b'=' * (-len(data) % 4)).decode('utf-8'))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def currency_decimals(currency: int | None) -> int:
|
|
47
|
+
"""ISO-4217 minor-unit exponent of a currency; 2 when unknown."""
|
|
48
|
+
return CURRENCY_DECIMALS.get(currency, 2) if currency is not None else 2
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def minor_units_factor(currency: int | None) -> int:
|
|
52
|
+
"""Power-of-ten factor that converts major units to the wire minor units."""
|
|
53
|
+
return 10 ** currency_decimals(currency)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class BaseRequest(BaseModel):
|
|
57
|
+
"""Fields common to every operation message, with validation and wire serialization."""
|
|
58
|
+
|
|
59
|
+
# Construct with the snake_case names or the wire names; unknown names are screened below.
|
|
60
|
+
model_config = ConfigDict(populate_by_name=True, extra='allow')
|
|
61
|
+
|
|
62
|
+
merchant_code: str = Field(alias='Ds_Merchant_MerchantCode', max_length=9)
|
|
63
|
+
terminal: int = Field(alias='Ds_Merchant_Terminal', ge=1, le=999)
|
|
64
|
+
transaction_type: int = Field(0, alias='Ds_Merchant_TransactionType')
|
|
65
|
+
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')
|
|
67
|
+
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
|
+
merchant_data: str | None = Field(None, alias='Ds_Merchant_MerchantData', max_length=1024)
|
|
69
|
+
|
|
70
|
+
@model_validator(mode='before')
|
|
71
|
+
@classmethod
|
|
72
|
+
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
|
+
"""
|
|
79
|
+
if not isinstance(data, dict):
|
|
80
|
+
return data
|
|
81
|
+
known = {name for name in cls.model_fields} | {f.alias for f in cls.model_fields.values() if f.alias}
|
|
82
|
+
for key in data:
|
|
83
|
+
if key in known:
|
|
84
|
+
continue
|
|
85
|
+
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}.')
|
|
89
|
+
if key.lower().startswith('ds_merchant_'):
|
|
90
|
+
warn(f'{key!r} is not modeled by this library; sent as given.', UnknownParameterWarning, stacklevel=2)
|
|
91
|
+
continue
|
|
92
|
+
raise ValueError(f'unknown field {key!r}; pass unmodeled parameters by their wire name (Ds_Merchant_*).')
|
|
93
|
+
return data
|
|
94
|
+
|
|
95
|
+
@field_validator('transaction_type')
|
|
96
|
+
@classmethod
|
|
97
|
+
def _validate_transaction_type(cls, value: int) -> int:
|
|
98
|
+
if value not in TRANSACTION_TYPES:
|
|
99
|
+
raise ValueError('transaction_type is not valid.')
|
|
100
|
+
return value
|
|
101
|
+
|
|
102
|
+
@field_validator('currency')
|
|
103
|
+
@classmethod
|
|
104
|
+
def _validate_currency(cls, value: int) -> int:
|
|
105
|
+
if value not in CURRENCIES:
|
|
106
|
+
raise ValueError('currency is not valid.')
|
|
107
|
+
return value
|
|
108
|
+
|
|
109
|
+
@field_serializer('amount')
|
|
110
|
+
def _serialize_amount(self, value: Decimal) -> int:
|
|
111
|
+
return int(value * minor_units_factor(self.currency))
|
|
112
|
+
|
|
113
|
+
def get_order(self) -> str:
|
|
114
|
+
return self.order
|
|
115
|
+
|
|
116
|
+
def prepare_parameters(self) -> dict:
|
|
117
|
+
"""The request as the RedSys wire dict (``Ds_Merchant_*`` keys)."""
|
|
118
|
+
return self.model_dump(by_alias=True, exclude_none=True)
|
|
119
|
+
|
|
120
|
+
def encode(self) -> bytes:
|
|
121
|
+
return b64encode(dumps(self.prepare_parameters(), sort_keys=True).encode('utf-8'))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _StoredCredentialFields(BaseModel):
|
|
125
|
+
"""Tokenization and credential-on-file fields, accepted by both connections."""
|
|
126
|
+
|
|
127
|
+
identifier: str | None = Field(
|
|
128
|
+
None, alias='Ds_Merchant_Identifier', max_length=40, description='Stored-card token; see redsys.api.cof.'
|
|
129
|
+
)
|
|
130
|
+
direct_payment: str | None = Field(None, alias='Ds_Merchant_DirectPayment', description='"true" or "moto".')
|
|
131
|
+
cof_ini: str | None = Field(None, alias='Ds_Merchant_Cof_Ini', max_length=1, description='See redsys.api.cof.')
|
|
132
|
+
cof_type: str | None = Field(None, alias='Ds_Merchant_Cof_Type', max_length=1, description='See redsys.api.cof.')
|
|
133
|
+
cof_txnid: str | None = Field(None, alias='Ds_Merchant_Cof_Txnid', max_length=15)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class _RedirectFields(_StoredCredentialFields):
|
|
137
|
+
"""Fields of the redirect connection (browser form)."""
|
|
138
|
+
|
|
139
|
+
merchant_url: str | None = Field(None, alias='Ds_Merchant_MerchantURL', max_length=250)
|
|
140
|
+
url_ok: str | None = Field(None, alias='Ds_Merchant_UrlOK', max_length=250)
|
|
141
|
+
url_ko: str | None = Field(None, alias='Ds_Merchant_UrlKO', max_length=250)
|
|
142
|
+
merchant_name: str | None = Field(None, alias='Ds_Merchant_MerchantName', max_length=25)
|
|
143
|
+
product_description: str | None = Field(None, alias='Ds_Merchant_ProductDescription', max_length=125)
|
|
144
|
+
titular: str | None = Field(None, alias='Ds_Merchant_Titular', max_length=60)
|
|
145
|
+
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.')
|
|
147
|
+
bizum_mobile_number: str | None = Field(None, alias='Ds_Merchant_Bizum_MobileNumber', max_length=19)
|
|
148
|
+
excep_sca: str | None = Field(
|
|
149
|
+
None,
|
|
150
|
+
alias='Ds_Merchant_Excep_SCA',
|
|
151
|
+
max_length=3,
|
|
152
|
+
description='PSD2 SCA exemption requested; codes in redsys.api.sca.',
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class _DirectFields(_StoredCredentialFields):
|
|
157
|
+
"""Fields of the direct connection (server-to-server card data, 3DS, stored credentials)."""
|
|
158
|
+
|
|
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.')
|
|
164
|
+
# RedSys spells the wire name "Frecuency".
|
|
165
|
+
date_frequency: str | None = Field(None, alias='Ds_Merchant_DateFrecuency', description='Days between charges.')
|
|
166
|
+
charge_expiry_date: str | None = Field(None, alias='Ds_Merchant_ChargeExpiryDate', description='yyyy-MM-dd.')
|
|
167
|
+
dcc: str | dict[str, Any] | None = Field(
|
|
168
|
+
None, alias='Ds_Merchant_DCC', description='"Y" on the 3DS initialization; then the accepted offer.'
|
|
169
|
+
)
|
|
170
|
+
excep_sca: str | None = Field(
|
|
171
|
+
None,
|
|
172
|
+
alias='Ds_Merchant_Excep_SCA',
|
|
173
|
+
max_length=3,
|
|
174
|
+
description='PSD2 SCA exemption requested; codes in redsys.api.sca. "Y" on the 3DS initialization asks.',
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class RedirectPaymentMessage(BaseRequest, _RedirectFields):
|
|
179
|
+
"""Redirect-connection payment (transaction type 0)."""
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class RedirectPreauthorizationMessage(BaseRequest, _RedirectFields):
|
|
183
|
+
"""Redirect-connection preauthorization (transaction type 1)."""
|
|
184
|
+
|
|
185
|
+
transaction_type: int = Field(1, alias='Ds_Merchant_TransactionType')
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class DirectPaymentMessage(BaseRequest, _DirectFields):
|
|
189
|
+
"""Direct-connection card payment (transaction type 0)."""
|
|
190
|
+
|
|
191
|
+
sum_total: Decimal | None = Field(
|
|
192
|
+
None, alias='Ds_Merchant_SumTotal', description='Recurring series total; scaled like amount.'
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
@field_serializer('sum_total')
|
|
196
|
+
def _serialize_sum_total(self, value: Decimal | None) -> int | None:
|
|
197
|
+
return int(value * minor_units_factor(self.currency)) if value is not None else None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class DirectPreauthorizationMessage(BaseRequest, _DirectFields):
|
|
201
|
+
"""Direct-connection preauthorization (transaction type 1)."""
|
|
202
|
+
|
|
203
|
+
transaction_type: int = Field(1, alias='Ds_Merchant_TransactionType')
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class ConfirmationMessage(BaseRequest):
|
|
207
|
+
"""Capture of a previous preauthorization (transaction type 2)."""
|
|
208
|
+
|
|
209
|
+
transaction_type: int = Field(2, alias='Ds_Merchant_TransactionType')
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class RefundMessage(BaseRequest):
|
|
213
|
+
"""Refund of a previous payment (transaction type 3)."""
|
|
214
|
+
|
|
215
|
+
transaction_type: int = Field(3, alias='Ds_Merchant_TransactionType')
|
|
216
|
+
authorization_code: str | None = Field(None, alias='Ds_Merchant_AuthorisationCode', max_length=6)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class CancellationMessage(BaseRequest):
|
|
220
|
+
"""Cancellation of a preauthorization (transaction type 9)."""
|
|
221
|
+
|
|
222
|
+
transaction_type: int = Field(9, alias='Ds_Merchant_TransactionType')
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class Response(BaseModel):
|
|
226
|
+
"""A parsed RedSys response with the standard result predicates.
|
|
227
|
+
|
|
228
|
+
Every field is optional; presence varies by operation and outcome. Parameters the
|
|
229
|
+
library does not list are kept, under their wire name, in ``model_extra``.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
model_config = ConfigDict(extra='allow', populate_by_name=True)
|
|
233
|
+
|
|
234
|
+
response: str | None = Field(None, alias='Ds_Response', description='Result code, 4 digits.')
|
|
235
|
+
amount: Decimal | None = Field(None, alias='Ds_Amount', description='Scaled from minor units by currency.')
|
|
236
|
+
amount_euro: str | None = Field(None, alias='Ds_Amount_Euro', description='Raw wire value; non-euro operations.')
|
|
237
|
+
date: str | None = Field(None, alias='Ds_Date')
|
|
238
|
+
hour: str | None = Field(None, alias='Ds_Hour')
|
|
239
|
+
merchant_code: str | None = Field(None, alias='Ds_MerchantCode')
|
|
240
|
+
terminal: str | None = Field(None, alias='Ds_Terminal')
|
|
241
|
+
transaction_type: str | None = Field(None, alias='Ds_TransactionType')
|
|
242
|
+
order: str | None = Field(None, alias='Ds_Order')
|
|
243
|
+
currency: int | None = Field(None, alias='Ds_Currency')
|
|
244
|
+
merchant_data: str | None = Field(None, alias='Ds_MerchantData')
|
|
245
|
+
consumer_language: str | None = Field(None, alias='Ds_ConsumerLanguage', description='Redirect notification.')
|
|
246
|
+
language: str | None = Field(None, alias='Ds_Language', description='REST responses.')
|
|
247
|
+
card_country: str | None = Field(None, alias='Ds_Card_Country')
|
|
248
|
+
card_type: str | None = Field(None, alias='Ds_Card_Type')
|
|
249
|
+
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.
|
|
252
|
+
card_typology: str | None = Field(None, alias='Ds_Card_Typology', description='For example CONSUMO.')
|
|
253
|
+
eci: str | None = Field(None, alias='Ds_ECI', description='E-commerce indicator of the authentication.')
|
|
254
|
+
response_description: str | None = Field(None, alias='Ds_Response_Description')
|
|
255
|
+
secure_payment: str | None = Field(None, alias='Ds_SecurePayment')
|
|
256
|
+
authorization_code: str | None = Field(None, alias='Ds_AuthorisationCode')
|
|
257
|
+
error_code: str | None = Field(None, alias='Ds_ErrorCode')
|
|
258
|
+
# REST responses send Ds_CardNumber; redirect notifications send Ds_Card_Number.
|
|
259
|
+
card_number: str | None = Field(
|
|
260
|
+
None, alias='Ds_CardNumber', validation_alias=AliasChoices('Ds_CardNumber', 'Ds_Card_Number', 'card_number')
|
|
261
|
+
)
|
|
262
|
+
expiry_date: str | None = Field(None, alias='Ds_ExpiryDate')
|
|
263
|
+
card_psd2: str | None = Field(None, alias='Ds_Card_PSD2')
|
|
264
|
+
excep_sca: str | None = Field(
|
|
265
|
+
None, alias='Ds_Excep_SCA', description='Exemptions the card allows, from the 3DS initialization.'
|
|
266
|
+
)
|
|
267
|
+
emv3ds: dict[str, Any] | None = Field(None, alias='Ds_EMV3DS')
|
|
268
|
+
dcc: dict[str, Any] | None = Field(None, alias='Ds_DCC', description='Offer from the 3DS initialization.')
|
|
269
|
+
identifier: str | None = Field(None, alias='Ds_Merchant_Identifier', description='Token returned on request.')
|
|
270
|
+
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.')
|
|
272
|
+
bizum_id_oper: str | None = Field(None, alias='Ds_Bizum_IdOper')
|
|
273
|
+
bizum_cuenta_truncada: str | None = Field(None, alias='Ds_Bizum_CuentaTruncada')
|
|
274
|
+
currency_dcc: str | None = Field(None, alias='Ds_Currency_DCC', description='Dynamic currency conversion.')
|
|
275
|
+
amount_dcc: str | None = Field(None, alias='Ds_Amount_DCC', description='Minor units in Ds_Currency_DCC.')
|
|
276
|
+
|
|
277
|
+
@field_validator('amount', mode='before')
|
|
278
|
+
@classmethod
|
|
279
|
+
def _blank_amount_to_none(cls, value):
|
|
280
|
+
# Keep the raw integer minor units here; scaling needs the currency and
|
|
281
|
+
# is applied in from_merchant_parameters.
|
|
282
|
+
return None if value in (None, '') else value
|
|
283
|
+
|
|
284
|
+
@classmethod
|
|
285
|
+
def from_merchant_parameters(cls, merchant_parameters: bytes) -> 'Response':
|
|
286
|
+
response = cls.model_validate(decode_merchant_parameters(merchant_parameters))
|
|
287
|
+
if response.amount is not None:
|
|
288
|
+
# scaleb keeps the currency's decimals: 100 EUR minor units read as 1.00, not 1.
|
|
289
|
+
response.amount = response.amount.scaleb(-currency_decimals(response.currency))
|
|
290
|
+
return response
|
|
291
|
+
|
|
292
|
+
def get_order(self) -> str | None:
|
|
293
|
+
return self.order
|
|
294
|
+
|
|
295
|
+
@property
|
|
296
|
+
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.
|
|
299
|
+
return int(self.response) if self.response not in (None, '') else None
|
|
300
|
+
|
|
301
|
+
def is_paid(self) -> bool:
|
|
302
|
+
return self.response_code is not None and 0 <= self.response_code <= 99
|
|
303
|
+
|
|
304
|
+
def is_refunded(self) -> bool:
|
|
305
|
+
return self.response_code == 900
|
|
306
|
+
|
|
307
|
+
def is_canceled(self) -> bool:
|
|
308
|
+
return self.response_code == 400
|
|
309
|
+
|
|
310
|
+
def is_authorized(self) -> bool:
|
|
311
|
+
return self.is_paid() or self.is_refunded() or self.is_canceled()
|
|
312
|
+
|
|
313
|
+
# 3-D Secure (EMV 3DS) accessors over the Ds_EMV3DS response object.
|
|
314
|
+
@property
|
|
315
|
+
def _emv3ds(self) -> dict:
|
|
316
|
+
return self.emv3ds or {}
|
|
317
|
+
|
|
318
|
+
@property
|
|
319
|
+
def protocol_version(self):
|
|
320
|
+
return self._emv3ds.get('protocolVersion')
|
|
321
|
+
|
|
322
|
+
@property
|
|
323
|
+
def three_ds_server_trans_id(self):
|
|
324
|
+
return self._emv3ds.get('threeDSServerTransID')
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def three_ds_method_url(self):
|
|
328
|
+
return self._emv3ds.get('threeDSMethodURL')
|
|
329
|
+
|
|
330
|
+
@property
|
|
331
|
+
def acs_url(self):
|
|
332
|
+
return self._emv3ds.get('acsURL')
|
|
333
|
+
|
|
334
|
+
@property
|
|
335
|
+
def creq(self):
|
|
336
|
+
return self._emv3ds.get('creq')
|
|
337
|
+
|
|
338
|
+
def is_no_3ds(self) -> bool:
|
|
339
|
+
return self.protocol_version == 'NO_3DS_V2'
|
|
340
|
+
|
|
341
|
+
def requires_challenge(self) -> bool:
|
|
342
|
+
return self._emv3ds.get('threeDSInfo') == 'ChallengeRequest'
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
__all__ = [
|
|
346
|
+
'BaseRequest',
|
|
347
|
+
'RedirectPaymentMessage',
|
|
348
|
+
'RedirectPreauthorizationMessage',
|
|
349
|
+
'DirectPaymentMessage',
|
|
350
|
+
'DirectPreauthorizationMessage',
|
|
351
|
+
'ConfirmationMessage',
|
|
352
|
+
'RefundMessage',
|
|
353
|
+
'CancellationMessage',
|
|
354
|
+
'Response',
|
|
355
|
+
'decode_merchant_parameters',
|
|
356
|
+
'currency_decimals',
|
|
357
|
+
'minor_units_factor',
|
|
358
|
+
]
|
redsys/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
redsys/request.py
CHANGED
|
@@ -1,166 +1,63 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
# Wire field names of the request/response envelope.
|
|
2
|
+
SIGNATURE_VERSION = 'Ds_SignatureVersion'
|
|
3
|
+
MERCHANT_PARAMETERS = 'Ds_MerchantParameters'
|
|
4
|
+
SIGNATURE = 'Ds_Signature'
|
|
5
5
|
|
|
6
|
-
from redsys.constants import CURRENCIES
|
|
7
|
-
from redsys.constants import LANGUAGES
|
|
8
|
-
from redsys.constants import TRANSACTIONS
|
|
9
6
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
TERMINAL = "Ds_Merchant_Terminal"
|
|
13
|
-
TRANSACTION_TYPE = "Ds_Merchant_TransactionType"
|
|
14
|
-
ORDER = "Ds_Merchant_Order"
|
|
15
|
-
CURRENCY = "Ds_Merchant_Currency"
|
|
16
|
-
AMOUNT = "Ds_Merchant_Amount"
|
|
17
|
-
|
|
18
|
-
# Recurring transaction parameters
|
|
19
|
-
SUM_TOTAL = "Ds_Merchant_SumTotal"
|
|
20
|
-
DATE_FREQUENCY = "Ds_Merchant_DateFrecuency"
|
|
21
|
-
CHARGE_EXPIRY_DATE = "Ds_Merchant_ChargeExpiryDate"
|
|
22
|
-
TRANSACTION_DATE = "Ds_Merchant_TransactionDate"
|
|
23
|
-
AUTHORIZATION_CODE = "Ds_Merchant_AuthorisationCode"
|
|
24
|
-
|
|
25
|
-
# Optional parameters
|
|
26
|
-
MERCHANT_DATA = "Ds_Merchant_MerchantData"
|
|
27
|
-
|
|
28
|
-
# Redirect client paramenters
|
|
29
|
-
MERCHANT_NAME = "Ds_Merchant_MerchantName"
|
|
30
|
-
PRODUCT_DESCRIPTION = "Ds_Merchant_ProductDescription"
|
|
31
|
-
TITULAR = "Ds_Merchant_Titular"
|
|
32
|
-
MERCHANT_URL = "Ds_Merchant_MerchantURL"
|
|
33
|
-
URL_OK = "Ds_Merchant_UrlOK"
|
|
34
|
-
URL_KO = "Ds_Merchant_UrlKO"
|
|
35
|
-
CONSUMER_LANGUAGE = "Ds_Merchant_ConsumerLanguage"
|
|
36
|
-
|
|
37
|
-
# Credit card data parameters
|
|
38
|
-
PAN = "Ds_Merchant_Pan"
|
|
39
|
-
EXPIRY_DATE = "Ds_Merchant_ExpiryDate"
|
|
40
|
-
CVV2 = "Ds_Merchant_Cvv2"
|
|
41
|
-
|
|
42
|
-
# BIZUM PAYMENT parameters
|
|
43
|
-
MOBILE_NUMBER = "Ds_Merchant_Bizum_MobileNumber"
|
|
44
|
-
PAYMENT_METHOD = "Ds_Merchant_PayMethods"
|
|
45
|
-
|
|
46
|
-
MERCHANT_PARAMETERS_MAP = {
|
|
47
|
-
"merchant_code": MERCHANT_CODE,
|
|
48
|
-
"terminal": TERMINAL,
|
|
49
|
-
"transaction_type": TRANSACTION_TYPE,
|
|
50
|
-
"order": ORDER,
|
|
51
|
-
"currency": CURRENCY,
|
|
52
|
-
"amount": AMOUNT,
|
|
53
|
-
"sum_total": SUM_TOTAL,
|
|
54
|
-
"date_frequency": DATE_FREQUENCY,
|
|
55
|
-
"charge_expiry_date": CHARGE_EXPIRY_DATE,
|
|
56
|
-
"transaction_date": TRANSACTION_DATE,
|
|
57
|
-
"authorization_code": AUTHORIZATION_CODE,
|
|
58
|
-
"merchant_data": MERCHANT_DATA,
|
|
59
|
-
"merchant_name": MERCHANT_NAME,
|
|
60
|
-
"product_description": PRODUCT_DESCRIPTION,
|
|
61
|
-
"titular": TITULAR,
|
|
62
|
-
"merchant_url": MERCHANT_URL,
|
|
63
|
-
"url_ok": URL_OK,
|
|
64
|
-
"url_ko": URL_KO,
|
|
65
|
-
"consumer_language": CONSUMER_LANGUAGE,
|
|
66
|
-
"pan": PAN,
|
|
67
|
-
"expiry_date": EXPIRY_DATE,
|
|
68
|
-
"cvv2": CVV2,
|
|
69
|
-
"mobile_number": MOBILE_NUMBER,
|
|
70
|
-
"payment_method": PAYMENT_METHOD,
|
|
71
|
-
}
|
|
7
|
+
def _as_text(value):
|
|
8
|
+
return value.decode('utf-8') if isinstance(value, (bytes, bytearray)) else value
|
|
72
9
|
|
|
73
10
|
|
|
74
11
|
class Request:
|
|
75
|
-
"""
|
|
76
|
-
Defines an atomic request with all the required parameters and sanitize
|
|
77
|
-
their values according to the platform specifications
|
|
78
|
-
"""
|
|
79
|
-
|
|
80
|
-
_parameters: Dict[str, Any]
|
|
81
|
-
|
|
82
|
-
def __init__(self, parameters: Dict[str, Any]) -> None:
|
|
83
|
-
# __setattr__ only handles merchant parameters, so the per-instance store is set directly.
|
|
84
|
-
object.__setattr__(self, "_parameters", {})
|
|
85
|
-
for key, value in parameters.items():
|
|
86
|
-
if key in MERCHANT_PARAMETERS_MAP:
|
|
87
|
-
if check := getattr(self, f"check_{str(key)}", None):
|
|
88
|
-
check(value)
|
|
89
|
-
self._parameters[key] = value
|
|
90
|
-
else:
|
|
91
|
-
raise ValueError(f"Unknown parameter {key}")
|
|
92
|
-
|
|
93
|
-
def __getattr__(self, item: str) -> Any:
|
|
94
|
-
if item in MERCHANT_PARAMETERS_MAP:
|
|
95
|
-
return self._parameters.get(item)
|
|
12
|
+
"""A signed request, ready to serialize or send -- the return of every operation.
|
|
96
13
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
check(value)
|
|
101
|
-
self._parameters[key] = value
|
|
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:
|
|
102
17
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
return parameters
|
|
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()``.
|
|
109
23
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
return int(value * 100)
|
|
113
|
-
|
|
114
|
-
@staticmethod
|
|
115
|
-
def prepare_sum_total(value):
|
|
116
|
-
return int(value * 100)
|
|
117
|
-
|
|
118
|
-
@staticmethod
|
|
119
|
-
def check_order(value):
|
|
120
|
-
if not re.match(r"[0-9]{4}[a-zA-Z0-9]{8}$", value):
|
|
121
|
-
raise ValueError("order format is not valid.")
|
|
122
|
-
|
|
123
|
-
@staticmethod
|
|
124
|
-
def check_transaction_type(value):
|
|
125
|
-
if value not in TRANSACTIONS:
|
|
126
|
-
raise ValueError("transaction_type is not valid.")
|
|
127
|
-
|
|
128
|
-
@staticmethod
|
|
129
|
-
def check_currency(value):
|
|
130
|
-
if value not in CURRENCIES:
|
|
131
|
-
raise ValueError("currency is not valid.")
|
|
132
|
-
|
|
133
|
-
@staticmethod
|
|
134
|
-
def check_amount(value):
|
|
135
|
-
if not isinstance(value, Decimal):
|
|
136
|
-
raise TypeError("amount must be defined as decimal.Decimal.")
|
|
137
|
-
|
|
138
|
-
@staticmethod
|
|
139
|
-
def check_sum_total(value):
|
|
140
|
-
if type(value) is not Decimal:
|
|
141
|
-
raise TypeError("sum_total must be defined as decimal.Decimal.")
|
|
142
|
-
|
|
143
|
-
@staticmethod
|
|
144
|
-
def check_merchant_data(value):
|
|
145
|
-
if len(value) > 1024:
|
|
146
|
-
raise ValueError("merchant_data cannot be longer than 1024 characters.")
|
|
147
|
-
|
|
148
|
-
@staticmethod
|
|
149
|
-
def check_merchant_url(value):
|
|
150
|
-
if len(value) > 250:
|
|
151
|
-
raise ValueError("merchant_url cannot be longer than 250 characters.")
|
|
152
|
-
|
|
153
|
-
@staticmethod
|
|
154
|
-
def check_url_ok(value):
|
|
155
|
-
if len(value) > 250:
|
|
156
|
-
raise ValueError("url_ok cannot be longer than 250 characters.")
|
|
157
|
-
|
|
158
|
-
@staticmethod
|
|
159
|
-
def check_url_ko(value):
|
|
160
|
-
if len(value) > 250:
|
|
161
|
-
raise ValueError("url_ko cannot be longer than 250 characters.")
|
|
24
|
+
Async clients bind an awaitable sender, so ``await request.send()`` works.
|
|
25
|
+
"""
|
|
162
26
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
27
|
+
def __init__(self, merchant_parameters: bytes, signature: bytes, signature_version: str, *, sender=None):
|
|
28
|
+
self._merchant_parameters = merchant_parameters
|
|
29
|
+
self._signature = signature
|
|
30
|
+
self._signature_version = signature_version
|
|
31
|
+
self._sender = sender
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def merchant_parameters(self) -> bytes:
|
|
35
|
+
return self._merchant_parameters
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def signature(self) -> bytes:
|
|
39
|
+
return self._signature
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def signature_version(self) -> str:
|
|
43
|
+
return self._signature_version
|
|
44
|
+
|
|
45
|
+
def to_dict(self) -> dict:
|
|
46
|
+
"""The three wire fields (``Ds_SignatureVersion`` / ``Ds_MerchantParameters`` / ``Ds_Signature``)."""
|
|
47
|
+
return {
|
|
48
|
+
SIGNATURE_VERSION: self._signature_version,
|
|
49
|
+
MERCHANT_PARAMETERS: _as_text(self._merchant_parameters),
|
|
50
|
+
SIGNATURE: _as_text(self._signature),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
def send(self, **httpx_kwargs):
|
|
54
|
+
"""Perform the direct (REST) call and return the parsed ``Response``.
|
|
55
|
+
|
|
56
|
+
Raises ``TypeError`` for redirect requests, which the browser submits;
|
|
57
|
+
use ``to_dict()`` to get their POST parameters instead.
|
|
58
|
+
"""
|
|
59
|
+
if self._sender is None:
|
|
60
|
+
raise TypeError(
|
|
61
|
+
'Redirect requests are submitted by the browser; call to_dict() to obtain the POST parameters.'
|
|
62
|
+
)
|
|
63
|
+
return self._sender(self, **httpx_kwargs)
|
redsys/threeds.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Builders for the Ds_Merchant_EMV3DS object used in 3-D Secure v2 (REST direct connection).
|
|
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.
|
|
6
|
+
|
|
7
|
+
Each function returns the dict to assign to a request's `emv3ds` field, e.g.
|
|
8
|
+
|
|
9
|
+
from redsys import threeds
|
|
10
|
+
|
|
11
|
+
response = client.inicia(..., emv3ds=threeds.card_data()).send()
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def card_data() -> dict:
|
|
16
|
+
"""EMV3DS payload for the iniciaPeticionREST call (step 1)."""
|
|
17
|
+
return {'threeDSInfo': 'CardData'}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def browser_info(
|
|
21
|
+
*,
|
|
22
|
+
accept_header,
|
|
23
|
+
user_agent,
|
|
24
|
+
language,
|
|
25
|
+
color_depth,
|
|
26
|
+
screen_height,
|
|
27
|
+
screen_width,
|
|
28
|
+
tz,
|
|
29
|
+
java_enabled=False,
|
|
30
|
+
javascript_enabled=True,
|
|
31
|
+
ip=None,
|
|
32
|
+
) -> dict:
|
|
33
|
+
"""Cardholder browser data the merchant page collects for the authorization."""
|
|
34
|
+
data = {
|
|
35
|
+
'browserAcceptHeader': accept_header,
|
|
36
|
+
'browserUserAgent': user_agent,
|
|
37
|
+
'browserJavaEnabled': 'true' if java_enabled else 'false',
|
|
38
|
+
'browserJavascriptEnabled': 'true' if javascript_enabled else 'false',
|
|
39
|
+
'browserLanguage': language,
|
|
40
|
+
'browserColorDepth': str(color_depth),
|
|
41
|
+
'browserScreenHeight': str(screen_height),
|
|
42
|
+
'browserScreenWidth': str(screen_width),
|
|
43
|
+
'browserTZ': str(tz),
|
|
44
|
+
}
|
|
45
|
+
if ip is not None:
|
|
46
|
+
data['browserIP'] = ip
|
|
47
|
+
return data
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def authentication_data(
|
|
51
|
+
*, three_ds_server_trans_id, notification_url, protocol_version, browser, three_ds_comp_ind='Y'
|
|
52
|
+
) -> dict:
|
|
53
|
+
"""EMV3DS payload for the authorization call (step 3). `browser` is `browser_info(...)`."""
|
|
54
|
+
data = {
|
|
55
|
+
'threeDSInfo': 'AuthenticationData',
|
|
56
|
+
'protocolVersion': protocol_version,
|
|
57
|
+
'threeDSServerTransID': three_ds_server_trans_id,
|
|
58
|
+
'notificationURL': notification_url,
|
|
59
|
+
'threeDSCompInd': three_ds_comp_ind,
|
|
60
|
+
}
|
|
61
|
+
data.update(browser)
|
|
62
|
+
return data
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
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}
|