Skip to main content

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(&params).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}