Skip to main content

shopify_sdk/rest/resources/v2025_10/
usage_charge.rs

1//! UsageCharge resource implementation.
2//!
3//! This module provides the [`UsageCharge`] resource for managing usage-based
4//! charges in Shopify apps. Usage charges are created under a parent
5//! `RecurringApplicationCharge` that has a `capped_amount` set.
6//!
7//! # Nested Resource
8//!
9//! UsageCharges are nested under RecurringApplicationCharges:
10//! - `GET /recurring_application_charges/{charge_id}/usage_charges.json`
11//! - `POST /recurring_application_charges/{charge_id}/usage_charges.json`
12//! - `GET /recurring_application_charges/{charge_id}/usage_charges/{id}.json`
13//!
14//! Note: Usage charges cannot be updated or deleted after creation.
15//!
16//! # Usage-Based Billing
17//!
18//! To implement usage-based billing:
19//!
20//! 1. Create a `RecurringApplicationCharge` with a `capped_amount`
21//! 2. As the merchant uses resources, create `UsageCharge` records
22//! 3. Charges accumulate up to the `capped_amount` per billing period
23//!
24//! # Example
25//!
26//! ```rust,ignore
27//! use shopify_sdk::rest::{RestResource, ResourceResponse};
28//! use shopify_sdk::rest::resources::v2025_10::{UsageCharge, UsageChargeListParams};
29//!
30//! // Create a usage charge under a recurring charge
31//! let usage = UsageCharge {
32//!     recurring_application_charge_id: Some(455696195),
33//!     description: Some("100 emails sent".to_string()),
34//!     price: Some("1.00".to_string()),
35//!     ..Default::default()
36//! };
37//! let saved = usage.save(&client).await?;
38//!
39//! // List all usage charges for a recurring charge
40//! let usages = UsageCharge::all_with_parent(
41//!     &client,
42//!     "recurring_application_charge_id",
43//!     455696195,
44//!     None
45//! ).await?;
46//! ```
47
48use std::collections::HashMap;
49
50use chrono::{DateTime, Utc};
51use serde::{Deserialize, Serialize};
52
53use crate::clients::RestClient;
54use crate::rest::{
55    build_path, get_path, ResourceError, ResourceOperation, ResourcePath, ResourceResponse,
56    RestResource,
57};
58use crate::HttpMethod;
59
60use super::common::ChargeCurrency;
61
62/// A usage-based charge under a recurring application charge.
63///
64/// Usage charges allow apps to bill merchants based on resource consumption.
65/// They require a parent `RecurringApplicationCharge` with a `capped_amount`.
66///
67/// # Nested Resource
68///
69/// This is a nested resource under `RecurringApplicationCharge`. All operations
70/// require the parent `recurring_application_charge_id`.
71///
72/// Use `UsageCharge::all_with_parent()` to list charges under a specific
73/// recurring charge.
74///
75/// # Fields
76///
77/// ## Read-Only Fields
78/// - `id` - The unique identifier of the usage charge
79/// - `currency` - The currency object with the currency code
80/// - `created_at` - When the charge was created
81/// - `updated_at` - When the charge was last updated
82///
83/// ## Writable Fields
84/// - `recurring_application_charge_id` - The parent charge ID (required)
85/// - `description` - Description shown to merchant
86/// - `price` - The price of this usage
87#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
88pub struct UsageCharge {
89    /// The unique identifier of the usage charge.
90    /// Read-only field.
91    #[serde(skip_serializing)]
92    pub id: Option<u64>,
93
94    /// The ID of the parent recurring application charge.
95    /// Required for creating new usage charges.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub recurring_application_charge_id: Option<u64>,
98
99    /// The description of the usage charge.
100    /// Displayed to the merchant on their invoice.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub description: Option<String>,
103
104    /// The price of the usage charge.
105    /// Must be a string representing the monetary amount (e.g., "1.00").
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub price: Option<String>,
108
109    /// The currency information for the charge.
110    /// Read-only field containing the currency code.
111    #[serde(skip_serializing)]
112    pub currency: Option<ChargeCurrency>,
113
114    /// When the charge was created.
115    /// Read-only field.
116    #[serde(skip_serializing)]
117    pub created_at: Option<DateTime<Utc>>,
118
119    /// When the charge was last updated.
120    /// Read-only field.
121    #[serde(skip_serializing)]
122    pub updated_at: Option<DateTime<Utc>>,
123}
124
125impl UsageCharge {
126    /// Counts usage charges under a specific recurring application charge.
127    ///
128    /// # Arguments
129    ///
130    /// * `client` - The REST client to use for the request
131    /// * `recurring_application_charge_id` - The parent charge ID
132    /// * `params` - Optional parameters for filtering
133    ///
134    /// # Returns
135    ///
136    /// The count of matching usage charges as a `u64`.
137    ///
138    /// # Errors
139    ///
140    /// Returns [`ResourceError::PathResolutionFailed`] if no count path exists.
141    ///
142    /// # Example
143    ///
144    /// ```rust,ignore
145    /// let count = UsageCharge::count_with_parent(&client, 455696195, None).await?;
146    /// println!("Total usage charges: {}", count);
147    /// ```
148    pub async fn count_with_parent(
149        client: &RestClient,
150        recurring_application_charge_id: u64,
151        _params: Option<()>,
152    ) -> Result<u64, ResourceError> {
153        let mut ids: HashMap<&str, String> = HashMap::new();
154        ids.insert(
155            "recurring_application_charge_id",
156            recurring_application_charge_id.to_string(),
157        );
158
159        let available_ids: Vec<&str> = ids.keys().copied().collect();
160        let path = get_path(Self::PATHS, ResourceOperation::Count, &available_ids).ok_or(
161            ResourceError::PathResolutionFailed {
162                resource: Self::NAME,
163                operation: "count",
164            },
165        )?;
166
167        let url = build_path(path.template, &ids);
168        let response = client.get(&url, None).await?;
169
170        if !response.is_ok() {
171            return Err(ResourceError::from_http_response(
172                response.code,
173                &response.body,
174                Self::NAME,
175                None,
176                response.request_id(),
177            ));
178        }
179
180        let count = response
181            .body
182            .get("count")
183            .and_then(serde_json::Value::as_u64)
184            .ok_or_else(|| {
185                ResourceError::Http(crate::clients::HttpError::Response(
186                    crate::clients::HttpResponseError {
187                        code: response.code,
188                        message: "Missing 'count' in response".to_string(),
189                        error_reference: response.request_id().map(ToString::to_string),
190                    },
191                ))
192            })?;
193
194        Ok(count)
195    }
196
197    /// Finds a single usage charge by ID under a recurring application charge.
198    ///
199    /// # Arguments
200    ///
201    /// * `client` - The REST client to use for the request
202    /// * `recurring_application_charge_id` - The parent charge ID
203    /// * `id` - The usage charge ID to find
204    /// * `params` - Optional parameters for the request
205    ///
206    /// # Errors
207    ///
208    /// Returns [`ResourceError::NotFound`] if the usage charge doesn't exist.
209    ///
210    /// # Example
211    ///
212    /// ```rust,ignore
213    /// let usage = UsageCharge::find_with_parent(&client, 455696195, 123, None).await?;
214    /// ```
215    pub async fn find_with_parent(
216        client: &RestClient,
217        recurring_application_charge_id: u64,
218        id: u64,
219        _params: Option<UsageChargeFindParams>,
220    ) -> Result<ResourceResponse<Self>, ResourceError> {
221        let mut ids: HashMap<&str, String> = HashMap::new();
222        ids.insert(
223            "recurring_application_charge_id",
224            recurring_application_charge_id.to_string(),
225        );
226        ids.insert("id", id.to_string());
227
228        let available_ids: Vec<&str> = ids.keys().copied().collect();
229        let path = get_path(Self::PATHS, ResourceOperation::Find, &available_ids).ok_or(
230            ResourceError::PathResolutionFailed {
231                resource: Self::NAME,
232                operation: "find",
233            },
234        )?;
235
236        let url = build_path(path.template, &ids);
237        let response = client.get(&url, None).await?;
238
239        if !response.is_ok() {
240            return Err(ResourceError::from_http_response(
241                response.code,
242                &response.body,
243                Self::NAME,
244                Some(&id.to_string()),
245                response.request_id(),
246            ));
247        }
248
249        let key = Self::resource_key();
250        ResourceResponse::from_http_response(response, &key)
251    }
252}
253
254impl RestResource for UsageCharge {
255    type Id = u64;
256    type FindParams = UsageChargeFindParams;
257    type AllParams = UsageChargeListParams;
258    type CountParams = ();
259
260    const NAME: &'static str = "UsageCharge";
261    const PLURAL: &'static str = "usage_charges";
262
263    /// Paths for the UsageCharge resource.
264    ///
265    /// All paths require `recurring_application_charge_id` as UsageCharges
266    /// are nested under RecurringApplicationCharges.
267    ///
268    /// Note: No Update or Delete paths - usage charges cannot be modified.
269    const PATHS: &'static [ResourcePath] = &[
270        ResourcePath::new(
271            HttpMethod::Get,
272            ResourceOperation::Find,
273            &["recurring_application_charge_id", "id"],
274            "recurring_application_charges/{recurring_application_charge_id}/usage_charges/{id}",
275        ),
276        ResourcePath::new(
277            HttpMethod::Get,
278            ResourceOperation::All,
279            &["recurring_application_charge_id"],
280            "recurring_application_charges/{recurring_application_charge_id}/usage_charges",
281        ),
282        ResourcePath::new(
283            HttpMethod::Post,
284            ResourceOperation::Create,
285            &["recurring_application_charge_id"],
286            "recurring_application_charges/{recurring_application_charge_id}/usage_charges",
287        ),
288        // Note: Count path not officially documented but following pattern
289        // No Update or Delete paths - usage charges cannot be modified after creation
290    ];
291
292    fn get_id(&self) -> Option<Self::Id> {
293        self.id
294    }
295}
296
297/// Parameters for finding a single usage charge.
298#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
299pub struct UsageChargeFindParams {
300    /// Comma-separated list of fields to include in the response.
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub fields: Option<String>,
303}
304
305/// Parameters for listing usage charges.
306#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
307pub struct UsageChargeListParams {
308    /// Maximum number of results to return (default: 50, max: 250).
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub limit: Option<u32>,
311
312    /// Return charges after this ID.
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub since_id: Option<u64>,
315
316    /// Comma-separated list of fields to include in the response.
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub fields: Option<String>,
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::rest::{get_path, ResourceOperation};
325
326    #[test]
327    fn test_usage_charge_serialization() {
328        let charge = UsageCharge {
329            id: Some(12345),
330            recurring_application_charge_id: Some(455696195),
331            description: Some("100 emails sent".to_string()),
332            price: Some("1.00".to_string()),
333            currency: Some(ChargeCurrency::new("USD")),
334            created_at: Some(
335                DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
336                    .unwrap()
337                    .with_timezone(&Utc),
338            ),
339            updated_at: Some(
340                DateTime::parse_from_rfc3339("2024-01-15T10:35:00Z")
341                    .unwrap()
342                    .with_timezone(&Utc),
343            ),
344        };
345
346        let json = serde_json::to_string(&charge).unwrap();
347        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
348
349        // Writable fields should be present
350        assert_eq!(parsed["recurring_application_charge_id"], 455696195);
351        assert_eq!(parsed["description"], "100 emails sent");
352        assert_eq!(parsed["price"], "1.00");
353
354        // Read-only fields should be omitted
355        assert!(parsed.get("id").is_none());
356        assert!(parsed.get("currency").is_none());
357        assert!(parsed.get("created_at").is_none());
358        assert!(parsed.get("updated_at").is_none());
359    }
360
361    #[test]
362    fn test_usage_charge_deserialization() {
363        let json = r#"{
364            "id": 1034618207,
365            "recurring_application_charge_id": 455696195,
366            "description": "Super Mega Plan 1000 emails",
367            "price": "1.00",
368            "currency": {
369                "currency": "USD"
370            },
371            "created_at": "2024-01-15T10:30:00Z",
372            "updated_at": "2024-01-15T10:35:00Z"
373        }"#;
374
375        let charge: UsageCharge = serde_json::from_str(json).unwrap();
376
377        assert_eq!(charge.id, Some(1034618207));
378        assert_eq!(charge.recurring_application_charge_id, Some(455696195));
379        assert_eq!(
380            charge.description,
381            Some("Super Mega Plan 1000 emails".to_string())
382        );
383        assert_eq!(charge.price, Some("1.00".to_string()));
384        assert_eq!(charge.currency.as_ref().unwrap().code(), Some("USD"));
385        assert!(charge.created_at.is_some());
386        assert!(charge.updated_at.is_some());
387    }
388
389    #[test]
390    fn test_usage_charge_nested_paths() {
391        // All paths should require recurring_application_charge_id
392
393        // Find requires both recurring_application_charge_id and id
394        let find_path = get_path(
395            UsageCharge::PATHS,
396            ResourceOperation::Find,
397            &["recurring_application_charge_id", "id"],
398        );
399        assert!(find_path.is_some());
400        assert_eq!(
401            find_path.unwrap().template,
402            "recurring_application_charges/{recurring_application_charge_id}/usage_charges/{id}"
403        );
404
405        // Find with only id should fail (no standalone path)
406        let find_without_parent = get_path(UsageCharge::PATHS, ResourceOperation::Find, &["id"]);
407        assert!(find_without_parent.is_none());
408
409        // All requires recurring_application_charge_id
410        let all_path = get_path(
411            UsageCharge::PATHS,
412            ResourceOperation::All,
413            &["recurring_application_charge_id"],
414        );
415        assert!(all_path.is_some());
416        assert_eq!(
417            all_path.unwrap().template,
418            "recurring_application_charges/{recurring_application_charge_id}/usage_charges"
419        );
420
421        // All without parent should fail
422        let all_without_parent = get_path(UsageCharge::PATHS, ResourceOperation::All, &[]);
423        assert!(all_without_parent.is_none());
424
425        // Create requires recurring_application_charge_id
426        let create_path = get_path(
427            UsageCharge::PATHS,
428            ResourceOperation::Create,
429            &["recurring_application_charge_id"],
430        );
431        assert!(create_path.is_some());
432        assert_eq!(
433            create_path.unwrap().template,
434            "recurring_application_charges/{recurring_application_charge_id}/usage_charges"
435        );
436        assert_eq!(create_path.unwrap().http_method, HttpMethod::Post);
437
438        // No Update path
439        let update_path = get_path(
440            UsageCharge::PATHS,
441            ResourceOperation::Update,
442            &["recurring_application_charge_id", "id"],
443        );
444        assert!(update_path.is_none());
445
446        // No Delete path
447        let delete_path = get_path(
448            UsageCharge::PATHS,
449            ResourceOperation::Delete,
450            &["recurring_application_charge_id", "id"],
451        );
452        assert!(delete_path.is_none());
453    }
454
455    #[test]
456    fn test_usage_charge_list_params() {
457        let params = UsageChargeListParams {
458            limit: Some(50),
459            since_id: Some(100),
460            fields: Some("id,description,price".to_string()),
461        };
462
463        let json = serde_json::to_value(&params).unwrap();
464        assert_eq!(json["limit"], 50);
465        assert_eq!(json["since_id"], 100);
466        assert_eq!(json["fields"], "id,description,price");
467
468        // Empty params should serialize to empty object
469        let empty_params = UsageChargeListParams::default();
470        let empty_json = serde_json::to_value(&empty_params).unwrap();
471        assert_eq!(empty_json, serde_json::json!({}));
472    }
473
474    #[test]
475    fn test_usage_charge_constants() {
476        assert_eq!(UsageCharge::NAME, "UsageCharge");
477        assert_eq!(UsageCharge::PLURAL, "usage_charges");
478    }
479
480    #[test]
481    fn test_usage_charge_get_id() {
482        let charge_with_id = UsageCharge {
483            id: Some(12345),
484            ..Default::default()
485        };
486        assert_eq!(charge_with_id.get_id(), Some(12345));
487
488        let charge_without_id = UsageCharge::default();
489        assert_eq!(charge_without_id.get_id(), None);
490    }
491
492    #[test]
493    fn test_usage_charge_with_currency_nested_object() {
494        // Test that currency deserializes correctly as a nested object
495        let json = r#"{
496            "id": 123,
497            "recurring_application_charge_id": 456,
498            "description": "Test charge",
499            "price": "5.00",
500            "currency": {
501                "currency": "EUR"
502            }
503        }"#;
504
505        let charge: UsageCharge = serde_json::from_str(json).unwrap();
506        assert!(charge.currency.is_some());
507        let currency = charge.currency.unwrap();
508        assert_eq!(currency.code(), Some("EUR"));
509    }
510}