From 2757bcee943a1524e25406575c0797b06ce17cc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armando=20Rodr=C3=ADguez?= <127134616+armando-rodriguez-cko@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:43:20 +0200 Subject: [PATCH 1/2] feat(inventory): add Inventory endpoint family Implements the new agentic:inventory OAuth-scoped endpoints: stock adjustments, atomic multi-variant reservations (create/get/commit/release), stock levels (get/set), and beta product knowledge (get/set/delete). Error responses surface through the SDK's existing CheckoutApiException, documented in method docstrings rather than a dedicated error class. --- checkout_sdk/checkout_api.py | 2 + checkout_sdk/inventory/__init__.py | 0 checkout_sdk/inventory/inventory.py | 500 ++++++++++++++++++ checkout_sdk/inventory/inventory_client.py | 193 +++++++ tests/inventory/__init__.py | 0 tests/inventory/inventory_client_test.py | 121 +++++ .../inventory/inventory_serialization_test.py | 292 ++++++++++ 7 files changed, 1108 insertions(+) create mode 100644 checkout_sdk/inventory/__init__.py create mode 100644 checkout_sdk/inventory/inventory.py create mode 100644 checkout_sdk/inventory/inventory_client.py create mode 100644 tests/inventory/__init__.py create mode 100644 tests/inventory/inventory_client_test.py create mode 100644 tests/inventory/inventory_serialization_test.py diff --git a/checkout_sdk/checkout_api.py b/checkout_sdk/checkout_api.py index 1e89742e..82b56396 100644 --- a/checkout_sdk/checkout_api.py +++ b/checkout_sdk/checkout_api.py @@ -11,6 +11,7 @@ from checkout_sdk.forex.forex_client import ForexClient from checkout_sdk.checkout_apm_api import CheckoutApmApi from checkout_sdk.instruments.instruments_client import InstrumentsClient +from checkout_sdk.inventory.inventory_client import InventoryClient from checkout_sdk.issuing.issuing_client import IssuingClient from checkout_sdk.payments.contexts.contexts_client import PaymentContextsClient from checkout_sdk.payments.sessions.sessions_client import PaymentSessionsClient @@ -105,6 +106,7 @@ def __init__(self, configuration: CheckoutConfiguration): self.forward = ForwardClient(api_client=forward_api_client, configuration=configuration) self.setups = PaymentSetupsClient(api_client=base_api_client, configuration=configuration) self.agentic_commerce = AgenticCommerceClient(api_client=base_api_client, configuration=configuration) + self.inventory = InventoryClient(api_client=base_api_client, configuration=configuration) self.apple_pay = ApplePayClient(api_client=base_api_client, configuration=configuration) self.google_pay = GooglePayClient(api_client=base_api_client, configuration=configuration) self.standalone_account_updater = StandaloneAccountUpdaterClient(api_client=base_api_client, diff --git a/checkout_sdk/inventory/__init__.py b/checkout_sdk/inventory/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/checkout_sdk/inventory/inventory.py b/checkout_sdk/inventory/inventory.py new file mode 100644 index 00000000..c37be69f --- /dev/null +++ b/checkout_sdk/inventory/inventory.py @@ -0,0 +1,500 @@ +from __future__ import absolute_import + +from datetime import datetime +from enum import Enum + + +class InventoryHalLink: + """A single HAL-style link describing an action available on an inventory resource.""" + # Absolute URI of the linked resource or action. + # [Optional] + href: str + # HTTP methods supported at `href`. + # [Optional] + actions: list # values of str + # Media types supported at `href`. + # [Optional] + types: list # values of str + + +class InventoryLevelsLinks: + """HAL links exposed alongside stock level responses.""" + # Link to retrieve the current stock levels for the variant. + # [Optional] + self: InventoryHalLink + # Link to set the stock levels for the variant. + # [Optional] + set: InventoryHalLink + + +class InventoryState(str, Enum): + IN_STOCK = 'in_stock' + LIMITED = 'limited' + OUT_OF_STOCK = 'out_of_stock' + + +class InventorySource(str, Enum): + MANAGED = 'managed' + SYNC = 'sync' + + +class InventoryCondition(str, Enum): + NEW = 'new' + USED = 'used' + REFURBISHED = 'refurbished' + + +class InventoryReservationState(str, Enum): + HELD = 'held' + COMMITTED = 'committed' + RELEASED = 'released' + EXPIRED = 'expired' + + +class InventoryMoney: + """An amount expressed in a currency's minor unit, used by inventory product knowledge.""" + # The amount, in the minor unit of `currency`. + # [Required] + amount: int + # The 3-letter ISO 4217 currency code. + # [Required] + # exactly 3 characters + currency: str + + +class InventoryAdjustmentRequest: + """Request body for POST /inventory/adjustments.""" + # Identifier of the variant to adjust. The variant must already exist. + # [Required] + # max 128 characters + variant_id: str + # Signed change to apply to `on_hand`. A negative delta that would drive `on_hand` below zero + # is rejected with 409. The value must be non-zero (enforced by the API, not a formal schema + # constraint). + # [Required] + delta: int + # Free-text reason recorded in the adjustment ledger. Must not contain personal data. + # [Required] + # min 1, max 256 characters + reason: str + + +class InventoryReservationItem: + """A single line item within an inventory reservation.""" + # Identifier of the variant to reserve. The variant must already exist. + # [Required] + # max 128 characters + variant_id: str + # Quantity of the variant to reserve. + # [Required] + # minimum 1 + quantity: int + + +class InventoryReservationRequest: + """Request body for POST /inventory/reservations.""" + # Type of the entity that owns this reservation, echoed back on the response. + # [Required] + # max 64 characters + owner_type: str + # Reference identifying the specific owner, echoed back on the response. + # [Required] + # max 256 characters + owner_reference: str + # Variants and quantities to reserve. Variant ids must be unique within the request. + # [Required] + # min 1, max 45 items + items: list # values of InventoryReservationItem + # Time to live for the hold, in seconds. + # [Optional] + # minimum 60, maximum 3600, default 900 + ttl_seconds: int + + +class InventoryReservationLinks: + """HAL links exposed alongside an inventory reservation. A `held` reservation exposes all + three; terminal states (`committed`, `released`, `expired`) expose `self` only. + """ + # Link to retrieve the reservation. + # [Optional] + self: InventoryHalLink + # Link to commit the reservation. Only present while `held`. + # [Optional] + commit: InventoryHalLink + # Link to release the reservation. Only present while `held`. + # [Optional] + release: InventoryHalLink + + +class InventoryReservation: + """Response body for createInventoryReservation, getInventoryReservation, + commitInventoryReservation and releaseInventoryReservation. + """ + # The reservation identifier, in the form `rsv_{base32-encoded GUID}`. + # [Optional] + id: str + # Current state of the reservation. A `held` reservation past `expires_at` reports as `expired`. + # [Optional] + # enum: held, committed, released, expired + state: InventoryReservationState + # Echo of the request's `owner_type`. + # [Optional] + owner_type: str + # Echo of the request's `owner_reference`. + # [Optional] + owner_reference: str + # The reserved variants and quantities. + # [Optional] + items: list # values of InventoryReservationItem + # When the hold expires if not committed or released. + # [Optional] + expires_at: datetime + # When the reservation was created. + # [Optional] + created_on: datetime + # HAL links available for this reservation, dependent on its state. + # [Optional] + _links: InventoryReservationLinks + + +class InventorySetLevelsRequest: + """Request body for PUT /inventory/{variant_id}.""" + # Physical stock on hand. + # [Required] + # minimum 0 + on_hand: int + # Buffer withheld from sale. Defaults to 0 on create; left unchanged on update if omitted. + # [Optional] + # minimum 0 + safety_stock: int + # Free-text reason recorded in the ledger. Must not contain personal data. + # [Optional] + # max 256 characters + reason: str + + +class InventoryProductKnowledgeLinks: + """HAL links exposed alongside inventory product knowledge.""" + # Link to retrieve the product knowledge. + # [Required] + self: InventoryHalLink + # Link to set the product knowledge. + # [Required] + set: InventoryHalLink + # Link to delete the product knowledge. + # [Required] + delete: InventoryHalLink + + +class InventoryProductKnowledge: + """Response body for getInventoryProduct and setInventoryProduct. Also embeddable as + `InventoryLevels.product` when `?expand=product` is passed. + + Beta: this schema and the endpoints that return it are marked Beta in the specification. + """ + # Identifier of the variant this product knowledge describes. + # [Required] + variant_id: str + # Product title. + # [Required] + title: str + # Product description. + # [Required] + description: str + # Canonical URL of the product page. + # [Required] + product_url: str + # URL of the primary product image. + # [Required] + image_url: str + # Additional image URLs. + # [Optional] + additional_image_urls: list # values of str + # URL of a product video. + # [Optional] + video_url: str + # URL of a 3D model of the product. + # [Optional] + model_3d_url: str + # Merchant SKU. + # [Optional] + sku: str + # Global Trade Item Number. + # [Optional] + gtin: str + # Manufacturer Part Number. + # [Optional] + mpn: str + # Brand name. + # [Optional] + brand: str + # Product category. + # [Optional] + category: str + # Regular price. Present as an embedded object (InventoryMoney), not a bare $ref. + # [Optional] + price: InventoryMoney + # Discounted price. When set, must share `price`'s currency and be <= `price`. + # [Optional] + sale_price: InventoryMoney + # Start of the sale price window. Pairs with `sale_price`. + # [Optional] + sale_price_starts_at: datetime + # End of the sale price window. Pairs with `sale_price`. + # [Optional] + sale_price_ends_at: datetime + # Identifier grouping variants of the same product (e.g. by color/size). When set, `color` + # and `size` are both expected (enforced by the API, not a formal schema constraint). + # [Optional] + group_id: str + # Title shared across all variants in `group_id`. + # [Optional] + group_title: str + # Color of this variant. Expected when `group_id` is set. + # [Optional] + color: str + # Size of this variant. Expected when `group_id` is set. + # [Optional] + size: str + # Sizing system used by `size` (e.g. `US`, `EU`). + # [Optional] + size_system: str + # Target gender. + # [Optional] + gender: str + # Condition of the item. Defaults to `new`. + # [Required] + # enum: new, used, refurbished + condition: InventoryCondition + # Material composition. + # [Optional] + material: str + # Target age group. + # [Optional] + age_group: str + # Length of the item. + # [Optional] + length: float + # Width of the item. + # [Optional] + width: float + # Height of the item. + # [Optional] + height: float + # Unit used by `length`, `width` and `height`. + # [Optional] + dimension_unit: str + # Weight of the item. + # [Optional] + weight: float + # Unit used by `weight`. + # [Optional] + weight_unit: str + # Expiration date of the item, if applicable. + # [Optional] + expiration_date: datetime + # Harmonized System code, for customs. + # [Optional] + harmonized_system_code: str + # 2-letter ISO 3166-1 alpha-2 country of origin. + # [Optional] + country_of_origin: str + # Name of the seller. + # [Optional] + seller_name: str + # URL of the seller. + # [Optional] + seller_url: str + # URL of the seller's privacy policy. + # [Optional] + seller_privacy_policy: str + # URL of the seller's terms of service. + # [Optional] + seller_tos: str + # When the product knowledge was created. + # [Required] + created_on: datetime + # When the product knowledge was last modified. + # [Required] + modified_on: datetime + # HAL links available for this product knowledge. + # [Required] + _links: InventoryProductKnowledgeLinks + + +class InventorySetProductRequest: + """Request body for PUT /inventory/{variant_id}/product. + + Beta: this schema and the endpoint it targets are marked Beta in the specification. + """ + # Product title. + # [Required] + # max 512 characters + title: str + # Product description. + # [Required] + # max 4000 characters + description: str + # Canonical URL of the product page. + # [Required] + # max 2048 characters + product_url: str + # URL of the primary product image. + # [Required] + # max 2048 characters + image_url: str + # Additional image URLs. + # [Optional] + additional_image_urls: list # values of str + # URL of a product video. + # [Optional] + video_url: str + # URL of a 3D model of the product. + # [Optional] + model_3d_url: str + # Merchant SKU. + # [Optional] + # max 128 characters + sku: str + # Global Trade Item Number. + # [Optional] + gtin: str + # Manufacturer Part Number. + # [Optional] + mpn: str + # Brand name. + # [Optional] + brand: str + # Product category. + # [Optional] + category: str + # Regular price. + # [Optional] + price: InventoryMoney + # Discounted price. Must share `price`'s currency and be <= `price`. + # [Optional] + sale_price: InventoryMoney + # Start of the sale price window. Pairs with `sale_price`. + # [Optional] + sale_price_starts_at: datetime + # End of the sale price window. Pairs with `sale_price`. + # [Optional] + sale_price_ends_at: datetime + # Identifier grouping variants of the same product. When set, `color` and `size` are both + # expected. + # [Optional] + group_id: str + # Title shared across all variants in `group_id`. + # [Optional] + group_title: str + # Color of this variant. Expected when `group_id` is set. + # [Optional] + color: str + # Size of this variant. Expected when `group_id` is set. + # [Optional] + size: str + # Sizing system used by `size`. + # [Optional] + size_system: str + # Target gender. + # [Optional] + gender: str + # Condition of the item. Exact lowercase match. Defaults to `new`. + # [Optional] + # enum: new, used, refurbished + condition: InventoryCondition + # Material composition. + # [Optional] + material: str + # Target age group. + # [Optional] + age_group: str + # Length of the item. + # [Optional] + length: float + # Width of the item. + # [Optional] + width: float + # Height of the item. + # [Optional] + height: float + # Unit used by `length`, `width` and `height`. + # [Optional] + dimension_unit: str + # Weight of the item. + # [Optional] + weight: float + # Unit used by `weight`. + # [Optional] + weight_unit: str + # Expiration date of the item, if applicable. + # [Optional] + expiration_date: datetime + # Harmonized System code, for customs. + # [Optional] + harmonized_system_code: str + # 2-letter ISO 3166-1 alpha-2 country of origin. + # [Optional] + country_of_origin: str + # Name of the seller. + # [Optional] + seller_name: str + # URL of the seller. + # [Optional] + seller_url: str + # URL of the seller's privacy policy. + # [Optional] + seller_privacy_policy: str + # URL of the seller's terms of service. + # [Optional] + seller_tos: str + + +class InventoryLevels: + """Response body for getInventoryLevels, setInventoryLevels and adjustInventory.""" + # The variant identifier. + # [Optional] + variant_id: str + # Physical stock on hand. + # [Optional] + on_hand: int + # Sum of active holds against this variant. + # [Optional] + reserved: int + # Buffer withheld from sale. + # [Optional] + safety_stock: int + # max(0, on_hand - reserved - safety_stock). + # [Optional] + available: int + # Stock state derived from `available`. + # [Optional] + # enum: in_stock, limited, out_of_stock + state: InventoryState + # Whether stock levels are merchant-managed or kept in sync from another system. + # [Optional] + # enum: managed, sync + source: InventorySource + # When the stock record was created. + # [Optional] + created_on: datetime + # When the stock record was last modified. + # [Optional] + modified_on: datetime + # Product knowledge for the variant. Only present when `?expand=product` was passed and + # product knowledge exists for the variant. + # [Optional] + product: InventoryProductKnowledge + # HAL links available for this stock record. + # [Optional] + _links: InventoryLevelsLinks + + +class InventoryLevelsQuery: + """Query parameters for GET /inventory/{variant_id}.""" + # Set to `product` to embed the variant's product knowledge alongside the stock fields. Does + # not change how `available` or the other stock fields are calculated. Omitted from the + # response if product knowledge has not been set for the variant. + # [Optional] + # enum: product + expand: str diff --git a/checkout_sdk/inventory/inventory_client.py b/checkout_sdk/inventory/inventory_client.py new file mode 100644 index 00000000..e57770ce --- /dev/null +++ b/checkout_sdk/inventory/inventory_client.py @@ -0,0 +1,193 @@ +from __future__ import absolute_import + +from checkout_sdk.api_client import ApiClient +from checkout_sdk.authorization_type import AuthorizationType +from checkout_sdk.checkout_configuration import CheckoutConfiguration +from checkout_sdk.client import Client +from checkout_sdk.inventory.inventory import InventoryAdjustmentRequest, InventoryReservationRequest, \ + InventorySetLevelsRequest, InventorySetProductRequest, InventoryLevelsQuery + + +class InventoryClient(Client): + __INVENTORY_PATH = 'inventory' + __ADJUSTMENTS_PATH = 'adjustments' + __RESERVATIONS_PATH = 'reservations' + __COMMIT_PATH = 'commit' + __RELEASE_PATH = 'release' + __PRODUCT_PATH = 'product' + + def __init__(self, api_client: ApiClient, configuration: CheckoutConfiguration): + super().__init__(api_client=api_client, + configuration=configuration, + authorization_type=AuthorizationType.OAUTH) + + def adjust_inventory(self, inventory_adjustment_request: InventoryAdjustmentRequest, + idempotency_key: str = None): + """Apply a signed adjustment to a variant's on-hand stock. + + Args: + inventory_adjustment_request: The adjustment to apply. + idempotency_key: Optional idempotency key (`Cko-Idempotency-Key`). + Returns: + ResponseWrapper with the resulting InventoryLevels data. 201 on first write, 200 on an + idempotent replay (with a `Cache-Control` response header present only on replay). + Raises: + CheckoutApiException on 404/409/422. The API returns an `InventoryErrorResponse` body + (`request_id`, `error_type`, `error_codes`, and on `insufficient_stock` conflicts also + `variant_id` and `available`); this SDK surfaces it the same way as every other + domain, through `CheckoutApiException.request_id` / `.error_type` / `.error_details` + (the latter populated from the body's `error_codes`), not a dedicated typed class. + """ + return self._api_client.post(self.build_path(self.__INVENTORY_PATH, self.__ADJUSTMENTS_PATH), + self._sdk_authorization(), + inventory_adjustment_request, + idempotency_key) + + def create_inventory_reservation(self, inventory_reservation_request: InventoryReservationRequest, + idempotency_key: str = None): + """Create an atomic, multi-variant stock reservation (hold). + + Args: + inventory_reservation_request: The reservation to create. + idempotency_key: Optional idempotency key (`Cko-Idempotency-Key`). + Returns: + ResponseWrapper with the resulting InventoryReservation data. 201 on first write, 200 on + an idempotent replay (with a `Cache-Control` response header present only on replay). + Raises: + CheckoutApiException on 404/409/422; see `adjust_inventory` for the error body shape + and how this SDK surfaces it. + """ + return self._api_client.post(self.build_path(self.__INVENTORY_PATH, self.__RESERVATIONS_PATH), + self._sdk_authorization(), + inventory_reservation_request, + idempotency_key) + + def get_inventory_reservation(self, reservation_id: str): + """Retrieve an inventory reservation by id. + + Args: + reservation_id: The reservation identifier. + Returns: + ResponseWrapper with InventoryReservation data. + Raises: + CheckoutApiException on 404; see `adjust_inventory` for the error body shape and how + this SDK surfaces it. + """ + return self._api_client.get( + self.build_path(self.__INVENTORY_PATH, self.__RESERVATIONS_PATH, reservation_id), + self._sdk_authorization()) + + def commit_inventory_reservation(self, reservation_id: str): + """Commit a held inventory reservation, consuming the reserved stock. + + Args: + reservation_id: The reservation identifier. + Returns: + ResponseWrapper with InventoryReservation data. + Raises: + CheckoutApiException on 404/409; see `adjust_inventory` for the error body shape and + how this SDK surfaces it. + """ + return self._api_client.post( + self.build_path(self.__INVENTORY_PATH, self.__RESERVATIONS_PATH, reservation_id, self.__COMMIT_PATH), + self._sdk_authorization()) + + def release_inventory_reservation(self, reservation_id: str): + """Release a held inventory reservation, returning the reserved stock. + + Args: + reservation_id: The reservation identifier. + Returns: + ResponseWrapper with InventoryReservation data. + Raises: + CheckoutApiException on 404/409; see `adjust_inventory` for the error body shape and + how this SDK surfaces it. + """ + return self._api_client.post( + self.build_path(self.__INVENTORY_PATH, self.__RESERVATIONS_PATH, reservation_id, self.__RELEASE_PATH), + self._sdk_authorization()) + + def get_inventory_levels(self, variant_id: str, inventory_levels_query: InventoryLevelsQuery = None): + """Retrieve the current stock levels for a variant. + + Args: + variant_id: The merchant-provided identifier for the variant. + inventory_levels_query: Optional query parameters, e.g. `expand=product`. + Returns: + ResponseWrapper with InventoryLevels data. + Raises: + CheckoutApiException on 404; see `adjust_inventory` for the error body shape and how + this SDK surfaces it. + """ + return self._api_client.get(self.build_path(self.__INVENTORY_PATH, variant_id), + self._sdk_authorization(), + inventory_levels_query) + + def set_inventory_levels(self, variant_id: str, inventory_set_levels_request: InventorySetLevelsRequest): + """Create or update the stock levels for a variant. + + Args: + variant_id: The merchant-provided identifier for the variant. + inventory_set_levels_request: The stock levels to set. + Returns: + ResponseWrapper with InventoryLevels data. + Raises: + CheckoutApiException on 404/422; see `adjust_inventory` for the error body shape and + how this SDK surfaces it. + """ + return self._api_client.put(self.build_path(self.__INVENTORY_PATH, variant_id), + self._sdk_authorization(), + inventory_set_levels_request) + + def get_inventory_product(self, variant_id: str): + """Retrieve product knowledge (merchandising metadata) for a variant. + + Beta: this endpoint is marked Beta in the specification. + + Args: + variant_id: The merchant-provided identifier for the variant. + Returns: + ResponseWrapper with InventoryProductKnowledge data. + Raises: + CheckoutApiException on 404; see `adjust_inventory` for the error body shape and how + this SDK surfaces it. + """ + return self._api_client.get( + self.build_path(self.__INVENTORY_PATH, variant_id, self.__PRODUCT_PATH), + self._sdk_authorization()) + + def set_inventory_product(self, variant_id: str, inventory_set_product_request: InventorySetProductRequest): + """Create or update product knowledge (merchandising metadata) for a variant. + + Beta: this endpoint is marked Beta in the specification. + + Args: + variant_id: The merchant-provided identifier for the variant. + inventory_set_product_request: The product knowledge to set. + Returns: + ResponseWrapper with InventoryProductKnowledge data. + Raises: + CheckoutApiException on 404/422; see `adjust_inventory` for the error body shape and + how this SDK surfaces it. + """ + return self._api_client.put( + self.build_path(self.__INVENTORY_PATH, variant_id, self.__PRODUCT_PATH), + self._sdk_authorization(), + inventory_set_product_request) + + def delete_inventory_product(self, variant_id: str): + """Delete product knowledge (merchandising metadata) for a variant. + + Beta: this endpoint is marked Beta in the specification. + + Args: + variant_id: The merchant-provided identifier for the variant. + Returns: + ResponseWrapper with no body (204). + Raises: + CheckoutApiException on 404; see `adjust_inventory` for the error body shape and how + this SDK surfaces it. + """ + return self._api_client.delete( + self.build_path(self.__INVENTORY_PATH, variant_id, self.__PRODUCT_PATH), + self._sdk_authorization()) diff --git a/tests/inventory/__init__.py b/tests/inventory/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/inventory/inventory_client_test.py b/tests/inventory/inventory_client_test.py new file mode 100644 index 00000000..44423092 --- /dev/null +++ b/tests/inventory/inventory_client_test.py @@ -0,0 +1,121 @@ +import pytest + +from tests._assertions import assert_api_call +from checkout_sdk.inventory.inventory import InventoryAdjustmentRequest, InventoryReservationRequest, \ + InventoryReservationItem, InventorySetLevelsRequest, InventorySetProductRequest, InventoryLevelsQuery, \ + InventoryMoney, InventoryCondition +from checkout_sdk.inventory.inventory_client import InventoryClient + + +@pytest.fixture(scope='class') +def client(mock_sdk_configuration, mock_api_client): + return InventoryClient(api_client=mock_api_client, configuration=mock_sdk_configuration) + + +class TestInventoryClient: + + def test_adjust_inventory(self, mocker, client: InventoryClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.post', return_value='response') + body = InventoryAdjustmentRequest() + body.variant_id = 'var_123' + body.delta = -5 + body.reason = 'damaged in warehouse' + + assert client.adjust_inventory(body) == 'response' + assert_api_call(mock, 'inventory/adjustments', body) + + def test_adjust_inventory_idempotency_key(self, mocker, client: InventoryClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.post', return_value='response') + body = InventoryAdjustmentRequest() + + assert client.adjust_inventory(body, 'idempotency_key') == 'response' + args = mock.call_args.args + assert args[3] == 'idempotency_key' + + def test_create_inventory_reservation(self, mocker, client: InventoryClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.post', return_value='response') + item = InventoryReservationItem() + item.variant_id = 'var_123' + item.quantity = 2 + body = InventoryReservationRequest() + body.owner_type = 'order' + body.owner_reference = 'order_456' + body.items = [item] + + assert client.create_inventory_reservation(body) == 'response' + assert_api_call(mock, 'inventory/reservations', body) + + def test_create_inventory_reservation_idempotency_key(self, mocker, client: InventoryClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.post', return_value='response') + body = InventoryReservationRequest() + + assert client.create_inventory_reservation(body, 'idempotency_key') == 'response' + args = mock.call_args.args + assert args[3] == 'idempotency_key' + + def test_get_inventory_reservation(self, mocker, client: InventoryClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.get', return_value='response') + + assert client.get_inventory_reservation('rsv_123') == 'response' + assert_api_call(mock, 'inventory/reservations/rsv_123') + + def test_commit_inventory_reservation(self, mocker, client: InventoryClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.post', return_value='response') + + assert client.commit_inventory_reservation('rsv_123') == 'response' + assert_api_call(mock, 'inventory/reservations/rsv_123/commit') + + def test_release_inventory_reservation(self, mocker, client: InventoryClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.post', return_value='response') + + assert client.release_inventory_reservation('rsv_123') == 'response' + assert_api_call(mock, 'inventory/reservations/rsv_123/release') + + def test_get_inventory_levels(self, mocker, client: InventoryClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.get', return_value='response') + + assert client.get_inventory_levels('var_123') == 'response' + assert_api_call(mock, 'inventory/var_123') + + def test_get_inventory_levels_with_expand(self, mocker, client: InventoryClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.get', return_value='response') + query = InventoryLevelsQuery() + query.expand = 'product' + + assert client.get_inventory_levels('var_123', query) == 'response' + assert_api_call(mock, 'inventory/var_123', query) + + def test_set_inventory_levels(self, mocker, client: InventoryClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.put', return_value='response') + body = InventorySetLevelsRequest() + body.on_hand = 100 + + assert client.set_inventory_levels('var_123', body) == 'response' + assert_api_call(mock, 'inventory/var_123', body) + + def test_get_inventory_product(self, mocker, client: InventoryClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.get', return_value='response') + + assert client.get_inventory_product('var_123') == 'response' + assert_api_call(mock, 'inventory/var_123/product') + + def test_set_inventory_product(self, mocker, client: InventoryClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.put', return_value='response') + body = InventorySetProductRequest() + body.title = 'Blue T-Shirt' + body.description = 'A blue t-shirt' + body.product_url = 'https://example.com/products/var_123' + body.image_url = 'https://example.com/images/var_123.png' + body.condition = InventoryCondition.NEW + body.price = InventoryMoney() + body.price.amount = 1999 + body.price.currency = 'USD' + + assert client.set_inventory_product('var_123', body) == 'response' + assert_api_call(mock, 'inventory/var_123/product', body) + + def test_delete_inventory_product(self, mocker, client: InventoryClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.delete', return_value='response') + + assert client.delete_inventory_product('var_123') == 'response' + assert_api_call(mock, 'inventory/var_123/product') diff --git a/tests/inventory/inventory_serialization_test.py b/tests/inventory/inventory_serialization_test.py new file mode 100644 index 00000000..3670cc94 --- /dev/null +++ b/tests/inventory/inventory_serialization_test.py @@ -0,0 +1,292 @@ +import json + +from checkout_sdk.inventory.inventory import ( + InventoryAdjustmentRequest, InventoryCondition, InventoryHalLink, InventoryLevels, + InventoryLevelsLinks, InventoryLevelsQuery, InventoryMoney, InventoryProductKnowledge, + InventoryProductKnowledgeLinks, InventoryReservation, InventoryReservationItem, InventoryReservationLinks, + InventoryReservationRequest, InventorySetLevelsRequest, InventorySetProductRequest, InventorySource, + InventoryState, InventoryReservationState, +) +from checkout_sdk.json_serializer import JsonSerializer + + +def _serialize(obj): + return json.loads(json.dumps(obj, cls=JsonSerializer)) + + +class TestInventoryAdjustmentRequestSerialization: + """Schema validation against InventoryAdjustmentRequest (swagger example).""" + + def test_serializes_the_swagger_example(self): + request = InventoryAdjustmentRequest() + request.variant_id = 'var_123' + request.delta = -3 + request.reason = 'damaged in warehouse' + + assert _serialize(request) == { + 'variant_id': 'var_123', + 'delta': -3, + 'reason': 'damaged in warehouse', + } + + +class TestInventorySetLevelsRequestSerialization: + """Schema validation against InventorySetLevelsRequest, all properties.""" + + def test_serializes_every_property(self): + request = InventorySetLevelsRequest() + request.on_hand = 25 + request.safety_stock = 2 + request.reason = 'stock take 2026-07' + + assert _serialize(request) == { + 'on_hand': 25, + 'safety_stock': 2, + 'reason': 'stock take 2026-07', + } + + +class TestInventoryReservationRequestSerialization: + """Schema validation against InventoryReservationRequest and InventoryReservationItem.""" + + def test_serializes_every_property(self): + item = InventoryReservationItem() + item.variant_id = 'var_123' + item.quantity = 2 + + request = InventoryReservationRequest() + request.owner_type = 'ucp_session' + request.owner_reference = 'cs_8f42' + request.items = [item] + request.ttl_seconds = 900 + + assert _serialize(request) == { + 'owner_type': 'ucp_session', + 'owner_reference': 'cs_8f42', + 'items': [{'variant_id': 'var_123', 'quantity': 2}], + 'ttl_seconds': 900, + } + + +class TestInventoryHalLinkSerialization: + + def test_serializes_every_property(self): + link = InventoryHalLink() + link.href = 'https://api.checkout.com/inventory/var_123' + link.actions = ['GET'] + link.types = ['application/json'] + + assert _serialize(link) == { + 'href': 'https://api.checkout.com/inventory/var_123', + 'actions': ['GET'], + 'types': ['application/json'], + } + + +class TestInventoryMoneySerialization: + + def test_serializes_every_property(self): + money = InventoryMoney() + money.amount = 1999 + money.currency = 'USD' + + assert _serialize(money) == {'amount': 1999, 'currency': 'USD'} + + +class TestInventoryReservationSerialization: + """Response-shape roundtrip against the swagger example for InventoryReservation.""" + + def test_deserializes_every_property_from_the_swagger_example(self): + payload = { + 'id': 'rsv_tkoi5db4hryu5cei5vwoabr7we', + 'state': 'held', + 'owner_type': 'ucp_session', + 'owner_reference': 'cs_8f42', + 'items': [{'variant_id': 'var_123', 'quantity': 2}], + 'expires_at': '2026-07-14T08:47:00Z', + 'created_on': '2026-07-14T08:32:00Z', + '_links': { + 'self': {'href': 'https://api.checkout.com/inventory/reservations/rsv_123', 'actions': ['GET']}, + 'commit': { + 'href': 'https://api.checkout.com/inventory/reservations/rsv_123/commit', + 'actions': ['POST'], + }, + 'release': { + 'href': 'https://api.checkout.com/inventory/reservations/rsv_123/release', + 'actions': ['POST'], + }, + }, + } + + reservation = InventoryReservation() + reservation.id = payload['id'] + reservation.state = InventoryReservationState(payload['state']) + reservation.owner_type = payload['owner_type'] + reservation.owner_reference = payload['owner_reference'] + reservation.items = [InventoryReservationItem()] + reservation.items[0].variant_id = payload['items'][0]['variant_id'] + reservation.items[0].quantity = payload['items'][0]['quantity'] + reservation.expires_at = payload['expires_at'] + reservation.created_on = payload['created_on'] + links = InventoryReservationLinks() + links.self = InventoryHalLink() + links.self.href = payload['_links']['self']['href'] + links.self.actions = payload['_links']['self']['actions'] + links.commit = InventoryHalLink() + links.commit.href = payload['_links']['commit']['href'] + links.commit.actions = payload['_links']['commit']['actions'] + links.release = InventoryHalLink() + links.release.href = payload['_links']['release']['href'] + links.release.actions = payload['_links']['release']['actions'] + reservation._links = links + + assert _serialize(reservation) == payload + + def test_state_enum_carries_all_four_values(self): + assert [e.value for e in InventoryReservationState] == ['held', 'committed', 'released', 'expired'] + + +class TestInventoryLevelsSerialization: + """Response-shape roundtrip against the swagger example for InventoryLevels, including the + embedded InventoryProductKnowledge when ?expand=product is used. + """ + + def test_serializes_every_property_without_expand(self): + levels = InventoryLevels() + levels.variant_id = 'var_123' + levels.on_hand = 10 + levels.reserved = 2 + levels.safety_stock = 1 + levels.available = 7 + levels.state = InventoryState.IN_STOCK + levels.source = InventorySource.MANAGED + levels.created_on = '2026-07-01T09:15:00Z' + levels.modified_on = '2026-07-13T14:02:11Z' + links = InventoryLevelsLinks() + links.self = InventoryHalLink() + links.self.href = 'https://api.checkout.com/inventory/var_123' + links.set = InventoryHalLink() + links.set.href = 'https://api.checkout.com/inventory/var_123' + levels._links = links + + assert _serialize(levels) == { + 'variant_id': 'var_123', + 'on_hand': 10, + 'reserved': 2, + 'safety_stock': 1, + 'available': 7, + 'state': 'in_stock', + 'source': 'managed', + 'created_on': '2026-07-01T09:15:00Z', + 'modified_on': '2026-07-13T14:02:11Z', + '_links': { + 'self': {'href': 'https://api.checkout.com/inventory/var_123'}, + 'set': {'href': 'https://api.checkout.com/inventory/var_123'}, + }, + } + + def test_embeds_product_knowledge_when_expanded(self): + levels = InventoryLevels() + levels.variant_id = 'var_123' + product = InventoryProductKnowledge() + product.variant_id = 'var_123' + product.title = 'Blue T-Shirt' + product.description = 'A blue t-shirt' + product.product_url = 'https://example.com/p/var_123' + product.image_url = 'https://example.com/i/var_123.png' + product.condition = InventoryCondition.NEW + product.created_on = '2026-07-01T09:15:00Z' + product.modified_on = '2026-07-13T14:02:11Z' + product._links = InventoryProductKnowledgeLinks() + levels.product = product + + serialized = _serialize(levels) + assert serialized['product']['title'] == 'Blue T-Shirt' + assert serialized['product']['condition'] == 'new' + + +class TestInventoryLevelsQuerySerialization: + + def test_serializes_the_expand_query_parameter(self): + query = InventoryLevelsQuery() + query.expand = 'product' + + assert _serialize(query) == {'expand': 'product'} + + +class TestInventorySetProductRequestSerialization: + """Schema validation against InventorySetProductRequest, including nested InventoryMoney.""" + + def test_serializes_every_property(self): + price = InventoryMoney() + price.amount = 1999 + price.currency = 'USD' + sale_price = InventoryMoney() + sale_price.amount = 1499 + sale_price.currency = 'USD' + + request = InventorySetProductRequest() + request.title = 'Blue T-Shirt' + request.description = 'A blue t-shirt' + request.product_url = 'https://example.com/p/var_123' + request.image_url = 'https://example.com/i/var_123.png' + request.additional_image_urls = ['https://example.com/i/var_123_2.png'] + request.video_url = 'https://example.com/v/var_123.mp4' + request.model_3d_url = 'https://example.com/m/var_123.glb' + request.sku = 'sku_123' + request.gtin = '01234567890128' + request.mpn = 'mpn_123' + request.brand = 'Acme' + request.category = 'Apparel' + request.price = price + request.sale_price = sale_price + request.sale_price_starts_at = '2026-07-01T00:00:00Z' + request.sale_price_ends_at = '2026-07-31T23:59:59Z' + request.group_id = 'grp_123' + request.group_title = 'Blue T-Shirt' + request.color = 'blue' + request.size = 'M' + request.size_system = 'US' + request.gender = 'unisex' + request.condition = InventoryCondition.NEW + request.material = 'cotton' + request.age_group = 'adult' + request.length = 10.0 + request.width = 5.0 + request.height = 1.0 + request.dimension_unit = 'cm' + request.weight = 0.2 + request.weight_unit = 'kg' + request.expiration_date = '2027-07-01T00:00:00Z' + request.harmonized_system_code = '610910' + request.country_of_origin = 'US' + request.seller_name = 'Acme Inc' + request.seller_url = 'https://example.com' + request.seller_privacy_policy = 'https://example.com/privacy' + request.seller_tos = 'https://example.com/tos' + + serialized = _serialize(request) + assert serialized['title'] == 'Blue T-Shirt' + assert serialized['price'] == {'amount': 1999, 'currency': 'USD'} + assert serialized['sale_price'] == {'amount': 1499, 'currency': 'USD'} + assert serialized['condition'] == 'new' + assert serialized['country_of_origin'] == 'US' + assert set(serialized.keys()) == set(InventorySetProductRequest.__annotations__.keys()) + + def test_condition_enum_defaults_to_new_and_carries_three_values(self): + assert [e.value for e in InventoryCondition] == ['new', 'used', 'refurbished'] + + +class TestInventoryDateTimeFields: + """INT-1699 concerned format:date fields typed as datetime by mistake; the Inventory fields + below are all format:date-time and are correctly annotated `datetime`, not `str`. + """ + + def test_datetime_fields_are_annotated_as_datetime(self): + from datetime import datetime + assert InventoryLevels.__annotations__['created_on'] is datetime + assert InventoryLevels.__annotations__['modified_on'] is datetime + assert InventoryReservation.__annotations__['expires_at'] is datetime + assert InventoryReservation.__annotations__['created_on'] is datetime + assert InventoryProductKnowledge.__annotations__['created_on'] is datetime + assert InventoryProductKnowledge.__annotations__['modified_on'] is datetime From a3c8786cdfad5d596b40409b341c1932e9e5504e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armando=20Rodr=C3=ADguez?= <127134616+armando-rodriguez-cko@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:08:27 +0200 Subject: [PATCH 2/2] refactor(inventory): dedupe shared product-knowledge fields Extract the ~30 fields identical between InventoryProductKnowledge and InventorySetProductRequest into an InventoryMerchandisingFields base class. Fixes the SonarCloud duplication gate (32.4% on new code) without changing the serialized shape of either class. --- checkout_sdk/inventory/inventory.py | 162 ++++-------------- .../inventory/inventory_serialization_test.py | 5 +- 2 files changed, 41 insertions(+), 126 deletions(-) diff --git a/checkout_sdk/inventory/inventory.py b/checkout_sdk/inventory/inventory.py index c37be69f..d5aefbc8 100644 --- a/checkout_sdk/inventory/inventory.py +++ b/checkout_sdk/inventory/inventory.py @@ -186,27 +186,13 @@ class InventoryProductKnowledgeLinks: delete: InventoryHalLink -class InventoryProductKnowledge: - """Response body for getInventoryProduct and setInventoryProduct. Also embeddable as - `InventoryLevels.product` when `?expand=product` is passed. - - Beta: this schema and the endpoints that return it are marked Beta in the specification. +class InventoryMerchandisingFields: + """Merchandising fields shared, with identical shape and constraints, between + `InventoryProductKnowledge` (response) and `InventorySetProductRequest` (request). Split out + to avoid duplicating ~30 identical field declarations; fields that differ in constraints or + required-ness between the two (`title`, `description`, `product_url`, `image_url`, `sku`, + `condition`) stay on the subclasses. """ - # Identifier of the variant this product knowledge describes. - # [Required] - variant_id: str - # Product title. - # [Required] - title: str - # Product description. - # [Required] - description: str - # Canonical URL of the product page. - # [Required] - product_url: str - # URL of the primary product image. - # [Required] - image_url: str # Additional image URLs. # [Optional] additional_image_urls: list # values of str @@ -216,9 +202,6 @@ class InventoryProductKnowledge: # URL of a 3D model of the product. # [Optional] model_3d_url: str - # Merchant SKU. - # [Optional] - sku: str # Global Trade Item Number. # [Optional] gtin: str @@ -262,10 +245,6 @@ class InventoryProductKnowledge: # Target gender. # [Optional] gender: str - # Condition of the item. Defaults to `new`. - # [Required] - # enum: new, used, refurbished - condition: InventoryCondition # Material composition. # [Optional] material: str @@ -311,6 +290,36 @@ class InventoryProductKnowledge: # URL of the seller's terms of service. # [Optional] seller_tos: str + + +class InventoryProductKnowledge(InventoryMerchandisingFields): + """Response body for getInventoryProduct and setInventoryProduct. Also embeddable as + `InventoryLevels.product` when `?expand=product` is passed. + + Beta: this schema and the endpoints that return it are marked Beta in the specification. + """ + # Identifier of the variant this product knowledge describes. + # [Required] + variant_id: str + # Product title. + # [Required] + title: str + # Product description. + # [Required] + description: str + # Canonical URL of the product page. + # [Required] + product_url: str + # URL of the primary product image. + # [Required] + image_url: str + # Merchant SKU. + # [Optional] + sku: str + # Condition of the item. Defaults to `new`. + # [Required] + # enum: new, used, refurbished + condition: InventoryCondition # When the product knowledge was created. # [Required] created_on: datetime @@ -322,7 +331,7 @@ class InventoryProductKnowledge: _links: InventoryProductKnowledgeLinks -class InventorySetProductRequest: +class InventorySetProductRequest(InventoryMerchandisingFields): """Request body for PUT /inventory/{variant_id}/product. Beta: this schema and the endpoint it targets are marked Beta in the specification. @@ -343,111 +352,14 @@ class InventorySetProductRequest: # [Required] # max 2048 characters image_url: str - # Additional image URLs. - # [Optional] - additional_image_urls: list # values of str - # URL of a product video. - # [Optional] - video_url: str - # URL of a 3D model of the product. - # [Optional] - model_3d_url: str # Merchant SKU. # [Optional] # max 128 characters sku: str - # Global Trade Item Number. - # [Optional] - gtin: str - # Manufacturer Part Number. - # [Optional] - mpn: str - # Brand name. - # [Optional] - brand: str - # Product category. - # [Optional] - category: str - # Regular price. - # [Optional] - price: InventoryMoney - # Discounted price. Must share `price`'s currency and be <= `price`. - # [Optional] - sale_price: InventoryMoney - # Start of the sale price window. Pairs with `sale_price`. - # [Optional] - sale_price_starts_at: datetime - # End of the sale price window. Pairs with `sale_price`. - # [Optional] - sale_price_ends_at: datetime - # Identifier grouping variants of the same product. When set, `color` and `size` are both - # expected. - # [Optional] - group_id: str - # Title shared across all variants in `group_id`. - # [Optional] - group_title: str - # Color of this variant. Expected when `group_id` is set. - # [Optional] - color: str - # Size of this variant. Expected when `group_id` is set. - # [Optional] - size: str - # Sizing system used by `size`. - # [Optional] - size_system: str - # Target gender. - # [Optional] - gender: str # Condition of the item. Exact lowercase match. Defaults to `new`. # [Optional] # enum: new, used, refurbished condition: InventoryCondition - # Material composition. - # [Optional] - material: str - # Target age group. - # [Optional] - age_group: str - # Length of the item. - # [Optional] - length: float - # Width of the item. - # [Optional] - width: float - # Height of the item. - # [Optional] - height: float - # Unit used by `length`, `width` and `height`. - # [Optional] - dimension_unit: str - # Weight of the item. - # [Optional] - weight: float - # Unit used by `weight`. - # [Optional] - weight_unit: str - # Expiration date of the item, if applicable. - # [Optional] - expiration_date: datetime - # Harmonized System code, for customs. - # [Optional] - harmonized_system_code: str - # 2-letter ISO 3166-1 alpha-2 country of origin. - # [Optional] - country_of_origin: str - # Name of the seller. - # [Optional] - seller_name: str - # URL of the seller. - # [Optional] - seller_url: str - # URL of the seller's privacy policy. - # [Optional] - seller_privacy_policy: str - # URL of the seller's terms of service. - # [Optional] - seller_tos: str class InventoryLevels: diff --git a/tests/inventory/inventory_serialization_test.py b/tests/inventory/inventory_serialization_test.py index 3670cc94..1961df51 100644 --- a/tests/inventory/inventory_serialization_test.py +++ b/tests/inventory/inventory_serialization_test.py @@ -1,4 +1,5 @@ import json +import typing from checkout_sdk.inventory.inventory import ( InventoryAdjustmentRequest, InventoryCondition, InventoryHalLink, InventoryLevels, @@ -271,7 +272,9 @@ def test_serializes_every_property(self): assert serialized['sale_price'] == {'amount': 1499, 'currency': 'USD'} assert serialized['condition'] == 'new' assert serialized['country_of_origin'] == 'US' - assert set(serialized.keys()) == set(InventorySetProductRequest.__annotations__.keys()) + # get_type_hints merges the InventoryMerchandisingFields base in with the subclass's + # own annotations; __annotations__ alone would only see the subclass's direct fields. + assert set(serialized.keys()) == set(typing.get_type_hints(InventorySetProductRequest).keys()) def test_condition_enum_defaults_to_new_and_carries_three_values(self): assert [e.value for e in InventoryCondition] == ['new', 'used', 'refurbished']