diff --git a/README.md b/README.md index b59392f..a265020 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,46 @@ sdk = Sdk( ) ``` +### Cloud POS order tips + +For POS and Cloud POS integrations, you can send tip information as part of the order creation payload with `amount_details`. Amount values are expressed in the smallest currency unit, so this example sends a total order amount of EUR 1.20 with EUR 0.20 marked as tip. + +```python +from multisafepay.api.paths.orders.request import OrderRequest +from multisafepay.api.paths.orders.request.components import ( + AmountDetails, + Tip, +) + + +order_request = ( + OrderRequest() + .add_type("redirect") + .add_order_id("cloud-pos-order-with-tip") + .add_description("Cloud POS order with tip") + .add_amount(120) + .add_currency("EUR") + .add_gateway_info({"terminal_id": ""}) + .add_amount_details( + AmountDetails().add_tip(Tip().add_amount(20)), + ) +) +``` + +This serializes to: + +```json +{ + "amount_details": { + "tip": { + "amount": 20 + } + } +} +``` + +See the full Cloud POS tip example in `examples/order_manager/cloud_pos_order_with_tip.py`. + ### Development-only custom base URL override By default, the SDK only targets: diff --git a/examples/order_manager/cloud_pos_order_with_tip.py b/examples/order_manager/cloud_pos_order_with_tip.py new file mode 100644 index 0000000..8baee61 --- /dev/null +++ b/examples/order_manager/cloud_pos_order_with_tip.py @@ -0,0 +1,63 @@ +"""Create a Cloud POS order with tip information.""" + +import os +import time + +from dotenv import load_dotenv + +from multisafepay import Sdk +from multisafepay.api.paths.orders.request import OrderRequest +from multisafepay.api.paths.orders.request.components import ( + AmountDetails, + Tip, +) +from multisafepay.client import ScopedCredentialResolver + +load_dotenv() + +DEFAULT_ACCOUNT_API_KEY = os.getenv("API_KEY", "") +TERMINAL_GROUP_DEFAULT_API_KEY = os.getenv( + "TERMINAL_GROUP_API_KEY_GROUP_DEFAULT", "" +) +CLOUD_POS_TERMINAL_GROUP_ID = os.getenv( + "CLOUD_POS_TERMINAL_GROUP_ID", "Default" +) +TERMINAL_ID = os.getenv("CLOUD_POS_TERMINAL_ID", "") +ORDER_AMOUNT = 120 +TIP_AMOUNT = 20 + +if __name__ == "__main__": + credential_resolver = ScopedCredentialResolver( + default_api_key=DEFAULT_ACCOUNT_API_KEY, + terminal_group_api_keys={ + CLOUD_POS_TERMINAL_GROUP_ID: TERMINAL_GROUP_DEFAULT_API_KEY, + }, + ) + + multisafepay_sdk = Sdk( + is_production=False, + credential_resolver=credential_resolver, + ) + order_manager = multisafepay_sdk.get_order_manager() + + order_request = ( + OrderRequest() + .add_type("redirect") + .add_order_id(f"cloud-pos-tip-{int(time.time())}") + .add_description("Cloud POS order with tip") + .add_amount(ORDER_AMOUNT) + .add_currency("EUR") + .add_gateway_info({"terminal_id": TERMINAL_ID}) + .add_amount_details( + AmountDetails().add_tip(Tip().add_amount(TIP_AMOUNT)), + ) + ) + + create_response = order_manager.create( + order_request, + terminal_group_id=CLOUD_POS_TERMINAL_GROUP_ID, + ) + order = create_response.get_data() + + print(f"Created Cloud POS order with tip: {order.order_id}") + print(order) diff --git a/src/multisafepay/api/paths/orders/request/components/__init__.py b/src/multisafepay/api/paths/orders/request/components/__init__.py index 7dcd738..2691ee0 100644 --- a/src/multisafepay/api/paths/orders/request/components/__init__.py +++ b/src/multisafepay/api/paths/orders/request/components/__init__.py @@ -1,5 +1,8 @@ """Order request components for detailed order configuration and settings.""" +from multisafepay.api.paths.orders.request.components.amount_details import ( + AmountDetails, +) from multisafepay.api.paths.orders.request.components.checkout_options import ( CheckoutOptions, ) @@ -16,12 +19,15 @@ from multisafepay.api.paths.orders.request.components.second_chance import ( SecondChance, ) +from multisafepay.api.paths.orders.request.components.tip import Tip __all__ = [ + "AmountDetails", "CheckoutOptions", "CustomInfo", "GoogleAnalytics", "PaymentOptions", "Plugin", "SecondChance", + "Tip", ] diff --git a/src/multisafepay/api/paths/orders/request/components/amount_details.py b/src/multisafepay/api/paths/orders/request/components/amount_details.py new file mode 100644 index 0000000..b031c96 --- /dev/null +++ b/src/multisafepay/api/paths/orders/request/components/amount_details.py @@ -0,0 +1,38 @@ +"""Amount details model for order request amount breakdowns.""" + +from typing import Optional + +from multisafepay.api.paths.orders.request.components.tip import Tip +from multisafepay.model.request_model import RequestModel + + +class AmountDetails(RequestModel): + """ + Represents amount details for an order request. + + Attributes + ---------- + tip (Optional[Tip]): The tip information. + + """ + + tip: Optional[Tip] + + def add_tip( + self: "AmountDetails", + tip: Optional[Tip], + ) -> "AmountDetails": + """ + Adds tip information to the amount details. + + Parameters + ---------- + tip (Optional[Tip]): The tip information. + + Returns + ------- + AmountDetails: The updated AmountDetails object. + + """ + self.tip = tip + return self diff --git a/src/multisafepay/api/paths/orders/request/components/tip.py b/src/multisafepay/api/paths/orders/request/components/tip.py new file mode 100644 index 0000000..7737306 --- /dev/null +++ b/src/multisafepay/api/paths/orders/request/components/tip.py @@ -0,0 +1,40 @@ +"""Tip model for order request amount details.""" + +from typing import Optional, Union + +from multisafepay.model.request_model import RequestModel +from multisafepay.value_object.amount import Amount + + +class Tip(RequestModel): + """ + Represents tip information in order amount details. + + Attributes + ---------- + amount (Optional[int]): The tip amount in the smallest currency unit. + + """ + + amount: Optional[int] + + def add_amount( + self: "Tip", + amount: Optional[Union[Amount, int]], + ) -> "Tip": + """ + Adds the tip amount. + + Parameters + ---------- + amount (Optional[Amount | int]): The tip amount as an Amount object or integer. + + Returns + ------- + Tip: The updated Tip object. + + """ + if isinstance(amount, int): + amount = Amount(amount=amount) + self.amount = amount.get() if amount is not None else None + return self diff --git a/src/multisafepay/api/paths/orders/request/order_request.py b/src/multisafepay/api/paths/orders/request/order_request.py index bea1150..d50829f 100644 --- a/src/multisafepay/api/paths/orders/request/order_request.py +++ b/src/multisafepay/api/paths/orders/request/order_request.py @@ -9,6 +9,9 @@ from typing import Optional, Union +from multisafepay.api.paths.orders.request.components.amount_details import ( + AmountDetails, +) from multisafepay.api.paths.orders.request.components.checkout_options import ( CheckoutOptions, ) @@ -67,6 +70,7 @@ class OrderRequest(RequestModel): order_id (Optional[str]): The order ID. currency (Optional[str]): The currency of the order. amount (Optional[str]): The amount of the order. + amount_details (Optional[AmountDetails]): The amount details. payment_options (Optional[PaymentOptions]): The payment options. customer (Optional[Customer]): The customer. delivery (Optional[Delivery]): The delivery information. @@ -93,6 +97,7 @@ class OrderRequest(RequestModel): order_id: Optional[str] currency: Optional[str] amount: Optional[int] + amount_details: Optional[AmountDetails] capture: Optional[str] payment_options: Optional[PaymentOptions] customer: Optional[Customer] @@ -248,6 +253,25 @@ def add_capture( self.capture = capture return self + def add_amount_details( + self: "OrderRequest", + amount_details: Optional[AmountDetails], + ) -> "OrderRequest": + """ + Adds amount details to the order request. + + Parameters + ---------- + amount_details (Optional[AmountDetails]): The amount details. + + Returns + ------- + OrderRequest: The updated OrderRequest object. + + """ + self.amount_details = amount_details + return self + def add_payment_options( self: "OrderRequest", payment_options: Optional[PaymentOptions], diff --git a/tests/multisafepay/integration/api/path/orders/manager/test_integration_order_manager_create.py b/tests/multisafepay/integration/api/path/orders/manager/test_integration_order_manager_create.py index 975c24b..194d68c 100644 --- a/tests/multisafepay/integration/api/path/orders/manager/test_integration_order_manager_create.py +++ b/tests/multisafepay/integration/api/path/orders/manager/test_integration_order_manager_create.py @@ -8,6 +8,7 @@ """Manager class for Test Integration Order Manager Create.Py API operations.""" +import json from unittest.mock import MagicMock from multisafepay.api.base.response.api_response import ApiResponse @@ -15,9 +16,13 @@ CustomApiResponse, ) from multisafepay.api.paths.orders.order_manager import OrderManager +from multisafepay.api.paths.orders.request.components.amount_details import ( + AmountDetails, +) from multisafepay.api.paths.orders.request.components.payment_options import ( PaymentOptions, ) +from multisafepay.api.paths.orders.request.components.tip import Tip from multisafepay.api.paths.orders.request.order_request import OrderRequest from multisafepay.api.paths.orders.response.order_response import Order from multisafepay.api.shared.customer import Customer @@ -113,7 +118,8 @@ def test_integration_order_manager_create_with_terminal_group_scope(): .add_type("direct") .add_order_id("cloud-pos-order") .add_currency("EUR") - .add_amount(100) + .add_amount(120) + .add_amount_details(AmountDetails().add_tip(Tip().add_amount(20))) ) order_manager = OrderManager(client) @@ -127,11 +133,17 @@ def test_integration_order_manager_create_with_terminal_group_scope(): assert response.get_data().order_id == "cloud-pos-order" called_endpoint = client.create_post_request.call_args.args[0] + called_request_body = client.create_post_request.call_args.kwargs[ + "request_body" + ] called_auth_scope = client.create_post_request.call_args.kwargs[ "auth_scope" ] assert called_endpoint == "json/orders" + assert json.loads(called_request_body)["amount_details"] == { + "tip": {"amount": 20}, + } assert called_auth_scope == AuthScope( scope=ScopedCredentialResolver.AUTH_SCOPE_TERMINAL_GROUP, group_id="Default", diff --git a/tests/multisafepay/integration/api/path/orders/request/test_integration_order_request.py b/tests/multisafepay/integration/api/path/orders/request/test_integration_order_request.py index cf19405..5d4c0ac 100644 --- a/tests/multisafepay/integration/api/path/orders/request/test_integration_order_request.py +++ b/tests/multisafepay/integration/api/path/orders/request/test_integration_order_request.py @@ -10,12 +10,16 @@ import pytest +from multisafepay.api.paths.orders.request.components.amount_details import ( + AmountDetails, +) from multisafepay.api.paths.orders.request.components.payment_options import ( PaymentOptions, ) from multisafepay.api.paths.orders.request.components.plugin import ( Plugin, ) +from multisafepay.api.paths.orders.request.components.tip import Tip from multisafepay.api.paths.orders.request.order_request import OrderRequest from multisafepay.api.shared.cart.cart_item import CartItem from multisafepay.api.shared.cart.shopping_cart import ShoppingCart @@ -92,6 +96,7 @@ def test_initializes_order_request_correctly(): .add_cancel_url("https://multisafepay.com/cancel_url") .add_close_window(True) ) + amount_details = AmountDetails().add_tip(Tip().add_amount(20)) order_request = ( OrderRequest() @@ -105,6 +110,7 @@ def test_initializes_order_request_correctly(): .add_delivery(customer) .add_plugin(plugin) .add_payment_options(payment_options) + .add_amount_details(amount_details) ) assert order_request.type == "redirect" @@ -117,6 +123,10 @@ def test_initializes_order_request_correctly(): assert order_request.delivery == customer assert order_request.plugin == plugin assert order_request.payment_options == payment_options + assert order_request.amount_details == amount_details + assert order_request.to_dict()["amount_details"] == { + "tip": {"amount": 20}, + } def test_initializes_order_request_validate_amount_valid(): diff --git a/tests/multisafepay/unit/api/path/orders/request/components/test_unit_amount_details.py b/tests/multisafepay/unit/api/path/orders/request/components/test_unit_amount_details.py new file mode 100644 index 0000000..d7d9edc --- /dev/null +++ b/tests/multisafepay/unit/api/path/orders/request/components/test_unit_amount_details.py @@ -0,0 +1,38 @@ +"""Test module for unit testing.""" + +from multisafepay.api.paths.orders.request.components.amount_details import ( + AmountDetails, +) +from multisafepay.api.paths.orders.request.components.tip import Tip + + +def test_initializes_amount_details_correctly(): + """Test that AmountDetails initializes correctly with all parameters.""" + tip = Tip(amount=20) + amount_details = AmountDetails(tip=tip) + + assert amount_details.tip == tip + + +def test_initializes_amount_details_with_empty_values(): + """Test that AmountDetails initializes with None values when no parameters are provided.""" + amount_details = AmountDetails() + + assert amount_details.tip is None + + +def test_add_tip_updates_value(): + """Test that add_tip updates the tip value.""" + amount_details = AmountDetails() + tip = Tip(amount=20) + amount_details_updated = amount_details.add_tip(tip) + + assert amount_details.tip == tip + assert isinstance(amount_details_updated, AmountDetails) + + +def test_amount_details_to_dict_serializes_tip(): + """Test that to_dict serializes tip information without null fields.""" + amount_details = AmountDetails(tip=Tip(amount=20)) + + assert amount_details.to_dict() == {"tip": {"amount": 20}} diff --git a/tests/multisafepay/unit/api/path/orders/request/components/test_unit_tip.py b/tests/multisafepay/unit/api/path/orders/request/components/test_unit_tip.py new file mode 100644 index 0000000..2f2fb1f --- /dev/null +++ b/tests/multisafepay/unit/api/path/orders/request/components/test_unit_tip.py @@ -0,0 +1,45 @@ +"""Test module for unit testing.""" + +from multisafepay.api.paths.orders.request.components.tip import Tip +from multisafepay.value_object.amount import Amount + + +def test_initializes_tip_correctly(): + """Test that Tip initializes correctly with all parameters.""" + tip = Tip(amount=20) + + assert tip.amount == 20 + + +def test_initializes_tip_with_empty_values(): + """Test that Tip initializes with None values when no parameters are provided.""" + tip = Tip() + + assert tip.amount is None + + +def test_add_tip_amount_updates_value(): + """Test that add_amount updates the tip amount value.""" + tip = Tip() + tip_updated = tip.add_amount(20) + + assert tip.amount == 20 + assert isinstance(tip_updated, Tip) + + +def test_add_tip_amount_accepts_amount_value_object(): + """Test that add_amount accepts an Amount value object.""" + tip = Tip() + tip_updated = tip.add_amount(Amount(amount=20)) + + assert tip.amount == 20 + assert isinstance(tip_updated, Tip) + + +def test_add_tip_amount_accepts_none(): + """Test that add_amount accepts None.""" + tip = Tip(amount=20) + tip_updated = tip.add_amount(None) + + assert tip.amount is None + assert isinstance(tip_updated, Tip) diff --git a/tests/multisafepay/unit/api/path/orders/request/test_unit_order_request.py b/tests/multisafepay/unit/api/path/orders/request/test_unit_order_request.py index 132b5b1..5c83f09 100644 --- a/tests/multisafepay/unit/api/path/orders/request/test_unit_order_request.py +++ b/tests/multisafepay/unit/api/path/orders/request/test_unit_order_request.py @@ -10,6 +10,10 @@ import pytest +from multisafepay.api.paths.orders.request.components.amount_details import ( + AmountDetails, +) +from multisafepay.api.paths.orders.request.components.tip import Tip from multisafepay.api.paths.orders.request.order_request import OrderRequest from multisafepay.api.shared.cart.cart_item import CartItem from multisafepay.api.shared.cart.shopping_cart import ShoppingCart @@ -27,6 +31,7 @@ def test_initializes_order_request_correctly(): order_id="12345", currency="USD", amount="1000", + amount_details=AmountDetails(tip=Tip(amount=20)), payment_options=None, customer=None, delivery=None, @@ -52,6 +57,7 @@ def test_initializes_order_request_correctly(): assert order_request.order_id == "12345" assert order_request.currency == "USD" assert order_request.amount == 1000 + assert order_request.amount_details == AmountDetails(tip=Tip(amount=20)) assert order_request.gateway_info == {"info": "test"} assert order_request.description == "Test description" assert order_request.recurring_id == "rec123" @@ -72,6 +78,7 @@ def test_initializes_order_request_with_default_values(): assert order_request.order_id is None assert order_request.currency is None assert order_request.amount is None + assert order_request.amount_details is None assert order_request.payment_options is None assert order_request.customer is None assert order_request.delivery is None @@ -159,6 +166,30 @@ def test_add_amount_updates_value(): assert isinstance(order_request_updated, OrderRequest) +def test_add_amount_details_updates_value(): + """Tests that the add_amount_details method updates the amount_details field correctly.""" + order_request = OrderRequest() + amount_details = AmountDetails(tip=Tip(amount=20)) + order_request_updated = order_request.add_amount_details(amount_details) + + assert order_request.amount_details == amount_details + assert isinstance(order_request_updated, OrderRequest) + + +def test_to_dict_serializes_amount_details(): + """Tests that to_dict includes the amount_details payload structure.""" + order_request = ( + OrderRequest() + .add_amount(120) + .add_amount_details(AmountDetails().add_tip(Tip().add_amount(20))) + ) + + assert order_request.to_dict() == { + "amount": 120, + "amount_details": {"tip": {"amount": 20}}, + } + + def test_add_gateway_updates_value(): """Tests that the add_gateway method updates the gateway field correctly.""" order_request = OrderRequest()