diff --git a/redsys/constants.py b/redsys/constants.py index 3397aec..20c6bb0 100644 --- a/redsys/constants.py +++ b/redsys/constants.py @@ -111,3 +111,117 @@ RECURRING_DEFERRED_PREAUTHORIZATION, SUCCESSIVE_RECURRING_TRANSACTION, ] + + +""" +Tokenization +------------ + +Tokenization is a security process that replaces sensitive card information +(such as the PAN, expiration date, and CVV) with a randomly generated unique +identifier known as a token . This token has no value outside the specific +system for which it was created and cannot be used to conduct fraudulent +transactions if intercepted. + +Credential on File (COF) +------------------------ + +A COF transaction, also known as Credential on File or Card on File, is a +transaction in which the merchant uses the cardholder's card data (PAN or +tokenized PAN and expiration date), with the cardholder having explicitly +authorized the merchant to store and use this data in that and subsequent +transactions. A COF transaction can be initiated by the cardholder or by +the merchant as a result of an agreement between the cardholder and the +merchant. When performing a COF transaction, you must identify the specific +scenario in which you intend to operate. + +Types of COF operations +----------------------- + +It's necessary to consider whether the operation is the first COF operation, +meaning the request and storage of credentials, or a subsequent one, meaning +you'll be using previously saved credentials. Additionally, you need to know +what type of operation you're going to perform: + +Main operations. + +* Installments / Deferred Payment: Always referring to an individual + purchase, the amount of the transactions must be fixed, and with a defined + time interval. +* Recurring / Recurring Payment: The amount of the transactions can be + variable, but the time interval must be defined. + +Special operations. + +* Reauthorization: This is usually done for partial shipments or when the + customer extends the paid service (hotel stay, vehicle rental, etc.) or when, + having an estimated authorization, the final amount is requested. +* Resubmission: Used when the original has been refused due to "insufficient + funds." It can only be used in certain sectors; you should consult the + trademark regulations for more information. +* Delayed: Charges made after the main transaction for services rendered, + such as use of the minibar in a hotel stay or damage to the rented vehicle. +* Incremental: Used when the contracted service incurs additional expenses + not included in the main operation. +* No Show: Type used when the business charges for services that the account + holder contracted but ultimately did not show up or did not use, such as a + hotel reservation that was not cancelled. + +More info: https://pagosonline.redsys.es/desarrolladores-inicio/documentacion-funcionalidades-avanzadas/tokenizacion/ +""" +COF_TYPE_INSTALLMENTS = "I" +COF_TYPE_RECURRING = "R" +COF_TYPE_REAUTHORISATION = "H" +COF_TYPE_RESUBMISSION = "E" +COF_TYPE_DELAYED = "D" +COF_TYPE_INCREMENTAL = "M" +COF_TYPE_NO_SHOW = "N" +COF_TYPE_OTRAS = "C" + +TYPES_OF_COF = [ + COF_TYPE_INSTALLMENTS, + COF_TYPE_RECURRING, + COF_TYPE_REAUTHORISATION, + COF_TYPE_RESUBMISSION, + COF_TYPE_DELAYED, + COF_TYPE_INCREMENTAL, + COF_TYPE_NO_SHOW, + COF_TYPE_OTRAS, +] + + +""" +COF Transition Indicator + S: It is first COF transaction (store credentials) + N: It is not the first COF transaction +""" +COF_FIRST_TRANSACTION = "S" +COF_NOT_FIRST_TRANSACTION = "N" +COF_TRANSACTIONS = [ + COF_FIRST_TRANSACTION, + COF_NOT_FIRST_TRANSACTION, +] + + +""" +Strong Customer Authentication (SCA): + +PSD2 requires payment service providers (also called PSPs) to implement +additional security measures to verify the customer's identity when making +electronic transactions, such as using one-time codes via SMS or accepting +the transaction within their bank's mobile application. +""" + +SCA_EXCEP_MIT = "MIT" # Merchant-Initiated Transactions +SCA_EXCEP_LWV = "LWV" # Low-value transactions +SCA_EXCEP_TRA = "TRA" # Low-risk transactions +SCA_EXCEP_COR = "COR" # Corporate Payment +SCA_EXCEP_ATD = "ATD" # Trusted Beneficiaries + +SCA_EXEMPTIONS = [ + SCA_EXCEP_MIT, + SCA_EXCEP_LWV, + SCA_EXCEP_TRA, + SCA_EXCEP_COR, + SCA_EXCEP_ATD, +] diff --git a/redsys/request.py b/redsys/request.py index 31d275e..af4fa42 100644 --- a/redsys/request.py +++ b/redsys/request.py @@ -3,9 +3,12 @@ from typing import Any from typing import Dict +from redsys.constants import COF_TRANSACTIONS from redsys.constants import CURRENCIES from redsys.constants import LANGUAGES +from redsys.constants import SCA_EXEMPTIONS from redsys.constants import TRANSACTIONS +from redsys.constants import TYPES_OF_COF # General parameters MERCHANT_CODE = "Ds_Merchant_MerchantCode" @@ -15,6 +18,15 @@ CURRENCY = "Ds_Merchant_Currency" AMOUNT = "Ds_Merchant_Amount" +# Tokenization - Credential on File (COF) +MERCHANT_IDENTIFIER = "Ds_Merchant_Identifier" +MERCHANT_COF_INI = "Ds_Merchant_COF_INI" +MERCHANT_COF_TYPE = "Ds_Merchant_COF_TYPE" +MERCHANT_COF_TXNID = "Ds_Merchant_Cof_Txnid" +# Merchant-Initiated Transactions (MITs) +MERCHANT_EXCEP_SCA = "Ds_Merchant_Excep_Sca" +MERCHANT_DIRECTPAYMENT = "Ds_Merchant_DirectPayment" + # Recurring transaction parameters SUM_TOTAL = "Ds_Merchant_SumTotal" DATE_FREQUENCY = "Ds_Merchant_DateFrecuency" @@ -57,6 +69,12 @@ "authorization_code": AUTHORIZATION_CODE, "merchant_data": MERCHANT_DATA, "merchant_name": MERCHANT_NAME, + "merchant_identifier": MERCHANT_IDENTIFIER, + "merchant_cof_txnid": MERCHANT_COF_TXNID, + "merchant_cof_ini": MERCHANT_COF_INI, + "merchant_cof_type": MERCHANT_COF_TYPE, + "merchant_excep_sca": MERCHANT_EXCEP_SCA, + "merchant_directpayment": MERCHANT_DIRECTPAYMENT, "product_description": PRODUCT_DESCRIPTION, "titular": TITULAR, "merchant_url": MERCHANT_URL, @@ -135,9 +153,29 @@ def check_amount(value): @staticmethod def check_sum_total(value): - if type(value) is not Decimal: + if not isinstance(value, Decimal): raise TypeError("sum_total must be defined as decimal.Decimal.") + @staticmethod + def check_authorization_code(value): + if not re.match(r"^[a-zA-Z0-9]{1,6}$", value): + raise ValueError("authorization_code format is not valid.") + + @staticmethod + def check_merchant_cof_ini(value): + if value not in COF_TRANSACTIONS: + raise ValueError("merchant_cof_ini is not valid.") + + @staticmethod + def check_merchant_cof_type(value): + if value not in TYPES_OF_COF: + raise ValueError("merchant_cof_type is not valid.") + + @staticmethod + def check_merchant_excep_sca(value): + if value not in SCA_EXEMPTIONS: + raise ValueError("merchant_excep_sca is not valid.") + @staticmethod def check_merchant_data(value): if len(value) > 1024: diff --git a/redsys/tests/test_client.py b/redsys/tests/test_client.py index c4a2056..179e436 100644 --- a/redsys/tests/test_client.py +++ b/redsys/tests/test_client.py @@ -3,12 +3,30 @@ import pytest +from redsys.client import Client from redsys.client import RedirectClient from redsys.constants import EUR from redsys.constants import STANDARD_PAYMENT from redsys.request import Request +class TestClient: + def test_abstract_create_response_raises_not_implemented(self): + class DummyClient(Client): + def create_response(self, signature, parameters): + return super().create_response(signature, parameters) + + def prepare_request(self, request): + return super().prepare_request(request) + + client = DummyClient("secret") + with pytest.raises(NotImplementedError): + client.create_response("sig", "params") + + with pytest.raises(NotImplementedError): + client.prepare_request({}) + + class TestRedirectClient: @pytest.fixture(autouse=True) def set_up(self): diff --git a/redsys/tests/test_request.py b/redsys/tests/test_request.py index 50b37be..5f1f788 100644 --- a/redsys/tests/test_request.py +++ b/redsys/tests/test_request.py @@ -63,3 +63,71 @@ def test_super_long_merchant_url_throws_error(self): merchant_url = "".join(choice("abcdefghijklmnopqrtsuvwxyz-0123456789") for _ in range(251)) with pytest.raises(ValueError): assert Request.check_merchant_url(merchant_url) + + def test_setattr(self): + request = Request({ + "merchant_code": "100000001", + "terminal": "1", + "transaction_type": STANDARD_PAYMENT, + "currency": EUR, + "order": "000000000001", + "amount": D("10.00"), + }) + request.merchant_data = "new data" + assert request.merchant_data == "new data" + + def test_check_order_invalid_format(self): + with pytest.raises(ValueError, match="order format is not valid"): + Request.check_order("invalid") + + def test_check_transaction_type_invalid(self): + with pytest.raises(ValueError, match="transaction_type is not valid"): + Request.check_transaction_type("X") + + def test_check_currency_invalid(self): + with pytest.raises(ValueError, match="currency is not valid"): + Request.check_currency(999) + + def test_check_amount_wrong_type(self): + with pytest.raises(TypeError, match="amount must be defined as decimal.Decimal"): + Request.check_amount("10.00") + + def test_check_sum_total_wrong_type(self): + with pytest.raises(TypeError, match="sum_total must be defined as decimal.Decimal"): + Request.check_sum_total("10.00") + + def test_check_merchant_cof_ini_invalid(self): + with pytest.raises(ValueError, match="merchant_cof_ini is not valid"): + Request.check_merchant_cof_ini("X") + + def test_check_merchant_cof_type_invalid(self): + with pytest.raises(ValueError, match="merchant_cof_type is not valid"): + Request.check_merchant_cof_type("X") + + def test_check_merchant_excep_sca_invalid(self): + with pytest.raises(ValueError, match="merchant_excep_sca is not valid"): + Request.check_merchant_excep_sca("X") + + def test_check_url_ok_too_long(self): + url_ok = "".join(choice("a") for _ in range(251)) + with pytest.raises(ValueError, match="url_ok cannot be longer than 250 characters"): + Request.check_url_ok(url_ok) + + def test_check_url_ko_too_long(self): + url_ko = "".join(choice("a") for _ in range(251)) + with pytest.raises(ValueError, match="url_ko cannot be longer than 250 characters"): + Request.check_url_ko(url_ko) + + def test_check_consumer_language_invalid(self): + with pytest.raises(ValueError, match="consumer_language is not valid"): + Request.check_consumer_language("999") + + def test_prepare_sum_total(self): + result = Request.prepare_sum_total(D("100.50")) + assert result == 10050 + + def test_check_authorization_code_invalid_format(self): + with pytest.raises(ValueError, match="authorization_code format is not valid"): + Request.check_authorization_code("invalid!") + with pytest.raises(ValueError, match="authorization_code format is not valid"): + Request.check_authorization_code("12345678") diff --git a/redsys/tests/test_response.py b/redsys/tests/test_response.py index 36c7c1d..11d7b7e 100644 --- a/redsys/tests/test_response.py +++ b/redsys/tests/test_response.py @@ -1,3 +1,5 @@ +from decimal import Decimal as D + from redsys.response import Response @@ -21,3 +23,30 @@ def test_create_response(self): assert response.is_paid is True assert response.is_canceled is False assert response.is_refunded is False + + def test_setattr(self): + response = Response({ + "Ds_Response": "90", + "Ds_MerchantCode": "1056", + "Ds_Terminal": "1", + "Ds_TransactionType": "1", + "Ds_Order": "000000000001", + "Ds_Amount": "10054", + "Ds_Currency": 978, + }) + response.merchant_data = "new data" + assert "Ds_MerchantData" in response._parameters + assert response._parameters["Ds_MerchantData"] == "new data" + + def test_parameters_property(self): + parameters = { + "Ds_Response": "90", + "Ds_MerchantCode": "1056", + "Ds_Terminal": "1", + } + response = Response(parameters) + assert response.parameters == response._parameters + + def test_clean_amount(self): + result = Response.clean_amount("10054") + assert result == D("100.54")