Skip to content

Commit 34e5649

Browse files
committed
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.
1 parent 41f42f2 commit 34e5649

File tree

26 files changed

+1232
-146
lines changed

26 files changed

+1232
-146
lines changed

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,13 @@ def refund_by_merchant_item_id(
150150
)
151151

152152
found_item = self.get_item_by_merchant_item_id(merchant_item_id)
153-
if quantity < 1 or quantity > found_item.quantity:
154-
quantity = found_item.get_quantity()
153+
item_quantity = found_item.quantity or 0
154+
if quantity < 1 or quantity > item_quantity:
155+
quantity = item_quantity
155156

156157
refund_item = found_item.clone()
157158
refund_item.add_quantity(quantity)
158-
refund_item.add_unit_price(found_item.unit_price * -1.0)
159+
refund_item.add_unit_price(found_item.unit_price * -1)
159160

160161
self.add_item(refund_item)
161162

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/paths/orders/request/order_request.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@
3333
from multisafepay.api.shared.description import Description
3434
from multisafepay.exception.invalid_argument import InvalidArgumentException
3535
from multisafepay.model.request_model import RequestModel
36-
from multisafepay.util.total_amount import validate_total_amount
36+
from multisafepay.util.total_amount import (
37+
RoundingMode,
38+
RoundingStrategy,
39+
validate_total_amount,
40+
)
3741
from multisafepay.value_object.amount import Amount
3842
from multisafepay.value_object.currency import Currency
3943

@@ -581,14 +585,16 @@ def add_var3(self: "OrderRequest", var3: Optional[str]) -> "OrderRequest":
581585
self.var3 = var3
582586
return self
583587

584-
def validate_amount(self: "OrderRequest") -> "OrderRequest":
585-
"""
586-
Validates the total amount of the order request and the shopping cart.
587-
588-
Returns
589-
-------
590-
OrderRequest: The validated OrderRequest object.
591-
592-
"""
593-
validate = validate_total_amount(self.dict())
588+
def validate_amount(
589+
self: "OrderRequest",
590+
*,
591+
rounding_strategy: RoundingStrategy = "end",
592+
rounding_mode: RoundingMode = "half_up",
593+
) -> "OrderRequest":
594+
"""Validates the total amount of the order request and the shopping cart."""
595+
validate_total_amount(
596+
self.dict(),
597+
rounding_strategy=rounding_strategy,
598+
rounding_mode=rounding_mode,
599+
)
594600
return self

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

Lines changed: 51 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, 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 multisafepay.value_object.decimal_amount import DecimalAmount
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,13 @@ 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+
weight: Optional["Weight"]
5257

5358
def add_cashback(self: "CartItem", cashback: str) -> "CartItem":
5459
"""
@@ -149,7 +154,7 @@ def add_name(self: "CartItem", name: str) -> "CartItem":
149154
self.name = name
150155
return self
151156

152-
def add_options(self: "CartItem", options: List[Dict]) -> "CartItem":
157+
def add_options(self: "CartItem", options: list) -> "CartItem":
153158
"""
154159
Add options to the cart item.
155160
@@ -216,23 +221,29 @@ def add_tax_table_selector(
216221
self.tax_table_selector = tax_table_selector
217222
return self
218223

219-
def add_unit_price(self: "CartItem", unit_price: float) -> "CartItem":
224+
def add_unit_price(
225+
self: "CartItem",
226+
unit_price: Union[DecimalAmount, Decimal, float, str],
227+
) -> "CartItem":
220228
"""
221-
Add unit price to the cart item.
229+
Add unit price to the cart item with precise Decimal conversion.
222230
223231
Parameters
224232
----------
225-
unit_price: (float) The unit price to be added.
233+
unit_price: (Union[DecimalAmount, Decimal, float, int, str]) The unit price to be added.
226234
227235
Returns
228236
-------
229237
CartItem: The updated CartItem instance.
230238
231239
"""
232-
self.unit_price = unit_price
240+
if isinstance(unit_price, DecimalAmount):
241+
self.unit_price = unit_price.get()
242+
else:
243+
self.unit_price = DecimalAmount(amount=unit_price).get()
233244
return self
234245

235-
def add_weight(self: "CartItem", weight: Weight) -> "CartItem":
246+
def add_weight(self: "CartItem", weight: "Weight") -> "CartItem":
236247
"""
237248
Add weight to the cart item.
238249
@@ -250,10 +261,10 @@ def add_weight(self: "CartItem", weight: Weight) -> "CartItem":
250261

251262
def add_tax_rate_percentage(
252263
self: "CartItem",
253-
tax_rate_percentage: int,
264+
tax_rate_percentage: Union[int, Decimal],
254265
) -> "CartItem":
255266
"""
256-
Add tax rate percentage to the cart item.
267+
Add tax rate percentage to the cart item using precise Decimal arithmetic.
257268
258269
This method sets the tax rate percentage for the cart item. The tax rate should be a non-negative number.
259270
@@ -263,7 +274,7 @@ def add_tax_rate_percentage(
263274
264275
Parameters
265276
----------
266-
tax_rate_percentage: (int) The tax rate percentage to be added.
277+
tax_rate_percentage: (Union[int, Decimal]) The tax rate percentage to be added.
267278
268279
Returns
269280
-------
@@ -275,13 +286,17 @@ def add_tax_rate_percentage(
275286
"Tax rate percentage cannot be negative.",
276287
)
277288

278-
if math.isnan(tax_rate_percentage) or math.isinf(tax_rate_percentage):
289+
if isinstance(tax_rate_percentage, float) and (
290+
math.isnan(tax_rate_percentage) or math.isinf(tax_rate_percentage)
291+
):
279292
raise InvalidArgumentException(
280293
"Tax rate percentage cannot be special floats.",
281294
)
282295

283296
try:
284-
rating = tax_rate_percentage / 100
297+
# Use Decimal for precise division
298+
percentage_decimal = Decimal(str(tax_rate_percentage))
299+
rating = percentage_decimal / Decimal("100")
285300
self.tax_table_selector = str(rating)
286301
except (ValueError, TypeError) as e:
287302
raise InvalidArgumentException(
@@ -290,9 +305,12 @@ def add_tax_rate_percentage(
290305

291306
return self
292307

293-
def add_tax_rate(self: "CartItem", tax_rate: float) -> "CartItem":
308+
def add_tax_rate(
309+
self: "CartItem",
310+
tax_rate: Union[Decimal, float],
311+
) -> "CartItem":
294312
"""
295-
Add tax rate to the cart item.
313+
Add tax rate to the cart item using Decimal for precision.
296314
297315
This method sets the tax rate for the cart item. The tax rate should be a non-negative number.
298316
@@ -302,7 +320,7 @@ def add_tax_rate(self: "CartItem", tax_rate: float) -> "CartItem":
302320
303321
Parameters
304322
----------
305-
tax_rate: (float) The tax rate to be added.
323+
tax_rate: (Union[Decimal, float]) The tax rate to be added.
306324
307325
Returns
308326
-------
@@ -312,12 +330,17 @@ def add_tax_rate(self: "CartItem", tax_rate: float) -> "CartItem":
312330
if tax_rate < 0:
313331
raise InvalidArgumentException("Tax rate cannot be negative.")
314332

315-
if math.isnan(tax_rate) or math.isinf(tax_rate):
333+
if isinstance(tax_rate, float) and (
334+
math.isnan(tax_rate) or math.isinf(tax_rate)
335+
):
316336
raise InvalidArgumentException(
317337
"Tax rate cannot be special floats.",
318338
)
319339

320340
try:
341+
# Convert to Decimal if not already
342+
if not isinstance(tax_rate, Decimal):
343+
tax_rate = Decimal(str(tax_rate))
321344
self.tax_table_selector = str(tax_rate)
322345
except (ValueError, TypeError) as e:
323346
raise InvalidArgumentException(
@@ -355,3 +378,10 @@ def from_dict(d: Optional[dict]) -> Optional["CartItem"]:
355378
return None
356379

357380
return CartItem(**d)
381+
382+
383+
# Update forward references to resolve Weight
384+
# pylint: disable=wrong-import-position
385+
from multisafepay.value_object.weight import Weight # noqa: E402
386+
387+
CartItem.update_forward_refs()

src/multisafepay/api/shared/costs.py

Lines changed: 15 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, Union
1112

1213
from multisafepay.model.api_model import ApiModel
14+
from multisafepay.value_object.decimal_amount import DecimalAmount
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,7 +32,7 @@ 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

@@ -82,20 +84,26 @@ def add_type(self: "Costs", type_: str) -> "Costs":
8284
self.type = type_
8385
return self
8486

85-
def add_amount(self: "Costs", amount: float) -> "Costs":
87+
def add_amount(
88+
self: "Costs",
89+
amount: Union[DecimalAmount, Decimal, float, str],
90+
) -> "Costs":
8691
"""
87-
Add an amount to the Costs instance.
92+
Add an amount to the Costs instance with precise Decimal conversion.
8893
8994
Parameters
9095
----------
91-
amount (float): The amount of the cost.
96+
amount (Union[DecimalAmount, Decimal, float, int, str]): The amount of the cost.
9297
9398
Returns
9499
-------
95100
Costs: The updated Costs instance.
96101
97102
"""
98-
self.amount = amount
103+
if isinstance(amount, DecimalAmount):
104+
self.amount = amount.get()
105+
else:
106+
self.amount = DecimalAmount(amount=amount).get()
99107
return self
100108

101109
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
]
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Copyright (c) MultiSafepay, Inc. All rights reserved.
2+
3+
# This file is licensed under the Open Software License (OSL) version 3.0.
4+
# For a copy of the license, see the LICENSE.txt file in the project root.
5+
6+
# See the DISCLAIMER.md file for disclaimer details.
7+
8+
"""JSON encoder utilities for API serialization."""
9+
10+
import json
11+
from decimal import Decimal
12+
13+
14+
class DecimalEncoder(json.JSONEncoder):
15+
"""
16+
Custom JSON encoder that converts Decimal objects to float for API serialization.
17+
18+
This encoder ensures that Decimal values used for precise calculations
19+
are properly serialized when sending data to the API.
20+
"""
21+
22+
def default(
23+
self: "DecimalEncoder",
24+
o: object,
25+
) -> object: # pylint: disable=invalid-name
26+
"""
27+
Convert Decimal to float, otherwise use default encoder.
28+
29+
Parameters
30+
----------
31+
o : object
32+
The object to serialize.
33+
34+
Returns
35+
-------
36+
object
37+
The serialized object (float for Decimal, default for others).
38+
39+
"""
40+
if isinstance(o, Decimal):
41+
return float(o)
42+
return super().default(o)

0 commit comments

Comments
 (0)