From 08221e9336ab8c86263a70ae74999c77955e75e6 Mon Sep 17 00:00:00 2001 From: Marco Antonio Gil Date: Thu, 30 Apr 2026 12:39:58 +0200 Subject: [PATCH] PTHMINT-118: Add POS receipt & order cancel endpoints Introduce POS and order cancellation functionality: add PosManager with get_receipt and add cancel_transaction to OrderManager. Support terminal-group scoped authentication for order create and cancel (AuthScope / ScopedCredentialResolver) and ensure order_id path segments are encoded. Add request/response models for cancel transaction and rich receipt models with component types (merchant, order, items, tips, payment, related transactions). Wire PosManager into Sdk. Include examples for Cloud POS create, cancel, and fetching receipts. Add unit and integration tests covering create with terminal-group scope, cancel_transaction behavior, PosManager.get_receipt, and existing order operations. --- examples/order_manager/cancel.py | 67 ++++++ examples/order_manager/cloud_pos_order.py | 58 ++++++ examples/pos_manager/get_receipt.py | 47 +++++ .../paths/orders/order_id/cancel/__init__.py | 8 + .../order_id/cancel/request/__init__.py | 16 ++ .../request/cancel_transaction_request.py | 42 ++++ .../order_id/cancel/response/__init__.py | 16 ++ .../cancel/response/cancel_transaction.py | 79 +++++++ .../api/paths/orders/order_manager.py | 86 ++++++++ src/multisafepay/api/paths/pos/__init__.py | 14 ++ src/multisafepay/api/paths/pos/pos_manager.py | 92 ++++++++ .../api/paths/pos/receipt/__init__.py | 8 + .../paths/pos/receipt/response/__init__.py | 14 ++ .../receipt/response/components/__init__.py | 40 ++++ .../receipt/response/components/merchant.py | 26 +++ .../pos/receipt/response/components/order.py | 56 +++++ .../receipt/response/components/order_item.py | 29 +++ .../receipt/response/components/order_tip.py | 39 ++++ .../response/components/order_tip_employee.py | 26 +++ .../receipt/response/components/payment.py | 36 ++++ .../components/related_transactions.py | 36 ++++ .../api/paths/pos/receipt/response/receipt.py | 75 +++++++ src/multisafepay/sdk.py | 13 ++ .../test_integration_order_manager_create.py | 49 ++++- .../manager/test_unit_cancel_transaction.py | 121 +++++++++++ .../orders/manager/test_unit_order_manager.py | 105 ++++++++++ .../test_unit_order_manager_operations.py | 170 +++++++++++++++ .../unit/api/path/pos/__init__.py | 8 + .../api/path/pos/test_unit_pos_manager.py | 196 ++++++++++++++++++ 29 files changed, 1571 insertions(+), 1 deletion(-) create mode 100644 examples/order_manager/cancel.py create mode 100644 examples/order_manager/cloud_pos_order.py create mode 100644 examples/pos_manager/get_receipt.py create mode 100644 src/multisafepay/api/paths/orders/order_id/cancel/__init__.py create mode 100644 src/multisafepay/api/paths/orders/order_id/cancel/request/__init__.py create mode 100644 src/multisafepay/api/paths/orders/order_id/cancel/request/cancel_transaction_request.py create mode 100644 src/multisafepay/api/paths/orders/order_id/cancel/response/__init__.py create mode 100644 src/multisafepay/api/paths/orders/order_id/cancel/response/cancel_transaction.py create mode 100644 src/multisafepay/api/paths/pos/__init__.py create mode 100644 src/multisafepay/api/paths/pos/pos_manager.py create mode 100644 src/multisafepay/api/paths/pos/receipt/__init__.py create mode 100644 src/multisafepay/api/paths/pos/receipt/response/__init__.py create mode 100644 src/multisafepay/api/paths/pos/receipt/response/components/__init__.py create mode 100644 src/multisafepay/api/paths/pos/receipt/response/components/merchant.py create mode 100644 src/multisafepay/api/paths/pos/receipt/response/components/order.py create mode 100644 src/multisafepay/api/paths/pos/receipt/response/components/order_item.py create mode 100644 src/multisafepay/api/paths/pos/receipt/response/components/order_tip.py create mode 100644 src/multisafepay/api/paths/pos/receipt/response/components/order_tip_employee.py create mode 100644 src/multisafepay/api/paths/pos/receipt/response/components/payment.py create mode 100644 src/multisafepay/api/paths/pos/receipt/response/components/related_transactions.py create mode 100644 src/multisafepay/api/paths/pos/receipt/response/receipt.py create mode 100644 tests/multisafepay/unit/api/path/orders/manager/test_unit_cancel_transaction.py create mode 100644 tests/multisafepay/unit/api/path/orders/manager/test_unit_order_manager.py create mode 100644 tests/multisafepay/unit/api/path/orders/manager/test_unit_order_manager_operations.py create mode 100644 tests/multisafepay/unit/api/path/pos/__init__.py create mode 100644 tests/multisafepay/unit/api/path/pos/test_unit_pos_manager.py diff --git a/examples/order_manager/cancel.py b/examples/order_manager/cancel.py new file mode 100644 index 0000000..9c5959b --- /dev/null +++ b/examples/order_manager/cancel.py @@ -0,0 +1,67 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Create a Cloud POS order, wait 5 seconds, and cancel it.""" + +import os +import time + +from dotenv import load_dotenv + +from multisafepay import Sdk +from multisafepay.api.paths.orders.request import OrderRequest +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", "") + +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-cancel-{int(time.time())}") + .add_description("Cloud POS cancel order") + .add_amount(100) + .add_currency("EUR") + .add_gateway_info({"terminal_id": TERMINAL_ID}) + ) + + create_response = order_manager.create( + order_request, + terminal_group_id=CLOUD_POS_TERMINAL_GROUP_ID, + ) + order = create_response.get_data() + order_id = order.order_id + + print(f"Created Cloud POS order: {order_id}") + print("Waiting 5 seconds before cancel...") + time.sleep(5) + + cancel_response = order_manager.cancel_transaction( + order_id, + terminal_group_id=CLOUD_POS_TERMINAL_GROUP_ID, + ) + + print(f"Canceled Cloud POS order: {order_id}") + print(cancel_response.get_data()) diff --git a/examples/order_manager/cloud_pos_order.py b/examples/order_manager/cloud_pos_order.py new file mode 100644 index 0000000..24ae677 --- /dev/null +++ b/examples/order_manager/cloud_pos_order.py @@ -0,0 +1,58 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Create a Cloud POS order using terminal-group scoped authentication.""" + +import os +import time + +from dotenv import load_dotenv + +from multisafepay import Sdk +from multisafepay.api.paths.orders.request import OrderRequest +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", "") + +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-{int(time.time())}") + .add_description("Cloud POS order") + .add_amount(100) + .add_currency("EUR") + .add_gateway_info({"terminal_id": TERMINAL_ID}) + ) + + 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: {order.order_id}") + if order.payment_url: + print(f"Payment URL: {order.payment_url}") diff --git a/examples/pos_manager/get_receipt.py b/examples/pos_manager/get_receipt.py new file mode 100644 index 0000000..74aa430 --- /dev/null +++ b/examples/pos_manager/get_receipt.py @@ -0,0 +1,47 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Fetch the receipt for an existing Cloud POS order.""" + +import os + +from dotenv import load_dotenv + +from multisafepay import Sdk +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") +ORDER_ID = os.getenv("CLOUD_POS_ORDER_ID", "") + +if __name__ == "__main__": + if not ORDER_ID: + raise SystemExit( + "Set CLOUD_POS_ORDER_ID to a completed Cloud POS order id " + "(receipts are only available for completed orders).", + ) + + 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, + ) + pos_manager = multisafepay_sdk.get_pos_manager() + + receipt_response = pos_manager.get_receipt( + order_id=ORDER_ID, + terminal_group_id=CLOUD_POS_TERMINAL_GROUP_ID, + ) + print(receipt_response.get_data()) diff --git a/src/multisafepay/api/paths/orders/order_id/cancel/__init__.py b/src/multisafepay/api/paths/orders/order_id/cancel/__init__.py new file mode 100644 index 0000000..37e056f --- /dev/null +++ b/src/multisafepay/api/paths/orders/order_id/cancel/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Cancel operations and endpoints for specific orders.""" diff --git a/src/multisafepay/api/paths/orders/order_id/cancel/request/__init__.py b/src/multisafepay/api/paths/orders/order_id/cancel/request/__init__.py new file mode 100644 index 0000000..6640c14 --- /dev/null +++ b/src/multisafepay/api/paths/orders/order_id/cancel/request/__init__.py @@ -0,0 +1,16 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Request models for order cancellation operations.""" + +from multisafepay.api.paths.orders.order_id.cancel.request.cancel_transaction_request import ( + CancelTransactionRequest, +) + +__all__ = [ + "CancelTransactionRequest", +] diff --git a/src/multisafepay/api/paths/orders/order_id/cancel/request/cancel_transaction_request.py b/src/multisafepay/api/paths/orders/order_id/cancel/request/cancel_transaction_request.py new file mode 100644 index 0000000..d477b69 --- /dev/null +++ b/src/multisafepay/api/paths/orders/order_id/cancel/request/cancel_transaction_request.py @@ -0,0 +1,42 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Request model for cancel transaction endpoint.""" + +from multisafepay.model.request_model import RequestModel + + +class CancelTransactionRequest(RequestModel): + """ + Represents a request to cancel a POS transaction. + + Attributes + ---------- + order_id (str): The order identifier used in the endpoint path. + + """ + + order_id: str + + def add_order_id( + self: "CancelTransactionRequest", + order_id: str, + ) -> "CancelTransactionRequest": + """ + Adds order id to the cancellation request. + + Parameters + ---------- + order_id (str): The order identifier. + + Returns + ------- + CancelTransactionRequest: The updated request instance. + + """ + self.order_id = order_id + return self diff --git a/src/multisafepay/api/paths/orders/order_id/cancel/response/__init__.py b/src/multisafepay/api/paths/orders/order_id/cancel/response/__init__.py new file mode 100644 index 0000000..356921c --- /dev/null +++ b/src/multisafepay/api/paths/orders/order_id/cancel/response/__init__.py @@ -0,0 +1,16 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Response models for order cancellation outcomes.""" + +from multisafepay.api.paths.orders.order_id.cancel.response.cancel_transaction import ( + CancelTransaction, +) + +__all__ = [ + "CancelTransaction", +] diff --git a/src/multisafepay/api/paths/orders/order_id/cancel/response/cancel_transaction.py b/src/multisafepay/api/paths/orders/order_id/cancel/response/cancel_transaction.py new file mode 100644 index 0000000..ff77591 --- /dev/null +++ b/src/multisafepay/api/paths/orders/order_id/cancel/response/cancel_transaction.py @@ -0,0 +1,79 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Response model for order cancellation endpoint payload.""" + +from typing import Optional + +from multisafepay.api.base.decorator import Decorator +from multisafepay.api.paths.orders.response.components.payment_details import ( + PaymentDetails, +) +from multisafepay.api.shared.costs import Costs +from multisafepay.api.shared.custom_info import CustomInfo +from multisafepay.api.shared.customer import Customer +from multisafepay.api.shared.payment_method import PaymentMethod +from multisafepay.model.response_model import ResponseModel + + +class CancelTransaction(ResponseModel): + """ + Represents the `data` payload returned by cancel order transaction. + + Attributes + ---------- + costs (Optional[list[Costs]]): The costs of the order. + created (Optional[str]): Creation timestamp. + modified (Optional[str]): Last modification timestamp. + custom_info (Optional[CustomInfo]): Additional custom info. + customer (Optional[Customer]): The customer data. + fastcheckout (Optional[str]): Fastcheckout flag/status. + financial_status (Optional[str]): Financial status. + items (Optional[str]): Rendered items payload. + payment_details (Optional[PaymentDetails]): Payment details. + payment_methods (Optional[list[PaymentMethod]]): Payment methods. + status (Optional[str]): Order status. + + """ + + costs: Optional[list[Costs]] + created: Optional[str] + modified: Optional[str] + custom_info: Optional[CustomInfo] + customer: Optional[Customer] + fastcheckout: Optional[str] + financial_status: Optional[str] + items: Optional[str] + payment_details: Optional[PaymentDetails] + payment_methods: Optional[list[PaymentMethod]] + status: Optional[str] + + @staticmethod + def from_dict(d: dict) -> Optional["CancelTransaction"]: + """ + Create a CancelTransaction from dictionary data. + + Parameters + ---------- + d (dict): The cancellation response data. + + Returns + ------- + Optional[CancelTransaction]: A cancellation response instance or None. + + """ + if d is None: + return None + cancel_dependency_adapter = Decorator(dependencies=d) + dependencies = ( + cancel_dependency_adapter.adapt_costs(d.get("costs")) + .adapt_custom_info(d.get("custom_info")) + .adapt_payment_details(d.get("payment_details")) + .adapt_payment_methods(d.get("payment_methods")) + .get_dependencies() + ) + return CancelTransaction(**dependencies) diff --git a/src/multisafepay/api/paths/orders/order_manager.py b/src/multisafepay/api/paths/orders/order_manager.py index 9ae9092..fae1b3f 100644 --- a/src/multisafepay/api/paths/orders/order_manager.py +++ b/src/multisafepay/api/paths/orders/order_manager.py @@ -15,6 +15,12 @@ from multisafepay.api.base.response.custom_api_response import ( CustomApiResponse, ) +from multisafepay.api.paths.orders.order_id.cancel.request.cancel_transaction_request import ( + CancelTransactionRequest, +) +from multisafepay.api.paths.orders.order_id.cancel.response.cancel_transaction import ( + CancelTransaction, +) from multisafepay.api.paths.orders.order_id.capture.request.capture_request import ( CaptureOrderRequest, ) @@ -38,6 +44,10 @@ from multisafepay.api.shared.cart.shopping_cart import ShoppingCart from multisafepay.api.shared.description import Description from multisafepay.client.client import Client +from multisafepay.client.credential_resolver import ( + AuthScope, + ScopedCredentialResolver, +) from multisafepay.util.dict_utils import dict_empty from multisafepay.util.json_encoder import DecimalEncoder from multisafepay.util.message import MessageList, gen_could_not_created_msg @@ -90,6 +100,26 @@ def __custom_api_response(response: ApiResponse) -> CustomApiResponse: return CustomApiResponse(**args) + @staticmethod + def __custom_cancel_transaction_response( + response: ApiResponse, + ) -> CustomApiResponse: + args: dict = { + **response.dict(), + "data": None, + } + if not dict_empty(response.get_body_data()): + try: + args["data"] = CancelTransaction.from_dict( + d=response.get_body_data().copy(), + ) + except ValidationError: + args["warnings"] = MessageList().add_message( + gen_could_not_created_msg("CancelTransaction"), + ) + + return CustomApiResponse(**args) + def get(self: "OrderManager", order_id: str) -> CustomApiResponse: """ Retrieve an order by its ID. @@ -115,6 +145,7 @@ def get(self: "OrderManager", order_id: str) -> CustomApiResponse: def create( self: "OrderManager", request_order: OrderRequest, + terminal_group_id: str = None, ) -> CustomApiResponse: """ Create a new order. @@ -122,6 +153,8 @@ def create( Parameters ---------- request_order (OrderRequest): The request object containing order details. + terminal_group_id (str): Optional terminal group identifier for + scoped auth resolution. Returns ------- @@ -132,6 +165,14 @@ def create( response: ApiResponse = self.client.create_post_request( "json/orders", request_body=json_data, + auth_scope=( + AuthScope( + scope=ScopedCredentialResolver.AUTH_SCOPE_TERMINAL_GROUP, + group_id=terminal_group_id, + ) + if terminal_group_id + else None + ), ) return OrderManager.__custom_api_response(response) @@ -246,6 +287,51 @@ def refund( return CustomApiResponse(**args) + def cancel_transaction( + self: "OrderManager", + cancel_transaction_request: Union[ + CancelTransactionRequest, + str, + ], + terminal_group_id: str = None, + ) -> CustomApiResponse: + """ + Cancel a POS transaction by order id. + + Parameters + ---------- + cancel_transaction_request (CancelTransactionRequest | str): + Request object or direct order identifier. + terminal_group_id (str): Optional terminal group identifier for + scoped auth resolution. + + Returns + ------- + CustomApiResponse: The cancellation response. + + """ + order_id = ( + cancel_transaction_request + if isinstance(cancel_transaction_request, str) + else cancel_transaction_request.order_id + ) + encoded_order_id = self.encode_path_segment(order_id) + endpoint = f"json/orders/{encoded_order_id}/cancel" + context = {"order_id": order_id} + response = self.client.create_post_request( + endpoint, + context=context, + auth_scope=( + AuthScope( + scope=ScopedCredentialResolver.AUTH_SCOPE_TERMINAL_GROUP, + group_id=terminal_group_id, + ) + if terminal_group_id + else None + ), + ) + return OrderManager.__custom_cancel_transaction_response(response) + def refund_by_item( self: "OrderManager", order: Order, diff --git a/src/multisafepay/api/paths/pos/__init__.py b/src/multisafepay/api/paths/pos/__init__.py new file mode 100644 index 0000000..3d5f3bc --- /dev/null +++ b/src/multisafepay/api/paths/pos/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Point of Sale API endpoints.""" + +from multisafepay.api.paths.pos.pos_manager import PosManager + +__all__ = [ + "PosManager", +] diff --git a/src/multisafepay/api/paths/pos/pos_manager.py b/src/multisafepay/api/paths/pos/pos_manager.py new file mode 100644 index 0000000..5e59c28 --- /dev/null +++ b/src/multisafepay/api/paths/pos/pos_manager.py @@ -0,0 +1,92 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""POS manager for `/json/pos/...` endpoints.""" + +from multisafepay.api.base.abstract_manager import AbstractManager +from multisafepay.api.base.response.api_response import ApiResponse +from multisafepay.api.base.response.custom_api_response import ( + CustomApiResponse, +) +from multisafepay.api.paths.pos.receipt.response.receipt import Receipt +from multisafepay.client.client import Client +from multisafepay.client.credential_resolver import ( + AuthScope, + ScopedCredentialResolver, +) +from multisafepay.util.dict_utils import dict_empty +from multisafepay.util.message import MessageList, gen_could_not_created_msg +from pydantic import ValidationError + + +class PosManager(AbstractManager): + """A class representing the PosManager.""" + + def __init__(self: "PosManager", client: Client) -> None: + """ + Initialize the PosManager with a client. + + Parameters + ---------- + client (Client): The client used to make API requests. + + """ + super().__init__(client) + + @staticmethod + def __custom_receipt_response(response: ApiResponse) -> CustomApiResponse: + args: dict = { + **response.dict(), + "data": None, + } + if not dict_empty(response.get_body_data()): + try: + args["data"] = Receipt.from_dict( + d=response.get_body_data().copy(), + ) + except ValidationError: + args["warnings"] = MessageList().add_message( + gen_could_not_created_msg("Receipt"), + ) + + return CustomApiResponse(**args) + + def get_receipt( + self: "PosManager", + order_id: str, + terminal_group_id: str = None, + ) -> CustomApiResponse: + """ + Retrieve receipt data for a POS transaction. + + Parameters + ---------- + order_id (str): Order identifier. + terminal_group_id (str): Optional terminal group identifier for + scoped auth. + + Returns + ------- + CustomApiResponse: The response containing receipt data. + + """ + encoded_order_id = self.encode_path_segment(order_id) + endpoint = f"json/pos/receipt/{encoded_order_id}" + context = {"order_id": order_id} + response = self.client.create_get_request( + endpoint, + context=context, + auth_scope=( + AuthScope( + scope=ScopedCredentialResolver.AUTH_SCOPE_TERMINAL_GROUP, + group_id=terminal_group_id, + ) + if terminal_group_id + else None + ), + ) + return PosManager.__custom_receipt_response(response) diff --git a/src/multisafepay/api/paths/pos/receipt/__init__.py b/src/multisafepay/api/paths/pos/receipt/__init__.py new file mode 100644 index 0000000..f3c3df3 --- /dev/null +++ b/src/multisafepay/api/paths/pos/receipt/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""POS receipt endpoint models.""" diff --git a/src/multisafepay/api/paths/pos/receipt/response/__init__.py b/src/multisafepay/api/paths/pos/receipt/response/__init__.py new file mode 100644 index 0000000..a6d3fab --- /dev/null +++ b/src/multisafepay/api/paths/pos/receipt/response/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Response models for POS receipt endpoint.""" + +from multisafepay.api.paths.pos.receipt.response.receipt import Receipt + +__all__ = [ + "Receipt", +] diff --git a/src/multisafepay/api/paths/pos/receipt/response/components/__init__.py b/src/multisafepay/api/paths/pos/receipt/response/components/__init__.py new file mode 100644 index 0000000..2d51e1f --- /dev/null +++ b/src/multisafepay/api/paths/pos/receipt/response/components/__init__.py @@ -0,0 +1,40 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Component models for POS receipt response payload.""" + +from multisafepay.api.paths.pos.receipt.response.components.merchant import ( + ReceiptMerchant, +) +from multisafepay.api.paths.pos.receipt.response.components.order import ( + ReceiptOrder, +) +from multisafepay.api.paths.pos.receipt.response.components.order_item import ( + ReceiptOrderItem, +) +from multisafepay.api.paths.pos.receipt.response.components.order_tip import ( + ReceiptOrderTip, +) +from multisafepay.api.paths.pos.receipt.response.components.order_tip_employee import ( + ReceiptOrderTipEmployee, +) +from multisafepay.api.paths.pos.receipt.response.components.payment import ( + ReceiptPayment, +) +from multisafepay.api.paths.pos.receipt.response.components.related_transactions import ( + ReceiptRelatedTransactions, +) + +__all__ = [ + "ReceiptMerchant", + "ReceiptOrder", + "ReceiptOrderItem", + "ReceiptOrderTip", + "ReceiptOrderTipEmployee", + "ReceiptPayment", + "ReceiptRelatedTransactions", +] diff --git a/src/multisafepay/api/paths/pos/receipt/response/components/merchant.py b/src/multisafepay/api/paths/pos/receipt/response/components/merchant.py new file mode 100644 index 0000000..5ee703c --- /dev/null +++ b/src/multisafepay/api/paths/pos/receipt/response/components/merchant.py @@ -0,0 +1,26 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Merchant section for POS receipt response.""" + +from typing import Optional + +from multisafepay.model.response_model import ResponseModel + + +class ReceiptMerchant(ResponseModel): + """Merchant information included in receipt data.""" + + name: Optional[str] + address: Optional[str] + + @staticmethod + def from_dict(d: dict) -> Optional["ReceiptMerchant"]: + """Create a ReceiptMerchant model from dictionary data.""" + if d is None: + return None + return ReceiptMerchant(**d) diff --git a/src/multisafepay/api/paths/pos/receipt/response/components/order.py b/src/multisafepay/api/paths/pos/receipt/response/components/order.py new file mode 100644 index 0000000..0947861 --- /dev/null +++ b/src/multisafepay/api/paths/pos/receipt/response/components/order.py @@ -0,0 +1,56 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Order section model for POS receipt response.""" + +from typing import Optional + +from multisafepay.api.paths.pos.receipt.response.components.order_item import ( + ReceiptOrderItem, +) +from multisafepay.api.paths.pos.receipt.response.components.order_tip import ( + ReceiptOrderTip, +) +from multisafepay.model.response_model import ResponseModel + + +class ReceiptOrder(ResponseModel): + """Order information included in receipt data.""" + + amount: Optional[int] + amount_refunded: Optional[int] + completed: Optional[str] + created: Optional[str] + currency: Optional[str] + financial_status: Optional[str] + modified: Optional[str] + order_id: Optional[str] + status: Optional[str] + transaction_id: Optional[int] + items: Optional[list[ReceiptOrderItem]] + tip: Optional[list[ReceiptOrderTip]] + + @staticmethod + def from_dict(d: dict) -> Optional["ReceiptOrder"]: + """Create a ReceiptOrder from dictionary data.""" + if d is None: + return None + + args = d.copy() + for key, model in ( + ("items", ReceiptOrderItem), + ("tip", ReceiptOrderTip), + ): + value = d.get(key) + if isinstance(value, list): + args[key] = [ + model.from_dict(item) + for item in value + if isinstance(item, dict) + ] + + return ReceiptOrder(**args) diff --git a/src/multisafepay/api/paths/pos/receipt/response/components/order_item.py b/src/multisafepay/api/paths/pos/receipt/response/components/order_item.py new file mode 100644 index 0000000..6be784c --- /dev/null +++ b/src/multisafepay/api/paths/pos/receipt/response/components/order_item.py @@ -0,0 +1,29 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Order item model for POS receipt response.""" + +from typing import Optional + +from multisafepay.model.response_model import ResponseModel + + +class ReceiptOrderItem(ResponseModel): + """Represents one printed order item on the receipt.""" + + currency: Optional[str] + item_price: Optional[int] + name: Optional[str] + quantity: Optional[int] + unit_price: Optional[int] + + @staticmethod + def from_dict(d: dict) -> Optional["ReceiptOrderItem"]: + """Create a ReceiptOrderItem from dictionary data.""" + if d is None: + return None + return ReceiptOrderItem(**d) diff --git a/src/multisafepay/api/paths/pos/receipt/response/components/order_tip.py b/src/multisafepay/api/paths/pos/receipt/response/components/order_tip.py new file mode 100644 index 0000000..a85ebe1 --- /dev/null +++ b/src/multisafepay/api/paths/pos/receipt/response/components/order_tip.py @@ -0,0 +1,39 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Tip model for POS receipt response.""" + +from typing import Optional + +from multisafepay.api.paths.pos.receipt.response.components.order_tip_employee import ( + ReceiptOrderTipEmployee, +) +from multisafepay.model.response_model import ResponseModel + + +class ReceiptOrderTip(ResponseModel): + """Tip information on the printed receipt.""" + + amount: Optional[int] + employee: Optional[list[ReceiptOrderTipEmployee]] + + @staticmethod + def from_dict(d: dict) -> Optional["ReceiptOrderTip"]: + """Create a ReceiptOrderTip from dictionary data.""" + if d is None: + return None + + args = d.copy() + employee_data = d.get("employee") + if isinstance(employee_data, list): + args["employee"] = [ + ReceiptOrderTipEmployee.from_dict(employee) + for employee in employee_data + if isinstance(employee, dict) + ] + + return ReceiptOrderTip(**args) diff --git a/src/multisafepay/api/paths/pos/receipt/response/components/order_tip_employee.py b/src/multisafepay/api/paths/pos/receipt/response/components/order_tip_employee.py new file mode 100644 index 0000000..975a691 --- /dev/null +++ b/src/multisafepay/api/paths/pos/receipt/response/components/order_tip_employee.py @@ -0,0 +1,26 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Tip employee model for POS receipt response.""" + +from typing import Optional + +from multisafepay.model.response_model import ResponseModel + + +class ReceiptOrderTipEmployee(ResponseModel): + """Employee info for receipt tip section.""" + + id: Optional[str] + name: Optional[str] + + @staticmethod + def from_dict(d: dict) -> Optional["ReceiptOrderTipEmployee"]: + """Create a ReceiptOrderTipEmployee from dictionary data.""" + if d is None: + return None + return ReceiptOrderTipEmployee(**d) diff --git a/src/multisafepay/api/paths/pos/receipt/response/components/payment.py b/src/multisafepay/api/paths/pos/receipt/response/components/payment.py new file mode 100644 index 0000000..9a0c753 --- /dev/null +++ b/src/multisafepay/api/paths/pos/receipt/response/components/payment.py @@ -0,0 +1,36 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Payment section model for POS receipt response.""" + +from typing import Optional + +from multisafepay.model.response_model import ResponseModel + + +class ReceiptPayment(ResponseModel): + """Payment information included in receipt data.""" + + application_id: Optional[str] + authorization_code: Optional[int] + card_acceptor_location: Optional[str] + card_entry_mode: Optional[str] + card_expiry_date: Optional[str] + cardholder_verification_method: Optional[str] + issuer_bin: Optional[str] + issuer_country_code: Optional[str] + last4: Optional[str] + payment_method: Optional[str] + response_code: Optional[str] + terminal_id: Optional[str] + + @staticmethod + def from_dict(d: dict) -> Optional["ReceiptPayment"]: + """Create a ReceiptPayment model from dictionary data.""" + if d is None: + return None + return ReceiptPayment(**d) diff --git a/src/multisafepay/api/paths/pos/receipt/response/components/related_transactions.py b/src/multisafepay/api/paths/pos/receipt/response/components/related_transactions.py new file mode 100644 index 0000000..f96d40b --- /dev/null +++ b/src/multisafepay/api/paths/pos/receipt/response/components/related_transactions.py @@ -0,0 +1,36 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Related transactions section model for POS receipt response.""" + +from typing import Optional + +from multisafepay.model.response_model import ResponseModel + + +class ReceiptRelatedTransactions(ResponseModel): + """Represents related transaction data returned for a receipt.""" + + amount: Optional[int] + created: Optional[str] + currency: Optional[str] + description: Optional[str] + items: Optional[str] + modified: Optional[str] + order_id: Optional[str] + reference_transaction_id: Optional[int] + status: Optional[str] + transaction_id: Optional[int] + type: Optional[str] + + @staticmethod + def from_dict(d: dict) -> Optional["ReceiptRelatedTransactions"]: + """Create a ReceiptRelatedTransactions model from dictionary data.""" + if d is None: + return None + + return ReceiptRelatedTransactions(**d) diff --git a/src/multisafepay/api/paths/pos/receipt/response/receipt.py b/src/multisafepay/api/paths/pos/receipt/response/receipt.py new file mode 100644 index 0000000..24503c3 --- /dev/null +++ b/src/multisafepay/api/paths/pos/receipt/response/receipt.py @@ -0,0 +1,75 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Response model for POS receipt data.""" + +from typing import Optional + +from multisafepay.api.paths.pos.receipt.response.components.merchant import ( + ReceiptMerchant, +) +from multisafepay.api.paths.pos.receipt.response.components.order import ( + ReceiptOrder, +) +from multisafepay.api.paths.pos.receipt.response.components.payment import ( + ReceiptPayment, +) +from multisafepay.api.paths.pos.receipt.response.components.related_transactions import ( + ReceiptRelatedTransactions, +) +from multisafepay.model.response_model import ResponseModel + + +class Receipt(ResponseModel): + """ + Represents receipt payload data returned by the POS receipt endpoint. + + Attributes + ---------- + merchant (Optional[ReceiptMerchant]): Information about the merchant. + order (Optional[ReceiptOrder]): Information about the order. + payment (Optional[ReceiptPayment]): Information about the payment. + printed_on (Optional[str]): Timestamp when the receipt was printed. + related_transactions (Optional[ReceiptRelatedTransactions]): Linked transaction information. + + """ + + merchant: Optional[ReceiptMerchant] + order: Optional[ReceiptOrder] + payment: Optional[ReceiptPayment] + printed_on: Optional[str] + related_transactions: Optional[ReceiptRelatedTransactions] + + @staticmethod + def from_dict(d: dict) -> Optional["Receipt"]: + """ + Create a Receipt from dictionary data. + + Parameters + ---------- + d (dict): The receipt data. + + Returns + ------- + Optional[Receipt]: A receipt instance or None. + + """ + if d is None: + return None + + args = d.copy() + for key, model in ( + ("merchant", ReceiptMerchant), + ("order", ReceiptOrder), + ("payment", ReceiptPayment), + ("related_transactions", ReceiptRelatedTransactions), + ): + value = d.get(key) + if isinstance(value, dict): + args[key] = model.from_dict(value) + + return Receipt(**args) diff --git a/src/multisafepay/sdk.py b/src/multisafepay/sdk.py index d835822..28f42b6 100644 --- a/src/multisafepay/sdk.py +++ b/src/multisafepay/sdk.py @@ -17,6 +17,7 @@ from multisafepay.api.paths.payment_methods.payment_method_manager import ( PaymentMethodManager, ) +from multisafepay.api.paths.pos.pos_manager import PosManager from multisafepay.api.paths.transactions.transaction_manager import ( TransactionManager, ) @@ -201,6 +202,18 @@ def get_capture_manager(self: "Sdk") -> CaptureManager: """ return CaptureManager(self.client) + def get_pos_manager(self: "Sdk") -> PosManager: + """ + Get the POS manager. + + Returns + ------- + PosManager + The POS manager instance. + + """ + return PosManager(self.client) + def get_client(self: "Sdk") -> Client: """ Get the client instance. 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 4b14730..975c24b 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 @@ -21,6 +21,10 @@ 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 +from multisafepay.client.credential_resolver import ( + AuthScope, + ScopedCredentialResolver, +) def test_integration_order_manager_create_redirect(): @@ -88,4 +92,47 @@ def test_integration_order_manager_create_redirect(): assert isinstance(response, CustomApiResponse) assert isinstance(response.get_data(), Order) - assert response.get_data() == Order(**data_response) + assert response.get_data() == Order.from_dict(data_response) + + +def test_integration_order_manager_create_with_terminal_group_scope(): + """Use terminal-group auth scope when terminal_group_id is provided.""" + client = MagicMock() + client.create_post_request.return_value = ApiResponse( + headers={}, + status_code=200, + body={ + "success": True, + "data": { + "order_id": "cloud-pos-order", + }, + }, + ) + order_request = ( + OrderRequest() + .add_type("direct") + .add_order_id("cloud-pos-order") + .add_currency("EUR") + .add_amount(100) + ) + + order_manager = OrderManager(client) + response = order_manager.create( + request_order=order_request, + terminal_group_id="Default", + ) + + assert isinstance(response, CustomApiResponse) + assert isinstance(response.get_data(), Order) + assert response.get_data().order_id == "cloud-pos-order" + + called_endpoint = client.create_post_request.call_args.args[0] + called_auth_scope = client.create_post_request.call_args.kwargs[ + "auth_scope" + ] + + assert called_endpoint == "json/orders" + assert called_auth_scope == AuthScope( + scope=ScopedCredentialResolver.AUTH_SCOPE_TERMINAL_GROUP, + group_id="Default", + ) diff --git a/tests/multisafepay/unit/api/path/orders/manager/test_unit_cancel_transaction.py b/tests/multisafepay/unit/api/path/orders/manager/test_unit_cancel_transaction.py new file mode 100644 index 0000000..c71df63 --- /dev/null +++ b/tests/multisafepay/unit/api/path/orders/manager/test_unit_cancel_transaction.py @@ -0,0 +1,121 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Unit tests for OrderManager.cancel_transaction behavior.""" + +from unittest.mock import MagicMock + +from multisafepay.api.base.response.api_response import ApiResponse +from multisafepay.api.base.response.custom_api_response import ( + CustomApiResponse, +) +from multisafepay.api.paths.orders.order_id.cancel.request.cancel_transaction_request import ( + CancelTransactionRequest, +) +from multisafepay.api.paths.orders.order_id.cancel.response.cancel_transaction import ( + CancelTransaction, +) +from multisafepay.api.paths.orders.order_manager import OrderManager +from multisafepay.client.credential_resolver import ( + AuthScope, + ScopedCredentialResolver, +) + +ORDER_ID = "cloud-pos-cancel-1" +TERMINAL_GROUP_ID = "Default" + + +def _build_cancel_api_response() -> ApiResponse: + return ApiResponse( + headers={}, + status_code=200, + body={ + "success": True, + "data": { + "status": "void", + "financial_status": "void", + "created": "2026-01-01T00:00:00", + "modified": "2026-01-01T00:00:01", + }, + }, + ) + + +def test_cancel_transaction_with_terminal_group_scope() -> None: + """Use terminal-group auth scope when terminal_group_id is provided.""" + client = MagicMock() + client.create_post_request.return_value = _build_cancel_api_response() + + manager = OrderManager(client) + response = manager.cancel_transaction( + cancel_transaction_request=ORDER_ID, + terminal_group_id=TERMINAL_GROUP_ID, + ) + + called_auth_scope = client.create_post_request.call_args.kwargs[ + "auth_scope" + ] + + assert isinstance(response, CustomApiResponse) + assert isinstance(response.get_data(), CancelTransaction) + assert response.get_data().status == "void" + assert called_auth_scope == AuthScope( + scope=ScopedCredentialResolver.AUTH_SCOPE_TERMINAL_GROUP, + group_id=TERMINAL_GROUP_ID, + ) + + +def test_cancel_transaction_without_terminal_group_scope() -> None: + """Omit auth scope when terminal_group_id is not provided.""" + client = MagicMock() + client.create_post_request.return_value = _build_cancel_api_response() + + manager = OrderManager(client) + response = manager.cancel_transaction( + cancel_transaction_request=ORDER_ID, + ) + + called_auth_scope = client.create_post_request.call_args.kwargs[ + "auth_scope" + ] + + assert isinstance(response, CustomApiResponse) + assert called_auth_scope is None + + +def test_cancel_transaction_accepts_request_object() -> None: + """Accept CancelTransactionRequest as input instead of raw string.""" + client = MagicMock() + client.create_post_request.return_value = _build_cancel_api_response() + + request = CancelTransactionRequest(order_id=ORDER_ID) + + manager = OrderManager(client) + response = manager.cancel_transaction( + cancel_transaction_request=request, + terminal_group_id=TERMINAL_GROUP_ID, + ) + + called_endpoint = client.create_post_request.call_args.args[0] + + assert isinstance(response, CustomApiResponse) + assert ORDER_ID in called_endpoint + assert called_endpoint.endswith("/cancel") + + +def test_cancel_transaction_encodes_order_id() -> None: + """Verify order ID with special chars is encoded in the endpoint.""" + client = MagicMock() + client.create_post_request.return_value = _build_cancel_api_response() + + manager = OrderManager(client) + manager.cancel_transaction( + cancel_transaction_request="order/special&chars", + ) + + called_endpoint = client.create_post_request.call_args.args[0] + assert "order%2Fspecial%26chars" in called_endpoint diff --git a/tests/multisafepay/unit/api/path/orders/manager/test_unit_order_manager.py b/tests/multisafepay/unit/api/path/orders/manager/test_unit_order_manager.py new file mode 100644 index 0000000..d068fc3 --- /dev/null +++ b/tests/multisafepay/unit/api/path/orders/manager/test_unit_order_manager.py @@ -0,0 +1,105 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Unit tests for basic order manager create behavior.""" + +from unittest.mock import MagicMock + +from multisafepay.api.base.response.api_response import ApiResponse +from multisafepay.api.base.response.custom_api_response import ( + CustomApiResponse, +) +from multisafepay.api.paths.orders.order_manager import OrderManager +from multisafepay.api.paths.orders.request.order_request import OrderRequest +from multisafepay.api.paths.orders.response.order_response import Order +from multisafepay.client.credential_resolver import ( + AuthScope, + ScopedCredentialResolver, +) + +ORDERS_ENDPOINT = "json/orders" +TERMINAL_GROUP_ID = "Default" +SCOPED_ORDER_ID = "cloud-pos-order" +DEFAULT_ORDER_ID = "default-order" + + +def _build_api_response(order_id: str) -> ApiResponse: + """Create a minimal successful ApiResponse for order manager tests.""" + return ApiResponse( + headers={}, + status_code=200, + body={ + "success": True, + "data": { + "order_id": order_id, + }, + }, + ) + + +def _build_order_request(order_id: str) -> OrderRequest: + """Create a minimal valid order request used in create() tests.""" + return ( + OrderRequest() + .add_type("direct") + .add_order_id(order_id) + .add_currency("EUR") + .add_amount(100) + ) + + +def test_create_uses_terminal_group_auth_scope_when_provided() -> None: + """Use terminal-group scope only when terminal_group_id is passed.""" + client = MagicMock() + client.create_post_request.return_value = _build_api_response( + SCOPED_ORDER_ID, + ) + request_order = _build_order_request(SCOPED_ORDER_ID) + + manager = OrderManager(client) + response = manager.create( + request_order=request_order, + terminal_group_id=TERMINAL_GROUP_ID, + ) + + called_endpoint = client.create_post_request.call_args.args[0] + called_auth_scope = client.create_post_request.call_args.kwargs[ + "auth_scope" + ] + + assert isinstance(response, CustomApiResponse) + assert isinstance(response.get_data(), Order) + assert called_endpoint == ORDERS_ENDPOINT + assert called_auth_scope == AuthScope( + scope=ScopedCredentialResolver.AUTH_SCOPE_TERMINAL_GROUP, + group_id=TERMINAL_GROUP_ID, + ) + + +def test_create_omits_auth_scope_when_terminal_group_id_is_not_passed() -> ( + None +): + """Do not set auth_scope when create request has no terminal group id.""" + client = MagicMock() + client.create_post_request.return_value = _build_api_response( + DEFAULT_ORDER_ID, + ) + request_order = _build_order_request(DEFAULT_ORDER_ID) + + manager = OrderManager(client) + response = manager.create(request_order=request_order) + + called_endpoint = client.create_post_request.call_args.args[0] + called_auth_scope = client.create_post_request.call_args.kwargs[ + "auth_scope" + ] + + assert isinstance(response, CustomApiResponse) + assert isinstance(response.get_data(), Order) + assert response.get_data().order_id == DEFAULT_ORDER_ID + assert called_endpoint == ORDERS_ENDPOINT + assert called_auth_scope is None diff --git a/tests/multisafepay/unit/api/path/orders/manager/test_unit_order_manager_operations.py b/tests/multisafepay/unit/api/path/orders/manager/test_unit_order_manager_operations.py new file mode 100644 index 0000000..c6135d4 --- /dev/null +++ b/tests/multisafepay/unit/api/path/orders/manager/test_unit_order_manager_operations.py @@ -0,0 +1,170 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Unit tests for OrderManager get, update, capture, and refund methods.""" + +from unittest.mock import MagicMock + +from multisafepay.api.base.response.api_response import ApiResponse +from multisafepay.api.base.response.custom_api_response import ( + CustomApiResponse, +) +from multisafepay.api.paths.orders.order_manager import OrderManager +from multisafepay.api.paths.orders.response.order_response import Order + +ORDER_ID = "test-order-1" + + +def _build_order_api_response(order_id: str) -> ApiResponse: + return ApiResponse( + headers={}, + status_code=200, + body={ + "success": True, + "data": { + "order_id": order_id, + "status": "completed", + }, + }, + ) + + +def _build_empty_api_response() -> ApiResponse: + return ApiResponse( + headers={}, + status_code=200, + body={"success": True, "data": {}}, + ) + + +def _build_capture_api_response() -> ApiResponse: + return ApiResponse( + headers={}, + status_code=200, + body={ + "success": True, + "data": { + "order_id": ORDER_ID, + "status": "completed", + }, + }, + ) + + +def _build_refund_api_response() -> ApiResponse: + return ApiResponse( + headers={}, + status_code=200, + body={ + "success": True, + "data": { + "refund_id": "refund-1", + "order_id": ORDER_ID, + }, + }, + ) + + +def test_get_order_by_id() -> None: + """Retrieve an order by its ID.""" + client = MagicMock() + client.create_get_request.return_value = _build_order_api_response( + ORDER_ID, + ) + + manager = OrderManager(client) + response = manager.get(order_id=ORDER_ID) + + called_endpoint = client.create_get_request.call_args.args[0] + + assert isinstance(response, CustomApiResponse) + assert isinstance(response.get_data(), Order) + assert response.get_data().order_id == ORDER_ID + assert ORDER_ID in called_endpoint + + +def test_get_order_encodes_special_chars_in_id() -> None: + """Verify order ID with special chars is encoded in the URL.""" + client = MagicMock() + client.create_get_request.return_value = _build_order_api_response( + "order/special", + ) + + manager = OrderManager(client) + manager.get(order_id="order/special") + + called_endpoint = client.create_get_request.call_args.args[0] + assert "order%2Fspecial" in called_endpoint + + +def test_get_order_returns_none_for_empty_data() -> None: + """Return None when body data is empty.""" + client = MagicMock() + client.create_get_request.return_value = _build_empty_api_response() + + manager = OrderManager(client) + response = manager.get(order_id=ORDER_ID) + + assert isinstance(response, CustomApiResponse) + assert response.get_data() is None + + +def test_update_order_sends_patch_request() -> None: + """Update sends PATCH to the correct endpoint.""" + client = MagicMock() + client.create_patch_request.return_value = _build_empty_api_response() + + update_request = MagicMock() + update_request.to_dict.return_value = {"description": "updated"} + + manager = OrderManager(client) + response = manager.update(order_id=ORDER_ID, update_request=update_request) + + called_endpoint = client.create_patch_request.call_args.args[0] + + assert isinstance(response, CustomApiResponse) + assert ORDER_ID in called_endpoint + + +def test_capture_order_sends_post_and_parses_response() -> None: + """Capture sends POST and parses OrderCapture response.""" + client = MagicMock() + client.create_post_request.return_value = _build_capture_api_response() + + capture_request = MagicMock() + capture_request.to_dict.return_value = {"amount": 100} + + manager = OrderManager(client) + response = manager.capture( + order_id=ORDER_ID, + capture_request=capture_request, + ) + + called_endpoint = client.create_post_request.call_args.args[0] + + assert isinstance(response, CustomApiResponse) + assert f"json/orders/{ORDER_ID}/capture" == called_endpoint + + +def test_refund_order_sends_post_and_parses_response() -> None: + """Refund sends POST and parses OrderRefund response.""" + client = MagicMock() + client.create_post_request.return_value = _build_refund_api_response() + + refund_request = MagicMock() + refund_request.to_dict.return_value = {"amount": 50} + + manager = OrderManager(client) + response = manager.refund( + order_id=ORDER_ID, + request_refund=refund_request, + ) + + called_endpoint = client.create_post_request.call_args.args[0] + + assert isinstance(response, CustomApiResponse) + assert f"json/orders/{ORDER_ID}/refunds" == called_endpoint diff --git a/tests/multisafepay/unit/api/path/pos/__init__.py b/tests/multisafepay/unit/api/path/pos/__init__.py new file mode 100644 index 0000000..9383b1b --- /dev/null +++ b/tests/multisafepay/unit/api/path/pos/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Unit tests for POS path package.""" diff --git a/tests/multisafepay/unit/api/path/pos/test_unit_pos_manager.py b/tests/multisafepay/unit/api/path/pos/test_unit_pos_manager.py new file mode 100644 index 0000000..37db026 --- /dev/null +++ b/tests/multisafepay/unit/api/path/pos/test_unit_pos_manager.py @@ -0,0 +1,196 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Unit tests for PosManager.get_receipt behavior.""" + +from unittest.mock import MagicMock + +from multisafepay.api.base.response.api_response import ApiResponse +from multisafepay.api.base.response.custom_api_response import ( + CustomApiResponse, +) +from multisafepay.api.paths.pos.pos_manager import PosManager +from multisafepay.api.paths.pos.receipt.response.receipt import Receipt +from multisafepay.client.credential_resolver import ( + AuthScope, + ScopedCredentialResolver, +) + +ORDER_ID = "cloud-pos-order-1" +TERMINAL_GROUP_ID = "Default" + + +def _build_receipt_api_response() -> ApiResponse: + return ApiResponse( + headers={}, + status_code=200, + body={ + "success": True, + "data": { + "merchant": {"name": "Test Merchant", "address": "123 St"}, + "order": { + "order_id": ORDER_ID, + "amount": 100, + "currency": "EUR", + "status": "completed", + "financial_status": "completed", + "created": "2026-01-01T00:00:00", + "modified": "2026-01-01T00:00:01", + "completed": "2026-01-01T00:00:02", + "amount_refunded": 0, + "transaction_id": 12345, + "items": [ + { + "name": "Widget", + "quantity": 1, + "unit_price": 100, + "item_price": 100, + "currency": "EUR", + }, + ], + "tip": [ + { + "amount": 50, + "employee": [ + {"id": "emp-1", "name": "Alice"}, + ], + }, + ], + }, + "payment": { + "payment_method": "VISA", + "last4": "1234", + "terminal_id": "T-001", + "authorization_code": 123456, + "application_id": "A001", + "card_acceptor_location": "NL", + "card_entry_mode": "contactless", + "card_expiry_date": "12/28", + "cardholder_verification_method": "pin", + "issuer_bin": "411111", + "issuer_country_code": "NL", + "response_code": "00", + }, + "printed_on": "2026-01-01T00:00:03", + "related_transactions": { + "amount": 100, + "created": "2026-01-01T00:00:00", + "currency": "EUR", + "description": "Refund", + "items": None, + "modified": "2026-01-01T00:00:01", + "order_id": ORDER_ID, + "reference_transaction_id": 99999, + "status": "completed", + "transaction_id": 12346, + "type": "refund", + }, + }, + }, + ) + + +def _build_empty_receipt_api_response() -> ApiResponse: + return ApiResponse( + headers={}, + status_code=200, + body={"success": True, "data": {}}, + ) + + +def test_get_receipt_with_terminal_group_scope() -> None: + """Use terminal-group auth scope when terminal_group_id is provided.""" + client = MagicMock() + client.create_get_request.return_value = _build_receipt_api_response() + + manager = PosManager(client) + response = manager.get_receipt( + order_id=ORDER_ID, + terminal_group_id=TERMINAL_GROUP_ID, + ) + + called_auth_scope = client.create_get_request.call_args.kwargs[ + "auth_scope" + ] + + assert isinstance(response, CustomApiResponse) + assert isinstance(response.get_data(), Receipt) + assert response.get_data().printed_on == "2026-01-01T00:00:03" + assert called_auth_scope == AuthScope( + scope=ScopedCredentialResolver.AUTH_SCOPE_TERMINAL_GROUP, + group_id=TERMINAL_GROUP_ID, + ) + + +def test_get_receipt_without_terminal_group_scope() -> None: + """Omit auth scope when terminal_group_id is not provided.""" + client = MagicMock() + client.create_get_request.return_value = _build_receipt_api_response() + + manager = PosManager(client) + response = manager.get_receipt(order_id=ORDER_ID) + + called_auth_scope = client.create_get_request.call_args.kwargs[ + "auth_scope" + ] + + assert isinstance(response, CustomApiResponse) + assert isinstance(response.get_data(), Receipt) + assert called_auth_scope is None + + +def test_get_receipt_parses_nested_receipt_components() -> None: + """Verify receipt response parses all nested model components.""" + client = MagicMock() + client.create_get_request.return_value = _build_receipt_api_response() + + manager = PosManager(client) + response = manager.get_receipt( + order_id=ORDER_ID, + terminal_group_id=TERMINAL_GROUP_ID, + ) + receipt = response.get_data() + + assert receipt.merchant.name == "Test Merchant" + assert receipt.merchant.address == "123 St" + assert receipt.order.order_id == ORDER_ID + assert receipt.order.amount == 100 + assert len(receipt.order.items) == 1 + assert receipt.order.items[0].name == "Widget" + assert len(receipt.order.tip) == 1 + assert receipt.order.tip[0].amount == 50 + assert receipt.order.tip[0].employee[0].name == "Alice" + assert receipt.payment.payment_method == "VISA" + assert receipt.payment.last4 == "1234" + assert receipt.related_transactions.transaction_id == 12346 + assert receipt.related_transactions.type == "refund" + + +def test_get_receipt_returns_none_data_for_empty_body() -> None: + """Return None data when body data is empty.""" + client = MagicMock() + client.create_get_request.return_value = ( + _build_empty_receipt_api_response() + ) + + manager = PosManager(client) + response = manager.get_receipt(order_id=ORDER_ID) + + assert isinstance(response, CustomApiResponse) + assert response.get_data() is None + + +def test_get_receipt_encodes_order_id_in_endpoint() -> None: + """Verify order ID with special chars is encoded in the URL.""" + client = MagicMock() + client.create_get_request.return_value = _build_receipt_api_response() + + manager = PosManager(client) + manager.get_receipt(order_id="order/special&chars") + + called_endpoint = client.create_get_request.call_args.args[0] + assert "order%2Fspecial%26chars" in called_endpoint