diff --git a/pycsob/client.py b/pycsob/client.py index 85a65cf..428f87d 100644 --- a/pycsob/client.py +++ b/pycsob/client.py @@ -73,6 +73,9 @@ def payment_init(self, order_no, total_amount, return_url, description, customer Initialize transaction, sum of cart items must be equal to total amount If cart is None, we create it for you from total_amount and description values. + The payload structure must follow the signature structure order. Please follow the documentation + https://github.com/csob/platebnibrana/wiki/Podpis-po%C5%BEadavku-a-ov%C4%9B%C5%99en%C3%AD-podpisu-odpov%C4%9Bdi + Cart example:: cart = [ @@ -124,11 +127,11 @@ def payment_init(self, order_no, total_amount, return_url, description, customer ('payMethod', 'card'), ('totalAmount', total_amount), ('currency', currency), - ('customer', utils.convert_keys_to_camel_case(customer_data)), ('closePayment', close_payment), ('returnUrl', return_url), ('returnMethod', return_method), ('cart', cart), + ('customer', utils.convert_keys_to_camel_case(customer_data)), ('merchantData', merchant_data), ('customerId', customer_id), ('language', language), @@ -225,19 +228,22 @@ def oneclick_init(self, orig_pay_id, order_no, total_amount, customer_data, curr """ Initialize one-click payment. Before this, you need to call payment_init(..., pay_operation='oneclickPayment') It will create payment template for you. Use pay_id returned from payment_init as orig_pay_id in this method. + + The payload structure must follow the signature structure order. Please follow the documentation + https://github.com/csob/platebnibrana/wiki/Podpis-po%C5%BEadavku-a-ov%C4%9B%C5%99en%C3%AD-podpisu-odpov%C4%9Bdi """ payload = utils.mk_payload(self.key, pairs=( ('merchantId', self.merchant_id), ('origPayId', orig_pay_id), ('orderNo', str(order_no)), - ('customer', utils.convert_keys_to_camel_case(customer_data)), ('dttm', utils.dttm()), ('totalAmount', total_amount), ('currency', currency), ('description', description), ('returnUrl', return_url), ('returnMethod', return_method), + ('customer', utils.convert_keys_to_camel_case(customer_data)), ('clientInitiated', client_initiated), )) url = utils.mk_url(base_url=self.base_url, endpoint_url='oneclick/init') diff --git a/pycsob/utils.py b/pycsob/utils.py index 961e205..f40ea1b 100644 --- a/pycsob/utils.py +++ b/pycsob/utils.py @@ -46,6 +46,8 @@ def mk_msg_for_sign(payload): for one in payload['cart']: cart_msg.extend(one.values()) payload['cart'] = '|'.join(map(str_or_jsbool, cart_msg)) + if payload.get('customer') not in conf.EMPTY_VALUES: + payload['customer'] = get_customer_data_signature_message(payload['customer']) msg = '|'.join(map(str_or_jsbool, payload.values())) return msg.encode('utf-8') @@ -134,10 +136,10 @@ def to_camel_case(value: str) -> str: the original value. """ first_word, *other_words = value.split('_') - return "".join([first_word.lower(), *map(str.title, other_words)]) if other_words else first_word + return ''.join([first_word.lower(), *map(str.title, other_words)]) if other_words else first_word -T = TypeVar("T", list[Any], dict[str, Any]) +T = TypeVar('T', list[Any], dict[str, Any]) def convert_keys_to_camel_case(data: T) -> T: @@ -165,3 +167,29 @@ def convert_keys_to_camel_case(data: T) -> T: else: converted_dict[key] = value return converted_dict + + +def get_customer_data_signature_message(customer_data: dict[str, Any]) -> str: + """ + Returns signature string from customer data used to sign the request. + For more information follow the API documentation + https://github.com/csob/platebnibrana/wiki/Podpis-po%C5%BEadavku-a-ov%C4%9B%C5%99en%C3%AD-podpisu-odpov%C4%9Bdi + """ + + def get_joined_values(data: dict[str, Any], keys: list) -> str: + """ + Args: + data: payload customer data + keys: list with ordered keys + """ + return '|'.join(str_or_jsbool(data[key]) for key in keys if key in data) + + customer_keys = ['name', 'email', 'mobilePhone'] + account_keys = ['createdAt', 'changedAt'] + login_keys = ['auth', 'authAt'] + + customer_msg = get_joined_values(customer_data, customer_keys) + account_msg = get_joined_values(customer_data.get('account', {}), account_keys) + login_msg = get_joined_values(customer_data.get('login', {}), login_keys) + + return '|'.join(filter(None, (customer_msg, account_msg, login_msg))) diff --git a/tests_pycsob/test_api.py b/tests_pycsob/test_api.py index 73e25d4..0d8c52f 100644 --- a/tests_pycsob/test_api.py +++ b/tests_pycsob/test_api.py @@ -8,7 +8,7 @@ from freezegun import freeze_time from unittest import TestCase from requests.exceptions import HTTPError, ConnectionError -from pycsob.utils import convert_keys_to_camel_case, to_camel_case +from pycsob.utils import convert_keys_to_camel_case, to_camel_case, get_customer_data_signature_message, mk_msg_for_sign from pycsob import conf, utils from pycsob.client import CsobClient @@ -277,42 +277,42 @@ def test_connection_exceptions_should_be_caught_and_be_handled(self): class CsobUtilsTests(TestCase): def test_to_camel_case_should_convert_string_to_camel_case(self): - assert to_camel_case("") == "" - assert to_camel_case("THIS_IS_SNAKE_CASE") == "thisIsSnakeCase" - assert to_camel_case("thisIsSnakeCase") == "thisIsSnakeCase" + assert to_camel_case('') == '' + assert to_camel_case('THIS_IS_SNAKE_CASE') == 'thisIsSnakeCase' + assert to_camel_case('thisIsSnakeCase') == 'thisIsSnakeCase' def test_convert_keys_to_camel_case_should_convert_dict_keys_to_camel_case(self): customer_data = { - "name": "Petr Novak", - "mobile_phone": "+420.735293123", - "addressInfo": { - "address_count": 2, - "addresses": [ + 'name': 'Petr Novak', + 'mobile_phone': '+420.735293123', + 'addressInfo': { + 'address_count': 2, + 'addresses': [ { - "street_address": "Malkovskeho", - "types": [], + 'street_address': 'Malkovskeho', + 'types': [], }, { - "street_address": "Holesovice", - "types": ["billing"], + 'street_address': 'Holesovice', + 'types': ['billing'], }, ] }, } assert convert_keys_to_camel_case(customer_data) == { - "name": "Petr Novak", - "mobilePhone": "+420.735293123", - "addressInfo": { - "addressCount": 2, - "addresses": [ + 'name': 'Petr Novak', + 'mobilePhone': '+420.735293123', + 'addressInfo': { + 'addressCount': 2, + 'addresses': [ { - "streetAddress": "Malkovskeho", - "types": [], + 'streetAddress': 'Malkovskeho', + 'types': [], }, { - "streetAddress": "Holesovice", - "types": ["billing"], + 'streetAddress': 'Holesovice', + 'types': ['billing'], }, ] }, @@ -321,15 +321,15 @@ def test_convert_keys_to_camel_case_should_convert_dict_keys_to_camel_case(self) def test_convert_keys_to_camel_case_should_convert_dict_keys_to_camel_case_even_inside_a_list(self): list_data = [ { - "customer_name": "test", + 'customer_name': 'test', 1:1, } ] assert convert_keys_to_camel_case(list_data) == [{'customerName': 'test', 1:1}] def test_convert_keys_to_camel_case_should_not_convert_list_of_strings_only(self): - list_data = ["customer_name", 1, None] - assert convert_keys_to_camel_case(list_data) == ["customer_name", 1, None] + list_data = ['customer_name', 1, None] + assert convert_keys_to_camel_case(list_data) == ['customer_name', 1, None] def test_convert_keys_to_camel_case_should_log_error_for_not_string_keys(self): with self.assertLogs() as logs: @@ -338,8 +338,169 @@ def test_convert_keys_to_camel_case_should_log_error_for_not_string_keys(self): "Incorrect value type '' during conversion to camcel case. String expected." ) - assert convert_keys_to_camel_case({1: "test"}) == {1: "test"} + assert convert_keys_to_camel_case({1: 'test'}) == {1: 'test'} assert logs.records[1].message == ( "Incorrect value type '' during conversion to camcel case. String expected." ) + def test_get_customer_data_signature_message_should_return_correct_string_format(self): + customer = { + 'name':'Jan Novák', + 'email':'jan.novak@example.com', + 'mobilePhone':'+420.800300300', + 'account': { + 'createdAt':'2022-01-12T12:10:37+01:00', + 'changedAt':'2022-01-15T15:10:12+01:00' + }, + 'login': { + 'auth':'account', + 'authAt':'2022-01-25T13:10:03+01:00' + } + } + + assert get_customer_data_signature_message(customer) == ( + 'Jan Novák|jan.novak@example.com|+420.800300300' + '|2022-01-12T12:10:37+01:00|2022-01-15T15:10:12+01:00' + '|account|2022-01-25T13:10:03+01:00' + ) + + def test_get_customer_data_signature_message_should_be_able_to_skip_missing_values(self): + # missing account + customer = { + 'name':'Jan Novák', + 'email':'jan.novak@example.com', + 'mobilePhone':'+420.800300300', + 'login': { + 'auth':'account', + 'authAt':'2022-01-25T13:10:03+01:00' + } + } + + assert get_customer_data_signature_message(customer) == ( + 'Jan Novák|jan.novak@example.com|+420.800300300|account|2022-01-25T13:10:03+01:00' + ) + + # missing login + customer = { + 'name':'Jan Novák', + 'email':'jan.novak@example.com', + 'mobilePhone':'+420.800300300', + 'account': { + 'createdAt':'2022-01-12T12:10:37+01:00', + 'changedAt':'2022-01-15T15:10:12+01:00' + }, + } + assert get_customer_data_signature_message(customer) == ( + 'Jan Novák|jan.novak@example.com|+420.800300300|2022-01-12T12:10:37+01:00|2022-01-15T15:10:12+01:00' + ) + + # missing customer email + customer = { + 'name':'Jan Novák', + 'mobilePhone':'+420.800300300', + } + assert get_customer_data_signature_message(customer) == ( + 'Jan Novák|+420.800300300' + ) + + # missing customer personal data + customer = { + 'account': { + 'createdAt':'2022-01-12T12:10:37+01:00', + } + } + assert get_customer_data_signature_message(customer) == ( + '2022-01-12T12:10:37+01:00' + ) + + def test_get_customer_data_signature_message_should_ignore_invalid_keys(self): + customer = { + 'name':'Jan Novák', + 'email':'jan.novak@example.com', + 'mobilePhone':'+420.800300300', + 'nonsense': 'Test', + 'account': { + 'createdAt':'2022-01-12T12:10:37+01:00', + 'changedAt':'2022-01-15T15:10:12+01:00', + 'nonsense': 'Test', + }, + 'login': { + 'auth':'account', + 'authAt':'2022-01-25T13:10:03+01:00', + 'nonsense': 'Test', + } + } + assert get_customer_data_signature_message(customer) == ( + 'Jan Novák|jan.novak@example.com|+420.800300300' + '|2022-01-12T12:10:37+01:00|2022-01-15T15:10:12+01:00' + '|account|2022-01-25T13:10:03+01:00' + ) + + def test_get_customer_data_signature_message_should_keep_correct_value_order(self): + customer = { + 'mobilePhone':'+420.800300300', + 'email':'jan.novak@example.com', + 'name':'Jan Novák', + 'login': { + 'authAt':'2022-01-25T13:10:03+01:00', + 'auth':'account', + }, + 'account': { + 'changedAt':'2022-01-15T15:10:12+01:00', + 'createdAt':'2022-01-12T12:10:37+01:00', + }, + } + + assert get_customer_data_signature_message(customer) == ( + 'Jan Novák|jan.novak@example.com|+420.800300300' + '|2022-01-12T12:10:37+01:00|2022-01-15T15:10:12+01:00' + '|account|2022-01-25T13:10:03+01:00' + ) + + def test_mk_msg_for_sign_should_return_correctly_ordered_message(self): + payload = { + 'merchantId':'M1MIPS0000', + 'orderNo':'5547', + 'dttm':'20220125131559', + 'payOperation':'payment', + 'payMethod':'card', + 'totalAmount':123400, + 'currency':'CZK', + 'closePayment': True, + 'returnUrl':'https://shop.example.com/return', + 'returnMethod':'POST', + 'cart':[ + { + 'name': 'Wireless headphones', + 'quantity': 1, + 'amount': 123400 + }, + { + 'name': 'Shipping', + 'quantity': 1, + 'amount': 0, + 'description': 'DPL' + } + ], + 'customer': { + 'name':'Jan Novák', + 'email':'jan.novak@example.com', + 'mobilePhone':'+420.800300300', + 'account': { + 'createdAt':'2022-01-12T12:10:37+01:00', + 'changedAt':'2022-01-15T15:10:12+01:00' + }, + 'login': { + 'auth':'account', + 'authAt':'2022-01-25T13:10:03+01:00' + } + }, + } + expected_message = ( + 'M1MIPS0000|5547|20220125131559|payment|card|123400|CZK|true|https://shop.example.com/return|POST' + '|Wireless headphones|1|123400|Shipping|1|0|DPL' + '|Jan Novák|jan.novak@example.com|+420.800300300' + '|2022-01-12T12:10:37+01:00|2022-01-15T15:10:12+01:00' + '|account|2022-01-25T13:10:03+01:00' + ).encode() + assert mk_msg_for_sign(payload) == expected_message