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(
373 "inventory_item_id".to_string(),
374 inventory_item_id.to_string(),
375 );
376 query.insert("location_id".to_string(), location_id.to_string());
377
378 let response = client.delete(path, Some(query)).await?;
379
380 if !response.is_ok() {
381 return Err(ResourceError::from_http_response(
382 response.code,
383 &response.body,
384 Self::NAME,
385 None,
386 response.request_id(),
387 ));
388 }
389
390 Ok(())
391 }
392}
393
394impl RestResource for InventoryLevel {
395 // Using String as ID type since we don't have a single ID field
396 // This is a workaround for the composite key nature of this resource
397 type Id = String;
398 type FindParams = ();
399 type AllParams = InventoryLevelListParams;
400 type CountParams = ();
401
402 const NAME: &'static str = "InventoryLevel";
403 const PLURAL: &'static str = "inventory_levels";
404
405 /// Paths for the `InventoryLevel` resource.
406 ///
407 /// Note: `InventoryLevel` has limited standard REST operations due to its
408 /// composite key nature. Most operations are handled through special
409 /// associated functions (adjust, connect, set, `delete_at_location`).
410 const PATHS: &'static [ResourcePath] = &[
411 // List all inventory levels (requires inventory_item_ids or location_ids param)
412 ResourcePath::new(
413 HttpMethod::Get,
414 ResourceOperation::All,
415 &[],
416 "inventory_levels",
417 ),
418 // Note: Delete is handled by delete_at_location with query params
419 // No Find, Create, Update, or Count paths
420 ];
421
422 fn get_id(&self) -> Option<Self::Id> {
423 // Composite key - return None since there's no single ID
424 // Use the special operations (adjust, connect, set, delete_at_location) instead
425 None
426 }
427}
428
429/// Parameters for listing inventory levels.
430///
431/// At least one of `inventory_item_ids` or `location_ids` must be provided.
432#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
433pub struct InventoryLevelListParams {
434 /// Comma-separated list of inventory item IDs to retrieve levels for.
435 #[serde(skip_serializing_if = "Option::is_none")]
436 pub inventory_item_ids: Option<String>,
437
438 /// Comma-separated list of location IDs to retrieve levels for.
439 #[serde(skip_serializing_if = "Option::is_none")]
440 pub location_ids: Option<String>,
441
442 /// Maximum number of results to return (default: 50, max: 250).
443 #[serde(skip_serializing_if = "Option::is_none")]
444 pub limit: Option<u32>,
445
446 /// Show inventory levels updated at or after this date.
447 #[serde(skip_serializing_if = "Option::is_none")]
448 pub updated_at_min: Option<DateTime<Utc>>,
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454 use crate::rest::{get_path, ResourceOperation};
455
456 #[test]
457 fn test_inventory_level_has_no_id_field() {
458 // InventoryLevel uses composite key (inventory_item_id + location_id)
459 let level = InventoryLevel {
460 inventory_item_id: Some(808950810),
461 location_id: Some(655441491),
462 available: Some(100),
463 updated_at: None,
464 admin_graphql_api_id: None,
465 };
466
467 // get_id should return None since there's no single ID field
468 assert!(level.get_id().is_none());
469 }
470
471 #[test]
472 fn test_inventory_level_serialization() {
473 let level = InventoryLevel {
474 inventory_item_id: Some(808950810),
475 location_id: Some(655441491),
476 available: Some(100),
477 updated_at: Some(
478 DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
479 .unwrap()
480 .with_timezone(&Utc),
481 ),
482 admin_graphql_api_id: Some("gid://shopify/InventoryLevel/123".to_string()),
483 };
484
485 let json = serde_json::to_string(&level).unwrap();
486 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
487
488 // Writable fields should be present
489 assert_eq!(parsed["inventory_item_id"], 808950810);
490 assert_eq!(parsed["location_id"], 655441491);
491 assert_eq!(parsed["available"], 100);
492
493 // Read-only fields should be omitted
494 assert!(parsed.get("updated_at").is_none());
495 assert!(parsed.get("admin_graphql_api_id").is_none());
496 }
497
498 #[test]
499 fn test_inventory_level_deserialization() {
500 let json = r#"{
501 "inventory_item_id": 808950810,
502 "location_id": 655441491,
503 "available": 42,
504 "updated_at": "2024-06-20T15:45:00Z",
505 "admin_graphql_api_id": "gid://shopify/InventoryLevel/808950810?inventory_item_id=808950810"
506 }"#;
507
508 let level: InventoryLevel = serde_json::from_str(json).unwrap();
509
510 assert_eq!(level.inventory_item_id, Some(808950810));
511 assert_eq!(level.location_id, Some(655441491));
512 assert_eq!(level.available, Some(42));
513 assert!(level.updated_at.is_some());
514 assert!(level.admin_graphql_api_id.is_some());
515 }
516
517 #[test]
518 fn test_inventory_level_special_operations_path_construction() {
519 // Verify the paths used by special operations
520 // These are NOT in the PATHS constant but are used by the associated functions
521
522 // adjust -> inventory_levels/adjust
523 assert_eq!(
524 format!("inventory_levels/adjust"),
525 "inventory_levels/adjust"
526 );
527
528 // connect -> inventory_levels/connect
529 assert_eq!(
530 format!("inventory_levels/connect"),
531 "inventory_levels/connect"
532 );
533
534 // set -> inventory_levels/set
535 assert_eq!(format!("inventory_levels/set"), "inventory_levels/set");
536
537 // delete_at_location -> inventory_levels with query params
538 assert_eq!(format!("inventory_levels"), "inventory_levels");
539 }
540
541 #[test]
542 fn test_inventory_level_list_params_serialization() {
543 let params = InventoryLevelListParams {
544 inventory_item_ids: Some("808950810,808950811".to_string()),
545 location_ids: Some("655441491,655441492".to_string()),
546 limit: Some(50),
547 updated_at_min: Some(
548 DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
549 .unwrap()
550 .with_timezone(&Utc),
551 ),
552 };
553
554 let json = serde_json::to_value(¶ms).unwrap();
555
556 assert_eq!(json["inventory_item_ids"], "808950810,808950811");
557 assert_eq!(json["location_ids"], "655441491,655441492");
558 assert_eq!(json["limit"], 50);
559 assert!(json["updated_at_min"].as_str().is_some());
560
561 // Test empty params
562 let empty_params = InventoryLevelListParams::default();
563 let empty_json = serde_json::to_value(&empty_params).unwrap();
564 assert_eq!(empty_json, serde_json::json!({}));
565 }
566
567 #[test]
568 fn test_inventory_level_paths() {
569 // Should only have All path
570 let all_path = get_path(InventoryLevel::PATHS, ResourceOperation::All, &[]);
571 assert!(all_path.is_some());
572 assert_eq!(all_path.unwrap().template, "inventory_levels");
573 assert_eq!(all_path.unwrap().http_method, HttpMethod::Get);
574
575 // Should NOT have Find, Create, Update, Delete, Count paths
576 assert!(get_path(InventoryLevel::PATHS, ResourceOperation::Find, &["id"]).is_none());
577 assert!(get_path(InventoryLevel::PATHS, ResourceOperation::Create, &[]).is_none());
578 assert!(get_path(InventoryLevel::PATHS, ResourceOperation::Update, &["id"]).is_none());
579 assert!(get_path(InventoryLevel::PATHS, ResourceOperation::Delete, &["id"]).is_none());
580 assert!(get_path(InventoryLevel::PATHS, ResourceOperation::Count, &[]).is_none());
581 }
582
583 #[test]
584 fn test_inventory_level_constants() {
585 assert_eq!(InventoryLevel::NAME, "InventoryLevel");
586 assert_eq!(InventoryLevel::PLURAL, "inventory_levels");
587 }
588}