Skip to content

Commit 70f2f34

Browse files
committed
PTHMINT-91: Implement Decimal precision for monetary calculations
- Replace float with Decimal in cart_item, costs, unit_price, total_amount - Add validators to convert values to Decimal for precise calculations - Extract DecimalEncoder to separate util/json_encoder.py module - Ensure all monetary values use Decimal to avoid floating-point errors - Update order_manager to use DecimalEncoder for JSON serialization - Add CartItem.update_forward_refs() to resolve Weight forward reference - Fix checkout_data.py to multiply Decimal by int instead of float - Update all tests to assert Decimal values instead of float values
1 parent 41f42f2 commit 70f2f34

File tree

14 files changed

+738
-62
lines changed

14 files changed

+738
-62
lines changed

PYDANTIC_V2_MIGRATION.md

Lines changed: 459 additions & 0 deletions
Large diffs are not rendered by default.

src/multisafepay/api/paths/orders/order_id/refund/request/components/checkout_data.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ def refund_by_merchant_item_id(
155155

156156
refund_item = found_item.clone()
157157
refund_item.add_quantity(quantity)
158-
refund_item.add_unit_price(found_item.unit_price * -1.0)
158+
refund_item.add_unit_price(found_item.unit_price * -1)
159159

160160
self.add_item(refund_item)
161161

src/multisafepay/api/paths/orders/order_manager.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from multisafepay.api.shared.description import Description
4040
from multisafepay.client.client import Client
4141
from multisafepay.util.dict_utils import dict_empty
42+
from multisafepay.util.json_encoder import DecimalEncoder
4243
from multisafepay.util.message import MessageList, gen_could_not_created_msg
4344
from multisafepay.value_object.amount import Amount
4445
from multisafepay.value_object.currency import Currency
@@ -127,7 +128,7 @@ def create(
127128
CustomApiResponse: The custom API response containing the created order data.
128129
129130
"""
130-
json_data = json.dumps(request_order.to_dict())
131+
json_data = json.dumps(request_order.to_dict(), cls=DecimalEncoder)
131132
response: ApiResponse = self.client.create_post_request(
132133
"json/orders",
133134
request_body=json_data,
@@ -152,7 +153,7 @@ def update(
152153
CustomApiResponse: The custom API response containing the updated order data.
153154
154155
"""
155-
json_data = json.dumps(update_request.to_dict())
156+
json_data = json.dumps(update_request.to_dict(), cls=DecimalEncoder)
156157
encoded_order_id = self.encode_path_segment(order_id)
157158
response = self.client.create_patch_request(
158159
f"json/orders/{encoded_order_id}",
@@ -182,7 +183,7 @@ def capture(
182183
CustomApiResponse: The custom API response containing the capture data.
183184
184185
"""
185-
json_data = json.dumps(capture_request.to_dict())
186+
json_data = json.dumps(capture_request.to_dict(), cls=DecimalEncoder)
186187
encoded_order_id = self.encode_path_segment(order_id)
187188

188189
response = self.client.create_post_request(
@@ -223,7 +224,7 @@ def refund(
223224
CustomApiResponse: The custom API response containing the refund data.
224225
225226
"""
226-
json_data = json.dumps(request_refund.to_dict())
227+
json_data = json.dumps(request_refund.to_dict(), cls=DecimalEncoder)
227228
encoded_order_id = self.encode_path_segment(order_id)
228229
response = self.client.create_post_request(
229230
f"json/orders/{encoded_order_id}/refunds",

src/multisafepay/api/shared/cart/cart_item.py

Lines changed: 74 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,15 @@
99

1010
import copy
1111
import math
12-
from typing import Dict, List, Optional
12+
from decimal import Decimal
13+
from typing import TYPE_CHECKING, Optional, Type, Union
1314

1415
from multisafepay.exception.invalid_argument import InvalidArgumentException
1516
from multisafepay.model.api_model import ApiModel
16-
from multisafepay.value_object.weight import Weight
17+
from pydantic import validator
18+
19+
if TYPE_CHECKING:
20+
from multisafepay.value_object.weight import Weight
1721

1822

1923
class CartItem(ApiModel):
@@ -32,7 +36,7 @@ class CartItem(ApiModel):
3236
product_url: (Optional[str]) The product URL.
3337
quantity: (Optional[int]) The quantity.
3438
tax_table_selector: (Optional[str]) The tax table selector.
35-
unit_price: (Optional[float]) The unit price.
39+
unit_price: (Optional[Decimal]) The unit price as a precise Decimal value.
3640
weight: (Optional[Weight]) The weight.
3741
3842
"""
@@ -43,12 +47,36 @@ class CartItem(ApiModel):
4347
image: Optional[str]
4448
merchant_item_id: Optional[str]
4549
name: Optional[str]
46-
options: Optional[List[Dict]]
50+
options: Optional[list]
4751
product_url: Optional[str]
4852
quantity: Optional[int]
4953
tax_table_selector: Optional[str]
50-
unit_price: Optional[float]
51-
weight: Optional[Weight]
54+
unit_price: Optional[Decimal]
55+
56+
@validator("unit_price", pre=True)
57+
def convert_unit_price_to_decimal(
58+
cls: Type["CartItem"],
59+
value: Union[str, float, Decimal, None],
60+
) -> Optional[Decimal]:
61+
"""
62+
Convert unit_price to Decimal for precise monetary calculations.
63+
64+
Parameters
65+
----------
66+
value (Union[str, int, float, Decimal, None]): The value to convert.
67+
68+
Returns
69+
-------
70+
Optional[Decimal]: The converted Decimal value or None.
71+
72+
"""
73+
if value is None:
74+
return None
75+
if isinstance(value, Decimal):
76+
return value
77+
return Decimal(str(value))
78+
79+
weight: Optional["Weight"]
5280

5381
def add_cashback(self: "CartItem", cashback: str) -> "CartItem":
5482
"""
@@ -149,7 +177,7 @@ def add_name(self: "CartItem", name: str) -> "CartItem":
149177
self.name = name
150178
return self
151179

152-
def add_options(self: "CartItem", options: List[Dict]) -> "CartItem":
180+
def add_options(self: "CartItem", options: list) -> "CartItem":
153181
"""
154182
Add options to the cart item.
155183
@@ -216,23 +244,29 @@ def add_tax_table_selector(
216244
self.tax_table_selector = tax_table_selector
217245
return self
218246

219-
def add_unit_price(self: "CartItem", unit_price: float) -> "CartItem":
247+
def add_unit_price(
248+
self: "CartItem",
249+
unit_price: Union[Decimal, float, str],
250+
) -> "CartItem":
220251
"""
221-
Add unit price to the cart item.
252+
Add unit price to the cart item with precise Decimal conversion.
222253
223254
Parameters
224255
----------
225-
unit_price: (float) The unit price to be added.
256+
unit_price: (Union[Decimal, float, int, str]) The unit price to be added.
226257
227258
Returns
228259
-------
229260
CartItem: The updated CartItem instance.
230261
231262
"""
232-
self.unit_price = unit_price
263+
if isinstance(unit_price, Decimal):
264+
self.unit_price = unit_price
265+
else:
266+
self.unit_price = Decimal(str(unit_price))
233267
return self
234268

235-
def add_weight(self: "CartItem", weight: Weight) -> "CartItem":
269+
def add_weight(self: "CartItem", weight: "Weight") -> "CartItem":
236270
"""
237271
Add weight to the cart item.
238272
@@ -250,10 +284,10 @@ def add_weight(self: "CartItem", weight: Weight) -> "CartItem":
250284

251285
def add_tax_rate_percentage(
252286
self: "CartItem",
253-
tax_rate_percentage: int,
287+
tax_rate_percentage: Union[int, Decimal],
254288
) -> "CartItem":
255289
"""
256-
Add tax rate percentage to the cart item.
290+
Add tax rate percentage to the cart item using precise Decimal arithmetic.
257291
258292
This method sets the tax rate percentage for the cart item. The tax rate should be a non-negative number.
259293
@@ -263,7 +297,7 @@ def add_tax_rate_percentage(
263297
264298
Parameters
265299
----------
266-
tax_rate_percentage: (int) The tax rate percentage to be added.
300+
tax_rate_percentage: (Union[int, Decimal]) The tax rate percentage to be added.
267301
268302
Returns
269303
-------
@@ -275,13 +309,17 @@ def add_tax_rate_percentage(
275309
"Tax rate percentage cannot be negative.",
276310
)
277311

278-
if math.isnan(tax_rate_percentage) or math.isinf(tax_rate_percentage):
312+
if isinstance(tax_rate_percentage, float) and (
313+
math.isnan(tax_rate_percentage) or math.isinf(tax_rate_percentage)
314+
):
279315
raise InvalidArgumentException(
280316
"Tax rate percentage cannot be special floats.",
281317
)
282318

283319
try:
284-
rating = tax_rate_percentage / 100
320+
# Use Decimal for precise division
321+
percentage_decimal = Decimal(str(tax_rate_percentage))
322+
rating = percentage_decimal / Decimal("100")
285323
self.tax_table_selector = str(rating)
286324
except (ValueError, TypeError) as e:
287325
raise InvalidArgumentException(
@@ -290,9 +328,12 @@ def add_tax_rate_percentage(
290328

291329
return self
292330

293-
def add_tax_rate(self: "CartItem", tax_rate: float) -> "CartItem":
331+
def add_tax_rate(
332+
self: "CartItem",
333+
tax_rate: Union[Decimal, float],
334+
) -> "CartItem":
294335
"""
295-
Add tax rate to the cart item.
336+
Add tax rate to the cart item using Decimal for precision.
296337
297338
This method sets the tax rate for the cart item. The tax rate should be a non-negative number.
298339
@@ -302,7 +343,7 @@ def add_tax_rate(self: "CartItem", tax_rate: float) -> "CartItem":
302343
303344
Parameters
304345
----------
305-
tax_rate: (float) The tax rate to be added.
346+
tax_rate: (Union[Decimal, float]) The tax rate to be added.
306347
307348
Returns
308349
-------
@@ -312,12 +353,17 @@ def add_tax_rate(self: "CartItem", tax_rate: float) -> "CartItem":
312353
if tax_rate < 0:
313354
raise InvalidArgumentException("Tax rate cannot be negative.")
314355

315-
if math.isnan(tax_rate) or math.isinf(tax_rate):
356+
if isinstance(tax_rate, float) and (
357+
math.isnan(tax_rate) or math.isinf(tax_rate)
358+
):
316359
raise InvalidArgumentException(
317360
"Tax rate cannot be special floats.",
318361
)
319362

320363
try:
364+
# Convert to Decimal if not already
365+
if not isinstance(tax_rate, Decimal):
366+
tax_rate = Decimal(str(tax_rate))
321367
self.tax_table_selector = str(tax_rate)
322368
except (ValueError, TypeError) as e:
323369
raise InvalidArgumentException(
@@ -355,3 +401,10 @@ def from_dict(d: Optional[dict]) -> Optional["CartItem"]:
355401
return None
356402

357403
return CartItem(**d)
404+
405+
406+
# Update forward references to resolve Weight
407+
# pylint: disable=wrong-import-position
408+
from multisafepay.value_object.weight import Weight # noqa: E402
409+
410+
CartItem.update_forward_refs()

src/multisafepay/api/shared/costs.py

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77

88
"""Transaction costs model for handling fees and charges in payment processing."""
99

10-
from typing import Optional
10+
from decimal import Decimal
11+
from typing import Optional, Type, Union
1112

1213
from multisafepay.model.api_model import ApiModel
14+
from pydantic import validator
1315

1416

1517
class Costs(ApiModel):
@@ -21,7 +23,7 @@ class Costs(ApiModel):
2123
transaction_id (Optional[int]): The ID of the transaction.
2224
description (Optional[str]): The description of the cost.
2325
type (Optional[str]): The type of the cost.
24-
amount (Optional[float]): The amount of the cost.
26+
amount (Optional[Decimal]): The amount of the cost as a precise Decimal value.
2527
currency (Optional[str]): The currency of the cost.
2628
status (Optional[str]): The status of the cost.
2729
@@ -30,10 +32,33 @@ class Costs(ApiModel):
3032
transaction_id: Optional[int]
3133
description: Optional[str]
3234
type: Optional[str]
33-
amount: Optional[float]
35+
amount: Optional[Decimal]
3436
currency: Optional[str]
3537
status: Optional[str]
3638

39+
@validator("amount", pre=True)
40+
def convert_amount_to_decimal(
41+
cls: Type["Costs"],
42+
value: Union[str, float, Decimal, None],
43+
) -> Optional[Decimal]:
44+
"""
45+
Convert amount to Decimal for precise monetary calculations.
46+
47+
Parameters
48+
----------
49+
value (Union[str, int, float, Decimal, None]): The value to convert.
50+
51+
Returns
52+
-------
53+
Optional[Decimal]: The converted Decimal value or None.
54+
55+
"""
56+
if value is None:
57+
return None
58+
if isinstance(value, Decimal):
59+
return value
60+
return Decimal(str(value))
61+
3762
def add_transaction_id(self: "Costs", transaction_id: int) -> "Costs":
3863
"""
3964
Add a transaction ID to the Costs instance.
@@ -82,20 +107,26 @@ def add_type(self: "Costs", type_: str) -> "Costs":
82107
self.type = type_
83108
return self
84109

85-
def add_amount(self: "Costs", amount: float) -> "Costs":
110+
def add_amount(
111+
self: "Costs",
112+
amount: Union[Decimal, float, str],
113+
) -> "Costs":
86114
"""
87-
Add an amount to the Costs instance.
115+
Add an amount to the Costs instance with precise Decimal conversion.
88116
89117
Parameters
90118
----------
91-
amount (float): The amount of the cost.
119+
amount (Union[Decimal, float, int, str]): The amount of the cost.
92120
93121
Returns
94122
-------
95123
Costs: The updated Costs instance.
96124
97125
"""
98-
self.amount = amount
126+
if isinstance(amount, Decimal):
127+
self.amount = amount
128+
else:
129+
self.amount = Decimal(str(amount))
99130
return self
100131

101132
def add_currency(self: "Costs", currency: str) -> "Costs":

src/multisafepay/util/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
"""Utility functions and helpers for MultiSafepay SDK operations."""
22

3+
from multisafepay.util.json_encoder import DecimalEncoder
34
from multisafepay.util.webhook import Webhook
45

56
__all__ = [
7+
"DecimalEncoder",
68
"Webhook",
79
]

0 commit comments

Comments
 (0)