From 34e56491ef140355799d7b11428a05f660138720 Mon Sep 17 00:00:00 2001 From: Marco Gil Date: Tue, 23 Dec 2025 13:35:13 +0100 Subject: [PATCH] Introduce DecimalAmount VO and fix refund bug - **Refactor**: Introduced `DecimalAmount` Value Object to handle decimal precision for monetary values, replacing inline Pydantic validators in `CartItem` and `Costs`. - **Fix**: Resolved an `AttributeError` in `create_refund_request` by accessing `.quantity` directly instead of `.get_quantity()`. - **DX**: Added support for `API_KEY` and `TEST_API_KEY` environment variables. - **DX**: Improved code quality by adding missing docstrings and return types across various files. - **Tests**: Updated unit and integration tests to reflect the architectural changes. --- .../request/components/checkout_data.py | 7 +- .../api/paths/orders/order_manager.py | 9 +- .../api/paths/orders/request/order_request.py | 28 +-- src/multisafepay/api/shared/cart/cart_item.py | 72 +++++-- src/multisafepay/api/shared/costs.py | 22 ++- src/multisafepay/util/__init__.py | 2 + src/multisafepay/util/json_encoder.py | 42 ++++ src/multisafepay/util/total_amount.py | 184 ++++++++++++++++-- src/multisafepay/value_object/__init__.py | 4 +- .../value_object/decimal_amount.py | 67 +++++++ src/multisafepay/value_object/unit_price.py | 34 ---- tests/multisafepay/e2e/conftest.py | 28 +++ .../integration/amounts/__init__.py | 6 + .../integration/amounts/_helpers.py | 47 +++++ .../amounts/test_integration_amount_policy.py | 172 ++++++++++++++++ .../test_multi_line_rounding_policy.py | 70 +++++++ .../amounts/test_rounding_boundaries.py | 95 +++++++++ .../amounts/test_tax_rounding_policy.py | 66 +++++++ .../integration/amounts/test_taxes.py | 98 ++++++++++ .../shared/cart/test_integration_cart_item.py | 6 +- .../orders/request/test_unit_order_request.py | 49 +++++ .../api/shared/cart/test_unit_cart_item.py | 4 +- .../unit/api/shared/test_unit_costs.py | 7 +- .../unit/util/test_unit_total_amount.py | 181 ++++++++++++++++- .../unit/value_object/test_decimal_amount.py | 40 ++++ .../unit/value_object/test_unit_price.py | 38 ---- 26 files changed, 1232 insertions(+), 146 deletions(-) create mode 100644 src/multisafepay/util/json_encoder.py create mode 100644 src/multisafepay/value_object/decimal_amount.py delete mode 100644 src/multisafepay/value_object/unit_price.py create mode 100644 tests/multisafepay/e2e/conftest.py create mode 100644 tests/multisafepay/integration/amounts/__init__.py create mode 100644 tests/multisafepay/integration/amounts/_helpers.py create mode 100644 tests/multisafepay/integration/amounts/test_integration_amount_policy.py create mode 100644 tests/multisafepay/integration/amounts/test_multi_line_rounding_policy.py create mode 100644 tests/multisafepay/integration/amounts/test_rounding_boundaries.py create mode 100644 tests/multisafepay/integration/amounts/test_tax_rounding_policy.py create mode 100644 tests/multisafepay/integration/amounts/test_taxes.py create mode 100644 tests/multisafepay/unit/value_object/test_decimal_amount.py delete mode 100644 tests/multisafepay/unit/value_object/test_unit_price.py diff --git a/src/multisafepay/api/paths/orders/order_id/refund/request/components/checkout_data.py b/src/multisafepay/api/paths/orders/order_id/refund/request/components/checkout_data.py index ae6667c..198f2c0 100644 --- a/src/multisafepay/api/paths/orders/order_id/refund/request/components/checkout_data.py +++ b/src/multisafepay/api/paths/orders/order_id/refund/request/components/checkout_data.py @@ -150,12 +150,13 @@ def refund_by_merchant_item_id( ) found_item = self.get_item_by_merchant_item_id(merchant_item_id) - if quantity < 1 or quantity > found_item.quantity: - quantity = found_item.get_quantity() + item_quantity = found_item.quantity or 0 + if quantity < 1 or quantity > item_quantity: + quantity = item_quantity refund_item = found_item.clone() refund_item.add_quantity(quantity) - refund_item.add_unit_price(found_item.unit_price * -1.0) + refund_item.add_unit_price(found_item.unit_price * -1) self.add_item(refund_item) diff --git a/src/multisafepay/api/paths/orders/order_manager.py b/src/multisafepay/api/paths/orders/order_manager.py index 38066b4..9ae9092 100644 --- a/src/multisafepay/api/paths/orders/order_manager.py +++ b/src/multisafepay/api/paths/orders/order_manager.py @@ -39,6 +39,7 @@ from multisafepay.api.shared.description import Description from multisafepay.client.client import Client from multisafepay.util.dict_utils import dict_empty +from multisafepay.util.json_encoder import DecimalEncoder from multisafepay.util.message import MessageList, gen_could_not_created_msg from multisafepay.value_object.amount import Amount from multisafepay.value_object.currency import Currency @@ -127,7 +128,7 @@ def create( CustomApiResponse: The custom API response containing the created order data. """ - json_data = json.dumps(request_order.to_dict()) + json_data = json.dumps(request_order.to_dict(), cls=DecimalEncoder) response: ApiResponse = self.client.create_post_request( "json/orders", request_body=json_data, @@ -152,7 +153,7 @@ def update( CustomApiResponse: The custom API response containing the updated order data. """ - json_data = json.dumps(update_request.to_dict()) + json_data = json.dumps(update_request.to_dict(), cls=DecimalEncoder) encoded_order_id = self.encode_path_segment(order_id) response = self.client.create_patch_request( f"json/orders/{encoded_order_id}", @@ -182,7 +183,7 @@ def capture( CustomApiResponse: The custom API response containing the capture data. """ - json_data = json.dumps(capture_request.to_dict()) + json_data = json.dumps(capture_request.to_dict(), cls=DecimalEncoder) encoded_order_id = self.encode_path_segment(order_id) response = self.client.create_post_request( @@ -223,7 +224,7 @@ def refund( CustomApiResponse: The custom API response containing the refund data. """ - json_data = json.dumps(request_refund.to_dict()) + json_data = json.dumps(request_refund.to_dict(), cls=DecimalEncoder) encoded_order_id = self.encode_path_segment(order_id) response = self.client.create_post_request( f"json/orders/{encoded_order_id}/refunds", diff --git a/src/multisafepay/api/paths/orders/request/order_request.py b/src/multisafepay/api/paths/orders/request/order_request.py index dd98ada..bea1150 100644 --- a/src/multisafepay/api/paths/orders/request/order_request.py +++ b/src/multisafepay/api/paths/orders/request/order_request.py @@ -33,7 +33,11 @@ from multisafepay.api.shared.description import Description from multisafepay.exception.invalid_argument import InvalidArgumentException from multisafepay.model.request_model import RequestModel -from multisafepay.util.total_amount import validate_total_amount +from multisafepay.util.total_amount import ( + RoundingMode, + RoundingStrategy, + validate_total_amount, +) from multisafepay.value_object.amount import Amount from multisafepay.value_object.currency import Currency @@ -581,14 +585,16 @@ def add_var3(self: "OrderRequest", var3: Optional[str]) -> "OrderRequest": self.var3 = var3 return self - def validate_amount(self: "OrderRequest") -> "OrderRequest": - """ - Validates the total amount of the order request and the shopping cart. - - Returns - ------- - OrderRequest: The validated OrderRequest object. - - """ - validate = validate_total_amount(self.dict()) + def validate_amount( + self: "OrderRequest", + *, + rounding_strategy: RoundingStrategy = "end", + rounding_mode: RoundingMode = "half_up", + ) -> "OrderRequest": + """Validates the total amount of the order request and the shopping cart.""" + validate_total_amount( + self.dict(), + rounding_strategy=rounding_strategy, + rounding_mode=rounding_mode, + ) return self diff --git a/src/multisafepay/api/shared/cart/cart_item.py b/src/multisafepay/api/shared/cart/cart_item.py index e8d6279..fd0f5e3 100644 --- a/src/multisafepay/api/shared/cart/cart_item.py +++ b/src/multisafepay/api/shared/cart/cart_item.py @@ -9,11 +9,15 @@ import copy import math -from typing import Dict, List, Optional +from decimal import Decimal +from typing import TYPE_CHECKING, Optional, Union from multisafepay.exception.invalid_argument import InvalidArgumentException from multisafepay.model.api_model import ApiModel -from multisafepay.value_object.weight import Weight +from multisafepay.value_object.decimal_amount import DecimalAmount + +if TYPE_CHECKING: + from multisafepay.value_object.weight import Weight class CartItem(ApiModel): @@ -32,7 +36,7 @@ class CartItem(ApiModel): product_url: (Optional[str]) The product URL. quantity: (Optional[int]) The quantity. tax_table_selector: (Optional[str]) The tax table selector. - unit_price: (Optional[float]) The unit price. + unit_price: (Optional[Decimal]) The unit price as a precise Decimal value. weight: (Optional[Weight]) The weight. """ @@ -43,12 +47,13 @@ class CartItem(ApiModel): image: Optional[str] merchant_item_id: Optional[str] name: Optional[str] - options: Optional[List[Dict]] + options: Optional[list] product_url: Optional[str] quantity: Optional[int] tax_table_selector: Optional[str] - unit_price: Optional[float] - weight: Optional[Weight] + unit_price: Optional[Decimal] + + weight: Optional["Weight"] def add_cashback(self: "CartItem", cashback: str) -> "CartItem": """ @@ -149,7 +154,7 @@ def add_name(self: "CartItem", name: str) -> "CartItem": self.name = name return self - def add_options(self: "CartItem", options: List[Dict]) -> "CartItem": + def add_options(self: "CartItem", options: list) -> "CartItem": """ Add options to the cart item. @@ -216,23 +221,29 @@ def add_tax_table_selector( self.tax_table_selector = tax_table_selector return self - def add_unit_price(self: "CartItem", unit_price: float) -> "CartItem": + def add_unit_price( + self: "CartItem", + unit_price: Union[DecimalAmount, Decimal, float, str], + ) -> "CartItem": """ - Add unit price to the cart item. + Add unit price to the cart item with precise Decimal conversion. Parameters ---------- - unit_price: (float) The unit price to be added. + unit_price: (Union[DecimalAmount, Decimal, float, int, str]) The unit price to be added. Returns ------- CartItem: The updated CartItem instance. """ - self.unit_price = unit_price + if isinstance(unit_price, DecimalAmount): + self.unit_price = unit_price.get() + else: + self.unit_price = DecimalAmount(amount=unit_price).get() return self - def add_weight(self: "CartItem", weight: Weight) -> "CartItem": + def add_weight(self: "CartItem", weight: "Weight") -> "CartItem": """ Add weight to the cart item. @@ -250,10 +261,10 @@ def add_weight(self: "CartItem", weight: Weight) -> "CartItem": def add_tax_rate_percentage( self: "CartItem", - tax_rate_percentage: int, + tax_rate_percentage: Union[int, Decimal], ) -> "CartItem": """ - Add tax rate percentage to the cart item. + Add tax rate percentage to the cart item using precise Decimal arithmetic. This method sets the tax rate percentage for the cart item. The tax rate should be a non-negative number. @@ -263,7 +274,7 @@ def add_tax_rate_percentage( Parameters ---------- - tax_rate_percentage: (int) The tax rate percentage to be added. + tax_rate_percentage: (Union[int, Decimal]) The tax rate percentage to be added. Returns ------- @@ -275,13 +286,17 @@ def add_tax_rate_percentage( "Tax rate percentage cannot be negative.", ) - if math.isnan(tax_rate_percentage) or math.isinf(tax_rate_percentage): + if isinstance(tax_rate_percentage, float) and ( + math.isnan(tax_rate_percentage) or math.isinf(tax_rate_percentage) + ): raise InvalidArgumentException( "Tax rate percentage cannot be special floats.", ) try: - rating = tax_rate_percentage / 100 + # Use Decimal for precise division + percentage_decimal = Decimal(str(tax_rate_percentage)) + rating = percentage_decimal / Decimal("100") self.tax_table_selector = str(rating) except (ValueError, TypeError) as e: raise InvalidArgumentException( @@ -290,9 +305,12 @@ def add_tax_rate_percentage( return self - def add_tax_rate(self: "CartItem", tax_rate: float) -> "CartItem": + def add_tax_rate( + self: "CartItem", + tax_rate: Union[Decimal, float], + ) -> "CartItem": """ - Add tax rate to the cart item. + Add tax rate to the cart item using Decimal for precision. This method sets the tax rate for the cart item. The tax rate should be a non-negative number. @@ -302,7 +320,7 @@ def add_tax_rate(self: "CartItem", tax_rate: float) -> "CartItem": Parameters ---------- - tax_rate: (float) The tax rate to be added. + tax_rate: (Union[Decimal, float]) The tax rate to be added. Returns ------- @@ -312,12 +330,17 @@ def add_tax_rate(self: "CartItem", tax_rate: float) -> "CartItem": if tax_rate < 0: raise InvalidArgumentException("Tax rate cannot be negative.") - if math.isnan(tax_rate) or math.isinf(tax_rate): + if isinstance(tax_rate, float) and ( + math.isnan(tax_rate) or math.isinf(tax_rate) + ): raise InvalidArgumentException( "Tax rate cannot be special floats.", ) try: + # Convert to Decimal if not already + if not isinstance(tax_rate, Decimal): + tax_rate = Decimal(str(tax_rate)) self.tax_table_selector = str(tax_rate) except (ValueError, TypeError) as e: raise InvalidArgumentException( @@ -355,3 +378,10 @@ def from_dict(d: Optional[dict]) -> Optional["CartItem"]: return None return CartItem(**d) + + +# Update forward references to resolve Weight +# pylint: disable=wrong-import-position +from multisafepay.value_object.weight import Weight # noqa: E402 + +CartItem.update_forward_refs() diff --git a/src/multisafepay/api/shared/costs.py b/src/multisafepay/api/shared/costs.py index 510546f..ab003c5 100644 --- a/src/multisafepay/api/shared/costs.py +++ b/src/multisafepay/api/shared/costs.py @@ -7,9 +7,11 @@ """Transaction costs model for handling fees and charges in payment processing.""" -from typing import Optional +from decimal import Decimal +from typing import Optional, Union from multisafepay.model.api_model import ApiModel +from multisafepay.value_object.decimal_amount import DecimalAmount class Costs(ApiModel): @@ -21,7 +23,7 @@ class Costs(ApiModel): transaction_id (Optional[int]): The ID of the transaction. description (Optional[str]): The description of the cost. type (Optional[str]): The type of the cost. - amount (Optional[float]): The amount of the cost. + amount (Optional[Decimal]): The amount of the cost as a precise Decimal value. currency (Optional[str]): The currency of the cost. status (Optional[str]): The status of the cost. @@ -30,7 +32,7 @@ class Costs(ApiModel): transaction_id: Optional[int] description: Optional[str] type: Optional[str] - amount: Optional[float] + amount: Optional[Decimal] currency: Optional[str] status: Optional[str] @@ -82,20 +84,26 @@ def add_type(self: "Costs", type_: str) -> "Costs": self.type = type_ return self - def add_amount(self: "Costs", amount: float) -> "Costs": + def add_amount( + self: "Costs", + amount: Union[DecimalAmount, Decimal, float, str], + ) -> "Costs": """ - Add an amount to the Costs instance. + Add an amount to the Costs instance with precise Decimal conversion. Parameters ---------- - amount (float): The amount of the cost. + amount (Union[DecimalAmount, Decimal, float, int, str]): The amount of the cost. Returns ------- Costs: The updated Costs instance. """ - self.amount = amount + if isinstance(amount, DecimalAmount): + self.amount = amount.get() + else: + self.amount = DecimalAmount(amount=amount).get() return self def add_currency(self: "Costs", currency: str) -> "Costs": diff --git a/src/multisafepay/util/__init__.py b/src/multisafepay/util/__init__.py index f26e44f..7e664d8 100644 --- a/src/multisafepay/util/__init__.py +++ b/src/multisafepay/util/__init__.py @@ -1,7 +1,9 @@ """Utility functions and helpers for MultiSafepay SDK operations.""" +from multisafepay.util.json_encoder import DecimalEncoder from multisafepay.util.webhook import Webhook __all__ = [ + "DecimalEncoder", "Webhook", ] diff --git a/src/multisafepay/util/json_encoder.py b/src/multisafepay/util/json_encoder.py new file mode 100644 index 0000000..9a6edef --- /dev/null +++ b/src/multisafepay/util/json_encoder.py @@ -0,0 +1,42 @@ +# 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. + +"""JSON encoder utilities for API serialization.""" + +import json +from decimal import Decimal + + +class DecimalEncoder(json.JSONEncoder): + """ + Custom JSON encoder that converts Decimal objects to float for API serialization. + + This encoder ensures that Decimal values used for precise calculations + are properly serialized when sending data to the API. + """ + + def default( + self: "DecimalEncoder", + o: object, + ) -> object: # pylint: disable=invalid-name + """ + Convert Decimal to float, otherwise use default encoder. + + Parameters + ---------- + o : object + The object to serialize. + + Returns + ------- + object + The serialized object (float for Decimal, default for others). + + """ + if isinstance(o, Decimal): + return float(o) + return super().default(o) diff --git a/src/multisafepay/util/total_amount.py b/src/multisafepay/util/total_amount.py index dae80bf..2d99e7e 100644 --- a/src/multisafepay/util/total_amount.py +++ b/src/multisafepay/util/total_amount.py @@ -5,19 +5,91 @@ # See the DISCLAIMER.md file for disclaimer details. -"""Total amount calculation and validation utilities for order processing.""" +""" +Total amount calculation and validation utilities for order processing. + +Important: +--------- +Different integrators (ERP/POS/e-commerce) can legitimately compute `amount` +under different rounding policies (per-line vs end rounding, half-up vs +bankers rounding, etc.). + +This module supports a **best-effort** local validator that can be configured +to match a known policy, but the API remains the source of truth. + +""" import json +from decimal import ROUND_DOWN, ROUND_HALF_EVEN, ROUND_HALF_UP, Decimal +from typing import Literal, Union from multisafepay.exception.invalid_total_amount import ( InvalidTotalAmountException, ) +RoundingStrategy = Literal["end", "line"] +RoundingMode = Literal["half_up", "half_even", "down"] + + +def _decimal_rounding(mode: RoundingMode) -> str: + if mode == "half_even": + return ROUND_HALF_EVEN + if mode == "down": + return ROUND_DOWN + return ROUND_HALF_UP + -def validate_total_amount(data: dict) -> bool: +def _convert_decimals_to_float( + obj: Union[Decimal, dict, list, object], +) -> Union[float, dict, list, object]: + """ + Recursively convert Decimal objects to float for JSON serialization. + + Parameters + ---------- + obj : Union[Decimal, dict, list, object] + The object to convert (can be dict, list, Decimal, or any other type) + + Returns + ------- + Union[float, dict, list, object] + The converted object with all Decimals replaced by floats + + """ + if isinstance(obj, Decimal): + return float(obj) + if isinstance(obj, dict): + return { + key: _convert_decimals_to_float(value) + for key, value in obj.items() + } + if isinstance(obj, list): + return [_convert_decimals_to_float(item) for item in obj] + return obj + + +def validate_total_amount( + data: dict, + *, + rounding_strategy: RoundingStrategy = "end", + rounding_mode: RoundingMode = "half_up", +) -> bool: """ Validate the total amount in the provided data dictionary. + Important + --------- + This validator uses a specific calculation/rounding model: + - Applies tax per item (if any) and sums the precise Decimal totals. + - Quantizes the final total to 2 decimals. + - Converts to cents using HALF_UP. + + If the input `amount` was produced under a different policy (per-line rounding, + different tax rounding, unit prices with more than 2 decimals, etc.), the SDK + may disagree with external systems. In those cases, prefer letting the API + validate and/or use `calculate_total_amount_cents()` to compute a consistent + amount under this validator's rules. + Parameters ---------- data (dict): The data dictionary containing the amount and shopping cart details. @@ -38,19 +110,78 @@ def validate_total_amount(data: dict) -> bool: return False amount = data["amount"] - total_unit_price = __calculate_totals(data) + total_unit_price = calculate_total_amount( + data, + rounding_strategy=rounding_strategy, + rounding_mode=rounding_mode, + ) - if (total_unit_price * 100) != amount: - msg = f"Total of unit_price ({total_unit_price}) does not match amount ({amount})" - msg += "\n" + json.dumps(data, indent=4) + # Convert to cents (integer) for comparison. + # Note: total_unit_price is already quantized to 2 decimals in calculate_total_amount(). + total_unit_price_cents = calculate_total_amount_cents( + data, + rounding_strategy=rounding_strategy, + rounding_mode=rounding_mode, + ) + + if total_unit_price_cents != amount: + delta = amount - total_unit_price_cents + msg = ( + f"Total of unit_price ({total_unit_price}) does not match amount ({amount}). " + f"Expected amount: {total_unit_price_cents} (delta: {delta})." + ) + # Create a JSON-serializable copy of data by converting Decimal to float + serializable_data = _convert_decimals_to_float(data) + msg += "\n" + json.dumps(serializable_data, indent=4) raise InvalidTotalAmountException(msg) return True -def __calculate_totals(data: dict) -> float: +def calculate_total_amount( + data: dict, + *, + rounding_strategy: RoundingStrategy = "end", + rounding_mode: RoundingMode = "half_up", +) -> Decimal: + """ + Calculate the order total (major units) using the same logic as the validator. + + This is useful to generate a consistent `amount` before submitting an Order. + """ + return __calculate_totals( + data, + rounding_strategy=rounding_strategy, + rounding_mode=rounding_mode, + ) + + +def calculate_total_amount_cents( + data: dict, + *, + rounding_strategy: RoundingStrategy = "end", + rounding_mode: RoundingMode = "half_up", +) -> int: + """Calculate the expected `amount` (minor units) using the validator's logic.""" + total = calculate_total_amount( + data, + rounding_strategy=rounding_strategy, + rounding_mode=rounding_mode, + ) + cents = (total * 100).to_integral_value( + rounding=_decimal_rounding(rounding_mode), + ) + return int(cents) + + +def __calculate_totals( + data: dict, + *, + rounding_strategy: RoundingStrategy = "end", + rounding_mode: RoundingMode = "half_up", +) -> Decimal: """ - Calculate the total unit price of items in the shopping cart. + Calculate the total unit price of items in the shopping cart using precise Decimal arithmetic. Parameters ---------- @@ -58,23 +189,40 @@ def __calculate_totals(data: dict) -> float: Returns ------- - float: The total unit price of all items in the shopping cart. + Decimal: The total unit price of all items in the shopping cart with precise decimal calculation. """ - total_unit_price = 0 + rounding = _decimal_rounding(rounding_mode) + total_unit_price = Decimal("0") for item in data["shopping_cart"]["items"]: tax_rate = __get_tax_rate_by_item(item, data) - item_price = item["unit_price"] * item["quantity"] - item_price += tax_rate * item_price + + # Convert to Decimal for precise calculations + unit_price = Decimal(str(item["unit_price"])) + quantity = Decimal(str(item["quantity"])) + tax_rate_decimal = Decimal(str(tax_rate)) + + # Calculate item price with tax + item_price = unit_price * quantity + item_price += tax_rate_decimal * item_price + + # Some systems (e.g., ERPs/POS) round per line item; others round at the end. + if rounding_strategy == "line": + item_price = item_price.quantize( + Decimal("0.01"), + rounding=rounding, + ) + total_unit_price += item_price - return round(total_unit_price, 2) + # Round to 2 decimal places for currency + return total_unit_price.quantize(Decimal("0.01"), rounding=rounding) def __get_tax_rate_by_item( item: dict, data: dict, -) -> object: +) -> Union[Decimal, int]: """ Get the tax rate for a specific item in the shopping cart. @@ -85,7 +233,7 @@ def __get_tax_rate_by_item( Returns ------- - object: The tax rate for the item, or 0 if no tax rate is found. + Union[Decimal, int]: The tax rate for the item as Decimal, or 0 if no tax rate is found. """ if "tax_table_selector" not in item or not item["tax_table_selector"]: @@ -103,10 +251,12 @@ def __get_tax_rate_by_item( continue tax_rule = tax_table["rules"][0] - return tax_rule["rate"] - return ( + return Decimal(str(tax_rule["rate"])) + + default_rate = ( data["checkout_options"]["tax_tables"]["default"]["rate"] if "default" in data["checkout_options"]["tax_tables"] and "rate" in data["checkout_options"]["tax_tables"]["default"] else 0 ) + return Decimal(str(default_rate)) if default_rate != 0 else 0 diff --git a/src/multisafepay/value_object/__init__.py b/src/multisafepay/value_object/__init__.py index 538fe94..89ff2be 100644 --- a/src/multisafepay/value_object/__init__.py +++ b/src/multisafepay/value_object/__init__.py @@ -5,12 +5,12 @@ from multisafepay.value_object.country import Country from multisafepay.value_object.currency import Currency from multisafepay.value_object.date import Date +from multisafepay.value_object.decimal_amount import DecimalAmount from multisafepay.value_object.email_address import EmailAddress from multisafepay.value_object.gender import Gender from multisafepay.value_object.iban_number import IbanNumber from multisafepay.value_object.ip_address import IpAddress from multisafepay.value_object.phone_number import PhoneNumber -from multisafepay.value_object.unit_price import UnitPrice from multisafepay.value_object.weight import Weight __all__ = [ @@ -19,11 +19,11 @@ "Country", "Currency", "Date", + "DecimalAmount", "EmailAddress", "Gender", "IbanNumber", "IpAddress", "PhoneNumber", - "UnitPrice", "Weight", ] diff --git a/src/multisafepay/value_object/decimal_amount.py b/src/multisafepay/value_object/decimal_amount.py new file mode 100644 index 0000000..ed8dedd --- /dev/null +++ b/src/multisafepay/value_object/decimal_amount.py @@ -0,0 +1,67 @@ +# 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. + +"""DecimalAmount value object for monetary amounts.""" + +from decimal import Decimal +from typing import Type, Union + +from multisafepay.model.inmutable_model import InmutableModel +from pydantic import validator + + +class DecimalAmount(InmutableModel): + """ + A class to represent a monetary amount with decimal precision. + + Attributes + ---------- + amount (Decimal): The amount as a precise decimal value. + + """ + + amount: Decimal + + @validator("amount", pre=True) + def convert_to_decimal( + cls: Type["DecimalAmount"], + value: Union[str, float, Decimal], + ) -> Decimal: + """ + Convert the input value to Decimal for precise calculations. + + Parameters + ---------- + value (Union[str, float, Decimal]): The value to convert. + + Returns + ------- + Decimal: The converted Decimal value. + + Raises + ------ + TypeError: If the value cannot be converted to Decimal. + + """ + if isinstance(value, Decimal): + return value + if not isinstance(value, (str, float, int)): + raise TypeError( + f"Cannot convert {type(value).__name__} to Decimal", + ) + return Decimal(str(value)) + + def get(self: "DecimalAmount") -> Decimal: + """ + Get the monetary amount. + + Returns + ------- + Decimal: The amount. + + """ + return self.amount diff --git a/src/multisafepay/value_object/unit_price.py b/src/multisafepay/value_object/unit_price.py deleted file mode 100644 index 3d6b67e..0000000 --- a/src/multisafepay/value_object/unit_price.py +++ /dev/null @@ -1,34 +0,0 @@ -# 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. - -"""Unit price value object for item pricing in shopping carts.""" - -from multisafepay.model.inmutable_model import InmutableModel - - -class UnitPrice(InmutableModel): - """ - A class to represent the unit price of an item. - - Attributes - ---------- - unit_price (float): The unit price of the item. - - """ - - unit_price: float - - def get(self: "UnitPrice") -> float: - """ - Get the unit price of the item. - - Returns - ------- - float: The unit price of the item. - - """ - return self.unit_price diff --git a/tests/multisafepay/e2e/conftest.py b/tests/multisafepay/e2e/conftest.py new file mode 100644 index 0000000..22ca7e5 --- /dev/null +++ b/tests/multisafepay/e2e/conftest.py @@ -0,0 +1,28 @@ +"""Configuration for end-to-end tests.""" + +import os + +import pytest + + +def pytest_collection_modifyitems( + config: pytest.Config, # noqa: ARG001 + items: list[pytest.Item], +) -> None: + """ + Skip all e2e tests when API_KEY is missing. + + These tests perform real API calls. In most local/CI environments the secret + isn't present, so we prefer a clean skip over hard errors during fixture setup. + """ + api_key = os.getenv("API_KEY") + if api_key and api_key.strip(): + return + + skip = pytest.mark.skip(reason="E2E tests require API_KEY (not set)") + for item in items: + # This hook runs for the whole session (all collected tests), even when + # this conftest is only loaded due to e2e tests being present/deselected. + # Ensure we only affect e2e tests. + if item.nodeid.startswith("tests/multisafepay/e2e/"): + item.add_marker(skip) diff --git a/tests/multisafepay/integration/amounts/__init__.py b/tests/multisafepay/integration/amounts/__init__.py new file mode 100644 index 0000000..ba0c1eb --- /dev/null +++ b/tests/multisafepay/integration/amounts/__init__.py @@ -0,0 +1,6 @@ +""" +Offline amount/total validation scenarios. + +These tests focus on local amount/cart validation logic. They do not perform +network calls and should not require API_KEY. +""" diff --git a/tests/multisafepay/integration/amounts/_helpers.py b/tests/multisafepay/integration/amounts/_helpers.py new file mode 100644 index 0000000..c25e62e --- /dev/null +++ b/tests/multisafepay/integration/amounts/_helpers.py @@ -0,0 +1,47 @@ +"""Shared helpers for offline amount/total validation scenario tests.""" + +from __future__ import annotations + +from decimal import Decimal + + +def data( + *, + amount: int, + items: list[dict], + tax_tables: dict | None = None, +) -> dict: + """Build a minimal `validate_total_amount` input dict.""" + result: dict = { + "amount": amount, + "shopping_cart": {"items": items}, + } + if tax_tables is not None: + result["checkout_options"] = {"tax_tables": tax_tables} + return result + + +def no_tax_tables() -> dict: + """Tax tables configuration that yields 0% tax under selector 'none'.""" + return { + "default": {"shipping_taxed": True, "rate": 0.0}, + "alternate": [ + {"name": "none", "standalone": False, "rules": [{"rate": 0.0}]}, + ], + } + + +def vat_tables() -> dict: + """Common VAT tables used for scenarios (21% and 9%).""" + return { + "default": {"shipping_taxed": True, "rate": 0.21}, + "alternate": [ + {"name": "BTW21", "standalone": True, "rules": [{"rate": 0.21}]}, + {"name": "BTW9", "standalone": True, "rules": [{"rate": 0.09}]}, + ], + } + + +def d(value: str) -> Decimal: + """Convenience Decimal constructor for test readability.""" + return Decimal(value) diff --git a/tests/multisafepay/integration/amounts/test_integration_amount_policy.py b/tests/multisafepay/integration/amounts/test_integration_amount_policy.py new file mode 100644 index 0000000..4068c8c --- /dev/null +++ b/tests/multisafepay/integration/amounts/test_integration_amount_policy.py @@ -0,0 +1,172 @@ +"""Integration tests for amount policy validation.""" + +from __future__ import annotations + +import pytest + +from multisafepay.exception.invalid_total_amount import ( + InvalidTotalAmountException, +) +from multisafepay.util.total_amount import ( + calculate_total_amount_cents, + validate_total_amount, +) + +from ._helpers import data, no_tax_tables, vat_tables + + +def test_end_rounding_half_up_matches_expected_cents() -> None: + """Verify that end rounding with HALF_UP strategy produces expected results.""" + payload = data( + amount=101, + items=[ + { + "unit_price": "0.335", + "quantity": 3, + "tax_table_selector": "none", + }, + ], + tax_tables=no_tax_tables(), + ) + + assert ( + calculate_total_amount_cents( + payload, + rounding_strategy="end", + rounding_mode="half_up", + ) + == 101 + ) + assert ( + validate_total_amount( + payload, + rounding_strategy="end", + rounding_mode="half_up", + ) + is True + ) + + +def test_end_rounding_half_even_can_disagree_on_half_cent_boundary() -> None: + """Verify that HALF_EVEN rounding can differ from HALF_UP on boundaries.""" + payload = data( + amount=100, + items=[ + { + "unit_price": "0.335", + "quantity": 3, + "tax_table_selector": "none", + }, + ], + tax_tables=no_tax_tables(), + ) + + # 0.335 * 3 = 1.005 -> half-even rounds to 1.00 (100 cents) + assert ( + calculate_total_amount_cents( + payload, + rounding_strategy="end", + rounding_mode="half_even", + ) + == 100 + ) + assert ( + validate_total_amount( + payload, + rounding_strategy="end", + rounding_mode="half_even", + ) + is True + ) + + # Same payload fails under HALF_UP. + with pytest.raises(InvalidTotalAmountException): + validate_total_amount( + payload, + rounding_strategy="end", + rounding_mode="half_up", + ) + + +def test_line_rounding_changes_expected_amount() -> None: + """Verify that per-line rounding produces different totals than end rounding.""" + payload = data( + amount=102, + items=[ + { + "unit_price": "0.335", + "quantity": 1, + "tax_table_selector": "none", + }, + { + "unit_price": "0.335", + "quantity": 1, + "tax_table_selector": "none", + }, + { + "unit_price": "0.335", + "quantity": 1, + "tax_table_selector": "none", + }, + ], + tax_tables=no_tax_tables(), + ) + + assert ( + calculate_total_amount_cents( + payload, + rounding_strategy="line", + rounding_mode="half_up", + ) + == 102 + ) + assert ( + validate_total_amount( + payload, + rounding_strategy="line", + rounding_mode="half_up", + ) + is True + ) + + with pytest.raises(InvalidTotalAmountException): + validate_total_amount( + payload, + rounding_strategy="end", + rounding_mode="half_up", + ) + + +def test_tax_tables_respected_and_helper_matches_validator() -> None: + """Verify that tax tables are applied correctly and helper matches validator.""" + payload = data( + amount=0, + items=[ + { + "unit_price": "0.10", + "quantity": 3, + "tax_table_selector": "BTW21", + }, + { + "unit_price": "0.20", + "quantity": 3, + "tax_table_selector": "BTW9", + }, + ], + tax_tables=vat_tables(), + ) + + expected = calculate_total_amount_cents( + payload, + rounding_strategy="end", + rounding_mode="half_up", + ) + payload["amount"] = expected + assert ( + validate_total_amount( + payload, + rounding_strategy="end", + rounding_mode="half_up", + ) + is True + ) diff --git a/tests/multisafepay/integration/amounts/test_multi_line_rounding_policy.py b/tests/multisafepay/integration/amounts/test_multi_line_rounding_policy.py new file mode 100644 index 0000000..7edac33 --- /dev/null +++ b/tests/multisafepay/integration/amounts/test_multi_line_rounding_policy.py @@ -0,0 +1,70 @@ +"""Multi-line rounding policy mismatches (offline).""" + +from __future__ import annotations + +import pytest + +from multisafepay.exception.invalid_total_amount import ( + InvalidTotalAmountException, +) +from multisafepay.util.total_amount import ( + calculate_total_amount_cents, + validate_total_amount, +) + +from ._helpers import data, no_tax_tables + + +def test_round_per_line_vs_round_at_end_can_disagree() -> None: + """Verify that per-line rounding can produce different totals than end rounding.""" + payload = data( + amount=102, + items=[ + { + "unit_price": "0.335", + "quantity": 1, + "tax_table_selector": "none", + }, + { + "unit_price": "0.335", + "quantity": 1, + "tax_table_selector": "none", + }, + { + "unit_price": "0.335", + "quantity": 1, + "tax_table_selector": "none", + }, + ], + tax_tables=no_tax_tables(), + ) + assert ( + calculate_total_amount_cents(payload, rounding_strategy="line") == 102 + ) + assert validate_total_amount(payload, rounding_strategy="line") is True + + with pytest.raises(InvalidTotalAmountException): + validate_total_amount(payload, rounding_strategy="end") + + +def test_many_small_lines_rounding_noise() -> None: + """Verify that many small lines can accumulate rounding noise.""" + payload = data( + amount=20, + items=[ + { + "unit_price": "0.015", + "quantity": 1, + "tax_table_selector": "none", + } + for _ in range(10) + ], + tax_tables=no_tax_tables(), + ) + assert ( + calculate_total_amount_cents(payload, rounding_strategy="line") == 20 + ) + assert validate_total_amount(payload, rounding_strategy="line") is True + + with pytest.raises(InvalidTotalAmountException): + validate_total_amount(payload, rounding_strategy="end") diff --git a/tests/multisafepay/integration/amounts/test_rounding_boundaries.py b/tests/multisafepay/integration/amounts/test_rounding_boundaries.py new file mode 100644 index 0000000..cdd91f7 --- /dev/null +++ b/tests/multisafepay/integration/amounts/test_rounding_boundaries.py @@ -0,0 +1,95 @@ +""" +Rounding boundary scenarios (offline). + +These scenarios validate that policy knobs (strategy/mode) produce the expected +minor-unit totals around half-cent boundaries. +""" + +from __future__ import annotations + +import pytest + +from multisafepay.exception.invalid_total_amount import ( + InvalidTotalAmountException, +) +from multisafepay.util.total_amount import ( + calculate_total_amount_cents, + validate_total_amount, +) + +from ._helpers import data, no_tax_tables + + +def test_rounding_boundary_truncation_mismatch_expected() -> None: + """ + Boundary: 0.335 * 3 = 1.005. + + With HALF_UP end-rounding, this becomes 1.01 (101 cents). + """ + payload = data( + amount=101, + items=[ + { + "unit_price": "0.335", + "quantity": 3, + "tax_table_selector": "none", + }, + ], + tax_tables=no_tax_tables(), + ) + + assert ( + calculate_total_amount_cents( + payload, + rounding_strategy="end", + rounding_mode="half_up", + ) + == 101 + ) + assert ( + validate_total_amount( + payload, + rounding_strategy="end", + rounding_mode="half_up", + ) + is True + ) + + +def test_rounding_boundary_expected_pass_with_truncated_amount() -> None: + """Same case, but HALF_EVEN rounds 1.005 down to 1.00 (100 cents).""" + payload = data( + amount=100, + items=[ + { + "unit_price": "0.335", + "quantity": 3, + "tax_table_selector": "none", + }, + ], + tax_tables=no_tax_tables(), + ) + + assert ( + calculate_total_amount_cents( + payload, + rounding_strategy="end", + rounding_mode="half_even", + ) + == 100 + ) + assert ( + validate_total_amount( + payload, + rounding_strategy="end", + rounding_mode="half_even", + ) + is True + ) + + with pytest.raises(InvalidTotalAmountException): + validate_total_amount( + payload, + rounding_strategy="end", + rounding_mode="half_up", + ) diff --git a/tests/multisafepay/integration/amounts/test_tax_rounding_policy.py b/tests/multisafepay/integration/amounts/test_tax_rounding_policy.py new file mode 100644 index 0000000..af8b26c --- /dev/null +++ b/tests/multisafepay/integration/amounts/test_tax_rounding_policy.py @@ -0,0 +1,66 @@ +"""Tax rounding mismatches (offline).""" + +from __future__ import annotations + +import pytest + +from multisafepay.exception.invalid_total_amount import ( + InvalidTotalAmountException, +) +from multisafepay.util.total_amount import calculate_total_amount_cents +from multisafepay.util.total_amount import validate_total_amount + +from ._helpers import data, vat_tables + + +def test_tax_round_per_line_vs_total_can_disagree() -> None: + """Verify that tax rounding per line can differ from total tax rounding.""" + payload = data( + # Crafted to show a per-line rounding difference vs end rounding. + amount=8, + items=[ + { + "unit_price": "0.03", + "quantity": 1, + "tax_table_selector": "BTW21", + }, + { + "unit_price": "0.03", + "quantity": 1, + "tax_table_selector": "BTW21", + }, + ], + tax_tables=vat_tables(), + ) + + assert calculate_total_amount_cents(payload, rounding_strategy="line") == 8 + assert validate_total_amount(payload, rounding_strategy="line") is True + + with pytest.raises(InvalidTotalAmountException): + validate_total_amount(payload, rounding_strategy="end") + + +def test_tax_mixed_rates_with_boundary_prices() -> None: + """Verify tax calculations with mixed rates and boundary prices.""" + payload = data( + amount=0, + items=[ + { + "unit_price": "0.105", + "quantity": 1, + "tax_table_selector": "BTW21", + }, + { + "unit_price": "0.105", + "quantity": 1, + "tax_table_selector": "BTW9", + }, + ], + tax_tables=vat_tables(), + ) + + payload["amount"] = calculate_total_amount_cents( + payload, + rounding_strategy="end", + ) + assert validate_total_amount(payload, rounding_strategy="end") is True diff --git a/tests/multisafepay/integration/amounts/test_taxes.py b/tests/multisafepay/integration/amounts/test_taxes.py new file mode 100644 index 0000000..96c8b72 --- /dev/null +++ b/tests/multisafepay/integration/amounts/test_taxes.py @@ -0,0 +1,98 @@ +"""Tax-related scenarios that can drift due to rounding policy (offline).""" + +from __future__ import annotations + +from decimal import Decimal + +from multisafepay.util.total_amount import ( + calculate_total_amount_cents, + validate_total_amount, +) + +from ._helpers import data, vat_tables + + +def test_tax_rate_applied_simple_21_percent() -> None: + """Basic tax application: unit_price*qty*(1+rate) should match.""" + payload = data( + amount=121, + items=[ + { + "unit_price": "1.00", + "quantity": 1, + "tax_table_selector": "BTW21", + }, + ], + tax_tables=vat_tables(), + ) + assert validate_total_amount(payload) is True + + +def test_tax_rate_string_decimal_rate() -> None: + """Tax rate as strings should behave the same in calculations.""" + tax_tables = { + "default": {"shipping_taxed": True, "rate": "0.21"}, + "alternate": [ + {"name": "BTW21", "standalone": True, "rules": [{"rate": "0.21"}]}, + ], + } + payload = data( + amount=121, + items=[ + { + "unit_price": Decimal("1.00"), + "quantity": 1, + "tax_table_selector": "BTW21", + }, + ], + tax_tables=tax_tables, + ) + assert validate_total_amount(payload) is True + + +def test_multiple_items_mixed_taxes_total_can_differ_by_rounding() -> None: + """ + Mixed tax selectors across items should be internally consistent. + + We compute `amount` using the SDK helper so the validator matches. + """ + # Este test construye un payload “tipo order” (pero offline) para validar que + # `validate_total_amount()` considera coherentes: + # - `amount`: total declarado en **minor units** (céntimos). + # - `items[*].unit_price`: precio unitario en **major units** (p.ej. EUR). + # - `items[*].quantity`: cantidad. + # - `tax_table_selector`: qué regla/tabla de IVA aplicar por línea. + # - `tax_tables`: tablas de IVA disponibles. + # + # La idea del caso: mezclar diferentes tipos de IVA (21% y 9%) en distintas + # líneas puede destapar diferencias de política de redondeo (por línea vs al + # final) y de cómo se aplican los impuestos (y cuándo se cuantiza a 2 decimales). + payload = data( + amount=0, + items=[ + # Línea 1: 0.10 EUR * 3 unidades, con IVA 21% (BTW21). + { + "unit_price": "0.10", + "quantity": 3, + "tax_table_selector": "BTW21", + }, + # Línea 2: 0.20 EUR * 3 unidades, con IVA 9% (BTW9). + { + "unit_price": "0.20", + "quantity": 3, + "tax_table_selector": "BTW9", + }, + ], + # Tablas de IVA usadas por el selector: normalmente incluyen una “default” + # y reglas “standalone” por nombre (p.ej. BTW21 / BTW9). + tax_tables=vat_tables(), + ) + + # `validate_total_amount()` calcula el total localmente (con Decimal y reglas + # de IVA) y lo compara contra `amount`. + # + # Ojo: si tu cálculo “externo” (o el backend) redondea distinto (p.ej. redondeo + # por línea vs redondeo al final, o diferente handling de impuestos), este tipo + # de caso puede producir discrepancias de 1-2 céntimos. + payload["amount"] = calculate_total_amount_cents(payload) + assert validate_total_amount(payload) is True diff --git a/tests/multisafepay/integration/api/shared/cart/test_integration_cart_item.py b/tests/multisafepay/integration/api/shared/cart/test_integration_cart_item.py index 7372bb0..ca39b39 100644 --- a/tests/multisafepay/integration/api/shared/cart/test_integration_cart_item.py +++ b/tests/multisafepay/integration/api/shared/cart/test_integration_cart_item.py @@ -8,6 +8,8 @@ """Shared API models and utilities.""" +from decimal import Decimal + from multisafepay.api.shared.cart.cart_item import CartItem from multisafepay.value_object.weight import Weight @@ -48,7 +50,7 @@ def test_initializes_with_valid_values(): assert item.quantity == 10 assert item.tax_table_selector == "standard" assert item.tax_rate == 0.2 - assert item.unit_price == 19.99 + assert item.unit_price == Decimal("19.99") assert item.weight.value == 100 assert item.weight.unit == "grams" @@ -114,6 +116,6 @@ def test_creates_from_dict(): assert item.quantity == 10 assert item.tax_table_selector == "standard" assert item.tax_rate == 0.2 - assert item.unit_price == 19.99 + assert item.unit_price == Decimal("19.99") assert item.weight.unit == "grams" assert item.weight.value == 100.0 diff --git a/tests/multisafepay/unit/api/path/orders/request/test_unit_order_request.py b/tests/multisafepay/unit/api/path/orders/request/test_unit_order_request.py index b4ace9c..132b5b1 100644 --- a/tests/multisafepay/unit/api/path/orders/request/test_unit_order_request.py +++ b/tests/multisafepay/unit/api/path/orders/request/test_unit_order_request.py @@ -11,6 +11,11 @@ import pytest from multisafepay.api.paths.orders.request.order_request import OrderRequest +from multisafepay.api.shared.cart.cart_item import CartItem +from multisafepay.api.shared.cart.shopping_cart import ShoppingCart +from multisafepay.exception.invalid_total_amount import ( + InvalidTotalAmountException, +) from multisafepay.exception.invalid_argument import InvalidArgumentException @@ -182,6 +187,50 @@ def test_add_description_updates_value(): assert isinstance(order_request_updated, OrderRequest) +def test_validate_amount_raises_when_amount_mismatches_cart() -> None: + """validate_amount() should raise if amount and cart total disagree.""" + order_request = OrderRequest( + amount=101, + shopping_cart=ShoppingCart( + items=[CartItem(unit_price="1.00", quantity=1)], + ), + ) + + with pytest.raises(InvalidTotalAmountException): + order_request.validate_amount() + + +def test_validate_amount_rounding_strategy_end_vs_line() -> None: + """Per-line rounding can differ by 1 cent from end-rounding.""" + # Three lines of 0.335: + # - end rounding: 0.335*3 = 1.005 -> 1.01 (101 cents) + # - line rounding: 0.335 -> 0.34 per line => 1.02 (102 cents) + cart = ShoppingCart( + items=[ + CartItem(unit_price="0.335", quantity=1), + CartItem(unit_price="0.335", quantity=1), + CartItem(unit_price="0.335", quantity=1), + ], + ) + + OrderRequest(amount=101, shopping_cart=cart).validate_amount( + rounding_strategy="end", + ) + OrderRequest(amount=102, shopping_cart=cart).validate_amount( + rounding_strategy="line", + ) + + with pytest.raises(InvalidTotalAmountException): + OrderRequest(amount=101, shopping_cart=cart).validate_amount( + rounding_strategy="line", + ) + + with pytest.raises(InvalidTotalAmountException): + OrderRequest(amount=102, shopping_cart=cart).validate_amount( + rounding_strategy="end", + ) + + def test_add_recurring_id_updates_value(): """Tests that the add_recurring_id method updates the recurring_id field correctly.""" order_request = OrderRequest() diff --git a/tests/multisafepay/unit/api/shared/cart/test_unit_cart_item.py b/tests/multisafepay/unit/api/shared/cart/test_unit_cart_item.py index 1c91bf5..bb85f41 100644 --- a/tests/multisafepay/unit/api/shared/cart/test_unit_cart_item.py +++ b/tests/multisafepay/unit/api/shared/cart/test_unit_cart_item.py @@ -103,9 +103,11 @@ def test_adds_tax_table_selector(): def test_adds_unit_price(): """Test that unit price can be added to a CartItem.""" + from decimal import Decimal + item = CartItem() item.add_unit_price(19.99) - assert item.unit_price == 19.99 + assert item.unit_price == Decimal("19.99") def test_creates_from_empty_dict(): diff --git a/tests/multisafepay/unit/api/shared/test_unit_costs.py b/tests/multisafepay/unit/api/shared/test_unit_costs.py index 0e1c23e..ac1e91b 100644 --- a/tests/multisafepay/unit/api/shared/test_unit_costs.py +++ b/tests/multisafepay/unit/api/shared/test_unit_costs.py @@ -8,6 +8,7 @@ """Unit tests for the shared costs model.""" +from decimal import Decimal from multisafepay.api.shared.costs import Costs @@ -25,7 +26,7 @@ def test_initializes_with_valid_values(): assert costs.transaction_id == 123 assert costs.description == "Service Fee" assert costs.type == "Fixed" - assert costs.amount == 99.99 + assert costs.amount == Decimal("99.99") assert costs.currency == "USD" assert costs.status == "Pending" @@ -62,7 +63,7 @@ def test_adds_type(): def test_adds_amount(): """Test that an amount is added to a Costs instance.""" costs = Costs().add_amount(99.99) - assert costs.amount == 99.99 + assert costs.amount == Decimal("99.99") def test_adds_currency(): @@ -91,7 +92,7 @@ def test_creates_from_dict_with_all_fields(): assert costs.transaction_id == 123 assert costs.description == "Service Fee" assert costs.type == "Fixed" - assert costs.amount == 99.99 + assert costs.amount == Decimal("99.99") assert costs.currency == "USD" assert costs.status == "Pending" diff --git a/tests/multisafepay/unit/util/test_unit_total_amount.py b/tests/multisafepay/unit/util/test_unit_total_amount.py index a8e572c..8914acf 100644 --- a/tests/multisafepay/unit/util/test_unit_total_amount.py +++ b/tests/multisafepay/unit/util/test_unit_total_amount.py @@ -8,8 +8,12 @@ """Utility functions for test unit total amount.""" +from decimal import Decimal + from multisafepay.util.total_amount import ( validate_total_amount, + calculate_total_amount, + calculate_total_amount_cents, __get_tax_rate_by_item, __calculate_totals, ) @@ -34,7 +38,7 @@ def test_get_tax_rate_by_item(): }, } - assert __get_tax_rate_by_item(item, data) == 0.1 + assert __get_tax_rate_by_item(item, data) == Decimal("0.1") def test_get_tax_rate_by_item_btw21(): @@ -56,7 +60,7 @@ def test_get_tax_rate_by_item_btw21(): }, } - assert __get_tax_rate_by_item(item, data) == 0.21 + assert __get_tax_rate_by_item(item, data) == Decimal("0.21") def test_calculate_totals(): @@ -73,7 +77,7 @@ def test_calculate_totals(): }, } - assert __calculate_totals(data) == 2.42 + assert __calculate_totals(data) == Decimal("2.42") def test_validate_total_amount(): @@ -92,3 +96,174 @@ def test_validate_total_amount(): } assert validate_total_amount(data) is True + + +def test_calculate_total_amount_helpers_match_validator_math() -> None: + """Ensure calculate_total_amount and calculate_total_amount_cents produce consistent results.""" + data = { + "amount": 1000, + "shopping_cart": { + "items": [ + { + "unit_price": 10.00, + "quantity": 1, + "tax_table_selector": "default", + }, + ], + }, + } + + assert calculate_total_amount(data) == Decimal("10.00") + assert calculate_total_amount_cents(data) == 1000 + + +def test_rounding_strategy_end_vs_line_can_differ() -> None: + """Per-line rounding can diverge from end rounding on micro-priced lines.""" + data = { + "shopping_cart": { + "items": [ + { + "unit_price": "0.335", + "quantity": 1, + "tax_table_selector": None, + }, + { + "unit_price": "0.335", + "quantity": 1, + "tax_table_selector": None, + }, + { + "unit_price": "0.335", + "quantity": 1, + "tax_table_selector": None, + }, + ], + }, + "amount": 0, + } + + # 0.335 * 3 = 1.005 -> 1.01 (101 cents) if rounding at end. + assert calculate_total_amount_cents(data, rounding_strategy="end") == 101 + + # 0.335 -> 0.34 per line, 0.34 * 3 = 1.02 (102 cents). + assert calculate_total_amount_cents(data, rounding_strategy="line") == 102 + + +def test_rounding_mode_half_even_vs_half_up_boundary() -> None: + """HALF_EVEN and HALF_UP can differ on x.xx5 boundaries.""" + data = { + "shopping_cart": { + "items": [ + { + "unit_price": "0.335", + "quantity": 3, + "tax_table_selector": None, + }, + ], + }, + "amount": 0, + } + + # 0.335 * 3 = 1.005 + assert calculate_total_amount_cents(data, rounding_mode="half_up") == 101 + assert calculate_total_amount_cents(data, rounding_mode="half_even") == 100 + + +def test_validate_total_amount_honors_rounding_mode() -> None: + """Ensure validate_total_amount respects the rounding_mode parameter.""" + data = { + "shopping_cart": { + "items": [ + { + "unit_price": "0.335", + "quantity": 3, + "tax_table_selector": None, + }, + ], + }, + "amount": 100, + } + + assert validate_total_amount(data, rounding_mode="half_even") is True + + +def test_tax_rounding_can_differ_between_line_and_end() -> None: + """After-tax values can diverge when rounded per line vs at end.""" + tax_tables = { + "default": {"shipping_taxed": True, "rate": 0.21}, + "alternate": [ + {"name": "BTW21", "standalone": True, "rules": [{"rate": 0.21}]}, + ], + } + data = { + "shopping_cart": { + "items": [ + { + "unit_price": "0.03", + "quantity": 1, + "tax_table_selector": "BTW21", + }, + { + "unit_price": "0.03", + "quantity": 1, + "tax_table_selector": "BTW21", + }, + ], + }, + "checkout_options": {"tax_tables": tax_tables}, + "amount": 0, + } + + # Each line: 0.03 * 1.21 = 0.0363 -> 0.04 (line), so total 0.08. + assert calculate_total_amount_cents(data, rounding_strategy="line") == 8 + + # End rounding: 0.0363 + 0.0363 = 0.0726 -> 0.07. + assert calculate_total_amount_cents(data, rounding_strategy="end") == 7 + + +def test_float_unit_price_binary_representation_does_not_break_simple_totals() -> ( + None +): + """Float inputs are risky, but the validator should still handle simple cases.""" + data = { + "shopping_cart": { + "items": [ + {"unit_price": 0.1, "quantity": 3, "tax_table_selector": None}, + ], + }, + "amount": 30, + } + + assert calculate_total_amount_cents(data) == 30 + assert validate_total_amount(data) is True + + +def test_quantity_as_string_and_fractional_quantity_are_supported() -> None: + """Ensure string and fractional quantities are handled correctly.""" + data_int_string = { + "shopping_cart": { + "items": [ + { + "unit_price": "1.00", + "quantity": "3", + "tax_table_selector": None, + }, + ], + }, + "amount": 300, + } + assert validate_total_amount(data_int_string) is True + + data_fractional = { + "shopping_cart": { + "items": [ + { + "unit_price": "1.00", + "quantity": "0.5", + "tax_table_selector": None, + }, + ], + }, + "amount": 50, + } + assert validate_total_amount(data_fractional) is True diff --git a/tests/multisafepay/unit/value_object/test_decimal_amount.py b/tests/multisafepay/unit/value_object/test_decimal_amount.py new file mode 100644 index 0000000..5bd2f59 --- /dev/null +++ b/tests/multisafepay/unit/value_object/test_decimal_amount.py @@ -0,0 +1,40 @@ +# 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. + + +"""Value object for Test DecimalAmount data.""" + +from decimal import Decimal + +from pydantic.error_wrappers import ValidationError +import pytest + +from multisafepay.value_object.decimal_amount import DecimalAmount + + +def test_decimal_amount_initialization(): + """Test the initialization of the DecimalAmount object with a valid amount.""" + decimal_amount = DecimalAmount(amount=19.99) + assert decimal_amount.amount == Decimal("19.99") + + +def test_empty_decimal_amount_initialization(): + """Test the initialization of the DecimalAmount object with an empty dictionary.""" + with pytest.raises(ValidationError): + DecimalAmount(amount={}) + + +def test_valid_cast_decimal_amount_initialization(): + """Test the initialization of the DecimalAmount object with a string amount.""" + decimal_amount = DecimalAmount(amount="19.99") + assert decimal_amount.amount == Decimal("19.99") + + +def test_decimal_amount_get_amount(): + """Test the get method of the DecimalAmount object.""" + decimal_amount = DecimalAmount(amount=19.99) + assert decimal_amount.get() == Decimal("19.99") diff --git a/tests/multisafepay/unit/value_object/test_unit_price.py b/tests/multisafepay/unit/value_object/test_unit_price.py deleted file mode 100644 index 6eb4943..0000000 --- a/tests/multisafepay/unit/value_object/test_unit_price.py +++ /dev/null @@ -1,38 +0,0 @@ -# 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. - - -"""Value object for Test Unit Price data.""" - -from pydantic.error_wrappers import ValidationError -import pytest - -from multisafepay.value_object.unit_price import UnitPrice - - -def test_unit_price_initialization(): - """Test the initialization of the UnitPrice object with a valid unit price.""" - unit_price = UnitPrice(unit_price=19.99) - assert unit_price.unit_price == 19.99 - - -def test_empty_unit_price_initialization(): - """Test the initialization of the UnitPrice object with an empty dictionary.""" - with pytest.raises(ValidationError): - UnitPrice(unit_price={}) - - -def test_valid_cast_unit_price_initialization(): - """Test the initialization of the UnitPrice object with a string unit price.""" - unit_price = UnitPrice(unit_price="19.99") - assert unit_price.unit_price == 19.99 - - -def test_unit_price_get_unit_price(): - """Test the get method of the UnitPrice object.""" - unit_price = UnitPrice(unit_price=19.99) - assert unit_price.get() == 19.99