Skip to content

Commit 9651692

Browse files
committed
PTHMINT-91: Enable optional validation betwen amount and calculated amount
1 parent cbb94e1 commit 9651692

File tree

13 files changed

+938
-23
lines changed

13 files changed

+938
-23
lines changed

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,9 @@ 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)

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/util/total_amount.py

Lines changed: 114 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,39 @@
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

1022
import 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

1426
from 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

1942
def _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

118222
def __get_tax_rate_by_item(

tests/multisafepay/e2e/conftest.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Configuration for end-to-end tests."""
2+
3+
import os
4+
5+
import pytest
6+
7+
8+
def pytest_collection_modifyitems(
9+
config: pytest.Config, # noqa: ARG001
10+
items: list[pytest.Item],
11+
) -> None:
12+
"""
13+
Skip all e2e tests when API_KEY is missing.
14+
15+
These tests perform real API calls. In most local/CI environments the secret
16+
isn't present, so we prefer a clean skip over hard errors during fixture setup.
17+
"""
18+
api_key = os.getenv("API_KEY")
19+
if api_key and api_key.strip():
20+
return
21+
22+
skip = pytest.mark.skip(reason="E2E tests require API_KEY (not set)")
23+
for item in items:
24+
# This hook runs for the whole session (all collected tests), even when
25+
# this conftest is only loaded due to e2e tests being present/deselected.
26+
# Ensure we only affect e2e tests.
27+
if item.nodeid.startswith("tests/multisafepay/e2e/"):
28+
item.add_marker(skip)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""
2+
Offline amount/total validation scenarios.
3+
4+
These tests focus on local amount/cart validation logic. They do not perform
5+
network calls and should not require API_KEY.
6+
"""
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Shared helpers for offline amount/total validation scenario tests."""
2+
3+
from __future__ import annotations
4+
5+
from decimal import Decimal
6+
7+
8+
def data(
9+
*,
10+
amount: int,
11+
items: list[dict],
12+
tax_tables: dict | None = None,
13+
) -> dict:
14+
"""Build a minimal `validate_total_amount` input dict."""
15+
result: dict = {
16+
"amount": amount,
17+
"shopping_cart": {"items": items},
18+
}
19+
if tax_tables is not None:
20+
result["checkout_options"] = {"tax_tables": tax_tables}
21+
return result
22+
23+
24+
def no_tax_tables() -> dict:
25+
"""Tax tables configuration that yields 0% tax under selector 'none'."""
26+
return {
27+
"default": {"shipping_taxed": True, "rate": 0.0},
28+
"alternate": [
29+
{"name": "none", "standalone": False, "rules": [{"rate": 0.0}]},
30+
],
31+
}
32+
33+
34+
def vat_tables() -> dict:
35+
"""Common VAT tables used for scenarios (21% and 9%)."""
36+
return {
37+
"default": {"shipping_taxed": True, "rate": 0.21},
38+
"alternate": [
39+
{"name": "BTW21", "standalone": True, "rules": [{"rate": 0.21}]},
40+
{"name": "BTW9", "standalone": True, "rules": [{"rate": 0.09}]},
41+
],
42+
}
43+
44+
45+
def d(value: str) -> Decimal:
46+
"""Convenience Decimal constructor for test readability."""
47+
return Decimal(value)

0 commit comments

Comments
 (0)