diff --git a/src/main/java/com/checkout/CheckoutApi.java b/src/main/java/com/checkout/CheckoutApi.java index 9f0a6271..6818065f 100644 --- a/src/main/java/com/checkout/CheckoutApi.java +++ b/src/main/java/com/checkout/CheckoutApi.java @@ -20,6 +20,7 @@ import com.checkout.identities.addressdocumentverification.AddressDocumentVerificationClient; import com.checkout.identities.amlscreening.AmlScreeningClient; import com.checkout.instruments.InstrumentsClient; +import com.checkout.inventory.InventoryClient; import com.checkout.issuing.IssuingClient; import com.checkout.metadata.MetadataClient; import com.checkout.networktokens.NetworkTokensClient; @@ -59,6 +60,8 @@ public interface CheckoutApi extends CheckoutApmApi { ForexClient forexClient(); + InventoryClient inventoryClient(); + PaymentLinksClient paymentLinksClient(); PaymentMethodsClient paymentMethodsClient(); diff --git a/src/main/java/com/checkout/CheckoutApiImpl.java b/src/main/java/com/checkout/CheckoutApiImpl.java index 2062e21d..8865cfe2 100644 --- a/src/main/java/com/checkout/CheckoutApiImpl.java +++ b/src/main/java/com/checkout/CheckoutApiImpl.java @@ -40,6 +40,8 @@ import com.checkout.identities.amlscreening.AmlScreeningClientImpl; import com.checkout.instruments.InstrumentsClient; import com.checkout.instruments.InstrumentsClientImpl; +import com.checkout.inventory.InventoryClient; +import com.checkout.inventory.InventoryClientImpl; import com.checkout.issuing.IssuingClient; import com.checkout.issuing.IssuingClientImpl; import com.checkout.metadata.MetadataClient; @@ -87,6 +89,7 @@ public class CheckoutApiImpl extends AbstractCheckoutApmApi implements CheckoutA private final AccountsClient accountsClient; private final SessionsClient sessionsClient; private final ForexClient forexClient; + private final InventoryClient inventoryClient; private final PaymentLinksClient paymentLinksClient; private final PaymentMethodsClient paymentMethodsClient; private final HostedPaymentsClient hostedPaymentsClient; @@ -125,6 +128,7 @@ public CheckoutApiImpl(final CheckoutConfiguration configuration) { this.workflowsClient = new WorkflowsClientImpl(this.apiClient, configuration); this.sessionsClient = new SessionsClientImpl(this.apiClient, configuration); this.forexClient = new ForexClientImpl(this.apiClient, configuration); + this.inventoryClient = new InventoryClientImpl(this.apiClient, configuration); this.paymentLinksClient = new PaymentLinksClientImpl(this.apiClient, configuration); this.paymentMethodsClient = new PaymentMethodsClientImpl(this.apiClient, configuration); this.hostedPaymentsClient = new HostedPaymentsClientImpl(this.apiClient, configuration); @@ -207,6 +211,11 @@ public ForexClient forexClient() { return forexClient; } + @Override + public InventoryClient inventoryClient() { + return inventoryClient; + } + @Override public PaymentLinksClient paymentLinksClient() { return paymentLinksClient; diff --git a/src/main/java/com/checkout/inventory/InventoryClient.java b/src/main/java/com/checkout/inventory/InventoryClient.java new file mode 100644 index 00000000..7c397231 --- /dev/null +++ b/src/main/java/com/checkout/inventory/InventoryClient.java @@ -0,0 +1,169 @@ +package com.checkout.inventory; + +import com.checkout.EmptyResponse; +import com.checkout.inventory.request.InventoryAdjustmentRequest; +import com.checkout.inventory.request.InventoryLevelsQueryFilter; +import com.checkout.inventory.request.InventoryReservationRequest; +import com.checkout.inventory.request.InventorySetLevelsRequest; +import com.checkout.inventory.request.InventorySetProductRequest; +import com.checkout.inventory.response.InventoryLevels; +import com.checkout.inventory.response.InventoryProductKnowledge; +import com.checkout.inventory.response.InventoryReservation; + +import java.util.concurrent.CompletableFuture; + +/** + * The Inventory client: stock levels, atomic multi-variant reservations, stock adjustments, and + * per-variant product knowledge for AI agents. Every operation on this client requires the + * {@code agentic:inventory} OAuth scope; it does not accept a secret or public API key. + * + *

Error responses ({@code 404}, {@code 409}, {@code 422}) are surfaced the same way as every + * other domain in this SDK: as a {@link com.checkout.CheckoutApiException} carrying the raw + * {@code Map} error body, not a typed class (this SDK has no domain that + * deserializes error bodies into a dedicated type). For the inventory endpoints, that map + * contains: {@code request_id} (String, for support and correlation), {@code error_type} + * (String, a high-level classification), {@code error_codes} (array of String, in + * {@code [subject]_[error]} form), and, present only on an {@code insufficient_stock} conflict, + * {@code variant_id} (String, the first failing variant) and {@code available} (Integer, its + * current sellable availability).

+ */ +public interface InventoryClient { + + /** + * Applies a relative adjustment to a variant's on-hand stock. + * + * @param request the adjustment to apply + * @return the variant's resulting stock levels + */ + CompletableFuture adjustInventory(InventoryAdjustmentRequest request); + + /** + * Applies a relative adjustment to a variant's on-hand stock. + * + * @param request the adjustment to apply + * @param idempotencyKey an optional idempotency key sent as {@code Cko-Idempotency-Key} + * @return the variant's resulting stock levels + */ + CompletableFuture adjustInventory(InventoryAdjustmentRequest request, String idempotencyKey); + + /** + * Retrieves the current stock levels for a variant. + * + * @param variantId the identifier of the variant + * @return the variant's current stock levels + */ + CompletableFuture getInventoryLevels(String variantId); + + /** + * Retrieves the current stock levels for a variant. + * + * @param variantId the identifier of the variant + * @param filter optional query parameters, for example {@code expand=product} + * @return the variant's current stock levels + */ + CompletableFuture getInventoryLevels(String variantId, InventoryLevelsQueryFilter filter); + + /** + * Sets absolute stock levels for a variant. Creates the inventory item if it does not exist. + * + * @param variantId the identifier of the variant + * @param request the absolute stock levels to set + * @return the variant's resulting stock levels + */ + CompletableFuture setInventoryLevels(String variantId, InventorySetLevelsRequest request); + + /** + * Creates an atomic multi-variant stock hold. + * + * @param request the reservation to create + * @return the created reservation + */ + CompletableFuture createInventoryReservation(InventoryReservationRequest request); + + /** + * Creates an atomic multi-variant stock hold. + * + * @param request the reservation to create + * @param idempotencyKey an optional idempotency key sent as {@code Cko-Idempotency-Key} + * @return the created reservation + */ + CompletableFuture createInventoryReservation(InventoryReservationRequest request, String idempotencyKey); + + /** + * Retrieves a reservation. + * + * @param reservationId the identifier of the reservation + * @return the reservation + */ + CompletableFuture getInventoryReservation(String reservationId); + + /** + * Commits a held reservation, converting the hold into a permanent stock deduction. + * + * @param reservationId the identifier of the reservation + * @return the committed reservation + */ + CompletableFuture commitInventoryReservation(String reservationId); + + /** + * Releases a held reservation, returning the held quantities to available stock. + * + * @param reservationId the identifier of the reservation + * @return the released reservation + */ + CompletableFuture releaseInventoryReservation(String reservationId); + + /** + * Beta. Retrieves the product knowledge for a variant. + * + * @param variantId the identifier of the variant + * @return the variant's product knowledge + */ + CompletableFuture getInventoryProduct(String variantId); + + /** + * Beta. Sets (upserts) the product knowledge for a variant. + * + * @param variantId the identifier of the variant + * @param request the product knowledge to set + * @return the resulting product knowledge + */ + CompletableFuture setInventoryProduct(String variantId, InventorySetProductRequest request); + + /** + * Beta. Deletes the product knowledge for a variant. + * + * @param variantId the identifier of the variant + * @return an empty response + */ + CompletableFuture deleteInventoryProduct(String variantId); + + // Synchronous methods + + InventoryLevels adjustInventorySync(InventoryAdjustmentRequest request); + + InventoryLevels adjustInventorySync(InventoryAdjustmentRequest request, String idempotencyKey); + + InventoryLevels getInventoryLevelsSync(String variantId); + + InventoryLevels getInventoryLevelsSync(String variantId, InventoryLevelsQueryFilter filter); + + InventoryLevels setInventoryLevelsSync(String variantId, InventorySetLevelsRequest request); + + InventoryReservation createInventoryReservationSync(InventoryReservationRequest request); + + InventoryReservation createInventoryReservationSync(InventoryReservationRequest request, String idempotencyKey); + + InventoryReservation getInventoryReservationSync(String reservationId); + + InventoryReservation commitInventoryReservationSync(String reservationId); + + InventoryReservation releaseInventoryReservationSync(String reservationId); + + InventoryProductKnowledge getInventoryProductSync(String variantId); + + InventoryProductKnowledge setInventoryProductSync(String variantId, InventorySetProductRequest request); + + EmptyResponse deleteInventoryProductSync(String variantId); + +} diff --git a/src/main/java/com/checkout/inventory/InventoryClientImpl.java b/src/main/java/com/checkout/inventory/InventoryClientImpl.java new file mode 100644 index 00000000..77712a41 --- /dev/null +++ b/src/main/java/com/checkout/inventory/InventoryClientImpl.java @@ -0,0 +1,231 @@ +package com.checkout.inventory; + +import com.checkout.AbstractClient; +import com.checkout.ApiClient; +import com.checkout.CheckoutConfiguration; +import com.checkout.EmptyResponse; +import com.checkout.SdkAuthorizationType; +import com.checkout.common.CheckoutUtils; +import com.checkout.inventory.request.InventoryAdjustmentRequest; +import com.checkout.inventory.request.InventoryLevelsQueryFilter; +import com.checkout.inventory.request.InventoryReservationRequest; +import com.checkout.inventory.request.InventorySetLevelsRequest; +import com.checkout.inventory.request.InventorySetProductRequest; +import com.checkout.inventory.response.InventoryLevels; +import com.checkout.inventory.response.InventoryProductKnowledge; +import com.checkout.inventory.response.InventoryReservation; + +import java.util.concurrent.CompletableFuture; + +/** + * Every operation on this client requires the {@code agentic:inventory} OAuth scope; see + * {@link SdkAuthorizationType#OAUTH}. There is no secret-key or public-key fallback. + */ +public class InventoryClientImpl extends AbstractClient implements InventoryClient { + + private static final String INVENTORY_PATH = "inventory"; + private static final String ADJUSTMENTS_PATH = "adjustments"; + private static final String RESERVATIONS_PATH = "reservations"; + private static final String COMMIT_PATH = "commit"; + private static final String RELEASE_PATH = "release"; + private static final String PRODUCT_PATH = "product"; + + public InventoryClientImpl(final ApiClient apiClient, final CheckoutConfiguration configuration) { + super(apiClient, configuration, SdkAuthorizationType.OAUTH); + } + + @Override + public CompletableFuture adjustInventory(final InventoryAdjustmentRequest request) { + return adjustInventory(request, null); + } + + @Override + public CompletableFuture adjustInventory(final InventoryAdjustmentRequest request, final String idempotencyKey) { + CheckoutUtils.validateParams("request", request); + return apiClient.postAsync( + buildPath(INVENTORY_PATH, ADJUSTMENTS_PATH), + sdkAuthorization(), + InventoryLevels.class, + request, + idempotencyKey); + } + + @Override + public CompletableFuture getInventoryLevels(final String variantId) { + CheckoutUtils.validateParams("variantId", variantId); + return apiClient.getAsync(buildPath(INVENTORY_PATH, variantId), sdkAuthorization(), InventoryLevels.class); + } + + @Override + public CompletableFuture getInventoryLevels(final String variantId, final InventoryLevelsQueryFilter filter) { + CheckoutUtils.validateParams("variantId", variantId, "filter", filter); + return apiClient.queryAsync(buildPath(INVENTORY_PATH, variantId), sdkAuthorization(), filter, InventoryLevels.class); + } + + @Override + public CompletableFuture setInventoryLevels(final String variantId, final InventorySetLevelsRequest request) { + CheckoutUtils.validateParams("variantId", variantId, "request", request); + return apiClient.putAsync(buildPath(INVENTORY_PATH, variantId), sdkAuthorization(), InventoryLevels.class, request); + } + + @Override + public CompletableFuture createInventoryReservation(final InventoryReservationRequest request) { + return createInventoryReservation(request, null); + } + + @Override + public CompletableFuture createInventoryReservation(final InventoryReservationRequest request, final String idempotencyKey) { + CheckoutUtils.validateParams("request", request); + return apiClient.postAsync( + buildPath(INVENTORY_PATH, RESERVATIONS_PATH), + sdkAuthorization(), + InventoryReservation.class, + request, + idempotencyKey); + } + + @Override + public CompletableFuture getInventoryReservation(final String reservationId) { + CheckoutUtils.validateParams("reservationId", reservationId); + return apiClient.getAsync(buildPath(INVENTORY_PATH, RESERVATIONS_PATH, reservationId), sdkAuthorization(), InventoryReservation.class); + } + + @Override + public CompletableFuture commitInventoryReservation(final String reservationId) { + CheckoutUtils.validateParams("reservationId", reservationId); + return apiClient.postAsync( + buildPath(INVENTORY_PATH, RESERVATIONS_PATH, reservationId, COMMIT_PATH), + sdkAuthorization(), + InventoryReservation.class, + null, + null); + } + + @Override + public CompletableFuture releaseInventoryReservation(final String reservationId) { + CheckoutUtils.validateParams("reservationId", reservationId); + return apiClient.postAsync( + buildPath(INVENTORY_PATH, RESERVATIONS_PATH, reservationId, RELEASE_PATH), + sdkAuthorization(), + InventoryReservation.class, + null, + null); + } + + @Override + public CompletableFuture getInventoryProduct(final String variantId) { + CheckoutUtils.validateParams("variantId", variantId); + return apiClient.getAsync(buildPath(INVENTORY_PATH, variantId, PRODUCT_PATH), sdkAuthorization(), InventoryProductKnowledge.class); + } + + @Override + public CompletableFuture setInventoryProduct(final String variantId, final InventorySetProductRequest request) { + CheckoutUtils.validateParams("variantId", variantId, "request", request); + return apiClient.putAsync(buildPath(INVENTORY_PATH, variantId, PRODUCT_PATH), sdkAuthorization(), InventoryProductKnowledge.class, request); + } + + @Override + public CompletableFuture deleteInventoryProduct(final String variantId) { + CheckoutUtils.validateParams("variantId", variantId); + return apiClient.deleteAsync(buildPath(INVENTORY_PATH, variantId, PRODUCT_PATH), sdkAuthorization()); + } + + // Synchronous methods + + @Override + public InventoryLevels adjustInventorySync(final InventoryAdjustmentRequest request) { + return adjustInventorySync(request, null); + } + + @Override + public InventoryLevels adjustInventorySync(final InventoryAdjustmentRequest request, final String idempotencyKey) { + CheckoutUtils.validateParams("request", request); + return apiClient.post( + buildPath(INVENTORY_PATH, ADJUSTMENTS_PATH), + sdkAuthorization(), + InventoryLevels.class, + request, + idempotencyKey); + } + + @Override + public InventoryLevels getInventoryLevelsSync(final String variantId) { + CheckoutUtils.validateParams("variantId", variantId); + return apiClient.get(buildPath(INVENTORY_PATH, variantId), sdkAuthorization(), InventoryLevels.class); + } + + @Override + public InventoryLevels getInventoryLevelsSync(final String variantId, final InventoryLevelsQueryFilter filter) { + CheckoutUtils.validateParams("variantId", variantId, "filter", filter); + return apiClient.query(buildPath(INVENTORY_PATH, variantId), sdkAuthorization(), filter, InventoryLevels.class); + } + + @Override + public InventoryLevels setInventoryLevelsSync(final String variantId, final InventorySetLevelsRequest request) { + CheckoutUtils.validateParams("variantId", variantId, "request", request); + return apiClient.put(buildPath(INVENTORY_PATH, variantId), sdkAuthorization(), InventoryLevels.class, request); + } + + @Override + public InventoryReservation createInventoryReservationSync(final InventoryReservationRequest request) { + return createInventoryReservationSync(request, null); + } + + @Override + public InventoryReservation createInventoryReservationSync(final InventoryReservationRequest request, final String idempotencyKey) { + CheckoutUtils.validateParams("request", request); + return apiClient.post( + buildPath(INVENTORY_PATH, RESERVATIONS_PATH), + sdkAuthorization(), + InventoryReservation.class, + request, + idempotencyKey); + } + + @Override + public InventoryReservation getInventoryReservationSync(final String reservationId) { + CheckoutUtils.validateParams("reservationId", reservationId); + return apiClient.get(buildPath(INVENTORY_PATH, RESERVATIONS_PATH, reservationId), sdkAuthorization(), InventoryReservation.class); + } + + @Override + public InventoryReservation commitInventoryReservationSync(final String reservationId) { + CheckoutUtils.validateParams("reservationId", reservationId); + return apiClient.post( + buildPath(INVENTORY_PATH, RESERVATIONS_PATH, reservationId, COMMIT_PATH), + sdkAuthorization(), + InventoryReservation.class, + null, + null); + } + + @Override + public InventoryReservation releaseInventoryReservationSync(final String reservationId) { + CheckoutUtils.validateParams("reservationId", reservationId); + return apiClient.post( + buildPath(INVENTORY_PATH, RESERVATIONS_PATH, reservationId, RELEASE_PATH), + sdkAuthorization(), + InventoryReservation.class, + null, + null); + } + + @Override + public InventoryProductKnowledge getInventoryProductSync(final String variantId) { + CheckoutUtils.validateParams("variantId", variantId); + return apiClient.get(buildPath(INVENTORY_PATH, variantId, PRODUCT_PATH), sdkAuthorization(), InventoryProductKnowledge.class); + } + + @Override + public InventoryProductKnowledge setInventoryProductSync(final String variantId, final InventorySetProductRequest request) { + CheckoutUtils.validateParams("variantId", variantId, "request", request); + return apiClient.put(buildPath(INVENTORY_PATH, variantId, PRODUCT_PATH), sdkAuthorization(), InventoryProductKnowledge.class, request); + } + + @Override + public EmptyResponse deleteInventoryProductSync(final String variantId) { + CheckoutUtils.validateParams("variantId", variantId); + return apiClient.delete(buildPath(INVENTORY_PATH, variantId, PRODUCT_PATH), sdkAuthorization()); + } + +} diff --git a/src/main/java/com/checkout/inventory/InventoryHalLink.java b/src/main/java/com/checkout/inventory/InventoryHalLink.java new file mode 100644 index 00000000..0a8a5774 --- /dev/null +++ b/src/main/java/com/checkout/inventory/InventoryHalLink.java @@ -0,0 +1,31 @@ +package com.checkout.inventory; + +import lombok.Data; + +import java.util.List; + +/** + * A HAL link describing a related operation on an inventory resource. + */ +@Data +public final class InventoryHalLink { + + /** + * Absolute URI of the linked resource. + * [Optional] + */ + private String href; + + /** + * The HTTP methods supported on the linked resource. + * [Optional] + */ + private List actions; + + /** + * The media types supported on the linked resource. + * [Optional] + */ + private List types; + +} diff --git a/src/main/java/com/checkout/inventory/InventoryLevelsSource.java b/src/main/java/com/checkout/inventory/InventoryLevelsSource.java new file mode 100644 index 00000000..2df43500 --- /dev/null +++ b/src/main/java/com/checkout/inventory/InventoryLevelsSource.java @@ -0,0 +1,16 @@ +package com.checkout.inventory; + +import com.google.gson.annotations.SerializedName; + +/** + * How the levels are maintained. Always {@code managed} in the current version. + */ +public enum InventoryLevelsSource { + + @SerializedName("managed") + MANAGED, + + @SerializedName("sync") + SYNC, + +} diff --git a/src/main/java/com/checkout/inventory/InventoryLevelsState.java b/src/main/java/com/checkout/inventory/InventoryLevelsState.java new file mode 100644 index 00000000..26b6e1af --- /dev/null +++ b/src/main/java/com/checkout/inventory/InventoryLevelsState.java @@ -0,0 +1,19 @@ +package com.checkout.inventory; + +import com.google.gson.annotations.SerializedName; + +/** + * A derived availability state for the variant. + */ +public enum InventoryLevelsState { + + @SerializedName("in_stock") + IN_STOCK, + + @SerializedName("limited") + LIMITED, + + @SerializedName("out_of_stock") + OUT_OF_STOCK, + +} diff --git a/src/main/java/com/checkout/inventory/InventoryMoney.java b/src/main/java/com/checkout/inventory/InventoryMoney.java new file mode 100644 index 00000000..6580d760 --- /dev/null +++ b/src/main/java/com/checkout/inventory/InventoryMoney.java @@ -0,0 +1,30 @@ +package com.checkout.inventory; + +import lombok.Builder; +import lombok.Data; +import lombok.NonNull; + +/** + * A monetary amount in the currency's minor units. + */ +@Data +@Builder +public final class InventoryMoney { + + /** + * The amount in the minor currency unit. + * [Required] + */ + @NonNull + private Long amount; + + /** + * The three-letter ISO 4217 currency code. + * [Required] + * min 3 characters + * max 3 characters + */ + @NonNull + private String currency; + +} diff --git a/src/main/java/com/checkout/inventory/InventoryProductCondition.java b/src/main/java/com/checkout/inventory/InventoryProductCondition.java new file mode 100644 index 00000000..254cb98f --- /dev/null +++ b/src/main/java/com/checkout/inventory/InventoryProductCondition.java @@ -0,0 +1,20 @@ +package com.checkout.inventory; + +import com.google.gson.annotations.SerializedName; + +/** + * The product's condition. Always present on the response; defaults to {@code new} when not + * provided on the request. + */ +public enum InventoryProductCondition { + + @SerializedName("new") + NEW, + + @SerializedName("used") + USED, + + @SerializedName("refurbished") + REFURBISHED, + +} diff --git a/src/main/java/com/checkout/inventory/InventoryReservationItem.java b/src/main/java/com/checkout/inventory/InventoryReservationItem.java new file mode 100644 index 00000000..52b04227 --- /dev/null +++ b/src/main/java/com/checkout/inventory/InventoryReservationItem.java @@ -0,0 +1,30 @@ +package com.checkout.inventory; + +import lombok.Builder; +import lombok.Data; +import lombok.NonNull; + +/** + * A single variant and quantity within a reservation. + */ +@Data +@Builder +public final class InventoryReservationItem { + + /** + * The identifier of the variant to hold. The variant must already exist. + * [Required] + * max 128 characters + */ + @NonNull + private String variantId; + + /** + * The quantity to hold for this variant. + * [Required] + * min 1 + */ + @NonNull + private Integer quantity; + +} diff --git a/src/main/java/com/checkout/inventory/InventoryReservationState.java b/src/main/java/com/checkout/inventory/InventoryReservationState.java new file mode 100644 index 00000000..8a18b0a4 --- /dev/null +++ b/src/main/java/com/checkout/inventory/InventoryReservationState.java @@ -0,0 +1,23 @@ +package com.checkout.inventory; + +import com.google.gson.annotations.SerializedName; + +/** + * The current state of a reservation hold. A {@code held} reservation past its + * {@code expires_at} is reported as {@code expired}. Transitions out of {@code held} are terminal. + */ +public enum InventoryReservationState { + + @SerializedName("held") + HELD, + + @SerializedName("committed") + COMMITTED, + + @SerializedName("released") + RELEASED, + + @SerializedName("expired") + EXPIRED, + +} diff --git a/src/main/java/com/checkout/inventory/request/InventoryAdjustmentRequest.java b/src/main/java/com/checkout/inventory/request/InventoryAdjustmentRequest.java new file mode 100644 index 00000000..d391aa90 --- /dev/null +++ b/src/main/java/com/checkout/inventory/request/InventoryAdjustmentRequest.java @@ -0,0 +1,40 @@ +package com.checkout.inventory.request; + +import lombok.Builder; +import lombok.Data; +import lombok.NonNull; + +/** + * Beta. The request body for applying a relative adjustment to a variant's on-hand stock. + */ +@Data +@Builder +public final class InventoryAdjustmentRequest { + + /** + * The identifier of the variant to adjust. The variant must already exist. + * [Required] + * max 128 characters + */ + @NonNull + private String variantId; + + /** + * The signed change to apply to {@code on_hand}. Must be non-zero. A negative delta that + * would drive {@code on_hand} below zero is rejected with {@code 409 conflict}. + * [Required] + */ + @NonNull + private Integer delta; + + /** + * A required free-text reason recorded in the ledger (for example, damage or found stock). + * Must not contain personal data. + * [Required] + * min 1 character + * max 256 characters + */ + @NonNull + private String reason; + +} diff --git a/src/main/java/com/checkout/inventory/request/InventoryLevelsQueryFilter.java b/src/main/java/com/checkout/inventory/request/InventoryLevelsQueryFilter.java new file mode 100644 index 00000000..a1729c13 --- /dev/null +++ b/src/main/java/com/checkout/inventory/request/InventoryLevelsQueryFilter.java @@ -0,0 +1,20 @@ +package com.checkout.inventory.request; + +import lombok.Builder; +import lombok.Data; + +/** + * Optional query parameters for retrieving a variant's stock levels. + */ +@Data +@Builder +public final class InventoryLevelsQueryFilter { + + /** + * When set to {@code product}, embeds the variant's product knowledge in the response + * {@code product} field, if it exists. + * [Optional] + */ + private String expand; + +} diff --git a/src/main/java/com/checkout/inventory/request/InventoryReservationRequest.java b/src/main/java/com/checkout/inventory/request/InventoryReservationRequest.java new file mode 100644 index 00000000..00d0490b --- /dev/null +++ b/src/main/java/com/checkout/inventory/request/InventoryReservationRequest.java @@ -0,0 +1,53 @@ +package com.checkout.inventory.request; + +import com.checkout.inventory.InventoryReservationItem; +import lombok.Builder; +import lombok.Data; +import lombok.NonNull; + +import java.util.List; + +/** + * The request body for creating an atomic multi-variant hold. All items are reserved together + * or none are. The hold is protocol-neutral: it is bound to an {@code owner_type} / + * {@code owner_reference} supplied by the calling protocol adapter (for example, a UCP session + * or an ACP checkout). + */ +@Data +@Builder +public final class InventoryReservationRequest { + + /** + * The kind of caller that owns the hold. + * [Required] + * max 64 characters + */ + @NonNull + private String ownerType; + + /** + * An opaque reference to the owning session or checkout. + * [Required] + * max 256 characters + */ + @NonNull + private String ownerReference; + + /** + * The variants and quantities to hold. {@code variant_id}s must be unique within the request. + * [Required] + * min 1 item + * max 45 items + */ + @NonNull + private List items; + + /** + * How long the hold remains valid before it auto-expires. Defaults to {@code 900}. + * [Optional] + * min 60 + * max 3600 + */ + private Integer ttlSeconds; + +} diff --git a/src/main/java/com/checkout/inventory/request/InventorySetLevelsRequest.java b/src/main/java/com/checkout/inventory/request/InventorySetLevelsRequest.java new file mode 100644 index 00000000..9c17d4ae --- /dev/null +++ b/src/main/java/com/checkout/inventory/request/InventorySetLevelsRequest.java @@ -0,0 +1,37 @@ +package com.checkout.inventory.request; + +import lombok.Builder; +import lombok.Data; +import lombok.NonNull; + +/** + * The request body for setting absolute stock levels on a variant. + */ +@Data +@Builder +public final class InventorySetLevelsRequest { + + /** + * The absolute physical stock to set for the variant. + * [Required] + * min 0 + */ + @NonNull + private Integer onHand; + + /** + * The buffer quantity to withhold from sale. Defaults to {@code 0} when the item is + * created and is left unchanged on update if omitted. + * [Optional] + * min 0 + */ + private Integer safetyStock; + + /** + * An optional free-text reason recorded in the ledger. Must not contain personal data. + * [Optional] + * max 256 characters + */ + private String reason; + +} diff --git a/src/main/java/com/checkout/inventory/request/InventorySetProductRequest.java b/src/main/java/com/checkout/inventory/request/InventorySetProductRequest.java new file mode 100644 index 00000000..6605b4b2 --- /dev/null +++ b/src/main/java/com/checkout/inventory/request/InventorySetProductRequest.java @@ -0,0 +1,271 @@ +package com.checkout.inventory.request; + +import com.checkout.inventory.InventoryMoney; +import com.checkout.inventory.InventoryProductCondition; +import com.google.gson.annotations.SerializedName; +import lombok.Builder; +import lombok.Data; +import lombok.NonNull; + +import java.time.Instant; +import java.util.List; + +/** + * Beta. The request body for setting the product knowledge for a variant. The call is an upsert. + */ +@Data +@Builder +public final class InventorySetProductRequest { + + /** + * The product's display title. + * [Required] + * max 512 characters + */ + @NonNull + private String title; + + /** + * The product's display description. + * [Required] + * max 4000 characters + */ + @NonNull + private String description; + + /** + * The canonical URL for the product page. + * [Required] + * max 2048 characters + */ + @NonNull + private String productUrl; + + /** + * The URL of the primary product image. + * [Required] + * max 2048 characters + */ + @NonNull + private String imageUrl; + + /** + * Additional product image URLs, beyond {@code image_url}. + * [Optional] + */ + private List additionalImageUrls; + + /** + * The URL of a product video. + * [Optional] + */ + private String videoUrl; + + /** + * The URL of a 3D model of the product. + * [Optional] + */ + @SerializedName("model_3d_url") + private String model3dUrl; + + /** + * The merchant's stock-keeping unit for the product. + * [Optional] + * max 128 characters + */ + private String sku; + + /** + * The product's Global Trade Item Number (UPC, EAN, ISBN, or JAN). + * [Optional] + */ + private String gtin; + + /** + * The product's Manufacturer Part Number. + * [Optional] + */ + private String mpn; + + /** + * The product's brand name. + * [Optional] + */ + private String brand; + + /** + * The merchant's category for the product. + * [Optional] + */ + private String category; + + /** + * The product's regular price. When provided together with {@code sale_price}, {@code sale_price} + * must use the same currency and be less than or equal to {@code price}. + * [Optional] + */ + private InventoryMoney price; + + /** + * The product's discounted price. Must share {@code price}'s currency and be less than or + * equal to {@code price}. + * [Optional] + */ + private InventoryMoney salePrice; + + /** + * The date and time from which {@code sale_price} applies. Paired with {@code sale_price}. + * [Optional] + * Format: date-time (RFC 3339) + */ + private Instant salePriceStartsAt; + + /** + * The date and time after which {@code sale_price} no longer applies. Paired with + * {@code sale_price}. + * [Optional] + * Format: date-time (RFC 3339) + */ + private Instant salePriceEndsAt; + + /** + * The identifier shared by all variants of the same product (for example, the same belt in + * different sizes). When set, {@code color} and {@code size} are both required. + * [Optional] + */ + private String groupId; + + /** + * A display title for the variant group. + * [Optional] + */ + private String groupTitle; + + /** + * The variant's color. Required when {@code group_id} is set. + * [Optional] + */ + private String color; + + /** + * The variant's size. Required when {@code group_id} is set. + * [Optional] + */ + private String size; + + /** + * The sizing system that {@code size} is expressed in. + * [Optional] + */ + private String sizeSystem; + + /** + * The target gender for the product. + * [Optional] + */ + private String gender; + + /** + * The product's condition, as an exact lowercase match of one of the enum values. + * Defaults to {@code new} when omitted. + * [Optional] + * Enum: "new" "used" "refurbished" + */ + private InventoryProductCondition condition; + + /** + * The product's primary material. + * [Optional] + */ + private String material; + + /** + * The target age group for the product. + * [Optional] + */ + private String ageGroup; + + /** + * The product's length. {@code length}, {@code width}, {@code height} and + * {@code dimension_unit} must be provided together, or not at all. + * [Optional] + */ + private Double length; + + /** + * The product's width. {@code length}, {@code width}, {@code height} and + * {@code dimension_unit} must be provided together, or not at all. + * [Optional] + */ + private Double width; + + /** + * The product's height. {@code length}, {@code width}, {@code height} and + * {@code dimension_unit} must be provided together, or not at all. + * [Optional] + */ + private Double height; + + /** + * The unit that {@code length}, {@code width} and {@code height} are expressed in. + * Required when any of {@code length}, {@code width} or {@code height} is set. + * [Optional] + */ + private String dimensionUnit; + + /** + * The product's weight. Must be provided together with {@code weight_unit}, or not at all. + * [Optional] + */ + private Double weight; + + /** + * The unit that {@code weight} is expressed in. Required when {@code weight} is set. + * [Optional] + */ + private String weightUnit; + + /** + * The date and time after which the product should no longer be offered. + * [Optional] + * Format: date-time (RFC 3339) + */ + private Instant expirationDate; + + /** + * The product's Harmonized System (HS) code, for customs purposes. + * [Optional] + */ + private String harmonizedSystemCode; + + /** + * The two-letter ISO 3166-1 alpha-2 country of origin. + * [Optional] + */ + private String countryOfOrigin; + + /** + * The name of the seller of record, when different from the merchant. + * [Optional] + */ + private String sellerName; + + /** + * The URL of the seller of record. + * [Optional] + */ + private String sellerUrl; + + /** + * The URL of the seller's privacy policy. + * [Optional] + */ + private String sellerPrivacyPolicy; + + /** + * The URL of the seller's terms of service. + * [Optional] + */ + private String sellerTos; + +} diff --git a/src/main/java/com/checkout/inventory/response/InventoryLevels.java b/src/main/java/com/checkout/inventory/response/InventoryLevels.java new file mode 100644 index 00000000..2c728669 --- /dev/null +++ b/src/main/java/com/checkout/inventory/response/InventoryLevels.java @@ -0,0 +1,94 @@ +package com.checkout.inventory.response; + +import com.checkout.HttpMetadata; +import com.checkout.inventory.InventoryLevelsSource; +import com.checkout.inventory.InventoryLevelsState; +import com.google.gson.annotations.SerializedName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.Instant; + +/** + * The current stock levels and sellable availability for a single variant. Returned by + * getInventoryLevels, setInventoryLevels and adjustInventory (both the {@code 200} idempotent + * replay and the {@code 201} created responses share this schema). + */ +@Data +@EqualsAndHashCode(callSuper = true) +public final class InventoryLevels extends HttpMetadata { + + /** + * The merchant-provided identifier for the variant. + * [Optional] + */ + private String variantId; + + /** + * Physical stock known to Checkout.com. + * [Optional] + */ + private Integer onHand; + + /** + * The sum of all currently active holds against this variant. + * [Optional] + */ + private Integer reserved; + + /** + * A buffer quantity withheld from sale. + * [Optional] + */ + private Integer safetyStock; + + /** + * The sellable quantity, clamped to zero: {@code max(0, on_hand - reserved - safety_stock)}. + * This is the value an availability feed should publish. + * [Optional] + */ + private Integer available; + + /** + * A derived availability state for the variant. + * [Optional] + * Enum: "in_stock" "limited" "out_of_stock" + */ + private InventoryLevelsState state; + + /** + * How the levels are maintained. Always {@code managed} in the current version. + * [Optional] + * Enum: "managed" "sync" + */ + private InventoryLevelsSource source; + + /** + * The date and time the inventory item was created. + * [Optional] + * Format: date-time (RFC 3339) + */ + private Instant createdOn; + + /** + * The date and time the inventory item was last modified. + * [Optional] + * Format: date-time (RFC 3339) + */ + private Instant modifiedOn; + + /** + * The variant's product knowledge, embedded when {@code expand=product} is passed on the + * request and product knowledge exists for the variant. Omitted otherwise. + * [Optional] + */ + private InventoryProductKnowledge product; + + /** + * Links to related operations on the variant. + * [Optional] + */ + @SerializedName("_links") + private InventoryLevelsLinks links; + +} diff --git a/src/main/java/com/checkout/inventory/response/InventoryLevelsLinks.java b/src/main/java/com/checkout/inventory/response/InventoryLevelsLinks.java new file mode 100644 index 00000000..b6e4eafd --- /dev/null +++ b/src/main/java/com/checkout/inventory/response/InventoryLevelsLinks.java @@ -0,0 +1,24 @@ +package com.checkout.inventory.response; + +import com.checkout.inventory.InventoryHalLink; +import lombok.Data; + +/** + * Links to related operations on the variant. + */ +@Data +public final class InventoryLevelsLinks { + + /** + * The link to retrieve the current stock levels for the variant. + * [Optional] + */ + private InventoryHalLink self; + + /** + * The link to set absolute stock levels for the variant. + * [Optional] + */ + private InventoryHalLink set; + +} diff --git a/src/main/java/com/checkout/inventory/response/InventoryProductKnowledge.java b/src/main/java/com/checkout/inventory/response/InventoryProductKnowledge.java new file mode 100644 index 00000000..93234972 --- /dev/null +++ b/src/main/java/com/checkout/inventory/response/InventoryProductKnowledge.java @@ -0,0 +1,287 @@ +package com.checkout.inventory.response; + +import com.checkout.HttpMetadata; +import com.checkout.inventory.InventoryMoney; +import com.checkout.inventory.InventoryProductCondition; +import com.google.gson.annotations.SerializedName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.Instant; +import java.util.List; + +/** + * Beta. The product knowledge for a single variant: the merchandising fields an AI agent needs + * to present, compare and recommend a product, independent of stock and pricing operations. + * Optional fields are omitted from the response entirely when not set. + */ +@Data +@EqualsAndHashCode(callSuper = true) +public final class InventoryProductKnowledge extends HttpMetadata { + + /** + * The merchant-provided identifier for the variant. + * [Required] + */ + private String variantId; + + /** + * The product's display title. + * [Required] + */ + private String title; + + /** + * The product's display description. + * [Required] + */ + private String description; + + /** + * The canonical URL for the product page. + * [Required] + */ + private String productUrl; + + /** + * The URL of the primary product image. + * [Required] + */ + private String imageUrl; + + /** + * Additional product image URLs, beyond {@code image_url}. + * [Optional] + */ + private List additionalImageUrls; + + /** + * The URL of a product video. + * [Optional] + */ + private String videoUrl; + + /** + * The URL of a 3D model of the product. + * [Optional] + */ + @SerializedName("model_3d_url") + private String model3dUrl; + + /** + * The merchant's stock-keeping unit for the product. + * [Optional] + */ + private String sku; + + /** + * The product's Global Trade Item Number (UPC, EAN, ISBN, or JAN). + * [Optional] + */ + private String gtin; + + /** + * The product's Manufacturer Part Number. + * [Optional] + */ + private String mpn; + + /** + * The product's brand name. + * [Optional] + */ + private String brand; + + /** + * The merchant's category for the product. + * [Optional] + */ + private String category; + + /** + * The product's regular price. + * [Optional] + */ + private InventoryMoney price; + + /** + * The product's discounted price. + * [Optional] + */ + private InventoryMoney salePrice; + + /** + * The date and time from which {@code sale_price} applies. Paired with {@code sale_price}. + * [Optional] + * Format: date-time (RFC 3339) + */ + private Instant salePriceStartsAt; + + /** + * The date and time after which {@code sale_price} no longer applies. Paired with + * {@code sale_price}. + * [Optional] + * Format: date-time (RFC 3339) + */ + private Instant salePriceEndsAt; + + /** + * The identifier shared by all variants of the same product (for example, the same belt in + * different sizes). When set, {@code color} and {@code size} are both required. + * [Optional] + */ + private String groupId; + + /** + * A display title for the variant group. + * [Optional] + */ + private String groupTitle; + + /** + * The variant's color. Required when {@code group_id} is set. + * [Optional] + */ + private String color; + + /** + * The variant's size. Required when {@code group_id} is set. + * [Optional] + */ + private String size; + + /** + * The sizing system that {@code size} is expressed in. + * [Optional] + */ + private String sizeSystem; + + /** + * The target gender for the product. + * [Optional] + */ + private String gender; + + /** + * The product's condition. Always present; defaults to {@code new} when not provided. + * [Required] + * Enum: "new" "used" "refurbished" + */ + private InventoryProductCondition condition; + + /** + * The product's primary material. + * [Optional] + */ + private String material; + + /** + * The target age group for the product. + * [Optional] + */ + private String ageGroup; + + /** + * The product's length. Provided together with {@code width}, {@code height} and + * {@code dimension_unit}, or not at all. + * [Optional] + */ + private Double length; + + /** + * The product's width. Provided together with {@code length}, {@code height} and + * {@code dimension_unit}, or not at all. + * [Optional] + */ + private Double width; + + /** + * The product's height. Provided together with {@code length}, {@code width} and + * {@code dimension_unit}, or not at all. + * [Optional] + */ + private Double height; + + /** + * The unit that {@code length}, {@code width} and {@code height} are expressed in. + * [Optional] + */ + private String dimensionUnit; + + /** + * The product's weight. Provided together with {@code weight_unit}, or not at all. + * [Optional] + */ + private Double weight; + + /** + * The unit that {@code weight} is expressed in. + * [Optional] + */ + private String weightUnit; + + /** + * The date and time after which the product should no longer be offered. + * [Optional] + * Format: date-time (RFC 3339) + */ + private Instant expirationDate; + + /** + * The product's Harmonized System (HS) code, for customs purposes. + * [Optional] + */ + private String harmonizedSystemCode; + + /** + * The two-letter ISO 3166-1 alpha-2 country of origin. + * [Optional] + */ + private String countryOfOrigin; + + /** + * The name of the seller of record, when different from the merchant. + * [Optional] + */ + private String sellerName; + + /** + * The URL of the seller of record. + * [Optional] + */ + private String sellerUrl; + + /** + * The URL of the seller's privacy policy. + * [Optional] + */ + private String sellerPrivacyPolicy; + + /** + * The URL of the seller's terms of service. + * [Optional] + */ + private String sellerTos; + + /** + * The date and time the product knowledge was created. + * [Required] + * Format: date-time (RFC 3339) + */ + private Instant createdOn; + + /** + * The date and time the product knowledge was last modified. + * [Required] + * Format: date-time (RFC 3339) + */ + private Instant modifiedOn; + + /** + * Links to related operations on the variant's product knowledge. + * [Required] + */ + @SerializedName("_links") + private InventoryProductLinks links; + +} diff --git a/src/main/java/com/checkout/inventory/response/InventoryProductLinks.java b/src/main/java/com/checkout/inventory/response/InventoryProductLinks.java new file mode 100644 index 00000000..60e49b8e --- /dev/null +++ b/src/main/java/com/checkout/inventory/response/InventoryProductLinks.java @@ -0,0 +1,30 @@ +package com.checkout.inventory.response; + +import com.checkout.inventory.InventoryHalLink; +import lombok.Data; + +/** + * Links to related operations on the variant's product knowledge. + */ +@Data +public final class InventoryProductLinks { + + /** + * The link to retrieve the product knowledge for the variant. + * [Optional] + */ + private InventoryHalLink self; + + /** + * The link to set the product knowledge for the variant. + * [Optional] + */ + private InventoryHalLink set; + + /** + * The link to delete the product knowledge for the variant. + * [Optional] + */ + private InventoryHalLink delete; + +} diff --git a/src/main/java/com/checkout/inventory/response/InventoryReservation.java b/src/main/java/com/checkout/inventory/response/InventoryReservation.java new file mode 100644 index 00000000..e678099f --- /dev/null +++ b/src/main/java/com/checkout/inventory/response/InventoryReservation.java @@ -0,0 +1,77 @@ +package com.checkout.inventory.response; + +import com.checkout.HttpMetadata; +import com.checkout.inventory.InventoryReservationItem; +import com.checkout.inventory.InventoryReservationState; +import com.google.gson.annotations.SerializedName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.Instant; +import java.util.List; + +/** + * A stock hold and its current lifecycle state. Returned by createInventoryReservation (both + * the {@code 200} idempotent replay and the {@code 201} created responses share this schema), + * getInventoryReservation, commitInventoryReservation and releaseInventoryReservation. + */ +@Data +@EqualsAndHashCode(callSuper = true) +public final class InventoryReservation extends HttpMetadata { + + /** + * The reservation identifier, in the {@code rsv_{base32-encoded GUID}} format. + * [Optional] + */ + private String id; + + /** + * The current state of the hold. A {@code held} reservation past its {@code expires_at} is + * reported as {@code expired}. Transitions out of {@code held} are terminal. + * [Optional] + * Enum: "held" "committed" "released" "expired" + */ + private InventoryReservationState state; + + /** + * Echo of the owning caller kind. + * [Optional] + */ + private String ownerType; + + /** + * Echo of the owning session or checkout reference. + * [Optional] + */ + private String ownerReference; + + /** + * The variants and quantities held by this reservation. + * [Optional] + */ + private List items; + + /** + * The server-authoritative time at which the hold expires. + * [Optional] + * Format: date-time (RFC 3339) + */ + private Instant expiresAt; + + /** + * The date and time the reservation was created. + * [Optional] + * Format: date-time (RFC 3339) + */ + private Instant createdOn; + + /** + * Links to the operations valid for the reservation's current state. A {@code held} + * reservation exposes {@code self}, {@code commit} and {@code release}; a terminal + * reservation exposes {@code self} only. + * [Optional] + */ + @SerializedName("_links") + private InventoryReservationLinks links; + +} diff --git a/src/main/java/com/checkout/inventory/response/InventoryReservationLinks.java b/src/main/java/com/checkout/inventory/response/InventoryReservationLinks.java new file mode 100644 index 00000000..b90ec69f --- /dev/null +++ b/src/main/java/com/checkout/inventory/response/InventoryReservationLinks.java @@ -0,0 +1,32 @@ +package com.checkout.inventory.response; + +import com.checkout.inventory.InventoryHalLink; +import lombok.Data; + +/** + * Links to the operations valid for the reservation's current state. A {@code held} + * reservation exposes {@code self}, {@code commit} and {@code release}; a terminal + * reservation exposes {@code self} only. + */ +@Data +public final class InventoryReservationLinks { + + /** + * The link to retrieve the reservation. + * [Optional] + */ + private InventoryHalLink self; + + /** + * The link to commit the reservation. Present only while the reservation is {@code held}. + * [Optional] + */ + private InventoryHalLink commit; + + /** + * The link to release the reservation. Present only while the reservation is {@code held}. + * [Optional] + */ + private InventoryHalLink release; + +} diff --git a/src/test/java/com/checkout/inventory/InventoryClientImplTest.java b/src/test/java/com/checkout/inventory/InventoryClientImplTest.java new file mode 100644 index 00000000..5c026694 --- /dev/null +++ b/src/test/java/com/checkout/inventory/InventoryClientImplTest.java @@ -0,0 +1,243 @@ +package com.checkout.inventory; + +import com.checkout.ApiClient; +import com.checkout.CheckoutConfiguration; +import com.checkout.EmptyResponse; +import com.checkout.SdkAuthorization; +import com.checkout.SdkAuthorizationType; +import com.checkout.SdkCredentials; +import com.checkout.inventory.request.InventoryAdjustmentRequest; +import com.checkout.inventory.request.InventoryReservationRequest; +import com.checkout.inventory.request.InventorySetLevelsRequest; +import com.checkout.inventory.request.InventorySetProductRequest; +import com.checkout.inventory.response.InventoryLevels; +import com.checkout.inventory.response.InventoryProductKnowledge; +import com.checkout.inventory.response.InventoryReservation; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Verifies InventoryClientImpl uses SdkAuthorizationType.OAUTH exclusively (the + * {@code agentic:inventory} scope) with no ApiSecretKey/ApiPublicKey fallback, and that each of + * the 10 inventory operations builds the expected path and forwards to ApiClient correctly. + */ +@ExtendWith(MockitoExtension.class) +class InventoryClientImplTest { + + private InventoryClient client; + + @Mock + private ApiClient apiClient; + + @Mock + private CheckoutConfiguration configuration; + + @Mock + private SdkCredentials sdkCredentials; + + @Mock + private SdkAuthorization authorization; + + @BeforeEach + void setUp() { + client = new InventoryClientImpl(apiClient, configuration); + } + + @Test + void shouldAdjustInventory() throws ExecutionException, InterruptedException { + setupMockCredentials(); + + final InventoryAdjustmentRequest request = InventoryAdjustmentRequest.builder() + .variantId("var_123") + .delta(-3) + .reason("damaged in warehouse") + .build(); + final InventoryLevels response = mock(InventoryLevels.class); + + when(apiClient.postAsync(eq("inventory/adjustments"), eq(authorization), eq(InventoryLevels.class), + eq(request), isNull())) + .thenReturn(CompletableFuture.completedFuture(response)); + + final CompletableFuture future = client.adjustInventory(request); + + assertEquals(response, future.get()); + } + + @Test + void shouldAdjustInventoryWithIdempotencyKey() throws ExecutionException, InterruptedException { + setupMockCredentials(); + + final InventoryAdjustmentRequest request = InventoryAdjustmentRequest.builder() + .variantId("var_123") + .delta(-3) + .reason("damaged in warehouse") + .build(); + final InventoryLevels response = mock(InventoryLevels.class); + + when(apiClient.postAsync(eq("inventory/adjustments"), eq(authorization), eq(InventoryLevels.class), + eq(request), eq("idem-key"))) + .thenReturn(CompletableFuture.completedFuture(response)); + + final CompletableFuture future = client.adjustInventory(request, "idem-key"); + + assertEquals(response, future.get()); + } + + @Test + void shouldGetInventoryLevels() throws ExecutionException, InterruptedException { + setupMockCredentials(); + + final InventoryLevels response = mock(InventoryLevels.class); + + when(apiClient.getAsync(eq("inventory/var_123"), eq(authorization), eq(InventoryLevels.class))) + .thenReturn(CompletableFuture.completedFuture(response)); + + final CompletableFuture future = client.getInventoryLevels("var_123"); + + assertEquals(response, future.get()); + } + + @Test + void shouldSetInventoryLevels() throws ExecutionException, InterruptedException { + setupMockCredentials(); + + final InventorySetLevelsRequest request = InventorySetLevelsRequest.builder().onHand(25).build(); + final InventoryLevels response = mock(InventoryLevels.class); + + when(apiClient.putAsync(eq("inventory/var_123"), eq(authorization), eq(InventoryLevels.class), eq(request))) + .thenReturn(CompletableFuture.completedFuture(response)); + + final CompletableFuture future = client.setInventoryLevels("var_123", request); + + assertEquals(response, future.get()); + } + + @Test + void shouldCreateInventoryReservation() throws ExecutionException, InterruptedException { + setupMockCredentials(); + + final InventoryReservationRequest request = InventoryReservationRequest.builder() + .ownerType("ucp_session") + .ownerReference("cs_8f42") + .items(java.util.List.of(InventoryReservationItem.builder().variantId("var_123").quantity(2).build())) + .build(); + final InventoryReservation response = mock(InventoryReservation.class); + + when(apiClient.postAsync(eq("inventory/reservations"), eq(authorization), eq(InventoryReservation.class), + eq(request), isNull())) + .thenReturn(CompletableFuture.completedFuture(response)); + + final CompletableFuture future = client.createInventoryReservation(request); + + assertEquals(response, future.get()); + } + + @Test + void shouldGetInventoryReservation() throws ExecutionException, InterruptedException { + setupMockCredentials(); + + final InventoryReservation response = mock(InventoryReservation.class); + + when(apiClient.getAsync(eq("inventory/reservations/rsv_123"), eq(authorization), eq(InventoryReservation.class))) + .thenReturn(CompletableFuture.completedFuture(response)); + + final CompletableFuture future = client.getInventoryReservation("rsv_123"); + + assertEquals(response, future.get()); + } + + @Test + void shouldCommitInventoryReservation() throws ExecutionException, InterruptedException { + setupMockCredentials(); + + final InventoryReservation response = mock(InventoryReservation.class); + + when(apiClient.postAsync(eq("inventory/reservations/rsv_123/commit"), eq(authorization), + eq(InventoryReservation.class), isNull(), isNull())) + .thenReturn(CompletableFuture.completedFuture(response)); + + final CompletableFuture future = client.commitInventoryReservation("rsv_123"); + + assertEquals(response, future.get()); + } + + @Test + void shouldReleaseInventoryReservation() throws ExecutionException, InterruptedException { + setupMockCredentials(); + + final InventoryReservation response = mock(InventoryReservation.class); + + when(apiClient.postAsync(eq("inventory/reservations/rsv_123/release"), eq(authorization), + eq(InventoryReservation.class), isNull(), isNull())) + .thenReturn(CompletableFuture.completedFuture(response)); + + final CompletableFuture future = client.releaseInventoryReservation("rsv_123"); + + assertEquals(response, future.get()); + } + + @Test + void shouldGetInventoryProduct() throws ExecutionException, InterruptedException { + setupMockCredentials(); + + final InventoryProductKnowledge response = mock(InventoryProductKnowledge.class); + + when(apiClient.getAsync(eq("inventory/var_123/product"), eq(authorization), eq(InventoryProductKnowledge.class))) + .thenReturn(CompletableFuture.completedFuture(response)); + + final CompletableFuture future = client.getInventoryProduct("var_123"); + + assertEquals(response, future.get()); + } + + @Test + void shouldSetInventoryProduct() throws ExecutionException, InterruptedException { + setupMockCredentials(); + + final InventorySetProductRequest request = InventorySetProductRequest.builder() + .title("Belt").description("desc").productUrl("https://example.com").imageUrl("https://example.com/i.jpg") + .build(); + final InventoryProductKnowledge response = mock(InventoryProductKnowledge.class); + + when(apiClient.putAsync(eq("inventory/var_123/product"), eq(authorization), eq(InventoryProductKnowledge.class), eq(request))) + .thenReturn(CompletableFuture.completedFuture(response)); + + final CompletableFuture future = client.setInventoryProduct("var_123", request); + + assertEquals(response, future.get()); + } + + @Test + void shouldDeleteInventoryProduct() throws ExecutionException, InterruptedException { + setupMockCredentials(); + + final EmptyResponse response = mock(EmptyResponse.class); + + when(apiClient.deleteAsync(eq("inventory/var_123/product"), eq(authorization))) + .thenReturn(CompletableFuture.completedFuture(response)); + + final CompletableFuture future = client.deleteInventoryProduct("var_123"); + + assertEquals(response, future.get()); + } + + // Common methods + + private void setupMockCredentials() { + when(sdkCredentials.getAuthorization(SdkAuthorizationType.OAUTH)).thenReturn(authorization); + when(configuration.getSdkCredentials()).thenReturn(sdkCredentials); + } + +} diff --git a/src/test/java/com/checkout/inventory/InventoryCommonSerializationTest.java b/src/test/java/com/checkout/inventory/InventoryCommonSerializationTest.java new file mode 100644 index 00000000..2010be15 --- /dev/null +++ b/src/test/java/com/checkout/inventory/InventoryCommonSerializationTest.java @@ -0,0 +1,145 @@ +package com.checkout.inventory; + +import com.checkout.GsonSerializer; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Schema validation tests for the shared Inventory types: InventoryHalLink, InventoryMoney and + * InventoryReservationItem. Fields verified against shared/swagger.json. + */ +class InventoryCommonSerializationTest { + + private final GsonSerializer serializer = new GsonSerializer(); + + // ------------------------------------------------------------------------ + // InventoryHalLink + // ------------------------------------------------------------------------ + + @Test + void shouldRoundTripInventoryHalLink() { + final InventoryHalLink original = new InventoryHalLink(); + original.setHref("https://api.checkout.com/inventory/var_123"); + original.setActions(List.of("GET")); + original.setTypes(List.of("application/json")); + + final String json = serializer.toJson(original); + final InventoryHalLink deserialized = serializer.fromJson(json, InventoryHalLink.class); + + assertEquals(original, deserialized); + assertEquals("https://api.checkout.com/inventory/var_123", deserialized.getHref()); + assertEquals(List.of("GET"), deserialized.getActions()); + assertEquals(List.of("application/json"), deserialized.getTypes()); + } + + @Test + void shouldDeserializeInventoryHalLinkSwaggerExample() { + final String swaggerJson = "{" + + "\"href\": \"https://api.checkout.com/inventory/var_123\"," + + "\"actions\": [\"GET\"]," + + "\"types\": [\"application/json\"]" + + "}"; + + final InventoryHalLink link = serializer.fromJson(swaggerJson, InventoryHalLink.class); + + assertNotNull(link); + assertEquals("https://api.checkout.com/inventory/var_123", link.getHref()); + } + + // ------------------------------------------------------------------------ + // InventoryMoney + // ------------------------------------------------------------------------ + + @Test + void shouldSerializeInventoryMoneyWithRequiredFields() { + final InventoryMoney money = InventoryMoney.builder() + .amount(1999L) + .currency("USD") + .build(); + + final String json = serializer.toJson(money); + + assertNotNull(json); + assertTrue(json.contains("\"amount\":1999")); + assertTrue(json.contains("\"currency\":\"USD\"")); + } + + @Test + void shouldRoundTripInventoryMoney() { + final InventoryMoney original = InventoryMoney.builder() + .amount(1999L) + .currency("USD") + .build(); + + final String json = serializer.toJson(original); + final InventoryMoney deserialized = serializer.fromJson(json, InventoryMoney.class); + + assertEquals(original, deserialized); + } + + // ------------------------------------------------------------------------ + // InventoryReservationItem + // ------------------------------------------------------------------------ + + @Test + void shouldSerializeInventoryReservationItemWithRequiredFields() { + final InventoryReservationItem item = InventoryReservationItem.builder() + .variantId("var_123") + .quantity(2) + .build(); + + final String json = serializer.toJson(item); + + assertNotNull(json); + assertTrue(json.contains("\"variant_id\":\"var_123\"")); + assertTrue(json.contains("\"quantity\":2")); + } + + @Test + void shouldRoundTripInventoryReservationItem() { + final InventoryReservationItem original = InventoryReservationItem.builder() + .variantId("var_123") + .quantity(2) + .build(); + + final String json = serializer.toJson(original); + final InventoryReservationItem deserialized = serializer.fromJson(json, InventoryReservationItem.class); + + assertEquals(original, deserialized); + } + + // ------------------------------------------------------------------------ + // Enums: InventoryLevelsState, InventoryLevelsSource, InventoryReservationState, + // InventoryProductCondition + // ------------------------------------------------------------------------ + + @Test + void shouldSerializeInventoryLevelsStateAsSnakeCase() { + assertEquals("\"limited\"", serializer.toJson(InventoryLevelsState.LIMITED)); + assertEquals("\"out_of_stock\"", serializer.toJson(InventoryLevelsState.OUT_OF_STOCK)); + } + + @Test + void shouldSerializeInventoryLevelsSourceAsSnakeCase() { + assertEquals("\"managed\"", serializer.toJson(InventoryLevelsSource.MANAGED)); + assertEquals("\"sync\"", serializer.toJson(InventoryLevelsSource.SYNC)); + } + + @Test + void shouldSerializeInventoryReservationStateAsSnakeCase() { + assertEquals("\"held\"", serializer.toJson(InventoryReservationState.HELD)); + assertEquals("\"expired\"", serializer.toJson(InventoryReservationState.EXPIRED)); + } + + @Test + void shouldSerializeInventoryProductConditionAsLowerCase() { + assertEquals("\"new\"", serializer.toJson(InventoryProductCondition.NEW)); + assertEquals("\"refurbished\"", serializer.toJson(InventoryProductCondition.REFURBISHED)); + } + +} diff --git a/src/test/java/com/checkout/inventory/request/InventoryRequestSerializationTest.java b/src/test/java/com/checkout/inventory/request/InventoryRequestSerializationTest.java new file mode 100644 index 00000000..0c2ea7b9 --- /dev/null +++ b/src/test/java/com/checkout/inventory/request/InventoryRequestSerializationTest.java @@ -0,0 +1,267 @@ +package com.checkout.inventory.request; + +import com.checkout.GsonSerializer; +import com.checkout.inventory.InventoryMoney; +import com.checkout.inventory.InventoryProductCondition; +import com.checkout.inventory.InventoryReservationItem; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Schema validation tests for the Inventory request classes: InventoryAdjustmentRequest, + * InventoryReservationRequest, InventorySetLevelsRequest, InventorySetProductRequest and + * InventoryLevelsQueryFilter. Fields verified against shared/swagger.json. + */ +class InventoryRequestSerializationTest { + + private final GsonSerializer serializer = new GsonSerializer(); + + // ------------------------------------------------------------------------ + // InventoryAdjustmentRequest + // ------------------------------------------------------------------------ + + @Test + void shouldSerializeInventoryAdjustmentRequest() { + final InventoryAdjustmentRequest request = InventoryAdjustmentRequest.builder() + .variantId("var_123") + .delta(-3) + .reason("damaged in warehouse") + .build(); + + final String json = serializer.toJson(request); + + assertNotNull(json); + assertTrue(json.contains("\"variant_id\":\"var_123\"")); + assertTrue(json.contains("\"delta\":-3")); + assertTrue(json.contains("\"reason\":\"damaged in warehouse\"")); + } + + @Test + void shouldRoundTripInventoryAdjustmentRequest() { + final InventoryAdjustmentRequest original = InventoryAdjustmentRequest.builder() + .variantId("var_123") + .delta(5) + .reason("found stock") + .build(); + + final String json = serializer.toJson(original); + final InventoryAdjustmentRequest deserialized = serializer.fromJson(json, InventoryAdjustmentRequest.class); + + assertEquals(original, deserialized); + } + + // ------------------------------------------------------------------------ + // InventoryReservationRequest + // ------------------------------------------------------------------------ + + @Test + void shouldSerializeInventoryReservationRequestWithRequiredFields() { + final InventoryReservationRequest request = InventoryReservationRequest.builder() + .ownerType("ucp_session") + .ownerReference("cs_8f42") + .items(List.of(InventoryReservationItem.builder().variantId("var_123").quantity(2).build())) + .build(); + + final String json = serializer.toJson(request); + + assertNotNull(json); + assertTrue(json.contains("\"owner_type\":\"ucp_session\"")); + assertTrue(json.contains("\"owner_reference\":\"cs_8f42\"")); + assertTrue(json.contains("\"items\"")); + assertFalse(json.contains("ttl_seconds")); + } + + @Test + void shouldSerializeInventoryReservationRequestWithAllFields() { + final InventoryReservationRequest request = InventoryReservationRequest.builder() + .ownerType("ucp_session") + .ownerReference("cs_8f42") + .items(List.of(InventoryReservationItem.builder().variantId("var_123").quantity(2).build())) + .ttlSeconds(900) + .build(); + + final String json = serializer.toJson(request); + + assertTrue(json.contains("\"ttl_seconds\":900")); + } + + @Test + void shouldRoundTripInventoryReservationRequest() { + final InventoryReservationRequest original = InventoryReservationRequest.builder() + .ownerType("ucp_session") + .ownerReference("cs_8f42") + .items(List.of(InventoryReservationItem.builder().variantId("var_123").quantity(2).build())) + .ttlSeconds(900) + .build(); + + final String json = serializer.toJson(original); + final InventoryReservationRequest deserialized = serializer.fromJson(json, InventoryReservationRequest.class); + + assertEquals(original, deserialized); + } + + // ------------------------------------------------------------------------ + // InventorySetLevelsRequest + // ------------------------------------------------------------------------ + + @Test + void shouldSerializeInventorySetLevelsRequestWithRequiredFields() { + final InventorySetLevelsRequest request = InventorySetLevelsRequest.builder() + .onHand(25) + .build(); + + final String json = serializer.toJson(request); + + assertTrue(json.contains("\"on_hand\":25")); + assertFalse(json.contains("safety_stock")); + assertFalse(json.contains("reason")); + } + + @Test + void shouldSerializeInventorySetLevelsRequestWithAllFields() { + final InventorySetLevelsRequest request = InventorySetLevelsRequest.builder() + .onHand(25) + .safetyStock(2) + .reason("stock take 2026-07") + .build(); + + final String json = serializer.toJson(request); + + assertTrue(json.contains("\"on_hand\":25")); + assertTrue(json.contains("\"safety_stock\":2")); + assertTrue(json.contains("\"reason\":\"stock take 2026-07\"")); + } + + @Test + void shouldRoundTripInventorySetLevelsRequest() { + final InventorySetLevelsRequest original = InventorySetLevelsRequest.builder() + .onHand(25) + .safetyStock(2) + .reason("stock take 2026-07") + .build(); + + final String json = serializer.toJson(original); + final InventorySetLevelsRequest deserialized = serializer.fromJson(json, InventorySetLevelsRequest.class); + + assertEquals(original, deserialized); + } + + // ------------------------------------------------------------------------ + // InventorySetProductRequest - covers all fields, including nested InventoryMoney and the + // model_3d_url snake_case override. + // ------------------------------------------------------------------------ + + @Test + void shouldSerializeInventorySetProductRequestWithRequiredFields() { + final InventorySetProductRequest request = InventorySetProductRequest.builder() + .title("Classic leather belt, brown") + .description("A full-grain leather belt with a brushed nickel buckle.") + .productUrl("https://merchant.example.com/products/classic-leather-belt-brown") + .imageUrl("https://merchant.example.com/images/belt-brown-main.jpg") + .build(); + + final String json = serializer.toJson(request); + + assertTrue(json.contains("\"title\":\"Classic leather belt, brown\"")); + assertTrue(json.contains("\"description\"")); + assertTrue(json.contains("\"product_url\"")); + assertTrue(json.contains("\"image_url\"")); + assertFalse(json.contains("\"condition\"")); + } + + @Test + void shouldSerializeInventorySetProductRequestWithAllFields() { + final InventorySetProductRequest request = InventorySetProductRequest.builder() + .title("Classic leather belt, brown") + .description("A full-grain leather belt with a brushed nickel buckle.") + .productUrl("https://merchant.example.com/products/classic-leather-belt-brown") + .imageUrl("https://merchant.example.com/images/belt-brown-main.jpg") + .additionalImageUrls(List.of("https://merchant.example.com/images/belt-brown-alt1.jpg")) + .videoUrl("https://merchant.example.com/videos/belt-brown.mp4") + .model3dUrl("https://merchant.example.com/models/belt-brown.glb") + .sku("BELT-BRN-001") + .gtin("00012345678905") + .mpn("MPN-4471") + .brand("Acme Leathercraft") + .category("Apparel & Accessories > Belts") + .price(InventoryMoney.builder().amount(1999L).currency("USD").build()) + .salePrice(InventoryMoney.builder().amount(1499L).currency("USD").build()) + .salePriceStartsAt(Instant.parse("2026-08-01T00:00:00Z")) + .salePriceEndsAt(Instant.parse("2026-08-31T23:59:59Z")) + .groupId("grp_belt_classic") + .groupTitle("Classic leather belt") + .color("Brown") + .size("M") + .sizeSystem("US") + .gender("unisex") + .condition(InventoryProductCondition.NEW) + .material("Full-grain leather") + .ageGroup("adult") + .length(110d) + .width(3.5) + .height(0.5) + .dimensionUnit("cm") + .weight(0.2) + .weightUnit("kg") + .expirationDate(Instant.parse("2027-01-01T00:00:00Z")) + .harmonizedSystemCode("4203.30") + .countryOfOrigin("IT") + .sellerName("Acme Leathercraft Ltd") + .sellerUrl("https://acme-leathercraft.example.com") + .sellerPrivacyPolicy("https://acme-leathercraft.example.com/privacy") + .sellerTos("https://acme-leathercraft.example.com/terms") + .build(); + + final String json = serializer.toJson(request); + + assertTrue(json.contains("\"model_3d_url\":\"https://merchant.example.com/models/belt-brown.glb\"")); + assertTrue(json.contains("\"condition\":\"new\"")); + assertTrue(json.contains("\"price\"")); + assertTrue(json.contains("\"sale_price\"")); + assertTrue(json.contains("\"sale_price_starts_at\"")); + assertTrue(json.contains("\"group_id\":\"grp_belt_classic\"")); + } + + @Test + void shouldRoundTripInventorySetProductRequest() { + final InventorySetProductRequest original = InventorySetProductRequest.builder() + .title("Classic leather belt, brown") + .description("A full-grain leather belt with a brushed nickel buckle.") + .productUrl("https://merchant.example.com/products/classic-leather-belt-brown") + .imageUrl("https://merchant.example.com/images/belt-brown-main.jpg") + .model3dUrl("https://merchant.example.com/models/belt-brown.glb") + .price(InventoryMoney.builder().amount(1999L).currency("USD").build()) + .condition(InventoryProductCondition.NEW) + .build(); + + final String json = serializer.toJson(original); + final InventorySetProductRequest deserialized = serializer.fromJson(json, InventorySetProductRequest.class); + + assertEquals(original, deserialized); + assertEquals("https://merchant.example.com/models/belt-brown.glb", deserialized.getModel3dUrl()); + assertEquals(InventoryProductCondition.NEW, deserialized.getCondition()); + } + + // ------------------------------------------------------------------------ + // InventoryLevelsQueryFilter + // ------------------------------------------------------------------------ + + @Test + void shouldSerializeInventoryLevelsQueryFilterWithExpand() { + final InventoryLevelsQueryFilter filter = InventoryLevelsQueryFilter.builder() + .expand("product") + .build(); + + final String json = serializer.toJson(filter); + + assertTrue(json.contains("\"expand\":\"product\"")); + } + +} diff --git a/src/test/java/com/checkout/inventory/response/InventoryResponseSerializationTest.java b/src/test/java/com/checkout/inventory/response/InventoryResponseSerializationTest.java new file mode 100644 index 00000000..93fadf87 --- /dev/null +++ b/src/test/java/com/checkout/inventory/response/InventoryResponseSerializationTest.java @@ -0,0 +1,255 @@ +package com.checkout.inventory.response; + +import com.checkout.GsonSerializer; +import com.checkout.inventory.InventoryHalLink; +import com.checkout.inventory.InventoryLevelsSource; +import com.checkout.inventory.InventoryLevelsState; +import com.checkout.inventory.InventoryMoney; +import com.checkout.inventory.InventoryProductCondition; +import com.checkout.inventory.InventoryReservationItem; +import com.checkout.inventory.InventoryReservationState; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Schema validation tests for the Inventory response classes: InventoryLevels, + * InventoryReservation and InventoryProductKnowledge. Fields verified against + * shared/swagger.json. + */ +class InventoryResponseSerializationTest { + + private final GsonSerializer serializer = new GsonSerializer(); + + // ------------------------------------------------------------------------ + // InventoryLevels - covers state/source enums, embedded product and _links. + // ------------------------------------------------------------------------ + + @Test + void shouldDeserializeInventoryLevelsSwaggerExample() { + final String swaggerJson = "{" + + "\"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\",\"actions\":[\"GET\"]}," + + "\"set\":{\"href\":\"https://api.checkout.com/inventory/var_123\",\"actions\":[\"PUT\"]}}" + + "}"; + + final InventoryLevels levels = serializer.fromJson(swaggerJson, InventoryLevels.class); + + assertNotNull(levels); + assertEquals("var_123", levels.getVariantId()); + assertEquals(10, levels.getOnHand()); + assertEquals(7, levels.getAvailable()); + assertEquals(InventoryLevelsState.IN_STOCK, levels.getState()); + assertEquals(InventoryLevelsSource.MANAGED, levels.getSource()); + assertEquals(Instant.parse("2026-07-01T09:15:00Z"), levels.getCreatedOn()); + assertNotNull(levels.getLinks()); + assertEquals("https://api.checkout.com/inventory/var_123", levels.getLinks().getSelf().getHref()); + assertNull(levels.getProduct()); + } + + @Test + void shouldDeserializeInventoryLevelsWithEmbeddedProduct() { + final String swaggerJson = "{" + + "\"variant_id\":\"var_123\"," + + "\"state\":\"limited\"," + + "\"product\":{" + + "\"variant_id\":\"var_123\",\"title\":\"Belt\",\"description\":\"desc\"," + + "\"product_url\":\"https://example.com\",\"image_url\":\"https://example.com/i.jpg\"," + + "\"condition\":\"new\",\"created_on\":\"2026-08-01T09:15:00Z\",\"modified_on\":\"2026-08-13T14:02:11Z\"," + + "\"_links\":{\"self\":{\"href\":\"https://example.com/self\"}}" + + "}" + + "}"; + + final InventoryLevels levels = serializer.fromJson(swaggerJson, InventoryLevels.class); + + assertNotNull(levels.getProduct()); + assertEquals("Belt", levels.getProduct().getTitle()); + assertEquals(InventoryProductCondition.NEW, levels.getProduct().getCondition()); + } + + @Test + void shouldRoundTripInventoryLevels() { + final InventoryLevels original = new InventoryLevels(); + original.setVariantId("var_123"); + original.setOnHand(10); + original.setReserved(2); + original.setSafetyStock(1); + original.setAvailable(7); + original.setState(InventoryLevelsState.OUT_OF_STOCK); + original.setSource(InventoryLevelsSource.SYNC); + original.setCreatedOn(Instant.parse("2026-07-01T09:15:00Z")); + original.setModifiedOn(Instant.parse("2026-07-13T14:02:11Z")); + final InventoryLevelsLinks links = new InventoryLevelsLinks(); + final InventoryHalLink self = new InventoryHalLink(); + self.setHref("https://api.checkout.com/inventory/var_123"); + links.setSelf(self); + original.setLinks(links); + + final String json = serializer.toJson(original); + final InventoryLevels deserialized = serializer.fromJson(json, InventoryLevels.class); + + assertEquals(original.getVariantId(), deserialized.getVariantId()); + assertEquals(original.getState(), deserialized.getState()); + assertEquals(original.getSource(), deserialized.getSource()); + assertEquals(original.getLinks().getSelf().getHref(), deserialized.getLinks().getSelf().getHref()); + } + + // ------------------------------------------------------------------------ + // InventoryReservation - covers state enum, items and the three-way _links. + // ------------------------------------------------------------------------ + + @Test + void shouldDeserializeInventoryReservationSwaggerExample() { + final String swaggerJson = "{" + + "\"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://example.com/self\"}," + + "\"commit\":{\"href\":\"https://example.com/commit\"}," + + "\"release\":{\"href\":\"https://example.com/release\"}}" + + "}"; + + final InventoryReservation reservation = serializer.fromJson(swaggerJson, InventoryReservation.class); + + assertNotNull(reservation); + assertEquals("rsv_tkoi5db4hryu5cei5vwoabr7we", reservation.getId()); + assertEquals(InventoryReservationState.HELD, reservation.getState()); + assertEquals(1, reservation.getItems().size()); + assertEquals("var_123", reservation.getItems().get(0).getVariantId()); + assertNotNull(reservation.getLinks().getCommit()); + assertNotNull(reservation.getLinks().getRelease()); + } + + @Test + void shouldRoundTripInventoryReservation() { + final InventoryReservation original = new InventoryReservation(); + original.setId("rsv_tkoi5db4hryu5cei5vwoabr7we"); + original.setState(InventoryReservationState.COMMITTED); + original.setOwnerType("ucp_session"); + original.setOwnerReference("cs_8f42"); + original.setItems(List.of(InventoryReservationItem.builder().variantId("var_123").quantity(2).build())); + original.setExpiresAt(Instant.parse("2026-07-14T08:47:00Z")); + original.setCreatedOn(Instant.parse("2026-07-14T08:32:00Z")); + final InventoryReservationLinks links = new InventoryReservationLinks(); + final InventoryHalLink self = new InventoryHalLink(); + self.setHref("https://example.com/self"); + links.setSelf(self); + original.setLinks(links); + + final String json = serializer.toJson(original); + final InventoryReservation deserialized = serializer.fromJson(json, InventoryReservation.class); + + assertEquals(original.getId(), deserialized.getId()); + assertEquals(original.getState(), deserialized.getState()); + assertEquals(original.getItems(), deserialized.getItems()); + } + + // ------------------------------------------------------------------------ + // InventoryProductKnowledge - covers all fields, including nested InventoryMoney, + // condition enum and _links (self/set/delete). + // ------------------------------------------------------------------------ + + @Test + void shouldDeserializeInventoryProductKnowledgeSwaggerExample() { + final String swaggerJson = "{" + + "\"variant_id\":\"var_123\",\"title\":\"Classic leather belt, brown\"," + + "\"description\":\"A full-grain leather belt with a brushed nickel buckle.\"," + + "\"product_url\":\"https://merchant.example.com/products/classic-leather-belt-brown\"," + + "\"image_url\":\"https://merchant.example.com/images/belt-brown-main.jpg\"," + + "\"model_3d_url\":\"https://merchant.example.com/models/belt-brown.glb\"," + + "\"price\":{\"amount\":1999,\"currency\":\"USD\"}," + + "\"sale_price\":{\"amount\":1499,\"currency\":\"USD\"}," + + "\"condition\":\"new\"," + + "\"created_on\":\"2026-08-01T09:15:00Z\",\"modified_on\":\"2026-08-13T14:02:11Z\"," + + "\"_links\":{\"self\":{\"href\":\"https://example.com/self\"}," + + "\"set\":{\"href\":\"https://example.com/set\"}," + + "\"delete\":{\"href\":\"https://example.com/delete\"}}" + + "}"; + + final InventoryProductKnowledge product = serializer.fromJson(swaggerJson, InventoryProductKnowledge.class); + + assertNotNull(product); + assertEquals("var_123", product.getVariantId()); + assertEquals("https://merchant.example.com/models/belt-brown.glb", product.getModel3dUrl()); + assertEquals(1999L, product.getPrice().getAmount()); + assertEquals("USD", product.getPrice().getCurrency()); + assertEquals(InventoryProductCondition.NEW, product.getCondition()); + assertNotNull(product.getLinks().getDelete()); + } + + @Test + void shouldRoundTripInventoryProductKnowledgeWithAllOptionalFields() { + final InventoryProductKnowledge original = new InventoryProductKnowledge(); + original.setVariantId("var_123"); + original.setTitle("Classic leather belt, brown"); + original.setDescription("A full-grain leather belt with a brushed nickel buckle."); + original.setProductUrl("https://merchant.example.com/products/classic-leather-belt-brown"); + original.setImageUrl("https://merchant.example.com/images/belt-brown-main.jpg"); + original.setAdditionalImageUrls(List.of("https://merchant.example.com/images/belt-brown-alt1.jpg")); + original.setVideoUrl("https://merchant.example.com/videos/belt-brown.mp4"); + original.setModel3dUrl("https://merchant.example.com/models/belt-brown.glb"); + original.setSku("BELT-BRN-001"); + original.setGtin("00012345678905"); + original.setMpn("MPN-4471"); + original.setBrand("Acme Leathercraft"); + original.setCategory("Apparel & Accessories > Belts"); + original.setPrice(InventoryMoney.builder().amount(1999L).currency("USD").build()); + original.setSalePrice(InventoryMoney.builder().amount(1499L).currency("USD").build()); + original.setSalePriceStartsAt(Instant.parse("2026-08-01T00:00:00Z")); + original.setSalePriceEndsAt(Instant.parse("2026-08-31T23:59:59Z")); + original.setGroupId("grp_belt_classic"); + original.setGroupTitle("Classic leather belt"); + original.setColor("Brown"); + original.setSize("M"); + original.setSizeSystem("US"); + original.setGender("unisex"); + original.setCondition(InventoryProductCondition.USED); + original.setMaterial("Full-grain leather"); + original.setAgeGroup("adult"); + original.setLength(110d); + original.setWidth(3.5); + original.setHeight(0.5); + original.setDimensionUnit("cm"); + original.setWeight(0.2); + original.setWeightUnit("kg"); + original.setExpirationDate(Instant.parse("2027-01-01T00:00:00Z")); + original.setHarmonizedSystemCode("4203.30"); + original.setCountryOfOrigin("IT"); + original.setSellerName("Acme Leathercraft Ltd"); + original.setSellerUrl("https://acme-leathercraft.example.com"); + original.setSellerPrivacyPolicy("https://acme-leathercraft.example.com/privacy"); + original.setSellerTos("https://acme-leathercraft.example.com/terms"); + final InventoryProductLinks links = new InventoryProductLinks(); + final InventoryHalLink self = new InventoryHalLink(); + self.setHref("https://example.com/self"); + links.setSelf(self); + original.setLinks(links); + + final String json = serializer.toJson(original); + final InventoryProductKnowledge deserialized = serializer.fromJson(json, InventoryProductKnowledge.class); + + assertEquals(original.getVariantId(), deserialized.getVariantId()); + assertEquals(original.getModel3dUrl(), deserialized.getModel3dUrl()); + assertEquals(original.getPrice(), deserialized.getPrice()); + assertEquals(original.getCondition(), deserialized.getCondition()); + assertEquals(original.getSalePriceStartsAt(), deserialized.getSalePriceStartsAt()); + } + +}