Skip to main content

shopify_sdk/rest/resources/v2026_04/
discount_code.rs

1//! DiscountCode resource implementation.
2//!
3//! This module provides the [`DiscountCode`] resource for managing discount codes
4//! associated with price rules. Discount codes are the customer-facing codes that
5//! customers enter at checkout to apply price rule discounts.
6//!
7//! # Nested Resource
8//!
9//! DiscountCodes are nested under PriceRules:
10//! - `GET /price_rules/{price_rule_id}/discount_codes.json`
11//! - `POST /price_rules/{price_rule_id}/discount_codes.json`
12//! - `GET /price_rules/{price_rule_id}/discount_codes/{id}.json`
13//! - `PUT /price_rules/{price_rule_id}/discount_codes/{id}.json`
14//! - `DELETE /price_rules/{price_rule_id}/discount_codes/{id}.json`
15//!
16//! # Special Operations
17//!
18//! - **Lookup by code**: `DiscountCode::lookup(&client, "CODE")` finds a discount
19//!   code by its code string using a standalone path.
20//! - **Batch create**: `DiscountCode::batch(&client, price_rule_id, codes)` creates
21//!   multiple discount codes at once under a price rule.
22//!
23//! # Example
24//!
25//! ```rust,ignore
26//! use shopify_sdk::rest::{RestResource, ResourceResponse};
27//! use shopify_sdk::rest::resources::v2026_04::{DiscountCode, DiscountCodeListParams};
28//!
29//! // Create a discount code under a price rule
30//! let code = DiscountCode {
31//!     price_rule_id: Some(507328175),
32//!     code: Some("SUMMER20".to_string()),
33//!     ..Default::default()
34//! };
35//! let saved = code.save(&client).await?;
36//!
37//! // Lookup a discount code by its code string
38//! let found = DiscountCode::lookup(&client, "SUMMER20").await?;
39//!
40//! // List all discount codes for a price rule
41//! let codes = DiscountCode::all_with_parent(
42//!     &client,
43//!     "price_rule_id",
44//!     507328175,
45//!     None
46//! ).await?;
47//! ```
48
49use std::collections::HashMap;
50
51use chrono::{DateTime, Utc};
52use serde::{Deserialize, Serialize};
53
54use crate::clients::RestClient;
55use crate::rest::{
56    build_path, get_path, ResourceError, ResourceOperation, ResourcePath, ResourceResponse,
57    RestResource,
58};
59use crate::HttpMethod;
60
61/// A discount code associated with a price rule.
62///
63/// Discount codes are the customer-facing codes that shoppers enter at checkout.
64/// Each discount code belongs to a price rule that defines the discount logic.
65///
66/// # Nested Resource
67///
68/// This is a nested resource under `PriceRule`. Most operations require the
69/// parent `price_rule_id`.
70///
71/// Use `DiscountCode::all_with_parent()` to list codes under a specific price rule.
72///
73/// # Fields
74///
75/// ## Read-Only Fields
76/// - `id` - The unique identifier
77/// - `usage_count` - Number of times the code has been used
78/// - `errors` - Any errors associated with the code
79/// - `created_at` - When the code was created
80/// - `updated_at` - When the code was last updated
81///
82/// ## Writable Fields
83/// - `price_rule_id` - The parent price rule ID (required)
84/// - `code` - The discount code string customers enter
85#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
86pub struct DiscountCode {
87    /// The unique identifier of the discount code.
88    /// Read-only field.
89    #[serde(skip_serializing)]
90    pub id: Option<u64>,
91
92    /// The ID of the parent price rule.
93    /// Required for creating new discount codes.
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub price_rule_id: Option<u64>,
96
97    /// The discount code that customers enter at checkout.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub code: Option<String>,
100
101    /// The number of times this discount code has been used.
102    /// Read-only field.
103    #[serde(skip_serializing)]
104    pub usage_count: Option<i32>,
105
106    /// Any errors associated with this discount code.
107    /// Read-only field populated after batch creation.
108    #[serde(skip_serializing)]
109    pub errors: Option<Vec<DiscountCodeError>>,
110
111    /// When the discount code was created.
112    /// Read-only field.
113    #[serde(skip_serializing)]
114    pub created_at: Option<DateTime<Utc>>,
115
116    /// When the discount code was last updated.
117    /// Read-only field.
118    #[serde(skip_serializing)]
119    pub updated_at: Option<DateTime<Utc>>,
120}
121
122/// An error associated with a discount code.
123#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
124pub struct DiscountCodeError {
125    /// The error code.
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub code: Option<String>,
128
129    /// The error message.
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub message: Option<String>,
132}
133
134/// The result of a batch discount code creation.
135#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
136pub struct DiscountCodeBatchResult {
137    /// The ID of the batch job.
138    pub id: Option<u64>,
139
140    /// The price rule ID the batch is associated with.
141    pub price_rule_id: Option<u64>,
142
143    /// When the batch job started.
144    pub started_at: Option<DateTime<Utc>>,
145
146    /// When the batch job completed.
147    pub completed_at: Option<DateTime<Utc>>,
148
149    /// When the batch job was created.
150    pub created_at: Option<DateTime<Utc>>,
151
152    /// When the batch job was last updated.
153    pub updated_at: Option<DateTime<Utc>>,
154
155    /// The status of the batch job: "queued", "running", "completed".
156    pub status: Option<String>,
157
158    /// The number of codes processed.
159    pub codes_count: Option<i32>,
160
161    /// The number of codes imported successfully.
162    pub imported_count: Option<i32>,
163
164    /// The number of codes that failed to import.
165    pub failed_count: Option<i32>,
166
167    /// The log entries for the batch job.
168    pub logs: Option<Vec<String>>,
169}
170
171impl DiscountCode {
172    /// Counts discount codes under a specific price rule.
173    ///
174    /// # Arguments
175    ///
176    /// * `client` - The REST client to use for the request
177    /// * `price_rule_id` - The parent price rule ID
178    /// * `params` - Optional parameters for filtering
179    ///
180    /// # Returns
181    ///
182    /// The count of matching discount codes as a `u64`.
183    ///
184    /// # Errors
185    ///
186    /// Returns [`ResourceError::PathResolutionFailed`] if no count path exists.
187    ///
188    /// # Example
189    ///
190    /// ```rust,ignore
191    /// let count = DiscountCode::count_with_parent(&client, 507328175, None).await?;
192    /// println!("Total discount codes: {}", count);
193    /// ```
194    pub async fn count_with_parent(
195        client: &RestClient,
196        price_rule_id: u64,
197        params: Option<DiscountCodeCountParams>,
198    ) -> Result<u64, ResourceError> {
199        let mut ids: HashMap<&str, String> = HashMap::new();
200        ids.insert("price_rule_id", price_rule_id.to_string());
201
202        let available_ids: Vec<&str> = ids.keys().copied().collect();
203        let path = get_path(Self::PATHS, ResourceOperation::Count, &available_ids).ok_or(
204            ResourceError::PathResolutionFailed {
205                resource: Self::NAME,
206                operation: "count",
207            },
208        )?;
209
210        let url = build_path(path.template, &ids);
211
212        // Build query params
213        let query = params
214            .map(|p| {
215                let value = serde_json::to_value(&p).map_err(|e| {
216                    ResourceError::Http(crate::clients::HttpError::Response(
217                        crate::clients::HttpResponseError {
218                            code: 400,
219                            message: format!("Failed to serialize params: {e}"),
220                            error_reference: None,
221                        },
222                    ))
223                })?;
224
225                let mut query = HashMap::new();
226                if let serde_json::Value::Object(map) = value {
227                    for (key, val) in map {
228                        match val {
229                            serde_json::Value::String(s) => {
230                                query.insert(key, s);
231                            }
232                            serde_json::Value::Number(n) => {
233                                query.insert(key, n.to_string());
234                            }
235                            serde_json::Value::Bool(b) => {
236                                query.insert(key, b.to_string());
237                            }
238                            _ => {}
239                        }
240                    }
241                }
242                Ok::<_, ResourceError>(query)
243            })
244            .transpose()?
245            .filter(|q| !q.is_empty());
246
247        let response = client.get(&url, query).await?;
248
249        if !response.is_ok() {
250            return Err(ResourceError::from_http_response(
251                response.code,
252                &response.body,
253                Self::NAME,
254                None,
255                response.request_id(),
256            ));
257        }
258
259        let count = response
260            .body
261            .get("count")
262            .and_then(serde_json::Value::as_u64)
263            .ok_or_else(|| {
264                ResourceError::Http(crate::clients::HttpError::Response(
265                    crate::clients::HttpResponseError {
266                        code: response.code,
267                        message: "Missing 'count' in response".to_string(),
268                        error_reference: response.request_id().map(ToString::to_string),
269                    },
270                ))
271            })?;
272
273        Ok(count)
274    }
275
276    /// Finds a single discount code by ID under a price rule.
277    ///
278    /// # Arguments
279    ///
280    /// * `client` - The REST client to use for the request
281    /// * `price_rule_id` - The parent price rule ID
282    /// * `id` - The discount code ID to find
283    /// * `params` - Optional parameters for the request
284    ///
285    /// # Errors
286    ///
287    /// Returns [`ResourceError::NotFound`] if the discount code doesn't exist.
288    ///
289    /// # Example
290    ///
291    /// ```rust,ignore
292    /// let code = DiscountCode::find_with_parent(&client, 507328175, 123, None).await?;
293    /// ```
294    pub async fn find_with_parent(
295        client: &RestClient,
296        price_rule_id: u64,
297        id: u64,
298        _params: Option<DiscountCodeFindParams>,
299    ) -> Result<ResourceResponse<Self>, ResourceError> {
300        let mut ids: HashMap<&str, String> = HashMap::new();
301        ids.insert("price_rule_id", price_rule_id.to_string());
302        ids.insert("id", id.to_string());
303
304        let available_ids: Vec<&str> = ids.keys().copied().collect();
305        let path = get_path(Self::PATHS, ResourceOperation::Find, &available_ids).ok_or(
306            ResourceError::PathResolutionFailed {
307                resource: Self::NAME,
308                operation: "find",
309            },
310        )?;
311
312        let url = build_path(path.template, &ids);
313        let response = client.get(&url, None).await?;
314
315        if !response.is_ok() {
316            return Err(ResourceError::from_http_response(
317                response.code,
318                &response.body,
319                Self::NAME,
320                Some(&id.to_string()),
321                response.request_id(),
322            ));
323        }
324
325        let key = Self::resource_key();
326        ResourceResponse::from_http_response(response, &key)
327    }
328
329    /// Looks up a discount code by its code string.
330    ///
331    /// This uses a standalone path that doesn't require knowing the price rule ID.
332    /// Useful when you only know the discount code string.
333    ///
334    /// # Arguments
335    ///
336    /// * `client` - The REST client to use for the request
337    /// * `code` - The discount code string to look up
338    ///
339    /// # Returns
340    ///
341    /// The discount code if found.
342    ///
343    /// # Errors
344    ///
345    /// Returns [`ResourceError::NotFound`] if no discount code with that code exists.
346    ///
347    /// # Example
348    ///
349    /// ```rust,ignore
350    /// let code = DiscountCode::lookup(&client, "SUMMER20").await?;
351    /// println!("Found discount code: {:?}", code.price_rule_id);
352    /// ```
353    pub async fn lookup(
354        client: &RestClient,
355        code: &str,
356    ) -> Result<ResourceResponse<Self>, ResourceError> {
357        let url = "discount_codes/lookup";
358        let mut query = HashMap::new();
359        query.insert("code".to_string(), code.to_string());
360
361        let response = client.get(url, Some(query)).await?;
362
363        if !response.is_ok() {
364            return Err(ResourceError::from_http_response(
365                response.code,
366                &response.body,
367                Self::NAME,
368                Some(code),
369                response.request_id(),
370            ));
371        }
372
373        let key = Self::resource_key();
374        ResourceResponse::from_http_response(response, &key)
375    }
376
377    /// Creates multiple discount codes in a batch.
378    ///
379    /// This starts an asynchronous job to create multiple discount codes
380    /// under a price rule. Use this for bulk creation of codes.
381    ///
382    /// # Arguments
383    ///
384    /// * `client` - The REST client to use for the request
385    /// * `price_rule_id` - The price rule ID to create codes under
386    /// * `codes` - A list of code strings to create
387    ///
388    /// # Returns
389    ///
390    /// A batch result containing the job status.
391    ///
392    /// # Example
393    ///
394    /// ```rust,ignore
395    /// let result = DiscountCode::batch(
396    ///     &client,
397    ///     507328175,
398    ///     vec!["CODE1".to_string(), "CODE2".to_string(), "CODE3".to_string()]
399    /// ).await?;
400    /// println!("Batch job status: {:?}", result.status);
401    /// ```
402    pub async fn batch(
403        client: &RestClient,
404        price_rule_id: u64,
405        codes: Vec<String>,
406    ) -> Result<DiscountCodeBatchResult, ResourceError> {
407        let url = format!("price_rules/{price_rule_id}/batch");
408
409        // Build the request body
410        let discount_codes: Vec<serde_json::Value> = codes
411            .into_iter()
412            .map(|code| serde_json::json!({ "code": code }))
413            .collect();
414
415        let body = serde_json::json!({
416            "discount_codes": discount_codes
417        });
418
419        let response = client.post(&url, body, None).await?;
420
421        if !response.is_ok() {
422            return Err(ResourceError::from_http_response(
423                response.code,
424                &response.body,
425                Self::NAME,
426                None,
427                response.request_id(),
428            ));
429        }
430
431        // Parse the batch result from the response
432        let result = response.body.get("discount_code_creation").ok_or_else(|| {
433            ResourceError::Http(crate::clients::HttpError::Response(
434                crate::clients::HttpResponseError {
435                    code: response.code,
436                    message: "Missing 'discount_code_creation' in response".to_string(),
437                    error_reference: response.request_id().map(ToString::to_string),
438                },
439            ))
440        })?;
441
442        let batch_result: DiscountCodeBatchResult = serde_json::from_value(result.clone())
443            .map_err(|e| {
444                ResourceError::Http(crate::clients::HttpError::Response(
445                    crate::clients::HttpResponseError {
446                        code: response.code,
447                        message: format!("Failed to parse batch result: {e}"),
448                        error_reference: response.request_id().map(ToString::to_string),
449                    },
450                ))
451            })?;
452
453        Ok(batch_result)
454    }
455
456    /// Gets the status of a batch discount code creation job.
457    ///
458    /// # Arguments
459    ///
460    /// * `client` - The REST client to use for the request
461    /// * `price_rule_id` - The price rule ID
462    /// * `batch_id` - The batch job ID from `batch()`
463    ///
464    /// # Returns
465    ///
466    /// The current batch job status.
467    ///
468    /// # Example
469    ///
470    /// ```rust,ignore
471    /// let status = DiscountCode::batch_status(&client, 507328175, 123).await?;
472    /// println!("Status: {:?}, Imported: {:?}", status.status, status.imported_count);
473    /// ```
474    pub async fn batch_status(
475        client: &RestClient,
476        price_rule_id: u64,
477        batch_id: u64,
478    ) -> Result<DiscountCodeBatchResult, ResourceError> {
479        let url = format!("price_rules/{price_rule_id}/batch/{batch_id}");
480
481        let response = client.get(&url, None).await?;
482
483        if !response.is_ok() {
484            return Err(ResourceError::from_http_response(
485                response.code,
486                &response.body,
487                Self::NAME,
488                Some(&batch_id.to_string()),
489                response.request_id(),
490            ));
491        }
492
493        let result = response.body.get("discount_code_creation").ok_or_else(|| {
494            ResourceError::Http(crate::clients::HttpError::Response(
495                crate::clients::HttpResponseError {
496                    code: response.code,
497                    message: "Missing 'discount_code_creation' in response".to_string(),
498                    error_reference: response.request_id().map(ToString::to_string),
499                },
500            ))
501        })?;
502
503        let batch_result: DiscountCodeBatchResult = serde_json::from_value(result.clone())
504            .map_err(|e| {
505                ResourceError::Http(crate::clients::HttpError::Response(
506                    crate::clients::HttpResponseError {
507                        code: response.code,
508                        message: format!("Failed to parse batch result: {e}"),
509                        error_reference: response.request_id().map(ToString::to_string),
510                    },
511                ))
512            })?;
513
514        Ok(batch_result)
515    }
516
517    /// Gets the discount codes from a completed batch job.
518    ///
519    /// # Arguments
520    ///
521    /// * `client` - The REST client to use for the request
522    /// * `price_rule_id` - The price rule ID
523    /// * `batch_id` - The batch job ID from `batch()`
524    ///
525    /// # Returns
526    ///
527    /// A list of discount codes created by the batch job.
528    ///
529    /// # Example
530    ///
531    /// ```rust,ignore
532    /// let codes = DiscountCode::batch_codes(&client, 507328175, 123).await?;
533    /// for code in codes {
534    ///     println!("Created code: {:?}", code.code);
535    /// }
536    /// ```
537    pub async fn batch_codes(
538        client: &RestClient,
539        price_rule_id: u64,
540        batch_id: u64,
541    ) -> Result<Vec<Self>, ResourceError> {
542        let url = format!("price_rules/{price_rule_id}/batch/{batch_id}/discount_codes");
543
544        let response = client.get(&url, None).await?;
545
546        if !response.is_ok() {
547            return Err(ResourceError::from_http_response(
548                response.code,
549                &response.body,
550                Self::NAME,
551                Some(&batch_id.to_string()),
552                response.request_id(),
553            ));
554        }
555
556        let codes_value = response.body.get(Self::PLURAL).ok_or_else(|| {
557            ResourceError::Http(crate::clients::HttpError::Response(
558                crate::clients::HttpResponseError {
559                    code: response.code,
560                    message: format!("Missing '{}' in response", Self::PLURAL),
561                    error_reference: response.request_id().map(ToString::to_string),
562                },
563            ))
564        })?;
565
566        let codes: Vec<Self> = serde_json::from_value(codes_value.clone()).map_err(|e| {
567            ResourceError::Http(crate::clients::HttpError::Response(
568                crate::clients::HttpResponseError {
569                    code: response.code,
570                    message: format!("Failed to parse discount codes: {e}"),
571                    error_reference: response.request_id().map(ToString::to_string),
572                },
573            ))
574        })?;
575
576        Ok(codes)
577    }
578}
579
580impl RestResource for DiscountCode {
581    type Id = u64;
582    type FindParams = DiscountCodeFindParams;
583    type AllParams = DiscountCodeListParams;
584    type CountParams = DiscountCodeCountParams;
585
586    const NAME: &'static str = "DiscountCode";
587    const PLURAL: &'static str = "discount_codes";
588
589    /// Paths for the DiscountCode resource.
590    ///
591    /// All paths except lookup require `price_rule_id` as DiscountCodes
592    /// are nested under PriceRules.
593    const PATHS: &'static [ResourcePath] = &[
594        ResourcePath::new(
595            HttpMethod::Get,
596            ResourceOperation::Find,
597            &["price_rule_id", "id"],
598            "price_rules/{price_rule_id}/discount_codes/{id}",
599        ),
600        ResourcePath::new(
601            HttpMethod::Get,
602            ResourceOperation::All,
603            &["price_rule_id"],
604            "price_rules/{price_rule_id}/discount_codes",
605        ),
606        ResourcePath::new(
607            HttpMethod::Get,
608            ResourceOperation::Count,
609            &["price_rule_id"],
610            "price_rules/{price_rule_id}/discount_codes/count",
611        ),
612        ResourcePath::new(
613            HttpMethod::Post,
614            ResourceOperation::Create,
615            &["price_rule_id"],
616            "price_rules/{price_rule_id}/discount_codes",
617        ),
618        ResourcePath::new(
619            HttpMethod::Put,
620            ResourceOperation::Update,
621            &["price_rule_id", "id"],
622            "price_rules/{price_rule_id}/discount_codes/{id}",
623        ),
624        ResourcePath::new(
625            HttpMethod::Delete,
626            ResourceOperation::Delete,
627            &["price_rule_id", "id"],
628            "price_rules/{price_rule_id}/discount_codes/{id}",
629        ),
630    ];
631
632    fn get_id(&self) -> Option<Self::Id> {
633        self.id
634    }
635}
636
637/// Parameters for finding a single discount code.
638#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
639pub struct DiscountCodeFindParams {
640    /// Comma-separated list of fields to include in the response.
641    #[serde(skip_serializing_if = "Option::is_none")]
642    pub fields: Option<String>,
643}
644
645/// Parameters for listing discount codes.
646#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
647pub struct DiscountCodeListParams {
648    /// Maximum number of results to return (default: 50, max: 250).
649    #[serde(skip_serializing_if = "Option::is_none")]
650    pub limit: Option<u32>,
651
652    /// Return codes after this ID.
653    #[serde(skip_serializing_if = "Option::is_none")]
654    pub since_id: Option<u64>,
655
656    /// Cursor for pagination.
657    #[serde(skip_serializing_if = "Option::is_none")]
658    pub page_info: Option<String>,
659
660    /// Comma-separated list of fields to include in the response.
661    #[serde(skip_serializing_if = "Option::is_none")]
662    pub fields: Option<String>,
663}
664
665/// Parameters for counting discount codes.
666#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
667pub struct DiscountCodeCountParams {
668    /// Filter by times used.
669    #[serde(skip_serializing_if = "Option::is_none")]
670    pub times_used: Option<i32>,
671
672    /// Filter by minimum times used.
673    #[serde(skip_serializing_if = "Option::is_none")]
674    pub times_used_min: Option<i32>,
675
676    /// Filter by maximum times used.
677    #[serde(skip_serializing_if = "Option::is_none")]
678    pub times_used_max: Option<i32>,
679}
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684    use crate::rest::{get_path, ResourceOperation};
685
686    #[test]
687    fn test_discount_code_serialization() {
688        let code = DiscountCode {
689            id: Some(12345),
690            price_rule_id: Some(507328175),
691            code: Some("SUMMER20".to_string()),
692            usage_count: Some(42),
693            errors: None,
694            created_at: Some(
695                DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
696                    .unwrap()
697                    .with_timezone(&Utc),
698            ),
699            updated_at: Some(
700                DateTime::parse_from_rfc3339("2024-06-20T15:45:00Z")
701                    .unwrap()
702                    .with_timezone(&Utc),
703            ),
704        };
705
706        let json = serde_json::to_string(&code).unwrap();
707        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
708
709        // Writable fields should be present
710        assert_eq!(parsed["price_rule_id"], 507328175);
711        assert_eq!(parsed["code"], "SUMMER20");
712
713        // Read-only fields should be omitted
714        assert!(parsed.get("id").is_none());
715        assert!(parsed.get("usage_count").is_none());
716        assert!(parsed.get("errors").is_none());
717        assert!(parsed.get("created_at").is_none());
718        assert!(parsed.get("updated_at").is_none());
719    }
720
721    #[test]
722    fn test_discount_code_deserialization() {
723        let json = r#"{
724            "id": 1054381139,
725            "price_rule_id": 507328175,
726            "code": "SUMMERSALE20OFF",
727            "usage_count": 25,
728            "errors": [],
729            "created_at": "2024-01-15T10:30:00Z",
730            "updated_at": "2024-06-20T15:45:00Z"
731        }"#;
732
733        let code: DiscountCode = serde_json::from_str(json).unwrap();
734
735        assert_eq!(code.id, Some(1054381139));
736        assert_eq!(code.price_rule_id, Some(507328175));
737        assert_eq!(code.code, Some("SUMMERSALE20OFF".to_string()));
738        assert_eq!(code.usage_count, Some(25));
739        assert!(code.errors.is_some());
740        assert!(code.errors.unwrap().is_empty());
741        assert!(code.created_at.is_some());
742        assert!(code.updated_at.is_some());
743    }
744
745    #[test]
746    fn test_discount_code_nested_paths() {
747        // All paths should require price_rule_id
748
749        // Find requires both price_rule_id and id
750        let find_path = get_path(
751            DiscountCode::PATHS,
752            ResourceOperation::Find,
753            &["price_rule_id", "id"],
754        );
755        assert!(find_path.is_some());
756        assert_eq!(
757            find_path.unwrap().template,
758            "price_rules/{price_rule_id}/discount_codes/{id}"
759        );
760
761        // Find with only id should fail (no standalone path)
762        let find_without_parent = get_path(DiscountCode::PATHS, ResourceOperation::Find, &["id"]);
763        assert!(find_without_parent.is_none());
764
765        // All requires price_rule_id
766        let all_path = get_path(
767            DiscountCode::PATHS,
768            ResourceOperation::All,
769            &["price_rule_id"],
770        );
771        assert!(all_path.is_some());
772        assert_eq!(
773            all_path.unwrap().template,
774            "price_rules/{price_rule_id}/discount_codes"
775        );
776
777        // All without parent should fail
778        let all_without_parent = get_path(DiscountCode::PATHS, ResourceOperation::All, &[]);
779        assert!(all_without_parent.is_none());
780
781        // Count requires price_rule_id
782        let count_path = get_path(
783            DiscountCode::PATHS,
784            ResourceOperation::Count,
785            &["price_rule_id"],
786        );
787        assert!(count_path.is_some());
788        assert_eq!(
789            count_path.unwrap().template,
790            "price_rules/{price_rule_id}/discount_codes/count"
791        );
792
793        // Create requires price_rule_id
794        let create_path = get_path(
795            DiscountCode::PATHS,
796            ResourceOperation::Create,
797            &["price_rule_id"],
798        );
799        assert!(create_path.is_some());
800        assert_eq!(
801            create_path.unwrap().template,
802            "price_rules/{price_rule_id}/discount_codes"
803        );
804        assert_eq!(create_path.unwrap().http_method, HttpMethod::Post);
805
806        // Update requires both price_rule_id and id
807        let update_path = get_path(
808            DiscountCode::PATHS,
809            ResourceOperation::Update,
810            &["price_rule_id", "id"],
811        );
812        assert!(update_path.is_some());
813        assert_eq!(
814            update_path.unwrap().template,
815            "price_rules/{price_rule_id}/discount_codes/{id}"
816        );
817        assert_eq!(update_path.unwrap().http_method, HttpMethod::Put);
818
819        // Delete requires both price_rule_id and id
820        let delete_path = get_path(
821            DiscountCode::PATHS,
822            ResourceOperation::Delete,
823            &["price_rule_id", "id"],
824        );
825        assert!(delete_path.is_some());
826        assert_eq!(
827            delete_path.unwrap().template,
828            "price_rules/{price_rule_id}/discount_codes/{id}"
829        );
830        assert_eq!(delete_path.unwrap().http_method, HttpMethod::Delete);
831    }
832
833    #[test]
834    fn test_discount_code_lookup_is_standalone_path() {
835        // The lookup method uses a standalone path "discount_codes/lookup"
836        // which doesn't require price_rule_id
837        // This is tested by the method signature - it only takes client and code
838        // No path in PATHS array for this - it's a special method
839    }
840
841    #[test]
842    fn test_discount_code_batch_result_deserialization() {
843        let json = r#"{
844            "id": 173232803,
845            "price_rule_id": 507328175,
846            "started_at": "2024-06-15T10:00:00Z",
847            "completed_at": "2024-06-15T10:05:00Z",
848            "created_at": "2024-06-15T09:55:00Z",
849            "updated_at": "2024-06-15T10:05:00Z",
850            "status": "completed",
851            "codes_count": 3,
852            "imported_count": 3,
853            "failed_count": 0,
854            "logs": []
855        }"#;
856
857        let result: DiscountCodeBatchResult = serde_json::from_str(json).unwrap();
858
859        assert_eq!(result.id, Some(173232803));
860        assert_eq!(result.price_rule_id, Some(507328175));
861        assert_eq!(result.status, Some("completed".to_string()));
862        assert_eq!(result.codes_count, Some(3));
863        assert_eq!(result.imported_count, Some(3));
864        assert_eq!(result.failed_count, Some(0));
865        assert!(result.started_at.is_some());
866        assert!(result.completed_at.is_some());
867    }
868
869    #[test]
870    fn test_discount_code_count_params() {
871        let params = DiscountCodeCountParams {
872            times_used: Some(5),
873            times_used_min: Some(1),
874            times_used_max: Some(100),
875        };
876
877        let json = serde_json::to_value(&params).unwrap();
878
879        assert_eq!(json["times_used"], 5);
880        assert_eq!(json["times_used_min"], 1);
881        assert_eq!(json["times_used_max"], 100);
882
883        // Empty params should serialize to empty object
884        let empty_params = DiscountCodeCountParams::default();
885        let empty_json = serde_json::to_value(&empty_params).unwrap();
886        assert_eq!(empty_json, serde_json::json!({}));
887    }
888
889    #[test]
890    fn test_discount_code_constants() {
891        assert_eq!(DiscountCode::NAME, "DiscountCode");
892        assert_eq!(DiscountCode::PLURAL, "discount_codes");
893    }
894
895    #[test]
896    fn test_discount_code_get_id() {
897        let code_with_id = DiscountCode {
898            id: Some(12345),
899            code: Some("TEST".to_string()),
900            ..Default::default()
901        };
902        assert_eq!(code_with_id.get_id(), Some(12345));
903
904        let code_without_id = DiscountCode::default();
905        assert_eq!(code_without_id.get_id(), None);
906    }
907}