Skip to main content

shopify_sdk/rest/resources/v2025_10/
province.rs

1//! Province resource implementation.
2//!
3//! This module provides the [`Province`] resource for managing provinces/states
4//! within a country. Provinces are nested under countries and have limited
5//! operations.
6//!
7//! # Nested Resource
8//!
9//! Provinces are nested under Countries:
10//! - `GET /countries/{country_id}/provinces.json`
11//! - `GET /countries/{country_id}/provinces/{id}.json`
12//! - `PUT /countries/{country_id}/provinces/{id}.json`
13//! - `GET /countries/{country_id}/provinces/count.json`
14//!
15//! Note: Provinces cannot be created or deleted directly. They are managed
16//! through the Country resource.
17//!
18//! # Example
19//!
20//! ```rust,ignore
21//! use shopify_sdk::rest::{RestResource, ResourceResponse};
22//! use shopify_sdk::rest::resources::v2025_10::{Province, ProvinceListParams};
23//!
24//! // List provinces for a country
25//! let provinces = Province::all_with_parent(&client, "country_id", 879921427, None).await?;
26//! for province in provinces.iter() {
27//!     println!("{} ({}) - tax: {:?}",
28//!         province.name.as_deref().unwrap_or(""),
29//!         province.code.as_deref().unwrap_or(""),
30//!         province.tax);
31//! }
32//!
33//! // Update a province's tax
34//! let mut province = Province::find_with_parent(&client, 879921427, 224293623, None).await?.into_inner();
35//! province.tax = Some(0.13);
36//! // Note: Updates require saving through the nested path
37//! ```
38
39use std::collections::HashMap;
40
41use serde::{Deserialize, Serialize};
42
43use crate::clients::RestClient;
44use crate::rest::{
45    build_path, get_path, ResourceError, ResourceOperation, ResourcePath, ResourceResponse,
46    RestResource,
47};
48use crate::HttpMethod;
49
50/// A province or state within a country.
51///
52/// Provinces define regional tax rates within a country. They are nested
53/// resources under Country and have limited operations (no create/delete).
54///
55/// # Nested Resource
56///
57/// This is a nested resource under `Country`. All operations require the
58/// parent `country_id`.
59///
60/// # Limited Operations
61///
62/// - **No Create**: Provinces are created when the country is created
63/// - **No Delete**: Provinces can only be removed by deleting the country
64/// - **Update**: Tax rates can be modified
65///
66/// # Fields
67///
68/// ## Read-Only Fields
69/// - `id` - The unique identifier
70/// - `country_id` - The parent country ID
71/// - `name` - The province name (derived from code)
72/// - `shipping_zone_id` - The associated shipping zone
73///
74/// ## Writable Fields
75/// - `code` - The province code (e.g., "ON", "CA")
76/// - `tax` - The provincial tax rate as a decimal
77/// - `tax_name` - The name of the tax (e.g., "HST", "PST")
78/// - `tax_type` - The tax calculation type
79/// - `tax_percentage` - The tax rate as a percentage
80#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
81pub struct Province {
82    /// The unique identifier of the province.
83    /// Read-only field.
84    #[serde(skip_serializing)]
85    pub id: Option<u64>,
86
87    /// The ID of the parent country.
88    /// Read-only field.
89    #[serde(skip_serializing)]
90    pub country_id: Option<u64>,
91
92    /// The full name of the province.
93    /// Read-only field - derived from the province code.
94    #[serde(skip_serializing)]
95    pub name: Option<String>,
96
97    /// The province code (e.g., "ON" for Ontario, "CA" for California).
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub code: Option<String>,
100
101    /// The provincial tax rate as a decimal.
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub tax: Option<f64>,
104
105    /// The name of the tax (e.g., "HST", "PST", "Sales Tax").
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub tax_name: Option<String>,
108
109    /// The tax calculation type: "normal", "compounded", or "harmonized".
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub tax_type: Option<String>,
112
113    /// The tax rate as a percentage (e.g., 13.0 for 13%).
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub tax_percentage: Option<f64>,
116
117    /// The ID of the shipping zone this province belongs to.
118    /// Read-only field.
119    #[serde(skip_serializing)]
120    pub shipping_zone_id: Option<u64>,
121}
122
123impl Province {
124    /// Counts provinces under a specific country.
125    ///
126    /// # Arguments
127    ///
128    /// * `client` - The REST client
129    /// * `country_id` - The parent country ID
130    /// * `params` - Optional count parameters
131    pub async fn count_with_parent(
132        client: &RestClient,
133        country_id: u64,
134        _params: Option<ProvinceCountParams>,
135    ) -> Result<u64, ResourceError> {
136        let mut ids: HashMap<&str, String> = HashMap::new();
137        ids.insert("country_id", country_id.to_string());
138
139        let available_ids: Vec<&str> = ids.keys().copied().collect();
140        let path = get_path(Self::PATHS, ResourceOperation::Count, &available_ids).ok_or(
141            ResourceError::PathResolutionFailed {
142                resource: Self::NAME,
143                operation: "count",
144            },
145        )?;
146
147        let url = build_path(path.template, &ids);
148        let response = client.get(&url, None).await?;
149
150        if !response.is_ok() {
151            return Err(ResourceError::from_http_response(
152                response.code,
153                &response.body,
154                Self::NAME,
155                None,
156                response.request_id(),
157            ));
158        }
159
160        let count = response
161            .body
162            .get("count")
163            .and_then(serde_json::Value::as_u64)
164            .ok_or_else(|| {
165                ResourceError::Http(crate::clients::HttpError::Response(
166                    crate::clients::HttpResponseError {
167                        code: response.code,
168                        message: "Missing 'count' in response".to_string(),
169                        error_reference: response.request_id().map(ToString::to_string),
170                    },
171                ))
172            })?;
173
174        Ok(count)
175    }
176
177    /// Finds a single province by ID under a country.
178    ///
179    /// # Arguments
180    ///
181    /// * `client` - The REST client
182    /// * `country_id` - The parent country ID
183    /// * `id` - The province ID to find
184    /// * `params` - Optional parameters
185    pub async fn find_with_parent(
186        client: &RestClient,
187        country_id: u64,
188        id: u64,
189        _params: Option<ProvinceFindParams>,
190    ) -> Result<ResourceResponse<Self>, ResourceError> {
191        let mut ids: HashMap<&str, String> = HashMap::new();
192        ids.insert("country_id", country_id.to_string());
193        ids.insert("id", id.to_string());
194
195        let available_ids: Vec<&str> = ids.keys().copied().collect();
196        let path = get_path(Self::PATHS, ResourceOperation::Find, &available_ids).ok_or(
197            ResourceError::PathResolutionFailed {
198                resource: Self::NAME,
199                operation: "find",
200            },
201        )?;
202
203        let url = build_path(path.template, &ids);
204        let response = client.get(&url, None).await?;
205
206        if !response.is_ok() {
207            return Err(ResourceError::from_http_response(
208                response.code,
209                &response.body,
210                Self::NAME,
211                Some(&id.to_string()),
212                response.request_id(),
213            ));
214        }
215
216        let key = Self::resource_key();
217        ResourceResponse::from_http_response(response, &key)
218    }
219}
220
221impl RestResource for Province {
222    type Id = u64;
223    type FindParams = ProvinceFindParams;
224    type AllParams = ProvinceListParams;
225    type CountParams = ProvinceCountParams;
226
227    const NAME: &'static str = "Province";
228    const PLURAL: &'static str = "provinces";
229
230    /// Paths for the Province resource.
231    ///
232    /// Limited operations - no Create or Delete.
233    /// All paths require country_id.
234    const PATHS: &'static [ResourcePath] = &[
235        ResourcePath::new(
236            HttpMethod::Get,
237            ResourceOperation::Find,
238            &["country_id", "id"],
239            "countries/{country_id}/provinces/{id}",
240        ),
241        ResourcePath::new(
242            HttpMethod::Get,
243            ResourceOperation::All,
244            &["country_id"],
245            "countries/{country_id}/provinces",
246        ),
247        ResourcePath::new(
248            HttpMethod::Get,
249            ResourceOperation::Count,
250            &["country_id"],
251            "countries/{country_id}/provinces/count",
252        ),
253        ResourcePath::new(
254            HttpMethod::Put,
255            ResourceOperation::Update,
256            &["country_id", "id"],
257            "countries/{country_id}/provinces/{id}",
258        ),
259        // Note: No Create or Delete paths - provinces managed via Country
260    ];
261
262    fn get_id(&self) -> Option<Self::Id> {
263        self.id
264    }
265}
266
267/// Parameters for finding a single province.
268#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
269pub struct ProvinceFindParams {
270    /// Comma-separated list of fields to include in the response.
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub fields: Option<String>,
273}
274
275/// Parameters for listing provinces.
276#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
277pub struct ProvinceListParams {
278    /// Return provinces after this ID.
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub since_id: Option<u64>,
281
282    /// Comma-separated list of fields to include in the response.
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub fields: Option<String>,
285}
286
287/// Parameters for counting provinces.
288#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
289pub struct ProvinceCountParams {}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use crate::rest::{get_path, ResourceOperation};
295
296    #[test]
297    fn test_province_serialization() {
298        let province = Province {
299            id: Some(224293623),
300            country_id: Some(879921427),
301            name: Some("Ontario".to_string()),
302            code: Some("ON".to_string()),
303            tax: Some(0.08),
304            tax_name: Some("HST".to_string()),
305            tax_type: Some("compounded".to_string()),
306            tax_percentage: Some(8.0),
307            shipping_zone_id: Some(123),
308        };
309
310        let json = serde_json::to_string(&province).unwrap();
311        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
312
313        // Writable fields should be present
314        assert_eq!(parsed["code"], "ON");
315        assert_eq!(parsed["tax"], 0.08);
316        assert_eq!(parsed["tax_name"], "HST");
317        assert_eq!(parsed["tax_type"], "compounded");
318        assert_eq!(parsed["tax_percentage"], 8.0);
319
320        // Read-only fields should be omitted
321        assert!(parsed.get("id").is_none());
322        assert!(parsed.get("country_id").is_none());
323        assert!(parsed.get("name").is_none());
324        assert!(parsed.get("shipping_zone_id").is_none());
325    }
326
327    #[test]
328    fn test_province_deserialization() {
329        let json = r#"{
330            "id": 224293623,
331            "country_id": 879921427,
332            "name": "Ontario",
333            "code": "ON",
334            "tax": 0.08,
335            "tax_name": "HST",
336            "tax_type": "compounded",
337            "tax_percentage": 8.0,
338            "shipping_zone_id": 123456
339        }"#;
340
341        let province: Province = serde_json::from_str(json).unwrap();
342
343        assert_eq!(province.id, Some(224293623));
344        assert_eq!(province.country_id, Some(879921427));
345        assert_eq!(province.name, Some("Ontario".to_string()));
346        assert_eq!(province.code, Some("ON".to_string()));
347        assert_eq!(province.tax, Some(0.08));
348        assert_eq!(province.tax_name, Some("HST".to_string()));
349        assert_eq!(province.tax_type, Some("compounded".to_string()));
350        assert_eq!(province.tax_percentage, Some(8.0));
351        assert_eq!(province.shipping_zone_id, Some(123456));
352    }
353
354    #[test]
355    fn test_province_nested_paths_no_create_delete() {
356        // Find requires both country_id and id
357        let find_path = get_path(
358            Province::PATHS,
359            ResourceOperation::Find,
360            &["country_id", "id"],
361        );
362        assert!(find_path.is_some());
363        assert_eq!(
364            find_path.unwrap().template,
365            "countries/{country_id}/provinces/{id}"
366        );
367
368        // All requires country_id
369        let all_path = get_path(Province::PATHS, ResourceOperation::All, &["country_id"]);
370        assert!(all_path.is_some());
371        assert_eq!(
372            all_path.unwrap().template,
373            "countries/{country_id}/provinces"
374        );
375
376        // Count requires country_id
377        let count_path = get_path(Province::PATHS, ResourceOperation::Count, &["country_id"]);
378        assert!(count_path.is_some());
379        assert_eq!(
380            count_path.unwrap().template,
381            "countries/{country_id}/provinces/count"
382        );
383
384        // Update requires both country_id and id
385        let update_path = get_path(
386            Province::PATHS,
387            ResourceOperation::Update,
388            &["country_id", "id"],
389        );
390        assert!(update_path.is_some());
391        assert_eq!(
392            update_path.unwrap().template,
393            "countries/{country_id}/provinces/{id}"
394        );
395
396        // No Create path (provinces managed via Country)
397        let create_path = get_path(Province::PATHS, ResourceOperation::Create, &["country_id"]);
398        assert!(create_path.is_none());
399
400        // No Delete path (provinces managed via Country)
401        let delete_path = get_path(
402            Province::PATHS,
403            ResourceOperation::Delete,
404            &["country_id", "id"],
405        );
406        assert!(delete_path.is_none());
407    }
408
409    #[test]
410    fn test_province_constants() {
411        assert_eq!(Province::NAME, "Province");
412        assert_eq!(Province::PLURAL, "provinces");
413    }
414
415    #[test]
416    fn test_province_get_id() {
417        let province_with_id = Province {
418            id: Some(224293623),
419            code: Some("ON".to_string()),
420            ..Default::default()
421        };
422        assert_eq!(province_with_id.get_id(), Some(224293623));
423
424        let province_without_id = Province::default();
425        assert_eq!(province_without_id.get_id(), None);
426    }
427}