Skip to main content

shopify_sdk/rest/resources/v2025_10/
inventory_item.rs

1//! `InventoryItem` resource implementation.
2//!
3//! This module provides the `InventoryItem` resource, which represents an inventory item
4//! in a Shopify store. Inventory items are linked to product variants via the
5//! `inventory_item_id` field on variants.
6//!
7//! # Important Notes
8//!
9//! - `InventoryItem` uses standalone paths only (`/inventory_items/{id}`)
10//! - The list operation requires the `ids` parameter (comma-separated inventory item IDs)
11//! - `InventoryItem` is linked to Variant via `Variant.inventory_item_id`
12//!
13//! # Example
14//!
15//! ```rust,ignore
16//! use shopify_sdk::rest::{RestResource, ResourceResponse};
17//! use shopify_sdk::rest::resources::v2025_10::{InventoryItem, InventoryItemListParams};
18//!
19//! // Find a single inventory item
20//! let inventory_item = InventoryItem::find(&client, 123, None).await?;
21//! println!("SKU: {}", inventory_item.sku.as_deref().unwrap_or(""));
22//!
23//! // List inventory items by IDs (ids parameter is required)
24//! let params = InventoryItemListParams {
25//!     ids: Some(vec![123, 456, 789]),
26//!     limit: Some(50),
27//!     ..Default::default()
28//! };
29//! let inventory_items = InventoryItem::all(&client, Some(params)).await?;
30//!
31//! // Update an inventory item
32//! let mut item = InventoryItem::find(&client, 123, None).await?.into_inner();
33//! item.cost = Some("15.99".to_string());
34//! item.tracked = Some(true);
35//! let saved = item.save(&client).await?;
36//! ```
37
38use chrono::{DateTime, Utc};
39use serde::{Deserialize, Serialize};
40
41use crate::rest::{ResourceOperation, ResourcePath, RestResource};
42use crate::HttpMethod;
43
44/// Country-specific harmonized system code for customs.
45///
46/// Used for international shipping and customs declarations.
47/// Each country may have its own harmonized system code for a product.
48#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
49pub struct CountryHarmonizedSystemCode {
50    /// The harmonized system code for this country.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub harmonized_system_code: Option<String>,
53
54    /// The ISO 3166-1 alpha-2 country code.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub country_code: Option<String>,
57}
58
59/// An inventory item in a Shopify store.
60///
61/// Inventory items are associated with product variants and contain
62/// information about tracking, cost, and customs declarations.
63///
64/// # Relationship to Variants
65///
66/// Each variant has an `inventory_item_id` field that links to its
67/// corresponding inventory item. The inventory item tracks:
68/// - Cost of goods
69/// - Whether inventory is tracked
70/// - Customs/HS codes for international shipping
71///
72/// # Fields
73///
74/// ## Writable Fields
75/// - `cost` - The cost of the item (for profit calculations)
76/// - `sku` - Stock keeping unit identifier
77/// - `country_code_of_origin` - ISO country code where item originated
78/// - `province_code_of_origin` - Province/state code where item originated
79/// - `harmonized_system_code` - HS code for customs
80/// - `tracked` - Whether inventory levels are tracked
81///
82/// ## Read-Only Fields
83/// - `id` - The unique identifier
84/// - `requires_shipping` - Whether the item requires shipping
85/// - `created_at` - When the inventory item was created
86/// - `updated_at` - When the inventory item was last updated
87/// - `admin_graphql_api_id` - The GraphQL API ID
88#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
89pub struct InventoryItem {
90    /// The unique identifier of the inventory item.
91    /// Read-only field.
92    #[serde(skip_serializing)]
93    pub id: Option<u64>,
94
95    /// The stock keeping unit (SKU) of the inventory item.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub sku: Option<String>,
98
99    /// The unit cost of the inventory item.
100    /// Stored as a string to preserve decimal precision.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub cost: Option<String>,
103
104    /// When the inventory item was created.
105    /// Read-only field.
106    #[serde(skip_serializing)]
107    pub created_at: Option<DateTime<Utc>>,
108
109    /// When the inventory item was last updated.
110    /// Read-only field.
111    #[serde(skip_serializing)]
112    pub updated_at: Option<DateTime<Utc>>,
113
114    /// Whether the inventory item requires shipping.
115    /// Read-only field.
116    #[serde(skip_serializing)]
117    pub requires_shipping: Option<bool>,
118
119    /// Whether inventory levels are tracked for this item.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub tracked: Option<bool>,
122
123    /// The ISO 3166-1 alpha-2 country code of origin.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub country_code_of_origin: Option<String>,
126
127    /// The province/state code of origin.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub province_code_of_origin: Option<String>,
130
131    /// The harmonized system code for customs.
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub harmonized_system_code: Option<String>,
134
135    /// Country-specific harmonized system codes.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub country_harmonized_system_codes: Option<Vec<CountryHarmonizedSystemCode>>,
138
139    /// The admin GraphQL API ID for this inventory item.
140    /// Read-only field.
141    #[serde(skip_serializing)]
142    pub admin_graphql_api_id: Option<String>,
143}
144
145impl RestResource for InventoryItem {
146    type Id = u64;
147    type FindParams = InventoryItemFindParams;
148    type AllParams = InventoryItemListParams;
149    type CountParams = ();
150
151    const NAME: &'static str = "InventoryItem";
152    const PLURAL: &'static str = "inventory_items";
153
154    /// Paths for the `InventoryItem` resource.
155    ///
156    /// `InventoryItem` uses STANDALONE PATHS only:
157    /// - `/inventory_items/{id}` for individual item access
158    /// - `/inventory_items.json` for listing (requires `ids` parameter)
159    ///
160    /// Note: There is no count endpoint for inventory items.
161    /// Note: Inventory items cannot be created or deleted directly;
162    ///       they are managed through variants.
163    const PATHS: &'static [ResourcePath] = &[
164        ResourcePath::new(
165            HttpMethod::Get,
166            ResourceOperation::Find,
167            &["id"],
168            "inventory_items/{id}",
169        ),
170        ResourcePath::new(
171            HttpMethod::Get,
172            ResourceOperation::All,
173            &[],
174            "inventory_items",
175        ),
176        ResourcePath::new(
177            HttpMethod::Put,
178            ResourceOperation::Update,
179            &["id"],
180            "inventory_items/{id}",
181        ),
182    ];
183
184    fn get_id(&self) -> Option<Self::Id> {
185        self.id
186    }
187}
188
189/// Parameters for finding a single inventory item.
190#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
191pub struct InventoryItemFindParams {
192    // No specific find params for inventory items
193}
194
195/// Parameters for listing inventory items.
196///
197/// # Important
198///
199/// The `ids` parameter is required when listing inventory items.
200/// The API will return an error if `ids` is not provided.
201///
202/// # Example
203///
204/// ```rust,ignore
205/// let params = InventoryItemListParams {
206///     ids: Some(vec![123, 456, 789]),
207///     limit: Some(50),
208///     ..Default::default()
209/// };
210/// let items = InventoryItem::all(&client, Some(params)).await?;
211/// ```
212#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
213pub struct InventoryItemListParams {
214    /// Comma-separated list of inventory item IDs to retrieve.
215    /// **Required** - the API requires this parameter.
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub ids: Option<Vec<u64>>,
218
219    /// Maximum number of results to return (default: 50, max: 250).
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub limit: Option<u32>,
222
223    /// Cursor for pagination.
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub page_info: Option<String>,
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::rest::{get_path, ResourceOperation};
232
233    #[test]
234    fn test_inventory_item_struct_serialization() {
235        let item = InventoryItem {
236            id: Some(12345), // Read-only, should be skipped
237            sku: Some("SKU-001".to_string()),
238            cost: Some("15.99".to_string()),
239            created_at: Some(
240                DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
241                    .unwrap()
242                    .with_timezone(&Utc),
243            ), // Read-only
244            updated_at: Some(
245                DateTime::parse_from_rfc3339("2024-06-20T15:45:00Z")
246                    .unwrap()
247                    .with_timezone(&Utc),
248            ), // Read-only
249            requires_shipping: Some(true), // Read-only
250            tracked: Some(true),
251            country_code_of_origin: Some("US".to_string()),
252            province_code_of_origin: Some("CA".to_string()),
253            harmonized_system_code: Some("6109.10".to_string()),
254            country_harmonized_system_codes: Some(vec![CountryHarmonizedSystemCode {
255                harmonized_system_code: Some("6109.10.0000".to_string()),
256                country_code: Some("CA".to_string()),
257            }]),
258            admin_graphql_api_id: Some("gid://shopify/InventoryItem/12345".to_string()), // Read-only
259        };
260
261        let json = serde_json::to_string(&item).unwrap();
262        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
263
264        // Writable fields should be present
265        assert_eq!(parsed["sku"], "SKU-001");
266        assert_eq!(parsed["cost"], "15.99");
267        assert_eq!(parsed["tracked"], true);
268        assert_eq!(parsed["country_code_of_origin"], "US");
269        assert_eq!(parsed["province_code_of_origin"], "CA");
270        assert_eq!(parsed["harmonized_system_code"], "6109.10");
271
272        // Read-only fields should be omitted
273        assert!(parsed.get("id").is_none());
274        assert!(parsed.get("created_at").is_none());
275        assert!(parsed.get("updated_at").is_none());
276        assert!(parsed.get("requires_shipping").is_none());
277        assert!(parsed.get("admin_graphql_api_id").is_none());
278
279        // Nested country harmonized system codes should be present
280        let codes = parsed["country_harmonized_system_codes"]
281            .as_array()
282            .unwrap();
283        assert_eq!(codes.len(), 1);
284        assert_eq!(codes[0]["harmonized_system_code"], "6109.10.0000");
285        assert_eq!(codes[0]["country_code"], "CA");
286    }
287
288    #[test]
289    fn test_inventory_item_deserialization() {
290        let json = r#"{
291            "id": 808950810,
292            "sku": "IPOD-342-N",
293            "cost": "25.00",
294            "created_at": "2024-01-15T10:30:00Z",
295            "updated_at": "2024-06-20T15:45:00Z",
296            "requires_shipping": true,
297            "tracked": true,
298            "country_code_of_origin": "US",
299            "province_code_of_origin": "CA",
300            "harmonized_system_code": "8523.29.90",
301            "country_harmonized_system_codes": [
302                {
303                    "harmonized_system_code": "8523.29.9000",
304                    "country_code": "CA"
305                },
306                {
307                    "harmonized_system_code": "8523.29.9090",
308                    "country_code": "GB"
309                }
310            ],
311            "admin_graphql_api_id": "gid://shopify/InventoryItem/808950810"
312        }"#;
313
314        let item: InventoryItem = serde_json::from_str(json).unwrap();
315
316        // Verify all fields are deserialized correctly
317        assert_eq!(item.id, Some(808950810));
318        assert_eq!(item.sku, Some("IPOD-342-N".to_string()));
319        assert_eq!(item.cost, Some("25.00".to_string()));
320        assert!(item.created_at.is_some());
321        assert!(item.updated_at.is_some());
322        assert_eq!(item.requires_shipping, Some(true));
323        assert_eq!(item.tracked, Some(true));
324        assert_eq!(item.country_code_of_origin, Some("US".to_string()));
325        assert_eq!(item.province_code_of_origin, Some("CA".to_string()));
326        assert_eq!(item.harmonized_system_code, Some("8523.29.90".to_string()));
327        assert_eq!(
328            item.admin_graphql_api_id,
329            Some("gid://shopify/InventoryItem/808950810".to_string())
330        );
331
332        // Verify nested country harmonized system codes
333        let codes = item.country_harmonized_system_codes.unwrap();
334        assert_eq!(codes.len(), 2);
335        assert_eq!(
336            codes[0].harmonized_system_code,
337            Some("8523.29.9000".to_string())
338        );
339        assert_eq!(codes[0].country_code, Some("CA".to_string()));
340        assert_eq!(
341            codes[1].harmonized_system_code,
342            Some("8523.29.9090".to_string())
343        );
344        assert_eq!(codes[1].country_code, Some("GB".to_string()));
345    }
346
347    #[test]
348    fn test_inventory_item_list_params_with_ids() {
349        let params = InventoryItemListParams {
350            ids: Some(vec![808950810, 808950811, 808950812]),
351            limit: Some(50),
352            page_info: None,
353        };
354
355        let json = serde_json::to_value(&params).unwrap();
356
357        // IDs should serialize as an array (converted to comma-separated by serialize_to_query)
358        assert_eq!(
359            json["ids"],
360            serde_json::json!([808950810, 808950811, 808950812])
361        );
362        assert_eq!(json["limit"], 50);
363
364        // page_info should be omitted when None
365        assert!(json.get("page_info").is_none());
366
367        // Test with minimal params
368        let empty_params = InventoryItemListParams::default();
369        let empty_json = serde_json::to_value(&empty_params).unwrap();
370        assert_eq!(empty_json, serde_json::json!({}));
371    }
372
373    #[test]
374    fn test_inventory_item_get_id_returns_correct_value() {
375        // Inventory item with ID
376        let item_with_id = InventoryItem {
377            id: Some(808950810),
378            sku: Some("TEST-SKU".to_string()),
379            ..Default::default()
380        };
381        assert_eq!(item_with_id.get_id(), Some(808950810));
382
383        // Inventory item without ID (should not normally happen since items are auto-created)
384        let item_without_id = InventoryItem {
385            id: None,
386            sku: Some("NEW-SKU".to_string()),
387            ..Default::default()
388        };
389        assert_eq!(item_without_id.get_id(), None);
390
391        // Verify trait constants
392        assert_eq!(InventoryItem::NAME, "InventoryItem");
393        assert_eq!(InventoryItem::PLURAL, "inventory_items");
394    }
395
396    #[test]
397    fn test_inventory_item_path_constants_are_correct() {
398        // Test Find path (standalone only)
399        let find_path = get_path(InventoryItem::PATHS, ResourceOperation::Find, &["id"]);
400        assert!(find_path.is_some());
401        assert_eq!(find_path.unwrap().template, "inventory_items/{id}");
402        assert_eq!(find_path.unwrap().http_method, HttpMethod::Get);
403
404        // Test All path (standalone)
405        let all_path = get_path(InventoryItem::PATHS, ResourceOperation::All, &[]);
406        assert!(all_path.is_some());
407        assert_eq!(all_path.unwrap().template, "inventory_items");
408        assert_eq!(all_path.unwrap().http_method, HttpMethod::Get);
409
410        // Test Update path
411        let update_path = get_path(InventoryItem::PATHS, ResourceOperation::Update, &["id"]);
412        assert!(update_path.is_some());
413        assert_eq!(update_path.unwrap().template, "inventory_items/{id}");
414        assert_eq!(update_path.unwrap().http_method, HttpMethod::Put);
415
416        // No Create path (inventory items are auto-created with variants)
417        let create_path = get_path(InventoryItem::PATHS, ResourceOperation::Create, &[]);
418        assert!(create_path.is_none());
419
420        // No Delete path (inventory items are auto-deleted with variants)
421        let delete_path = get_path(InventoryItem::PATHS, ResourceOperation::Delete, &["id"]);
422        assert!(delete_path.is_none());
423
424        // No Count path for inventory items
425        let count_path = get_path(InventoryItem::PATHS, ResourceOperation::Count, &[]);
426        assert!(count_path.is_none());
427    }
428
429    #[test]
430    fn test_country_harmonized_system_code_struct() {
431        let code = CountryHarmonizedSystemCode {
432            harmonized_system_code: Some("8523.29.9000".to_string()),
433            country_code: Some("CA".to_string()),
434        };
435
436        // Test serialization
437        let json = serde_json::to_value(&code).unwrap();
438        assert_eq!(json["harmonized_system_code"], "8523.29.9000");
439        assert_eq!(json["country_code"], "CA");
440
441        // Test deserialization
442        let json_str = r#"{"harmonized_system_code": "1234.56.7890", "country_code": "US"}"#;
443        let parsed: CountryHarmonizedSystemCode = serde_json::from_str(json_str).unwrap();
444        assert_eq!(
445            parsed.harmonized_system_code,
446            Some("1234.56.7890".to_string())
447        );
448        assert_eq!(parsed.country_code, Some("US".to_string()));
449
450        // Test with optional fields omitted
451        let empty_code = CountryHarmonizedSystemCode::default();
452        let empty_json = serde_json::to_value(&empty_code).unwrap();
453        assert_eq!(empty_json, serde_json::json!({}));
454    }
455}