55
66# See the DISCLAIMER.md file for disclaimer details.
77
8- """Total amount calculation and validation utilities for order processing."""
8+ """
9+ Total amount calculation and validation utilities for order processing.
10+
11+ Important:
12+ ---------
13+ Different integrators (ERP/POS/e-commerce) can legitimately compute `amount`
14+ under different rounding policies (per-line vs end rounding, half-up vs
15+ bankers rounding, etc.).
16+
17+ This module supports a **best-effort** local validator that can be configured
18+ to match a known policy, but the API remains the source of truth.
19+
20+ """
921
1022import json
11- from decimal import Decimal
12- from typing import Union
23+ from decimal import ROUND_DOWN , ROUND_HALF_EVEN , ROUND_HALF_UP , Decimal
24+ from typing import Literal , Union
1325
1426from multisafepay .exception .invalid_total_amount import (
1527 InvalidTotalAmountException ,
1628)
1729
30+ RoundingStrategy = Literal ["end" , "line" ]
31+ RoundingMode = Literal ["half_up" , "half_even" , "down" ]
32+
33+
34+ def _decimal_rounding (mode : RoundingMode ) -> str :
35+ if mode == "half_even" :
36+ return ROUND_HALF_EVEN
37+ if mode == "down" :
38+ return ROUND_DOWN
39+ return ROUND_HALF_UP
40+
1841
1942def _convert_decimals_to_float (
2043 obj : Union [Decimal , dict , list , object ],
@@ -45,10 +68,28 @@ def _convert_decimals_to_float(
4568 return obj
4669
4770
48- def validate_total_amount (data : dict ) -> bool :
71+ def validate_total_amount (
72+ data : dict ,
73+ * ,
74+ rounding_strategy : RoundingStrategy = "end" ,
75+ rounding_mode : RoundingMode = "half_up" ,
76+ ) -> bool :
4977 """
5078 Validate the total amount in the provided data dictionary.
5179
80+ Important
81+ ---------
82+ This validator uses a specific calculation/rounding model:
83+ - Applies tax per item (if any) and sums the precise Decimal totals.
84+ - Quantizes the final total to 2 decimals.
85+ - Converts to cents using HALF_UP.
86+
87+ If the input `amount` was produced under a different policy (per-line rounding,
88+ different tax rounding, unit prices with more than 2 decimals, etc.), the SDK
89+ may disagree with external systems. In those cases, prefer letting the API
90+ validate and/or use `calculate_total_amount_cents()` to compute a consistent
91+ amount under this validator's rules.
92+
5293 Parameters
5394 ----------
5495 data (dict): The data dictionary containing the amount and shopping cart details.
@@ -69,13 +110,26 @@ def validate_total_amount(data: dict) -> bool:
69110 return False
70111
71112 amount = data ["amount" ]
72- total_unit_price = __calculate_totals (data )
113+ total_unit_price = calculate_total_amount (
114+ data ,
115+ rounding_strategy = rounding_strategy ,
116+ rounding_mode = rounding_mode ,
117+ )
73118
74- # Convert total_unit_price to cents (integer) for comparison
75- total_unit_price_cents = int (total_unit_price * 100 )
119+ # Convert to cents (integer) for comparison.
120+ # Note: total_unit_price is already quantized to 2 decimals in calculate_total_amount().
121+ total_unit_price_cents = calculate_total_amount_cents (
122+ data ,
123+ rounding_strategy = rounding_strategy ,
124+ rounding_mode = rounding_mode ,
125+ )
76126
77127 if total_unit_price_cents != amount :
78- msg = f"Total of unit_price ({ total_unit_price } ) does not match amount ({ amount } )"
128+ delta = amount - total_unit_price_cents
129+ msg = (
130+ f"Total of unit_price ({ total_unit_price } ) does not match amount ({ amount } ). "
131+ f"Expected amount: { total_unit_price_cents } (delta: { delta } )."
132+ )
79133 # Create a JSON-serializable copy of data by converting Decimal to float
80134 serializable_data = _convert_decimals_to_float (data )
81135 msg += "\n " + json .dumps (serializable_data , indent = 4 )
@@ -84,7 +138,48 @@ def validate_total_amount(data: dict) -> bool:
84138 return True
85139
86140
87- def __calculate_totals (data : dict ) -> Decimal :
141+ def calculate_total_amount (
142+ data : dict ,
143+ * ,
144+ rounding_strategy : RoundingStrategy = "end" ,
145+ rounding_mode : RoundingMode = "half_up" ,
146+ ) -> Decimal :
147+ """
148+ Calculate the order total (major units) using the same logic as the validator.
149+
150+ This is useful to generate a consistent `amount` before submitting an Order.
151+ """
152+ return __calculate_totals (
153+ data ,
154+ rounding_strategy = rounding_strategy ,
155+ rounding_mode = rounding_mode ,
156+ )
157+
158+
159+ def calculate_total_amount_cents (
160+ data : dict ,
161+ * ,
162+ rounding_strategy : RoundingStrategy = "end" ,
163+ rounding_mode : RoundingMode = "half_up" ,
164+ ) -> int :
165+ """Calculate the expected `amount` (minor units) using the validator's logic."""
166+ total = calculate_total_amount (
167+ data ,
168+ rounding_strategy = rounding_strategy ,
169+ rounding_mode = rounding_mode ,
170+ )
171+ cents = (total * 100 ).to_integral_value (
172+ rounding = _decimal_rounding (rounding_mode ),
173+ )
174+ return int (cents )
175+
176+
177+ def __calculate_totals (
178+ data : dict ,
179+ * ,
180+ rounding_strategy : RoundingStrategy = "end" ,
181+ rounding_mode : RoundingMode = "half_up" ,
182+ ) -> Decimal :
88183 """
89184 Calculate the total unit price of items in the shopping cart using precise Decimal arithmetic.
90185
@@ -97,6 +192,7 @@ def __calculate_totals(data: dict) -> Decimal:
97192 Decimal: The total unit price of all items in the shopping cart with precise decimal calculation.
98193
99194 """
195+ rounding = _decimal_rounding (rounding_mode )
100196 total_unit_price = Decimal ("0" )
101197 for item in data ["shopping_cart" ]["items" ]:
102198 tax_rate = __get_tax_rate_by_item (item , data )
@@ -109,10 +205,18 @@ def __calculate_totals(data: dict) -> Decimal:
109205 # Calculate item price with tax
110206 item_price = unit_price * quantity
111207 item_price += tax_rate_decimal * item_price
208+
209+ # Some systems (e.g., ERPs/POS) round per line item; others round at the end.
210+ if rounding_strategy == "line" :
211+ item_price = item_price .quantize (
212+ Decimal ("0.01" ),
213+ rounding = rounding ,
214+ )
215+
112216 total_unit_price += item_price
113217
114218 # Round to 2 decimal places for currency
115- return total_unit_price .quantize (Decimal ("0.01" ))
219+ return total_unit_price .quantize (Decimal ("0.01" ), rounding = rounding )
116220
117221
118222def __get_tax_rate_by_item (
0 commit comments