shopify_sdk/rest/resources/v2025_10/inventory_level.rs
1//! `InventoryLevel` resource implementation.
2//!
3//! This module provides the [`InventoryLevel`] resource for managing inventory levels
4//! at locations in a Shopify store. Inventory levels represent the quantity of an
5//! inventory item available at a specific location.
6//!
7//! # Composite Key
8//!
9//! Unlike most resources, `InventoryLevel` does NOT have an `id` field. Instead, it uses
10//! a composite key of `inventory_item_id` + `location_id` to uniquely identify a record.
11//!
12//! # Special Operations
13//!
14//! Due to the composite key nature, inventory levels have special operations that are
15//! implemented as associated functions rather than instance methods:
16//!
17//! - [`InventoryLevel::adjust`] - Adjust available quantity by a relative amount
18//! - [`InventoryLevel::connect`] - Connect an inventory item to a location
19//! - [`InventoryLevel::set`] - Set the available quantity to an absolute value
20//!
21//! # Example
22//!
23//! ```rust,ignore
24//! use shopify_sdk::rest::resources::v2025_10::{InventoryLevel, InventoryLevelListParams};
25//!
26//! // List inventory levels
27//! let params = InventoryLevelListParams {
28//! inventory_item_ids: Some("808950810,808950811".to_string()),
29//! location_ids: Some("655441491".to_string()),
30//! ..Default::default()
31//! };
32//! let levels = InventoryLevel::all(&client, Some(params)).await?;
33//!
34//! // Adjust inventory by a relative amount
35//! let adjusted = InventoryLevel::adjust(&client, 808950810, 655441491, -5).await?;
36//!
37//! // Set inventory to an absolute value
38//! let set_level = InventoryLevel::set(&client, 808950810, 655441491, 100, None).await?;
39//!
40//! // Connect an inventory item to a location
41//! let connected = InventoryLevel::connect(&client, 808950810, 655441491, None).await?;
42//!
43//! // Delete inventory level at a location
44//! InventoryLevel::delete_at_location(&client, 808950810, 655441491).await?;
45//! ```
46
47use std::collections::HashMap;
48
49use chrono::{DateTime, Utc};
50use serde::{Deserialize, Serialize};
51
52use crate::clients::RestClient;
53use crate::rest::{ResourceError, ResourceOperation, ResourcePath, RestResource};
54use crate::HttpMethod;
55
56/// An inventory level in a Shopify store.
57///
58/// Inventory levels represent the quantity of an inventory item available at
59/// a specific location. This is a special resource that uses a composite key
60/// (`inventory_item_id` + `location_id`) instead of a single `id` field.
61///
62/// # Composite Key
63///
64/// This resource does NOT have an `id` field. It is uniquely identified by:
65/// - `inventory_item_id` - The ID of the inventory item
66/// - `location_id` - The ID of the location
67///
68/// # Fields
69///
70/// - `inventory_item_id` - The ID of the inventory item
71/// - `location_id` - The ID of the location
72/// - `available` - The quantity available for sale
73/// - `updated_at` - When the level was last updated
74/// - `admin_graphql_api_id` - GraphQL API ID
75#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
76pub struct InventoryLevel {
77 /// The ID of the inventory item.
78 /// Part of the composite key.
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub inventory_item_id: Option<u64>,
81
82 /// The ID of the location.
83 /// Part of the composite key.
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub location_id: Option<u64>,
86
87 /// The quantity available for sale.
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub available: Option<i64>,
90
91 /// When the inventory level was last updated.
92 #[serde(skip_serializing)]
93 pub updated_at: Option<DateTime<Utc>>,
94
95 /// The admin GraphQL API ID for this inventory level.
96 #[serde(skip_serializing)]
97 pub admin_graphql_api_id: Option<String>,
98}
99
100impl InventoryLevel {
101 /// Adjusts the inventory level by a relative amount.
102 ///
103 /// Sends a POST request to `/admin/api/{version}/inventory_levels/adjust.json`.
104 ///
105 /// # Arguments
106 ///
107 /// * `client` - The REST client to use for the request
108 /// * `inventory_item_id` - The ID of the inventory item
109 /// * `location_id` - The ID of the location
110 /// * `available_adjustment` - The amount to adjust by (positive to add, negative to subtract)
111 ///
112 /// # Returns
113 ///
114 /// The updated inventory level.
115 ///
116 /// # Example
117 ///
118 /// ```rust,ignore
119 /// // Decrease inventory by 5
120 /// let level = InventoryLevel::adjust(&client, 808950810, 655441491, -5).await?;
121 ///
122 /// // Increase inventory by 10
123 /// let level = InventoryLevel::adjust(&client, 808950810, 655441491, 10).await?;
124 /// ```
125 pub async fn adjust(
126 client: &RestClient,
127 inventory_item_id: u64,
128 location_id: u64,
129 available_adjustment: i64,
130 ) -> Result<Self, ResourceError> {
131 let path = "inventory_levels/adjust";
132 let body = serde_json::json!({
133 "inventory_item_id": inventory_item_id,
134 "location_id": location_id,
135 "available_adjustment": available_adjustment
136 });
137
138 let response = client.post(path, body, None).await?;
139
140 if !response.is_ok() {
141 return Err(ResourceError::from_http_response(
142 response.code,
143 &response.body,
144 Self::NAME,
145 None,
146 response.request_id(),
147 ));
148 }
149
150 // Parse the response - inventory level is wrapped in "inventory_level" key
151 let level: Self = response
152 .body
153 .get("inventory_level")
154 .ok_or_else(|| {
155 ResourceError::Http(crate::clients::HttpError::Response(
156 crate::clients::HttpResponseError {
157 code: response.code,
158 message: "Missing 'inventory_level' in response".to_string(),
159 error_reference: response.request_id().map(ToString::to_string),
160 },
161 ))
162 })
163 .and_then(|v| {
164 serde_json::from_value(v.clone()).map_err(|e| {
165 ResourceError::Http(crate::clients::HttpError::Response(
166 crate::clients::HttpResponseError {
167 code: response.code,
168 message: format!("Failed to deserialize inventory level: {e}"),
169 error_reference: response.request_id().map(ToString::to_string),
170 },
171 ))
172 })
173 })?;
174
175 Ok(level)
176 }
177
178 /// Connects an inventory item to a location.
179 ///
180 /// Sends a POST request to `/admin/api/{version}/inventory_levels/connect.json`.
181 ///
182 /// # Arguments
183 ///
184 /// * `client` - The REST client to use for the request
185 /// * `inventory_item_id` - The ID of the inventory item
186 /// * `location_id` - The ID of the location
187 /// * `relocate_if_necessary` - If true and the item is stocked at another location,
188 /// the stock will be moved to the new location. If false, the connection will fail
189 /// if the item is already stocked elsewhere.
190 ///
191 /// # Returns
192 ///
193 /// The created inventory level.
194 ///
195 /// # Example
196 ///
197 /// ```rust,ignore
198 /// // Connect item to location, relocating if necessary
199 /// let level = InventoryLevel::connect(&client, 808950810, 655441491, Some(true)).await?;
200 /// ```
201 pub async fn connect(
202 client: &RestClient,
203 inventory_item_id: u64,
204 location_id: u64,
205 relocate_if_necessary: Option<bool>,
206 ) -> Result<Self, ResourceError> {
207 let path = "inventory_levels/connect";
208 let mut body = serde_json::json!({
209 "inventory_item_id": inventory_item_id,
210 "location_id": location_id
211 });
212
213 if let Some(relocate) = relocate_if_necessary {
214 body["relocate_if_necessary"] = serde_json::json!(relocate);
215 }
216
217 let response = client.post(path, body, None).await?;
218
219 if !response.is_ok() {
220 return Err(ResourceError::from_http_response(
221 response.code,
222 &response.body,
223 Self::NAME,
224 None,
225 response.request_id(),
226 ));
227 }
228
229 // Parse the response
230 let level: Self = response
231 .body
232 .get("inventory_level")
233 .ok_or_else(|| {
234 ResourceError::Http(crate::clients::HttpError::Response(
235 crate::clients::HttpResponseError {
236 code: response.code,
237 message: "Missing 'inventory_level' in response".to_string(),
238 error_reference: response.request_id().map(ToString::to_string),
239 },
240 ))
241 })
242 .and_then(|v| {
243 serde_json::from_value(v.clone()).map_err(|e| {
244 ResourceError::Http(crate::clients::HttpError::Response(
245 crate::clients::HttpResponseError {
246 code: response.code,
247 message: format!("Failed to deserialize inventory level: {e}"),
248 error_reference: response.request_id().map(ToString::to_string),
249 },
250 ))
251 })
252 })?;
253
254 Ok(level)
255 }
256
257 /// Sets the inventory level to an absolute value.
258 ///
259 /// Sends a POST request to `/admin/api/{version}/inventory_levels/set.json`.
260 ///
261 /// # Arguments
262 ///
263 /// * `client` - The REST client to use for the request
264 /// * `inventory_item_id` - The ID of the inventory item
265 /// * `location_id` - The ID of the location
266 /// * `available` - The absolute quantity to set
267 /// * `disconnect_if_necessary` - If true and the available quantity is 0,
268 /// the inventory item will be disconnected from the location.
269 ///
270 /// # Returns
271 ///
272 /// The updated inventory level.
273 ///
274 /// # Example
275 ///
276 /// ```rust,ignore
277 /// // Set inventory to 100 units
278 /// let level = InventoryLevel::set(&client, 808950810, 655441491, 100, None).await?;
279 ///
280 /// // Set inventory to 0 and disconnect
281 /// let level = InventoryLevel::set(&client, 808950810, 655441491, 0, Some(true)).await?;
282 /// ```
283 pub async fn set(
284 client: &RestClient,
285 inventory_item_id: u64,
286 location_id: u64,
287 available: i64,
288 disconnect_if_necessary: Option<bool>,
289 ) -> Result<Self, ResourceError> {
290 let path = "inventory_levels/set";
291 let mut body = serde_json::json!({
292 "inventory_item_id": inventory_item_id,
293 "location_id": location_id,
294 "available": available
295 });
296
297 if let Some(disconnect) = disconnect_if_necessary {
298 body["disconnect_if_necessary"] = serde_json::json!(disconnect);
299 }
300
301 let response = client.post(path, body, None).await?;
302
303 if !response.is_ok() {
304 return Err(ResourceError::from_http_response(
305 response.code,
306 &response.body,
307 Self::NAME,
308 None,
309 response.request_id(),
310 ));
311 }
312
313 // Parse the response
314 let level: Self = response
315 .body
316 .get("inventory_level")
317 .ok_or_else(|| {
318 ResourceError::Http(crate::clients::HttpError::Response(
319 crate::clients::HttpResponseError {
320 code: response.code,
321 message: "Missing 'inventory_level' in response".to_string(),
322 error_reference: response.request_id().map(ToString::to_string),
323 },
324 ))
325 })
326 .and_then(|v| {
327 serde_json::from_value(v.clone()).map_err(|e| {
328 ResourceError::Http(crate::clients::HttpError::Response(
329 crate::clients::HttpResponseError {
330 code: response.code,
331 message: format!("Failed to deserialize inventory level: {e}"),
332 error_reference: response.request_id().map(ToString::to_string),
333 },
334 ))
335 })
336 })?;
337
338 Ok(level)
339 }
340
341 /// Deletes an inventory level at a specific location.
342 ///
343 /// Sends a DELETE request to `/admin/api/{version}/inventory_levels.json`
344 /// with query parameters for `inventory_item_id` and `location_id`.
345 ///
346 /// Note: This is different from most resources where DELETE uses a path parameter.
347 /// For inventory levels, the composite key is passed as query parameters.
348 ///
349 /// # Arguments
350 ///
351 /// * `client` - The REST client to use for the request
352 /// * `inventory_item_id` - The ID of the inventory item
353 /// * `location_id` - The ID of the location
354 ///
355 /// # Errors
356 ///
357 /// Returns a [`ResourceError`] if the deletion fails.
358 ///
359 /// # Example
360 ///
361 /// ```rust,ignore
362 /// // Delete inventory level at a location
363 /// InventoryLevel::delete_at_location(&client, 808950810, 655441491).await?;
364 /// ```
365 pub async fn delete_at_location(
366 client: &RestClient,
367 inventory_item_id: u64,
368 location_id: u64,
369 ) -> Result<(), ResourceError> {
370 let path = "inventory_levels";
371 let mut query = HashMap::new();
372 query.insert("inventory_item_id".to_string(), inventory_item_id.to_string());
373 query.insert("location_id".to_string(), location_id.to_string());
374
375 let response = client.delete(path, Some(query)).await?;
376
377 if !response.is_ok() {
378 return Err(ResourceError::from_http_response(
379 response.code,
380 &response.body,
381 Self::NAME,
382 None,
383 response.request_id(),
384 ));
385 }
386
387 Ok(())
388 }
389}
390
391impl RestResource for InventoryLevel {
392 // Using String as ID type since we don't have a single ID field
393 // This is a workaround for the composite key nature of this resource
394 type Id = String;
395 type FindParams = ();
396 type AllParams = InventoryLevelListParams;
397 type CountParams = ();
398
399 const NAME: &'static str = "InventoryLevel";
400 const PLURAL: &'static str = "inventory_levels";
401
402 /// Paths for the `InventoryLevel` resource.
403 ///
404 /// Note: `InventoryLevel` has limited standard REST operations due to its
405 /// composite key nature. Most operations are handled through special
406 /// associated functions (adjust, connect, set, `delete_at_location`).
407 const PATHS: &'static [ResourcePath] = &[
408 // List all inventory levels (requires inventory_item_ids or location_ids param)
409 ResourcePath::new(
410 HttpMethod::Get,
411 ResourceOperation::All,
412 &[],
413 "inventory_levels",
414 ),
415 // Note: Delete is handled by delete_at_location with query params
416 // No Find, Create, Update, or Count paths
417 ];
418
419 fn get_id(&self) -> Option<Self::Id> {
420 // Composite key - return None since there's no single ID
421 // Use the special operations (adjust, connect, set, delete_at_location) instead
422 None
423 }
424}
425
426/// Parameters for listing inventory levels.
427///
428/// At least one of `inventory_item_ids` or `location_ids` must be provided.
429#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
430pub struct InventoryLevelListParams {
431 /// Comma-separated list of inventory item IDs to retrieve levels for.
432 #[serde(skip_serializing_if = "Option::is_none")]
433 pub inventory_item_ids: Option<String>,
434
435 /// Comma-separated list of location IDs to retrieve levels for.
436 #[serde(skip_serializing_if = "Option::is_none")]
437 pub location_ids: Option<String>,
438
439 /// Maximum number of results to return (default: 50, max: 250).
440 #[serde(skip_serializing_if = "Option::is_none")]
441 pub limit: Option<u32>,
442
443 /// Show inventory levels updated at or after this date.
444 #[serde(skip_serializing_if = "Option::is_none")]
445 pub updated_at_min: Option<DateTime<Utc>>,
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451 use crate::rest::{get_path, ResourceOperation};
452
453 #[test]
454 fn test_inventory_level_has_no_id_field() {
455 // InventoryLevel uses composite key (inventory_item_id + location_id)
456 let level = InventoryLevel {
457 inventory_item_id: Some(808950810),
458 location_id: Some(655441491),
459 available: Some(100),
460 updated_at: None,
461 admin_graphql_api_id: None,
462 };
463
464 // get_id should return None since there's no single ID field
465 assert!(level.get_id().is_none());
466 }
467
468 #[test]
469 fn test_inventory_level_serialization() {
470 let level = InventoryLevel {
471 inventory_item_id: Some(808950810),
472 location_id: Some(655441491),
473 available: Some(100),
474 updated_at: Some(
475 DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
476 .unwrap()
477 .with_timezone(&Utc),
478 ),
479 admin_graphql_api_id: Some("gid://shopify/InventoryLevel/123".to_string()),
480 };
481
482 let json = serde_json::to_string(&level).unwrap();
483 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
484
485 // Writable fields should be present
486 assert_eq!(parsed["inventory_item_id"], 808950810);
487 assert_eq!(parsed["location_id"], 655441491);
488 assert_eq!(parsed["available"], 100);
489
490 // Read-only fields should be omitted
491 assert!(parsed.get("updated_at").is_none());
492 assert!(parsed.get("admin_graphql_api_id").is_none());
493 }
494
495 #[test]
496 fn test_inventory_level_deserialization() {
497 let json = r#"{
498 "inventory_item_id": 808950810,
499 "location_id": 655441491,
500 "available": 42,
501 "updated_at": "2024-06-20T15:45:00Z",
502 "admin_graphql_api_id": "gid://shopify/InventoryLevel/808950810?inventory_item_id=808950810"
503 }"#;
504
505 let level: InventoryLevel = serde_json::from_str(json).unwrap();
506
507 assert_eq!(level.inventory_item_id, Some(808950810));
508 assert_eq!(level.location_id, Some(655441491));
509 assert_eq!(level.available, Some(42));
510 assert!(level.updated_at.is_some());
511 assert!(level.admin_graphql_api_id.is_some());
512 }
513
514 #[test]
515 fn test_inventory_level_special_operations_path_construction() {
516 // Verify the paths used by special operations
517 // These are NOT in the PATHS constant but are used by the associated functions
518
519 // adjust -> inventory_levels/adjust
520 assert_eq!(format!("inventory_levels/adjust"), "inventory_levels/adjust");
521
522 // connect -> inventory_levels/connect
523 assert_eq!(
524 format!("inventory_levels/connect"),
525 "inventory_levels/connect"
526 );
527
528 // set -> inventory_levels/set
529 assert_eq!(format!("inventory_levels/set"), "inventory_levels/set");
530
531 // delete_at_location -> inventory_levels with query params
532 assert_eq!(format!("inventory_levels"), "inventory_levels");
533 }
534
535 #[test]
536 fn test_inventory_level_list_params_serialization() {
537 let params = InventoryLevelListParams {
538 inventory_item_ids: Some("808950810,808950811".to_string()),
539 location_ids: Some("655441491,655441492".to_string()),
540 limit: Some(50),
541 updated_at_min: Some(
542 DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
543 .unwrap()
544 .with_timezone(&Utc),
545 ),
546 };
547
548 let json = serde_json::to_value(¶ms).unwrap();
549
550 assert_eq!(json["inventory_item_ids"], "808950810,808950811");
551 assert_eq!(json["location_ids"], "655441491,655441492");
552 assert_eq!(json["limit"], 50);
553 assert!(json["updated_at_min"].as_str().is_some());
554
555 // Test empty params
556 let empty_params = InventoryLevelListParams::default();
557 let empty_json = serde_json::to_value(&empty_params).unwrap();
558 assert_eq!(empty_json, serde_json::json!({}));
559 }
560
561 #[test]
562 fn test_inventory_level_paths() {
563 // Should only have All path
564 let all_path = get_path(InventoryLevel::PATHS, ResourceOperation::All, &[]);
565 assert!(all_path.is_some());
566 assert_eq!(all_path.unwrap().template, "inventory_levels");
567 assert_eq!(all_path.unwrap().http_method, HttpMethod::Get);
568
569 // Should NOT have Find, Create, Update, Delete, Count paths
570 assert!(get_path(InventoryLevel::PATHS, ResourceOperation::Find, &["id"]).is_none());
571 assert!(get_path(InventoryLevel::PATHS, ResourceOperation::Create, &[]).is_none());
572 assert!(get_path(InventoryLevel::PATHS, ResourceOperation::Update, &["id"]).is_none());
573 assert!(get_path(InventoryLevel::PATHS, ResourceOperation::Delete, &["id"]).is_none());
574 assert!(get_path(InventoryLevel::PATHS, ResourceOperation::Count, &[]).is_none());
575 }
576
577 #[test]
578 fn test_inventory_level_constants() {
579 assert_eq!(InventoryLevel::NAME, "InventoryLevel");
580 assert_eq!(InventoryLevel::PLURAL, "inventory_levels");
581 }
582}