Skip to content

Commit

Permalink
Merge pull request #2 from HonzaSaibic/KNJ-17025
Browse files Browse the repository at this point in the history
KNJ-17025 Add the support for customer object for payment/oneclick in…
  • Loading branch information
xdaniel3 authored Jul 22, 2024
2 parents 5c7e9e4 + e863e2b commit 21bb597
Show file tree
Hide file tree
Showing 5 changed files with 197 additions and 23 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ __pycache__
/.pydevproject
/.idea
/.settings/
.python-version
13 changes: 8 additions & 5 deletions pycsob/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def __init__(self, merchant_id, base_url, private_key, csob_pub_key):
:param merchant_id: Your Merchant ID (you can find it in POSMerchant)
:param base_url: Base API url development / production
:param private_key: Path to generated private key file, or its contents
:param private_key: CSOB private key string
:param csob_pub_key: Path to CSOB public key file, or its contents
"""
self.merchant_id = merchant_id
Expand All @@ -65,9 +65,9 @@ def _get_key(self, value):
except FileNotFoundError:
return value

def payment_init(self, order_no, total_amount, return_url, description, merchant_data=None, cart=None,
customer_id=None, currency='CZK', language='CZ', close_payment=True,
return_method='POST', pay_operation='payment', ttl_sec=600,
def payment_init(self, order_no, total_amount, return_url, description, customer_data,
merchant_data=None, cart=None, customer_id=None, currency='CZK', language='CZ',
close_payment=True, return_method='POST', pay_operation='payment', ttl_sec=600,
logo_version=None, color_scheme_version=None):
"""
Initialize transaction, sum of cart items must be equal to total amount
Expand All @@ -93,6 +93,7 @@ def payment_init(self, order_no, total_amount, return_url, description, merchant
:param return_url: URL to be returned to from payment gateway
:param cart: items in cart, currently min one item, max two as mentioned in CSOB spec
:param description: order description
:param customer_data: dict with customer name and either email or phone
:param customer_id: optional customer id
:param language: supported languages: 'CZ', 'EN', 'DE', 'SK', 'HU', 'IT', 'JP', 'PL', 'PT', 'RO', 'RU', 'SK', 'ES', 'TR' or 'VN'
:param currency: supported currencies: 'CZK', 'EUR', 'USD', 'GBP'
Expand Down Expand Up @@ -123,6 +124,7 @@ def payment_init(self, order_no, total_amount, return_url, description, merchant
('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),
Expand Down Expand Up @@ -218,7 +220,7 @@ def customer_info(self, customer_id):
r = self._client.get(url)
return utils.validate_response(r, self.pubkey)

def oneclick_init(self, orig_pay_id, order_no, total_amount, currency='CZK', description=None,
def oneclick_init(self, orig_pay_id, order_no, total_amount, customer_data, currency='CZK', description=None,
return_url='http://localhost', return_method='GET', client_initiated=False):
"""
Initialize one-click payment. Before this, you need to call payment_init(..., pay_operation='oneclickPayment')
Expand All @@ -229,6 +231,7 @@ def oneclick_init(self, orig_pay_id, order_no, total_amount, currency='CZK', des
('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),
Expand Down
44 changes: 44 additions & 0 deletions pycsob/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import logging
import sys
import re
from typing import Any, TypeVar
from base64 import b64encode, b64decode
from collections import OrderedDict
from Crypto.Hash import SHA256
Expand All @@ -13,6 +15,9 @@
from requests.exceptions import HTTPError


logger = logging.getLogger(__name__)


try:
from django.utils import timezone as datetime
except ImportError:
Expand Down Expand Up @@ -121,3 +126,42 @@ def get_card_provider(long_masked_number):
if rx.match(long_masked_number[:6]):
return provider_id, conf.CARD_PROVIDERS[provider_id]
return None, None


def to_camel_case(value: str) -> str:
"""
Convert the value from snake_case to camelCase format. If the value is not in the snake_case format, return
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


T = TypeVar("T", list[Any], dict[str, Any])


def convert_keys_to_camel_case(data: T) -> T:
"""
Convert all dictionary keys and nested dictionary keys from snake_case to camelCase format.
Returns the same data type that was in the input of the function in the data parameter.
"""
if not data:
return data

if isinstance(data, list):
return [convert_keys_to_camel_case(value) if isinstance(value, (dict, list)) else value for value in data]

converted_dict = {}
for key, value in data.items():
if isinstance(key, str):
key = to_camel_case(key)
else:
logger.error(
"Incorrect value type '%s' during conversion to camcel case. String expected.", type(key)
)

if isinstance(value, (dict, list)):
converted_dict[key] = convert_keys_to_camel_case(value)
else:
converted_dict[key] = value
return converted_dict
159 changes: 142 additions & 17 deletions tests_pycsob/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +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 import conf, utils
from pycsob.client import CsobClient
Expand All @@ -25,7 +26,7 @@ def setUp(self):
self.key = open(KEY_PATH).read()
self.c = CsobClient(merchant_id='MERCHANT',
base_url=BASE_URL,
private_key=KEY_PATH,
private_key=self.key,
csob_pub_key=KEY_PATH)

def test_client_init_can_take_key_string(self):
Expand All @@ -36,14 +37,6 @@ def test_client_init_can_take_key_string(self):
assert client.key == self.key
assert client.pubkey == self.key

def test_client_init_can_take_key_path(self):
client = CsobClient(merchant_id='MERCHANT',
base_url=BASE_URL,
private_key=KEY_PATH,
csob_pub_key=KEY_PATH)
assert client.key == self.key
assert client.pubkey == self.key

@freeze_time(datetime.now())
@responses.activate
def test_echo_post(self):
Expand Down Expand Up @@ -98,15 +91,67 @@ def test_payment_init_success(self):
('dttm', utils.dttm()),
('resultCode', conf.RETURN_CODE_OK),
('resultMessage', 'OK'),
('paymentStatus', 1)
('paymentStatus', 1),
))
responses.add(responses.POST, utils.mk_url(BASE_URL, '/payment/init'), body=json.dumps(resp_payload),
status=200)
out = self.c.payment_init(order_no=666, total_amount='66600', return_url='http://example.com',
description='Nějaký popis').payload
response = self.c.payment_init(
order_no=666,
total_amount='66600',
return_url='http://example.com',
description='Nějaký popis',
customer_data={
'name': "Jiri Novak",
'email': "j@novak.cz",
'mobile_phone': "+420.602123123",
},
)

assert out['paymentStatus'] == conf.PAYMENT_STATUS_INIT
assert out['resultCode'] == conf.RETURN_CODE_OK
request_body = json.loads(response.request.body)
assert request_body['customer'] == {
'name': "Jiri Novak",
'email': "j@novak.cz",
'mobilePhone': "+420.602123123",
}
payload = response.payload
assert payload['paymentStatus'] == conf.PAYMENT_STATUS_INIT
assert payload['resultCode'] == conf.RETURN_CODE_OK
assert len(responses.calls) == 1

@freeze_time(datetime.now())
@responses.activate
def test_onelick_init_success(self):
resp_payload = utils.mk_payload(self.key, pairs=(
('payId', PAY_ID),
('dttm', utils.dttm()),
('resultCode', conf.RETURN_CODE_OK),
('resultMessage', 'OK'),
('paymentStatus', 1),
))
responses.add(
responses.POST, utils.mk_url(BASE_URL, '/oneclick/init'), body=json.dumps(resp_payload), status=200
)
response = self.c.oneclick_init(
orig_pay_id=PAY_ID,
order_no=666,
total_amount='66600',
return_url='http://example.com',
customer_data={
'name': "Jiri Novak",
'email': "j@novak.cz",
'mobile_phone': "+420.602123123",
},
)

request_body = json.loads(response.request.body)
assert request_body['customer'] == {
'name': "Jiri Novak",
'email': "j@novak.cz",
'mobilePhone': "+420.602123123",
}
payload = response.payload
assert payload['paymentStatus'] == conf.PAYMENT_STATUS_INIT
assert payload['resultCode'] == conf.RETURN_CODE_OK
assert len(responses.calls) == 1

@freeze_time(datetime.now())
Expand All @@ -129,12 +174,22 @@ def test_payment_init_bad_cart(self):
('dttm', utils.dttm()),
('resultCode', conf.RETURN_CODE_PARAM_INVALID),
('resultMessage', "Invalid 'cart' amounts, does not sum to totalAmount"),
('paymentStatus', conf.PAYMENT_STATUS_REJECTED)
('paymentStatus', conf.PAYMENT_STATUS_REJECTED),
))
responses.add(responses.POST, utils.mk_url(BASE_URL, '/payment/init'), body=json.dumps(resp_payload),
status=200)
out = self.c.payment_init(order_no=666, total_amount='2200000', return_url='http://',
description='X', cart=cart).payload
out = self.c.payment_init(
order_no=666,
total_amount='2200000',
return_url='http://',
description='X',
cart=cart,
customer_data={
'name': "Jiri Novak",
'email': "j@novak.cz",
'mobile_phone': "+420.602123123",
},
).payload

assert out['paymentStatus'] == conf.PAYMENT_STATUS_REJECTED
assert out['resultCode'] == conf.RETURN_CODE_PARAM_INVALID
Expand Down Expand Up @@ -218,3 +273,73 @@ def test_connection_exceptions_should_be_caught_and_be_handled(self):
with pytest.raises(CsobBaseException) as excinfo:
self.c.echo(method='POST')
assert 'Can\'t connect' in str(excinfo.value)


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"

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": [
{
"street_address": "Malkovskeho",
"types": [],
},
{
"street_address": "Holesovice",
"types": ["billing"],
},
]
},
}

assert convert_keys_to_camel_case(customer_data) == {
"name": "Petr Novak",
"mobilePhone": "+420.735293123",
"addressInfo": {
"addressCount": 2,
"addresses": [
{
"streetAddress": "Malkovskeho",
"types": [],
},
{
"streetAddress": "Holesovice",
"types": ["billing"],
},
]
},
}

def test_convert_keys_to_camel_case_should_convert_dict_keys_to_camel_case_even_inside_a_list(self):
list_data = [
{
"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]

def test_convert_keys_to_camel_case_should_log_error_for_not_string_keys(self):
with self.assertLogs() as logs:
assert convert_keys_to_camel_case({None: None}) == {None: None}
assert logs.records[0].message == (
"Incorrect value type '<class 'NoneType'>' during conversion to camcel case. String expected."
)

assert convert_keys_to_camel_case({1: "test"}) == {1: "test"}
assert logs.records[1].message == (
"Incorrect value type '<class 'int'>' during conversion to camcel case. String expected."
)

3 changes: 2 additions & 1 deletion tox.ini
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
[tox]
envlist = py{36,37,38}
envlist = py{36,37,38,311}
usedevelop = True

[testenv]
basepython =
py36: python3.6
py37: python3.7
py38: python3.8
py311: python3.11

deps =
-rrequirements-test.pip
Expand Down

0 comments on commit 21bb597

Please sign in to comment.