Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
9 changes: 5 additions & 4 deletions src/multisafepay/api/paths/orders/order_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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}",
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand Down
28 changes: 17 additions & 11 deletions src/multisafepay/api/paths/orders/request/order_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
72 changes: 51 additions & 21 deletions src/multisafepay/api/shared/cart/cart_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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.

"""
Expand All @@ -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":
"""
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand All @@ -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.

Expand All @@ -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
-------
Expand All @@ -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(
Expand All @@ -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.

Expand All @@ -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
-------
Expand All @@ -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(
Expand Down Expand Up @@ -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()
22 changes: 15 additions & 7 deletions src/multisafepay/api/shared/costs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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.

Expand All @@ -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]

Expand Down Expand Up @@ -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":
Expand Down
2 changes: 2 additions & 0 deletions src/multisafepay/util/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
42 changes: 42 additions & 0 deletions src/multisafepay/util/json_encoder.py
Original file line number Diff line number Diff line change
@@ -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)
Loading