From e6288164d1a2bc8fb09c68d3ed86fee5c474dbf5 Mon Sep 17 00:00:00 2001 From: oto-stefan <130049861+oto-stefan@users.noreply.github.com> Date: Wed, 3 Jul 2024 14:41:37 +0200 Subject: [PATCH] Sign properly complex data (#2) * Sign properly complex data Compose message from input data for signing accounting for nested sequences and mappings. * fixup - Correction from code review * fixup - Fix typo * fixup - Fix typo in docstring * fixup - Code review (round 2) * fixup - Add docs comment to enums * fixup - Unnecessary trimming * fixup - strip after trimming * Correction from review - round 3 * fixup - Corrections from review (round 4) * fixup - Change date/time related fields * fixup - Custom formatting should have higher priority * fixup - Add docstrings * Improve type annotations * Use all amounts always as integers * fixup - README and type annotation * fixup - Add enumerations * Remove Python 3.8 --- .github/workflows/python-package.yml | 2 +- README.rst | 42 ++++ pycsob/client.py | 349 ++++++++++++++++++++++++--- pycsob/utils.py | 37 ++- setup.py | 6 +- tests_pycsob/test_api.py | 320 +++++++++++++++++++++--- 6 files changed, 665 insertions(+), 91 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 0248457..5379f8b 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.6, 3.7, 3.8] + python-version: [3.9, 3.10, 3.11] steps: - uses: actions/checkout@v2 diff --git a/README.rst b/README.rst index 2d6e5c3..0662bb8 100644 --- a/README.rst +++ b/README.rst @@ -87,6 +87,48 @@ You can check payment status. #[Out]# ('authCode', '042760'), #[Out]# ('dttime', datetime.datetime(2016, 6, 15, 10, 45, 1))]) +Structured data (i.e. ``cart``, ``customer`` and ``order``) are passed to ``payment_init`` as dataclasses, for some items +appropriate enumerations are available. + +.. code-block:: python + + tz = ZoneInfo("Europe/Prague") + data = { + "pay_operation": PayOperation.PAYMENT, + "pay_method": PayMethod.CARD, + "currency": Currency.CZK, + "close_payment": True, + "return_method": ReturnMethod.POST, + "cart": [ + CartItem(name="Wireless sluchátka", quantity=2, amount=123400), + CartItem(name="Doprava", quantity=1, amount=0, description="DPL"), + ], + "customer": Customer( + name="Jan Novák", + email="jan.novak@example.com", + mobile_phone="+420800-300-300", + account=CustomerAccount(created_at=datetime(2022, 1, 12, 12, 10, 37, tzinfo=tz), + changed_at=datetime(2022, 1, 15, 15, 10, 12, tzinfo=tz)), + login=CustomerLogin(auth=CustomerLoginType.ACCOUNT, auth_at=datetime(2022, 6, 25, 13, 10, 3, tzinfo=tz)), + ), + "order": Order( + type=OrderType.PURCHASE, + availability=OrderAvailability.NOW, + delivery=OrderDelivery.SHIPPING, + delivery_mode=OrderDeliveryMode.SAME_DAY, + address_match=True, + billing=OrderAddress( + address_1="Karlova 1", + city="Praha", + zip="11000", + country="CZE", + ), + ), + "language": Language.CZECH, + } + r = c.payment_init(16, 123400, "http://twisto.dev/", "Testovací nákup", **data) + + Custom payments are initialized with ``c.payment_init(pay_operation='customPayment')``, you can optionally set payment validity by ``custom_expiry='YYYYMMDDhhmmss'``. diff --git a/pycsob/client.py b/pycsob/client.py index 620c99b..211f8c9 100644 --- a/pycsob/client.py +++ b/pycsob/client.py @@ -1,8 +1,12 @@ # coding: utf-8 -from base64 import b64encode, b64decode +from base64 import b64decode import json import requests.adapters from collections import OrderedDict +from dataclasses import Field, dataclass, fields +from datetime import datetime +from enum import Enum, unique +from typing import Any, Dict, Iterable, List, Optional, Union from . import conf, utils @@ -17,6 +21,277 @@ def send(self, request, **kwargs): return super(HTTPAdapter, self).send(request, **kwargs) +def reorder_fields(instance: object) -> Iterable[Field]: + """Reorder fields of dataclass.""" + for name in instance._ordered_fields: + yield instance.__dataclass_fields__[name] + + +class ConvertMixin: + """Convert instance into ordered dict.""" + + _max_length: Dict[str, int] = {} # parameters to string formatting (empty by default) + + def _format_field(self, name: str, value: Any) -> str: + """Format value - use custom or predefined formatter.""" + if hasattr(self, f"_format_{name}"): + value = getattr(self, f"_format_{name}")(value) # custom formatting method + elif isinstance(value, Enum): + value = str(value.value) + elif isinstance(value, datetime): + value = value.isoformat() + elif isinstance(value, str) and name in self._max_length: + value = value[:self._max_length[name]].rstrip() + return value + + def _to_camel_case(self, name: str) -> str: + """Convert name to camel case form.""" + parts = name.split("_") + return parts[0] + "".join(i.title() for i in parts[1:]) + + def to_dict(self) -> Dict[str, Union[str, Dict]]: + """Carry out conversion.""" + data = [] + get_fields = reorder_fields if hasattr(self, '_ordered_fields') else fields + for field in get_fields(self): + value = getattr(self, field.name) + if value in conf.EMPTY_VALUES: + continue + if hasattr(value, 'to_dict'): + value = value.to_dict() + data.append((self._to_camel_case(field.name), self._format_field(field.name, value))) + return OrderedDict(data) + + +@unique +class Currency(Enum): + """Currencies allowed in card gatewway.""" + + CZK = "CZK" + EUR = "EUR" + USD = "USD" + GBP = "GBP" + HUF = "HUF" + PLN = "PLN" + RON = "RON" + NOK = "NOK" + SEK = "SEK" + + +@unique +class PayOperation(Enum): + """Pay operations allowed on card gateway.""" + + PAYMENT = "payment" + ONECLICK_PAYMENT = "oneclickPayment" + CUSTOM_PAYMENT = "customPayment" + + +@unique +class PayMethod(Enum): + """Pay methods allowed on card gateway.""" + + CARD = "card" + CARD_LVP = "card#LVP" + + +@unique +class ReturnMethod(Enum): + """Available HTTP methods.""" + + GET = "GET" + POST = "POST" + + +@unique +class Language(Enum): + """Allowed languages in card gateway.""" + + CZECH = "cs" + ENGLISH = "en" + GERMAN = "de" + FRENCH = "fr" + HUNGARIAN = "hu" + ITALIAN = "it" + JAPANESE = "jp" + POLISH = "po" + PORTUGUESE = "pt" + ROMANIAN = "ro" + RUSSIAN = "ru" + SLOVAK = "sk" + SPANISH = "es" + TURKISH = "tu" + VIETNAMESE = "vt" + CROATIAN = "hr" + SLOVENIAN = "sl" + SWEDISH = "sv" + + +@dataclass +class CartItem(ConvertMixin): + """Cart item for creating card payment.""" + # Documentation: https://github.com/csob/paymentgateway/wiki/Basic-methods#item---cart-item-object-cart- + + name: str + quantity: int + amount: int + description: Optional[str] = None + + _max_length = {"name": 20, "description": 40} + + +@dataclass +class CustomerAccount(ConvertMixin): + """Customer account data.""" + # Documentation: https://github.com/csob/paymentgateway/wiki/Purchase-metadata#customeraccount-data- + + created_at: Optional[datetime] = None + changed_at: Optional[datetime] = None + changed_pwd_at: Optional[datetime] = None + order_history: Optional[int] = None + payments_day: Optional[int] = None + payments_year: Optional[int] = None + oneclick_adds: Optional[int] = None + suspicious: Optional[bool] = None + + +@unique +class CustomerLoginType(Enum): + """Type of customer login.""" + # Documentation: https://github.com/csob/paymentgateway/wiki/Purchase-metadata#customerlogin-data- + + GUEST = "guest" + ACCOUNT = "account" + FEDERATED = "federated" + ISSUER = "issuer" + THIRDPARTY = "thirdparty" + FIDO = "fido" + FIDO_SIGNED = "fido_signed" + API = "api" + + +@dataclass +class CustomerLogin(ConvertMixin): + """Customer login data.""" + # Documentation: https://github.com/csob/paymentgateway/wiki/Purchase-metadata#customerlogin-data- + + auth: Optional[CustomerLoginType] = None + auth_at: Optional[datetime] = None + auth_data: Optional[str] = None + + +@dataclass +class Customer(ConvertMixin): + """Customer data for creating card payment.""" + # Documentation: https://github.com/csob/paymentgateway/wiki/Purchase-metadata#customer-data- + + name: str + email: Optional[str] = None + home_phone: Optional[str] = None + work_phone: Optional[str] = None + mobile_phone: Optional[str] = None + account: Optional[CustomerAccount] = None + login: Optional[CustomerLogin] = None + + _max_length = {"name": 45} + + +@dataclass +class OrderAddress(ConvertMixin): + """Order address (billing or shipping).""" + # Documentation: https://github.com/csob/paymentgateway/wiki/Purchase-metadata#orderaddress-data- + + address_1: str + city: str + zip: str + country: str + address_2: Optional[str] = None + address_3: Optional[str] = None + state: Optional[str] = None + + _max_length = {"address_1": 50, "address_2": 50, "address_3": 50, "city": 50, "zip": 16} + _ordered_fields = ("address_1", "address_2", "address_3", "city", "zip", "country", "state") + + +@dataclass +class OrderGiftcards(ConvertMixin): + """Order giftcards data.""" + # Documentation: https://github.com/csob/paymentgateway/wiki/Purchase-metadata#ordergiftcards-data- + + total_amount: Optional[int] = None + currency: Optional[Currency] = None + quantity: Optional[int] = None + + +@unique +class OrderType(Enum): + """Type of order.""" + # Documentation: https://github.com/csob/paymentgateway/wiki/Purchase-metadata#order-data- + + PURCHASE = "purchase" + BALANCE = "balance" + PREPAID = "prepaid" + CASH = "cash" + CHECK = "check" + + +@unique +class OrderAvailability(Enum): + """Availability of order.""" + # Documentation: https://github.com/csob/paymentgateway/wiki/Purchase-metadata#order-data- + + NOW = "now" + PREORDER = "preorder" + + +@unique +class OrderDelivery(Enum): + """Delivery of order.""" + # Documentation: https://github.com/csob/paymentgateway/wiki/Purchase-metadata#order-data- + + SHIPPING = "shipping" + SHIPPING_VERIFIED = "shipping_verified" + INSTORE = "instore" + DIGITAL = "digital" + TICKET = "ticket" + OTHER = "other" + + +@unique +class OrderDeliveryMode(Enum): + """Delivery mode of order.""" + # Documentation: https://github.com/csob/paymentgateway/wiki/Purchase-metadata#order-data- + + ELECTRONIC = 0 + SAME_DAY = 1 + NEXT_DAY = 2 + LATER = 3 + + +OrderAvailabilityType = Union[OrderAvailability, datetime] + + +@dataclass +class Order(ConvertMixin): + """Order data for creating card payment.""" + # Documentation: https://github.com/csob/paymentgateway/wiki/Purchase-metadata#order-data- + + type: Optional[OrderType] = None + availability: Optional[OrderAvailabilityType] = None + delivery: Optional[OrderDelivery] = None + delivery_mode: Optional[OrderDeliveryMode] = None + delivery_email: Optional[str] = None + name_match: Optional[bool] = None + address_match: Optional[bool] = None + billing: Optional[OrderAddress] = None + shipping: Optional[OrderAddress] = None + shipping_added_at: Optional[datetime] = None + reorder: Optional[bool] = None + giftcards: Optional[OrderGiftcards] = None + + _max_length = {"delivery_email": 100} + + class CsobClient(object): def __init__(self, merchant_id, base_url, private_key_file, csob_pub_key_file): @@ -40,11 +315,28 @@ def __init__(self, merchant_id, base_url, private_key_file, csob_pub_key_file): self._client = session - def payment_init(self, order_no, total_amount, return_url, description, cart=None, - customer_id=None, currency='CZK', language='cs', close_payment=True, - return_method='POST', pay_operation='payment', ttl_sec=600, - logo_version=None, color_scheme_version=None, merchant_data=None, - customer_data=None, order=None, custom_expiry=None, pay_method='card'): + def payment_init( + self, + order_no: Union[int, str], + total_amount: int, + return_url: str, + description: str, + cart: Optional[List[CartItem]] = None, + customer_id: Optional[str] = None, + currency: Currency = Currency.CZK, + language: Language = Language.CZECH, + close_payment: bool = True, + return_method: ReturnMethod = ReturnMethod.POST, + pay_operation: PayOperation = PayOperation.PAYMENT, + ttl_sec: int = 600, + logo_version: Optional[int] = None, + color_scheme_version: Optional[int] = None, + merchant_data: Optional[bytearray] = None, + customer: Optional[Customer] = None, + order: Optional[Order] = None, + custom_expiry: Optional[str] = None, + pay_method: PayMethod = PayMethod.CARD, + ) -> OrderedDict[str, Any]: """ 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. @@ -52,16 +344,8 @@ def payment_init(self, order_no, total_amount, return_url, description, cart=Non Cart example:: cart = [ - OrderedDict([ - ('name', 'Order in sho XYZ'), - ('quantity', 5), - ('amount', 12345), - ]), - OrderedDict([ - ('name', 'Postage'), - ('quantity', 1), - ('amount', 0), - ]) + CartItem(name='Order in sho XYZ', quantity=5, amount=12345), + CartItem(name='Postage', quantity=1, amount=0), ] :param order_no: order number @@ -70,17 +354,16 @@ def payment_init(self, order_no, total_amount, return_url, description, cart=Non :param cart: items in cart, currently min one item, max two as mentioned in CSOB spec :param description: product name - it is a part of the cart :param customer_id: optional customer id - :param language: supported languages: 'cs', 'en', 'de', 'sk', 'hu', 'it', 'jp', 'pl', 'pt', 'ro', 'ru', 'sk', - 'es', 'tr' or 'vn' - :param currency: supported currencies: 'CZK', 'EUR', 'USD', 'GBP' + :param language: supported languages: cs, en, de, fr, hu, it, ja, pl, pt, ro, ru, sk, es, tr, vi, hr, sl, sv + :param currency: supported currencies: CZK, EUR, USD, GBP, HUF, PLN, RON, NOK, SEK :param close_payment: :param return_method: method which be used for return to shop from gateway POST (default) or GET - :param pay_operation: `payment` or `oneclickPayment` + :param pay_operation: `payment`, `customPayment` or `oneclickPayment` :param ttl_sec: number of seconds to the timeout :param logo_version: Logo version number :param color_scheme_version: Color scheme version number :param merchant_data: bytearray of merchant data - :param customer_data: Additional customer purchase data + :param customer: Additional customer purchase data :param order: Additional purchase data related to the order :param custom_expiry: Custom payment expiration, format YYYYMMDDHHMMSS :param pay_method: 'card' = card payment, 'card#LVP' = card payment with low value payment @@ -89,31 +372,25 @@ def payment_init(self, order_no, total_amount, return_url, description, cart=Non # fill cart if not set if not cart: - cart = [ - OrderedDict([ - ('name', description[:20].strip()), - ('quantity', 1), - ('amount', total_amount) - ]) - ] + cart = [CartItem(name=description, quantity=1, amount=total_amount)] payload = utils.mk_payload(self.f_key, pairs=( ('merchantId', self.merchant_id), ('orderNo', str(order_no)), ('dttm', utils.dttm()), - ('payOperation', pay_operation), - ('payMethod', pay_method), + ('payOperation', pay_operation.value), + ('payMethod', pay_method.value), ('totalAmount', total_amount), - ('currency', currency), + ('currency', currency.value), ('closePayment', close_payment), ('returnUrl', return_url), - ('returnMethod', return_method), - ('cart', cart), - ('customer', customer_data), - ('order', order), + ('returnMethod', return_method.value), + ('cart', [item.to_dict() for item in cart]), + ('customer', customer.to_dict() if customer is not None else None), + ('order', order.to_dict() if order is not None else None), ('merchantData', utils.encode_merchant_data(merchant_data)), ('customerId', customer_id), - ('language', language[:2]), + ('language', language.value), ('ttlSec', ttl_sec), ('logoVersion', logo_version), ('colorSchemeVersion', color_scheme_version), diff --git a/pycsob/utils.py b/pycsob/utils.py index eed49d4..24f3614 100644 --- a/pycsob/utils.py +++ b/pycsob/utils.py @@ -7,6 +7,7 @@ from Crypto.Hash import SHA256 from Crypto.PublicKey import RSA from Crypto.Signature import PKCS1_v1_5 +from typing import Any, List from . import conf @@ -57,15 +58,25 @@ def verify(payload, signature, pubkeyfile): return verifier.verify(h, b64decode(signature)) -def mk_msg_for_sign(payload): - payload = payload.copy() - if 'cart' in payload and payload['cart'] not in conf.EMPTY_VALUES: - cart_msg = [] - for one in payload['cart']: - cart_msg.extend(one.values()) - payload['cart'] = '|'.join(map(str_or_jsbool, cart_msg)) - msg = '|'.join(map(str_or_jsbool, payload.values())) - return msg.encode('utf-8') +def mk_msg_item(value: Any) -> List[str]: + """Prepare message item for making signature.""" + data: List[str] = [] + if value in conf.EMPTY_VALUES: + return data + if isinstance(value, (list, tuple)): + for item in value: + data.extend(mk_msg_item(item)) + elif isinstance(value, (dict, OrderedDict)): + for item in value.values(): + data.extend(mk_msg_item(item)) + else: + data.append(str_or_jsbool(value)) + return data + + +def mk_msg_for_sign(payload: OrderedDict[str, Any]) -> bytes: + """Prepare message for signature.""" + return '|'.join(mk_msg_item(payload)).encode('utf-8', 'xmlcharrefreplace') def mk_payload(keyfile, pairs): @@ -81,10 +92,10 @@ def mk_url(base_url, endpoint_url, payload=None): return urljoin(url, '/'.join(map(quote_plus, payload.values()))) -def str_or_jsbool(v): - if type(v) == bool: - return str(v).lower() - return str(v) +def str_or_jsbool(value): + if isinstance(value, bool): + return str(value).lower() + return str(value).strip() def dttm(format_='%Y%m%d%H%M%S'): diff --git a/setup.py b/setup.py index 37f36a2..b161e11 100644 --- a/setup.py +++ b/setup.py @@ -57,9 +57,9 @@ def run_tests(self): "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Topic :: Utilities", ], zip_safe=False, diff --git a/tests_pycsob/test_api.py b/tests_pycsob/test_api.py index 1308ca2..c6a024c 100644 --- a/tests_pycsob/test_api.py +++ b/tests_pycsob/test_api.py @@ -1,15 +1,16 @@ # coding: utf-8 -import datetime import json import os from collections import OrderedDict +from datetime import datetime, timedelta, timezone from unittest import TestCase from unittest.mock import call, patch import pytest from freezegun import freeze_time from pycsob import __version__, conf, utils -from pycsob.client import CsobClient +from pycsob.client import (CartItem, CsobClient, Currency, CustomerAccount, Customer, CustomerLogin, CustomerLoginType, + Language, OrderAddress, Order, OrderGiftcards, OrderType, PayOperation) from requests.exceptions import HTTPError from testfixtures import LogCapture from urllib3_mock import Responses @@ -21,11 +22,133 @@ responses = Responses(package='requests.packages.urllib3') +class CartItemTests(TestCase): + + def test_to_dict(self): + cart_item = CartItem(name="01234567890123456789xxx", quantity=5, amount=1000) + expected = OrderedDict([ + ("name", "01234567890123456789"), # trimmed to 20 chars + ("quantity", 5), + ("amount", 1000), + ]) + self.assertEqual(cart_item.to_dict(), expected) + + def test_to_dict_description(self): + cart_item = CartItem(name="test", quantity=5, amount=1000, description="x" * 50) + expected = OrderedDict([ + ("name", "test"), + ("quantity", 5), + ("amount", 1000), + ("description", "x" * 40), # trimmed to 40 chars + ]) + self.assertEqual(cart_item.to_dict(), expected) + + +class CustomerTests(TestCase): + + def test_to_dict_minimum(self): + customer = Customer(name="test " * 10) + expected = OrderedDict([ + ("name", "test " * 8 + "test"), # trimmed to 45 chars + space stripped off + ]) + self.assertEqual(customer.to_dict(), expected) + + def test_to_dict_complex(self): + tz_cest = timezone(timedelta(hours=2)) + customer = Customer( + name="test", + mobile_phone="+420.123456789", + account = CustomerAccount( + created_at=datetime(2024, 6, 25, 13, 30, 15, tzinfo=tz_cest), + changed_at=datetime(2024, 6, 25, 15, 21, 7, tzinfo=tz_cest), + ), + login = CustomerLogin( + auth=CustomerLoginType.ACCOUNT, + auth_at=datetime(2024, 6, 26, 8, 53, 47, tzinfo=tz_cest), + ), + ) + expected = OrderedDict([ + ("name", "test"), + ("mobilePhone", "+420.123456789"), + ("account", OrderedDict([ + ("createdAt", "2024-06-25T13:30:15+02:00"), + ("changedAt", "2024-06-25T15:21:07+02:00"), + ])), + ("login", OrderedDict([ + ("auth", "account"), + ("authAt", "2024-06-26T08:53:47+02:00"), + ])), + ]) + self.assertEqual(customer.to_dict(), expected) + + +class OrderTests(TestCase): + + def test_address(self): + address = OrderAddress( + address_1="Address 1", + address_2="0123456789" * 8, + city="City", + zip="123 45", + country="CZ", + ) + expected = OrderedDict([ + ("address1", "Address 1"), + ("address2", "0123456789" * 5), # trimmed to 50 chars + ("city", "City"), + ("zip", "123 45"), + ("country", "CZ"), + ]) + self.assertEqual(address.to_dict(), expected) + + def test_order_simple(self): + order = Order( + type=OrderType.PURCHASE, + availability="now", + ) + expected = OrderedDict([ + ("type", "purchase"), + ("availability", "now"), + ]) + self.assertEqual(order.to_dict(), expected) + + def test_order_complex(self): + address = OrderAddress( + address_1="Address 1", + city="City", + zip="123 45", + country="CZ", + ) + order = Order( + type=OrderType.PURCHASE, + availability="now", + address_match=True, + billing=address, + giftcards=OrderGiftcards(total_amount=6, currency=Currency.USD), + ) + expected = OrderedDict([ + ("type", "purchase"), + ("availability", "now"), + ("addressMatch", True), + ("billing", OrderedDict([ + ("address1", "Address 1"), + ("city", "City"), + ("zip", "123 45"), + ("country", "CZ"), + ])), + ("giftcards", OrderedDict([ + ("totalAmount", 6), + ("currency", "USD"), + ])), + ]) + self.assertEqual(order.to_dict(), expected) + + @freeze_time("2019-05-02 16:14:26") class CsobClientTests(TestCase): dttm = "20190502161426" - dttime = datetime.datetime(2019, 5, 2, 16, 14, 26) + dttime = datetime(2019, 5, 2, 16, 14, 26) def setUp(self): self.c = CsobClient(merchant_id='MERCHANT', @@ -102,6 +225,103 @@ def test_sign_message(self): sig = payload.pop('signature') assert utils.verify(payload, sig, KEY_PATH) + def test_complex_message_for_sign(self): + pairs = ( + ("merchantId", self.c.merchant_id), + ("orderNo", "5547"), + ("dttm", utils.dttm()), + ("payOperation", "payment"), + ("payMethod", "card"), + ("totalAmount", 123400), + ("currency", "CZK"), + ("closePayment", True), + ("returnUrl", "https://shop.example.com/return"), + ("returnMethod", "POST"), + ("cart", [ + OrderedDict([ + ("name", "Wireless headphones"), + ("quantity", 1), + ("amount", 123400), + ]), + OrderedDict([ + ("name", "Shipping"), + ("quantity", 1), + ("amount", 0), + ("description", "DPL"), + ]), + ]), + ("customer", OrderedDict([ + ("name", "Jan Novák"), + ("email","jan.novak@example.com"), + ("mobilePhone", "+420.800300300"), + ("account", OrderedDict([ + ("createdAt","2022-01-12T12:10:37+01:00"), + ("changedAt","2022-01-15T15:10:12+01:00"), + ])), + ("login", OrderedDict([ + ("auth", "account"), + ("authAt", "2022-01-25T13:10:03+01:00"), + ])), + ])), + ("order", OrderedDict([ + ("type", "purchase"), + ("availability", "now"), + ("delivery", "shipping"), + ("deliveryMode", "1"), + ("addressMatch", True), + ("billing", OrderedDict([ + ("address1", "Karlova 1"), + ("city", "Praha"), + ("zip", "11000"), + ("country", "CZE"), + ])), + ])), + ("merchantData", "some-base64-encoded-merchant-data"), + ("language", "cs"), + ) + payload = utils.mk_payload(KEY_PATH, pairs) + sig = payload.pop('signature') + assert utils.verify(payload, sig, KEY_PATH) + msg = utils.mk_msg_for_sign(payload).decode('utf-8') + expected_items = ( + 'MERCHANT', + '5547', + '20190502161426', + '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', + 'purchase', + 'now', + 'shipping', + '1', + 'true', + 'Karlova 1', + 'Praha', + '11000', + 'CZE', + 'some-base64-encoded-merchant-data', + 'cs', + ) + assert msg == "|".join(expected_items) + @responses.activate def test_payment_init_success(self): resp_url = '/payment/init' @@ -113,7 +333,7 @@ def test_payment_init_success(self): ('paymentStatus', 1) )) responses.add(responses.POST, resp_url, body=json.dumps(resp_payload), status=200) - out = self.c.payment_init(order_no=666, total_amount='66600', return_url='http://example.com', + out = self.c.payment_init(order_no=666, total_amount=66600, return_url='http://example.com', description='Nějaký popis').payload assert out['paymentStatus'] == conf.PAYMENT_STATUS_INIT @@ -122,33 +342,57 @@ def test_payment_init_success(self): self.log_handler.check( ('pycsob', 'INFO', 'Pycsob request POST: https://gw.cz/payment/init; Data: {"merchantId": ' '"MERCHANT", "orderNo": "666", "dttm": "20190502161426", "payOperation": ' - '"payment", "payMethod": "card", "totalAmount": "66600", "currency": "CZK", ' + '"payment", "payMethod": "card", "totalAmount": 66600, "currency": "CZK", ' '"closePayment": true, "returnUrl": "http://example.com", "returnMethod": ' '"POST", "cart": [{"name": "N\\u011bjak\\u00fd popis", "quantity": 1, ' - '"amount": "66600"}], "language": "cs", "ttlSec": 600, "signature": ' + '"amount": 66600}], "language": "cs", "ttlSec": 600, "signature": ' '"KMLqDJs+vSFqLaEG66i6MtkRZEL6U9HwqT3dPrYh237agzlkPnkXHHrCF2p+Sntzq/UWN03HfDhL5IHSsHvp6Q=="}; ' 'Json: None; {}'), ('pycsob', 'DEBUG', "Pycsob request headers: {'content-type': 'application/json', 'user-agent': " - + USER_AGENT + ", 'Content-Length': '460'}"), + + USER_AGENT + ", 'Content-Length': '456'}"), ('pycsob', 'INFO', 'Pycsob response: [200] {"payId": "34ae55eb69e2cBF", "dttm": "20190502161426", ' '"resultCode": 0, "resultMessage": "OK", "paymentStatus": 1, "signature": ' '"Zd+PKspUEkrsEyxTmXAwrX3pgfS45Sg35dhMo5Oi0aoI8LoLs3dlyPS9vEXw80fxKyduAl5ws8D0Fu2mXLy9bA=="}'), ('pycsob', 'DEBUG', "Pycsob response headers: {'Content-Type': 'text/plain'}"), ) + @responses.activate + def test_payment_init_complex_data(self): + resp_url = '/payment/init' + resp_payload = utils.mk_payload(KEY_PATH, pairs=( + ('payId', PAY_ID), + ('dttm', utils.dttm()), + ('resultCode', conf.RETURN_CODE_OK), + ('resultMessage', 'OK'), + ('paymentStatus', 1) + )) + responses.add(responses.POST, resp_url, body=json.dumps(resp_payload), status=200) + + cart_item = CartItem(name="test", quantity=5, amount=1000) + customer = Customer(name="Karel Sedlo", email="karel@sadlo.cz") + order = Order(type=OrderType.PURCHASE, availability="preorder") + self.c.payment_init(order_no=500, total_amount=5000, return_url='http://example.com', + description='Nějaký popis', cart=[cart_item], customer=customer, + order=order).payload + + self.log_handler.check_present( + ('pycsob', 'INFO', 'Pycsob request POST: https://gw.cz/payment/init; Data: {"merchantId": ' + '"MERCHANT", "orderNo": "500", "dttm": "20190502161426", "payOperation": ' + '"payment", "payMethod": "card", "totalAmount": 5000, "currency": "CZK", ' + '"closePayment": true, "returnUrl": "http://example.com", "returnMethod": ' + '"POST", "cart": [{"name": "test", "quantity": 5, "amount": 1000}], ' + '"customer": {"name": "Karel Sedlo", "email": "karel@sadlo.cz"}, "order": ' + '{"type": "purchase", "availability": "preorder"}, "language": "cs", ' + '"ttlSec": 600, "signature": ' + '"XXvDDiGA/J7tRLss/14VDYF60Uk2FQqsRzu5v6PESd6l1LJ6WHbwQqT1TCVFFb2VwHdlQqwb10Ha5LpD+ovaiw=="}; ' + 'Json: None; {}'), + ) + @responses.activate def test_payment_init_bad_cart(self): cart = [ - OrderedDict([ - ('name', 'Order in sho XYZ'), - ('quantity', 5), - ('amount', 12345), - ]), - OrderedDict([ - ('name', 'Postage'), - ('quantity', 1), - ('amount', 0), - ]) + CartItem(name='Order in sho XYZ', quantity=5, amount=12345), + CartItem(name='Postage', quantity=1, amount=0), ] resp_payload = utils.mk_payload(KEY_PATH, pairs=( ('payId', PAY_ID), @@ -159,7 +403,7 @@ def test_payment_init_bad_cart(self): )) resp_url = '/payment/init' responses.add(responses.POST, resp_url, body=json.dumps(resp_payload), status=200) - out = self.c.payment_init(order_no=666, total_amount='2200000', return_url='http://', + out = self.c.payment_init(order_no=666, total_amount=2200000, return_url='http://', description='X', cart=cart).payload assert out['paymentStatus'] == conf.PAYMENT_STATUS_REJECTED @@ -167,13 +411,13 @@ def test_payment_init_bad_cart(self): self.log_handler.check( ('pycsob', 'INFO', 'Pycsob request POST: https://gw.cz/payment/init; Data: {"merchantId": "MERCHANT", ' '"orderNo": "666", "dttm": "20190502161426", "payOperation": "payment", "payMethod": "card", ' - '"totalAmount": "2200000", "currency": "CZK", "closePayment": true, ' + '"totalAmount": 2200000, "currency": "CZK", "closePayment": true, ' '"returnUrl": "http://", "returnMethod": "POST", "cart": [{"name": "Order in sho XYZ", "quantity": 5, ' '"amount": 12345}, {"name": "Postage", "quantity": 1, "amount": 0}], "language": "cs", "ttlSec": 600, ' '"signature": "FcfTzD5ChQXyWAgBMZX+d/QOBbaGKXRusHwpiOaX+Aticygm1D8EzH+MtnMFq+Gp3dcQMTUg0bQKaCXfcQBeiA=="}; ' 'Json: None; {}'), ('pycsob', 'DEBUG', "Pycsob request headers: {'content-type': 'application/json', 'user-agent': " - + USER_AGENT + ", 'Content-Length': '492'}"), + + USER_AGENT + ", 'Content-Length': '490'}"), ('pycsob', 'INFO', 'Pycsob response: [200] {"payId": "34ae55eb69e2cBF", "dttm": "20190502161426", ' '"resultCode": 110, "resultMessage": "Invalid \'cart\' amounts, does not sum to totalAmount", ' '"paymentStatus": 6, "signature": ' @@ -286,7 +530,7 @@ def test_payment_init_with_merchant_data(self): ('paymentStatus', 1), )) responses.add(responses.POST, resp_url, body=json.dumps(resp_payload), status=200) - out = self.c.payment_init(order_no=666, total_amount='66600', return_url='http://example.com', + out = self.c.payment_init(order_no=666, total_amount=66600, return_url='http://example.com', description='Fooo', merchant_data=b'Foo').payload assert out['paymentStatus'] == conf.PAYMENT_STATUS_INIT @@ -295,13 +539,13 @@ def test_payment_init_with_merchant_data(self): self.log_handler.check( ('pycsob', 'INFO', 'Pycsob request POST: https://gw.cz/payment/init; Data: {"merchantId": "MERCHANT", ' '"orderNo": "666", "dttm": "20190502161426", "payOperation": "payment", "payMethod": "card", ' - '"totalAmount": "66600", "currency": "CZK", "closePayment": true, "returnUrl": "http://example.com", ' - '"returnMethod": "POST", "cart": [{"name": "Fooo", "quantity": 1, "amount": "66600"}], ' + '"totalAmount": 66600, "currency": "CZK", "closePayment": true, "returnUrl": "http://example.com", ' + '"returnMethod": "POST", "cart": [{"name": "Fooo", "quantity": 1, "amount": 66600}], ' '"merchantData": "Rm9v", "language": "cs", "ttlSec": 600, "signature": ' '"a5jKBePOpjgX0CjUkKFTe3UzedHzFgrvSsVf3NnSZ7uzuFyBIs5QEVxN9QZ8y7LKKRiigEzU8r6GZ3MiEFf9RA=="}; ' 'Json: None; {}'), ('pycsob', 'DEBUG', "Pycsob request headers: {'content-type': 'application/json', " - "'user-agent': " + USER_AGENT + ", 'Content-Length': '466'}"), + "'user-agent': " + USER_AGENT + ", 'Content-Length': '462'}"), ('pycsob', 'INFO', 'Pycsob response: [200] {"payId": "34ae55eb69e2cBF", "dttm": "20190502161426", ' '"resultCode": 0, "resultMessage": "OK", "paymentStatus": 1, "signature": ' '"Zd+PKspUEkrsEyxTmXAwrX3pgfS45Sg35dhMo5Oi0aoI8LoLs3dlyPS9vEXw80fxKyduAl5ws8D0Fu2mXLy9bA=="}'), @@ -320,7 +564,7 @@ def test_payment_init_with_too_long_merchant_data(self): )) responses.add(responses.POST, resp_url, body=json.dumps(resp_payload), status=200) with self.assertRaisesRegex(ValueError, 'Merchant data length encoded to BASE64 is over 255 chars'): - self.c.payment_init(order_no=666, total_amount='66600', return_url='http://example.com', + self.c.payment_init(order_no=666, total_amount=66600, return_url='http://example.com', description='Fooo', merchant_data=b'Foo' * 80).payload self.log_handler.check() @@ -335,8 +579,8 @@ def test_payment_init_language_with_locale_cs(self): ('paymentStatus', 1), )) responses.add(responses.POST, resp_url, body=json.dumps(resp_payload), status=200) - out = self.c.payment_init(order_no=666, total_amount='66600', return_url='http://example.com', - description='Fooo', language='cs_CZ.utf8').payload + out = self.c.payment_init(order_no=666, total_amount=66600, return_url='http://example.com', + description='Fooo', language=Language.CZECH).payload assert out['paymentStatus'] == conf.PAYMENT_STATUS_INIT assert out['resultCode'] == conf.RETURN_CODE_OK @@ -344,13 +588,13 @@ def test_payment_init_language_with_locale_cs(self): self.log_handler.check( ('pycsob', 'INFO', 'Pycsob request POST: https://gw.cz/payment/init; Data: {"merchantId": "MERCHANT", ' '"orderNo": "666", "dttm": "20190502161426", "payOperation": "payment", "payMethod": "card", ' - '"totalAmount": "66600", "currency": "CZK", "closePayment": true, "returnUrl": "http://example.com", ' - '"returnMethod": "POST", "cart": [{"name": "Fooo", "quantity": 1, "amount": "66600"}], "language": "cs", ' + '"totalAmount": 66600, "currency": "CZK", "closePayment": true, "returnUrl": "http://example.com", ' + '"returnMethod": "POST", "cart": [{"name": "Fooo", "quantity": 1, "amount": 66600}], "language": "cs", ' '"ttlSec": 600, "signature": ' '"XH4RdW0dXrDh81dUHNKMrF+LVfZZtIOKJXzVUSxB/RVKK2Sb59SJvl8jonujNZC78GJkr5THLCbnMJNUfXpQag=="}; ' 'Json: None; {}'), ('pycsob', 'DEBUG', "Pycsob request headers: {'content-type': 'application/json', 'user-agent': " - + USER_AGENT + ", 'Content-Length': '442'}"), + + USER_AGENT + ", 'Content-Length': '438'}"), ('pycsob', 'INFO', 'Pycsob response: [200] {"payId": "34ae55eb69e2cBF", "dttm": "20190502161426", ' '"resultCode": 0, "resultMessage": "OK", "paymentStatus": 1, "signature": ' '"Zd+PKspUEkrsEyxTmXAwrX3pgfS45Sg35dhMo5Oi0aoI8LoLs3dlyPS9vEXw80fxKyduAl5ws8D0Fu2mXLy9bA=="}'), @@ -369,8 +613,8 @@ def test_payment_init_custom_payment(self): ('customerCode', 'E61EC8'), )) responses.add(responses.POST, resp_url, body=json.dumps(resp_payload), status=200) - out = self.c.payment_init(order_no=666, total_amount='66600', return_url='http://example.com', - description='Fooo', pay_operation='customPayment', custom_expiry='20190531120000' + out = self.c.payment_init(order_no=666, total_amount=66600, return_url='http://example.com', + description='Fooo', pay_operation=PayOperation.CUSTOM_PAYMENT, custom_expiry='20190531120000' ).payload assert out['paymentStatus'] == conf.PAYMENT_STATUS_INIT @@ -379,13 +623,13 @@ def test_payment_init_custom_payment(self): self.log_handler.check( ('pycsob', 'INFO', 'Pycsob request POST: https://gw.cz/payment/init; Data: {"merchantId": "MERCHANT", ' '"orderNo": "666", "dttm": "20190502161426", "payOperation": "customPayment", "payMethod": "card", ' - '"totalAmount": "66600", "currency": "CZK", "closePayment": true, "returnUrl": "http://example.com", ' - '"returnMethod": "POST", "cart": [{"name": "Fooo", "quantity": 1, "amount": "66600"}], "language": "cs", ' + '"totalAmount": 66600, "currency": "CZK", "closePayment": true, "returnUrl": "http://example.com", ' + '"returnMethod": "POST", "cart": [{"name": "Fooo", "quantity": 1, "amount": 66600}], "language": "cs", ' '"ttlSec": 600, "customExpiry": "20190531120000", "signature": ' '"H+eKbex5KdHUtZ/fxB5vfMlgEkH3H6RfDj3oR9i/R/8HYInmyP0tz6+lqzF8EztHmpA/vxevW9qvNTgV535eZw=="}; ' 'Json: None; {}'), ('pycsob', 'DEBUG', "Pycsob request headers: {'content-type': 'application/json', 'user-agent': " - + USER_AGENT + ", 'Content-Length': '482'}"), + + USER_AGENT + ", 'Content-Length': '478'}"), ('pycsob', 'INFO', 'Pycsob response: [200] {"payId": "34ae55eb69e2cBF", "dttm": "20190502161426", ' '"resultCode": 0, "resultMessage": "OK", "paymentStatus": 1, "customerCode": "E61EC8", "signature": ' '"KmqB9foNOz7aJuyujNcHDpD7rmPZzkN/AePWw62h5xYxowrd1Jb5o6JdF1S76USHaPn4yc+iOIM+pw601l3PxQ=="}'), @@ -441,7 +685,7 @@ def test_description_strip(self): responses.add(responses.POST, resp_url, body=json.dumps(resp_payload), status=200) with patch('pycsob.utils.mk_payload', return_value=resp_payload) as mock_mk_payload: - self.c.payment_init(42, '100', 'http://example.com', 'Konference Internet a Technologie 19') + self.c.payment_init(42, 100, 'http://example.com', 'Konference Internet a Technologie 19') self.assertEqual(mock_mk_payload.mock_calls, [ call(KEY_PATH, pairs=( @@ -450,7 +694,7 @@ def test_description_strip(self): ('dttm', '20190502161426'), ('payOperation', 'payment'), ('payMethod', 'card'), - ('totalAmount', '100'), + ('totalAmount', 100), ('currency', 'CZK'), ('closePayment', True), ('returnUrl', 'http://example.com'), @@ -458,7 +702,7 @@ def test_description_strip(self): ('cart', [OrderedDict([ ('name', 'Konference Internet'), ('quantity', 1), - ('amount', '100')])]), + ('amount', 100)])]), ('customer', None), ('order', None), ('merchantData', None),