From c4b3a0edb9228ef45da0e5428303d4ee71ab56ce Mon Sep 17 00:00:00 2001 From: Marco Gil Date: Mon, 21 Jul 2025 17:40:05 +0200 Subject: [PATCH 1/4] PTHMINT-77: Add address parser function --- src/multisafepay/util/address_parser.py | 88 ++++++++ .../unit/util/test_unit_address_parser.py | 208 ++++++++++++++++++ 2 files changed, 296 insertions(+) create mode 100644 src/multisafepay/util/address_parser.py create mode 100644 tests/multisafepay/unit/util/test_unit_address_parser.py diff --git a/src/multisafepay/util/address_parser.py b/src/multisafepay/util/address_parser.py new file mode 100644 index 0000000..bcd04df --- /dev/null +++ b/src/multisafepay/util/address_parser.py @@ -0,0 +1,88 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +import re +from typing import List + + +class AddressParser: + """ + Class AddressParser. + + Parses and splits up an address in street and house number + """ + + def parse( + self: "AddressParser", + address1: str, + address2: str = "", + ) -> List[str]: + """ + Parses and splits up an address in street and house number. + + Args: + ---- + address1 (str): Primary address line + address2 (str): Secondary address line (optional) + + Returns: + ------- + List[str]: [street, house_number] where street is the street name + and house_number is the house number with any extensions + + """ + # Remove whitespaces from the beginning and end + full_address = f"{address1} {address2}".strip() + + # Turn multiple whitespaces into one single whitespace + full_address = re.sub(r"\s+", " ", full_address) + + # Split the address into 3 groups: street, apartment and extension + pattern = r"(.+?)\s?([\d]+[\S]*)((\s?[A-z])*?)$" + matches = re.match(pattern, full_address) + + if not matches: + return [full_address, ""] + + return self.extract_street_and_apartment( + matches.group(1) or "", + matches.group(2) or "", + matches.group(3) or "", + ) + + def extract_street_and_apartment( + self: "AddressParser", + group1: str, + group2: str, + group3: str, + ) -> List[str]: + """ + Extract the street and apartment from the matched RegEx results. + + When the address starts with a number, it is most likely that group1 and group2 are the house number and + extension. We therefore check if group1 and group2 are numeric, if so, we can assume that group3 + will be the street and return group1 and group2 together as the apartment. + If group1 or group2 contains more than just numbers, we can assume group1 is the street and group2 and + group3 are the house number and extension. We therefore return group1 as the street and return group2 and + group3 together as the apartment. + + Args: + ---- + group1 (str): First captured group from regex + group2 (str): Second captured group from regex + group3 (str): Third captured group from regex + + Returns: + ------- + List[str]: [street, apartment] where street is the street name + and apartment is the house number with extensions + + """ + if group1.isdigit() and group2.isdigit(): + return [group3.strip(), f"{group1}{group2}".strip()] + + return [group1.strip(), f"{group2}{group3}".strip()] diff --git a/tests/multisafepay/unit/util/test_unit_address_parser.py b/tests/multisafepay/unit/util/test_unit_address_parser.py new file mode 100644 index 0000000..a8e6b50 --- /dev/null +++ b/tests/multisafepay/unit/util/test_unit_address_parser.py @@ -0,0 +1,208 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +import pytest +from multisafepay.util.address_parser import AddressParser + + +class TestAddressParser: + """Test class for AddressParser functionality.""" + + @pytest.mark.parametrize( + ("address1", "address2", "expected_street", "expected_apartment"), + [ + ( + "Kraanspoor", + "39", + "Kraanspoor", + "39", + ), + ( + "Kraanspoor ", + "39", + "Kraanspoor", + "39", + ), + ( + "Kraanspoor 39", + "", + "Kraanspoor", + "39", + ), + ( + "Kraanspoor 39 ", + "", + "Kraanspoor", + "39", + ), + ( + "Kraanspoor", + "39 ", + "Kraanspoor", + "39", + ), + ( + "Kraanspoor39", + "", + "Kraanspoor", + "39", + ), + ( + "Kraanspoor39c", + "", + "Kraanspoor", + "39c", + ), + ( + "laan 1933 2", + "", + "laan 1933", + "2", + ), + ( + "laan 1933", + "2", + "laan 1933", + "2", + ), + ( + "18 septemberplein 12", + "", + "18 septemberplein", + "12", + ), + ( + "18 septemberplein", + "12", + "18 septemberplein", + "12", + ), + ( + "kerkstraat 42-f3", + "", + "kerkstraat", + "42-f3", + ), + ( + "kerkstraat", + "42-f3", + "kerkstraat", + "42-f3", + ), + ( + "Kerk straat 2b", + "", + "Kerk straat", + "2b", + ), + ( + "Kerk straat", + "2b", + "Kerk straat", + "2b", + ), + ( + "1e constantijn huigensstraat 1b", + "", + "1e constantijn huigensstraat", + "1b", + ), + ( + "1e constantijn huigensstraat", + "1b", + "1e constantijn huigensstraat", + "1b", + ), + ( + "Heuvel, 2a", + "", + "Heuvel,", + "2a", + ), + ( + "1e Jan van Kraanspoor", + "2", + "1e Jan van Kraanspoor", + "2", + ), + ( + "Neherkade 1 XI", + "", + "Neherkade", + "1 XI", + ), + ( + "Kamp 20 38", + "", + "Kamp 20", + "38", + ), + ( + "2065 Rue de la Gare", + "", + "Rue de la Gare", + "2065", + ), + ( + "10 Downing Street", + "", + "Downing Street", + "10", + ), + ( + "27", + "Alexander Road", + "Alexander Road", + "27", + ), + ( + "15 Sullivan", + "", + "Sullivan", + "15", + ), + ( + "110 Kraanspoor", + "", + "Kraanspoor", + "110", + ), + ( + "Plaza Callao s/n", + "", + "Plaza Callao s/n", + "", + ), + ], + ) + def test_parse_addresses_from_data_provider( + self: "TestAddressParser", + address1: str, + address2: str, + expected_street: str, + expected_apartment: str, + ) -> None: + """ + Test the function parse with a provider, to confirm all addresses work. + + Args: + ---- + address1: Primary address line + address2: Secondary address line + expected_street: Expected street name result + expected_apartment: Expected apartment/house number result + + """ + parser = AddressParser() + result = parser.parse(address1, address2) + + assert ( + result[0] == expected_street + ), f"Street mismatch: expected '{expected_street}', got '{result[0]}'" + assert ( + result[1] == expected_apartment + ), f"Apartment mismatch: expected '{expected_apartment}', got '{result[1]}'" From a741595799726c8aa3c0b432a319744c648aed56 Mon Sep 17 00:00:00 2001 From: Marco Gil Date: Thu, 24 Jul 2025 13:32:55 +0200 Subject: [PATCH 2/4] PTHMINT-78: Avoid duplicate taxes when checkout options is autogenerated --- .gitignore | 3 + .../request/components/checkout_options.py | 12 ++- ...tion_orders_components_checkout_options.py | 76 +++++++++++++++---- 3 files changed, 74 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 401214c..6f4cad7 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,6 @@ dmypy.json # Mac files .DS_Store + +# VS Code settings +.vscode/ \ No newline at end of file diff --git a/src/multisafepay/api/paths/orders/request/components/checkout_options.py b/src/multisafepay/api/paths/orders/request/components/checkout_options.py index 15bd773..b893d08 100644 --- a/src/multisafepay/api/paths/orders/request/components/checkout_options.py +++ b/src/multisafepay/api/paths/orders/request/components/checkout_options.py @@ -110,12 +110,18 @@ def generate_from_shopping_cart( ) ] + # reduce the array of items to unique tax tables + unique_tax_tables = { + item.tax_table_selector + for item in items_with_tax_table_selector + } + tax_rules = [ TaxRule( - name=str(item.tax_table_selector), - rules=[TaxRate(rate=item.tax_table_selector)], + name=str(tax_table_selector), + rules=[TaxRate(rate=tax_table_selector)], ) - for item in items_with_tax_table_selector + for tax_table_selector in unique_tax_tables ] return CheckoutOptions( tax_tables=CheckoutOptionsApiModel( diff --git a/tests/multisafepay/integration/api/path/orders/request/components/test_integration_orders_components_checkout_options.py b/tests/multisafepay/integration/api/path/orders/request/components/test_integration_orders_components_checkout_options.py index e86319a..3b1dee9 100644 --- a/tests/multisafepay/integration/api/path/orders/request/components/test_integration_orders_components_checkout_options.py +++ b/tests/multisafepay/integration/api/path/orders/request/components/test_integration_orders_components_checkout_options.py @@ -60,22 +60,23 @@ def test_generate_from_shopping_cart(): generated_checkout_options = CheckoutOptions.generate_from_shopping_cart( shopping_cart, ) + # Ordenar ambas listas antes de comparar + generated_checkout_options.tax_tables.alternate = sorted( + generated_checkout_options.tax_tables.alternate, + key=lambda x: x.name, + ) + expected_alternate = sorted( + [ + TaxRule(name="0.21", rules=[TaxRate(rate=0.21)]), + TaxRule(name="0.09", rules=[TaxRate(rate=0.09)]), + TaxRule(name="0", rules=[TaxRate(rate=0.0)]), + ], + key=lambda x: x.name, + ) + test_checkout_options = CheckoutOptions( tax_tables=CheckoutOptionsApiModel( - alternate=[ - TaxRule( - name="0.21", - rules=[TaxRate(rate=0.21)], - ), - TaxRule( - name="0.09", - rules=[TaxRate(rate=0.09)], - ), - TaxRule( - name="0", - rules=[TaxRate(rate=0)], - ), - ], + alternate=expected_alternate, ), ) @@ -149,3 +150,50 @@ def test_generate_from_shopping_cart_with_no_tax_table_selector(): tax_tables=CheckoutOptionsApiModel(default=None, alternate=[]), validate_cart=None, ) + + +def test_generate_from_shopping_cart_with_items_with_same_tax_table_selector(): + """ + Test the generate_from_shopping_cart method of CheckoutOptions with a shopping cart that has no tax_table_selector. + + This test creates a ShoppingCart with items that have no tax_table_selector and checks if the generated CheckoutOptions is None. + + """ + shopping_cart = ShoppingCart( + items=[ + CartItem( + name="Geometric Candle Holders", + description="Geometric Candle Holders description", + unit_price=90, + quantity=3, + merchant_item_id="1111", + tax_table_selector=0.21, + weight=Weight(value=1.0, unit="kg"), + ), + CartItem( + name="Geometric Candle Holders", + description="Geometric Candle Holders description", + unit_price=90, + quantity=3, + merchant_item_id="1111", + tax_table_selector=0.21, + weight=Weight(value=1.0, unit="kg"), + ), + ], + ) + generated_checkout_options = CheckoutOptions.generate_from_shopping_cart( + shopping_cart, + ) + + test_checkout_options = CheckoutOptions( + tax_tables=CheckoutOptionsApiModel( + alternate=[ + TaxRule( + name="0.21", + rules=[TaxRate(rate=0.21)], + ), + ], + ), + ) + + assert generated_checkout_options == test_checkout_options From 8fbf3862c663e6c5ebcfd4b808c76713fc9fdb5b Mon Sep 17 00:00:00 2001 From: Daniel Civit Date: Thu, 24 Jul 2025 15:12:02 +0200 Subject: [PATCH 3/4] Update tests/multisafepay/integration/api/path/orders/request/components/test_integration_orders_components_checkout_options.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../test_integration_orders_components_checkout_options.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/multisafepay/integration/api/path/orders/request/components/test_integration_orders_components_checkout_options.py b/tests/multisafepay/integration/api/path/orders/request/components/test_integration_orders_components_checkout_options.py index 3b1dee9..93358db 100644 --- a/tests/multisafepay/integration/api/path/orders/request/components/test_integration_orders_components_checkout_options.py +++ b/tests/multisafepay/integration/api/path/orders/request/components/test_integration_orders_components_checkout_options.py @@ -60,7 +60,7 @@ def test_generate_from_shopping_cart(): generated_checkout_options = CheckoutOptions.generate_from_shopping_cart( shopping_cart, ) - # Ordenar ambas listas antes de comparar + # Sort both lists before comparing generated_checkout_options.tax_tables.alternate = sorted( generated_checkout_options.tax_tables.alternate, key=lambda x: x.name, From 9db8adfae556b89b535b7fa1bb243b8041a95a1d Mon Sep 17 00:00:00 2001 From: Daniel Civit Date: Thu, 24 Jul 2025 15:12:12 +0200 Subject: [PATCH 4/4] Update tests/multisafepay/integration/api/path/orders/request/components/test_integration_orders_components_checkout_options.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../test_integration_orders_components_checkout_options.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/multisafepay/integration/api/path/orders/request/components/test_integration_orders_components_checkout_options.py b/tests/multisafepay/integration/api/path/orders/request/components/test_integration_orders_components_checkout_options.py index 93358db..e9f21d7 100644 --- a/tests/multisafepay/integration/api/path/orders/request/components/test_integration_orders_components_checkout_options.py +++ b/tests/multisafepay/integration/api/path/orders/request/components/test_integration_orders_components_checkout_options.py @@ -154,9 +154,9 @@ def test_generate_from_shopping_cart_with_no_tax_table_selector(): def test_generate_from_shopping_cart_with_items_with_same_tax_table_selector(): """ - Test the generate_from_shopping_cart method of CheckoutOptions with a shopping cart that has no tax_table_selector. + Test the generate_from_shopping_cart method of CheckoutOptions with a shopping cart that has items with the same tax_table_selector. - This test creates a ShoppingCart with items that have no tax_table_selector and checks if the generated CheckoutOptions is None. + This test creates a ShoppingCart with items that all have the same tax_table_selector (0.21) and checks if the generated CheckoutOptions matches the expected output. """ shopping_cart = ShoppingCart(