Skip to main content

shopify_sdk/rest/resources/v2025_10/
application_charge.rs

1//! ApplicationCharge resource implementation.
2//!
3//! This module provides the [`ApplicationCharge`] resource for managing one-time
4//! charges in Shopify apps. Application charges are used to bill merchants
5//! for one-time purchases within an app.
6//!
7//! # API Endpoints
8//!
9//! - `GET /application_charges.json` - List all application charges
10//! - `POST /application_charges.json` - Create a new application charge
11//! - `GET /application_charges/{id}.json` - Retrieve a single application charge
12//!
13//! Note: Application charges cannot be updated or deleted after creation.
14//!
15//! # Example
16//!
17//! ```rust,ignore
18//! use shopify_sdk::rest::{RestResource, ResourceResponse};
19//! use shopify_sdk::rest::resources::v2025_10::{ApplicationCharge, ApplicationChargeListParams};
20//!
21//! // Create a new application charge
22//! let charge = ApplicationCharge {
23//!     name: Some("Super Widget".to_string()),
24//!     price: Some("9.99".to_string()),
25//!     return_url: Some("https://myapp.com/charge-callback".to_string()),
26//!     test: Some(true), // Test charges don't actually bill
27//!     ..Default::default()
28//! };
29//! let saved = charge.save(&client).await?;
30//!
31//! // Redirect merchant to the confirmation_url for approval
32//! if let Some(url) = saved.confirmation_url.as_ref() {
33//!     println!("Redirect merchant to: {}", url);
34//! }
35//!
36//! // Check charge status
37//! if saved.is_active() {
38//!     println!("Charge was accepted!");
39//! }
40//!
41//! // List all charges
42//! let charges = ApplicationCharge::all(&client, None).await?;
43//! ```
44
45use chrono::{DateTime, Utc};
46use serde::{Deserialize, Serialize};
47
48use crate::rest::{ResourceOperation, ResourcePath, RestResource};
49use crate::HttpMethod;
50
51use super::common::{ChargeCurrency, ChargeStatus};
52
53/// A one-time application charge.
54///
55/// Application charges allow apps to bill merchants for one-time purchases.
56/// After creating a charge, redirect the merchant to the `confirmation_url`
57/// for them to approve the charge.
58///
59/// # Charge Lifecycle
60///
61/// 1. App creates a charge via POST
62/// 2. App redirects merchant to `confirmation_url`
63/// 3. Merchant approves or declines the charge
64/// 4. Merchant is redirected back to the app's `return_url`
65/// 5. App checks the charge status
66///
67/// # Test Charges
68///
69/// Set `test: true` to create test charges that don't actually bill.
70/// Test charges are automatically approved when created on development stores.
71///
72/// # Fields
73///
74/// ## Read-Only Fields
75/// - `id` - The unique identifier of the charge
76/// - `confirmation_url` - The URL to redirect merchant for approval
77/// - `status` - The current status of the charge
78/// - `currency` - The currency object with the currency code
79/// - `created_at` - When the charge was created
80/// - `updated_at` - When the charge was last updated
81///
82/// ## Writable Fields
83/// - `name` - The name of the charge (displayed to merchant)
84/// - `price` - The price of the charge
85/// - `return_url` - The URL to redirect after merchant action
86/// - `test` - Whether this is a test charge
87#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
88pub struct ApplicationCharge {
89    /// The unique identifier of the application charge.
90    /// Read-only field.
91    #[serde(skip_serializing)]
92    pub id: Option<u64>,
93
94    /// The name of the application charge.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub name: Option<String>,
97
98    /// The price of the application charge.
99    /// Must be a string representing the monetary amount (e.g., "9.99").
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub price: Option<String>,
102
103    /// The current status of the charge.
104    /// Read-only field.
105    #[serde(skip_serializing)]
106    pub status: Option<ChargeStatus>,
107
108    /// Whether this is a test charge.
109    /// Test charges don't actually bill the merchant.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub test: Option<bool>,
112
113    /// The URL to redirect the merchant after they approve/decline the charge.
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub return_url: Option<String>,
116
117    /// The URL where the merchant can approve/decline the charge.
118    /// Read-only field, populated after creating the charge.
119    #[serde(skip_serializing)]
120    pub confirmation_url: Option<String>,
121
122    /// The currency information for the charge.
123    /// Read-only field containing the currency code.
124    #[serde(skip_serializing)]
125    pub currency: Option<ChargeCurrency>,
126
127    /// When the charge was created.
128    /// Read-only field.
129    #[serde(skip_serializing)]
130    pub created_at: Option<DateTime<Utc>>,
131
132    /// When the charge was last updated.
133    /// Read-only field.
134    #[serde(skip_serializing)]
135    pub updated_at: Option<DateTime<Utc>>,
136}
137
138impl ApplicationCharge {
139    /// Returns `true` if the charge is active (approved and billed).
140    ///
141    /// # Example
142    ///
143    /// ```rust,ignore
144    /// if charge.is_active() {
145    ///     println!("Charge has been paid!");
146    /// }
147    /// ```
148    #[must_use]
149    pub fn is_active(&self) -> bool {
150        self.status.as_ref().map_or(false, ChargeStatus::is_active)
151    }
152
153    /// Returns `true` if the charge is pending merchant approval.
154    ///
155    /// # Example
156    ///
157    /// ```rust,ignore
158    /// if charge.is_pending() {
159    ///     println!("Waiting for merchant approval");
160    /// }
161    /// ```
162    #[must_use]
163    pub fn is_pending(&self) -> bool {
164        self.status.as_ref().map_or(false, ChargeStatus::is_pending)
165    }
166
167    /// Returns `true` if this is a test charge.
168    ///
169    /// Test charges don't actually bill the merchant and are
170    /// automatically approved on development stores.
171    ///
172    /// # Example
173    ///
174    /// ```rust,ignore
175    /// if charge.is_test() {
176    ///     println!("This is a test charge");
177    /// }
178    /// ```
179    #[must_use]
180    pub fn is_test(&self) -> bool {
181        self.test.unwrap_or(false)
182    }
183}
184
185impl RestResource for ApplicationCharge {
186    type Id = u64;
187    type FindParams = ApplicationChargeFindParams;
188    type AllParams = ApplicationChargeListParams;
189    type CountParams = ();
190
191    const NAME: &'static str = "ApplicationCharge";
192    const PLURAL: &'static str = "application_charges";
193
194    /// Paths for the ApplicationCharge resource.
195    ///
196    /// Note: ApplicationCharge has limited CRUD - no Update or Delete operations.
197    const PATHS: &'static [ResourcePath] = &[
198        ResourcePath::new(
199            HttpMethod::Get,
200            ResourceOperation::Find,
201            &["id"],
202            "application_charges/{id}",
203        ),
204        ResourcePath::new(
205            HttpMethod::Get,
206            ResourceOperation::All,
207            &[],
208            "application_charges",
209        ),
210        ResourcePath::new(
211            HttpMethod::Post,
212            ResourceOperation::Create,
213            &[],
214            "application_charges",
215        ),
216        // No Update or Delete paths - charges cannot be modified after creation
217    ];
218
219    fn get_id(&self) -> Option<Self::Id> {
220        self.id
221    }
222}
223
224/// Parameters for finding a single application charge.
225#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
226pub struct ApplicationChargeFindParams {
227    /// Comma-separated list of fields to include in the response.
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub fields: Option<String>,
230}
231
232/// Parameters for listing application charges.
233#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
234pub struct ApplicationChargeListParams {
235    /// Maximum number of results to return (default: 50, max: 250).
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub limit: Option<u32>,
238
239    /// Return charges after this ID.
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub since_id: Option<u64>,
242
243    /// Comma-separated list of fields to include in the response.
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub fields: Option<String>,
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::rest::{get_path, ResourceOperation};
252
253    #[test]
254    fn test_application_charge_serialization() {
255        let charge = ApplicationCharge {
256            id: Some(12345),
257            name: Some("Pro Widget".to_string()),
258            price: Some("19.99".to_string()),
259            status: Some(ChargeStatus::Pending),
260            test: Some(true),
261            return_url: Some("https://myapp.com/callback".to_string()),
262            confirmation_url: Some("https://shop.myshopify.com/confirm".to_string()),
263            currency: Some(ChargeCurrency::new("USD")),
264            created_at: Some(
265                DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
266                    .unwrap()
267                    .with_timezone(&Utc),
268            ),
269            updated_at: Some(
270                DateTime::parse_from_rfc3339("2024-01-15T10:35:00Z")
271                    .unwrap()
272                    .with_timezone(&Utc),
273            ),
274        };
275
276        let json = serde_json::to_string(&charge).unwrap();
277        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
278
279        // Writable fields should be present
280        assert_eq!(parsed["name"], "Pro Widget");
281        assert_eq!(parsed["price"], "19.99");
282        assert_eq!(parsed["test"], true);
283        assert_eq!(parsed["return_url"], "https://myapp.com/callback");
284
285        // Read-only fields should be omitted
286        assert!(parsed.get("id").is_none());
287        assert!(parsed.get("status").is_none());
288        assert!(parsed.get("confirmation_url").is_none());
289        assert!(parsed.get("currency").is_none());
290        assert!(parsed.get("created_at").is_none());
291        assert!(parsed.get("updated_at").is_none());
292    }
293
294    #[test]
295    fn test_application_charge_deserialization() {
296        let json = r#"{
297            "id": 675931192,
298            "name": "Super Duper Expensive action",
299            "price": "100.00",
300            "status": "active",
301            "test": false,
302            "return_url": "https://super-duper.shopifyapps.com/",
303            "confirmation_url": "https://jsmith.myshopify.com/admin/charges/675931192/confirm_application_charge",
304            "currency": {
305                "currency": "USD"
306            },
307            "created_at": "2024-01-15T10:30:00Z",
308            "updated_at": "2024-01-15T10:35:00Z"
309        }"#;
310
311        let charge: ApplicationCharge = serde_json::from_str(json).unwrap();
312
313        assert_eq!(charge.id, Some(675931192));
314        assert_eq!(
315            charge.name,
316            Some("Super Duper Expensive action".to_string())
317        );
318        assert_eq!(charge.price, Some("100.00".to_string()));
319        assert_eq!(charge.status, Some(ChargeStatus::Active));
320        assert_eq!(charge.test, Some(false));
321        assert_eq!(
322            charge.return_url,
323            Some("https://super-duper.shopifyapps.com/".to_string())
324        );
325        assert!(charge.confirmation_url.is_some());
326        assert_eq!(charge.currency.as_ref().unwrap().code(), Some("USD"));
327        assert!(charge.created_at.is_some());
328        assert!(charge.updated_at.is_some());
329    }
330
331    #[test]
332    fn test_application_charge_convenience_methods() {
333        // Test is_active
334        let active_charge = ApplicationCharge {
335            status: Some(ChargeStatus::Active),
336            ..Default::default()
337        };
338        assert!(active_charge.is_active());
339        assert!(!active_charge.is_pending());
340
341        // Test is_pending
342        let pending_charge = ApplicationCharge {
343            status: Some(ChargeStatus::Pending),
344            ..Default::default()
345        };
346        assert!(pending_charge.is_pending());
347        assert!(!pending_charge.is_active());
348
349        // Test is_test
350        let test_charge = ApplicationCharge {
351            test: Some(true),
352            ..Default::default()
353        };
354        assert!(test_charge.is_test());
355
356        let non_test_charge = ApplicationCharge {
357            test: Some(false),
358            ..Default::default()
359        };
360        assert!(!non_test_charge.is_test());
361
362        // Test default (no test field)
363        let default_charge = ApplicationCharge::default();
364        assert!(!default_charge.is_test());
365        assert!(!default_charge.is_active());
366        assert!(!default_charge.is_pending());
367    }
368
369    #[test]
370    fn test_application_charge_paths() {
371        // Find path
372        let find_path = get_path(
373            ApplicationCharge::PATHS,
374            ResourceOperation::Find,
375            &["id"],
376        );
377        assert!(find_path.is_some());
378        assert_eq!(find_path.unwrap().template, "application_charges/{id}");
379
380        // All path
381        let all_path = get_path(ApplicationCharge::PATHS, ResourceOperation::All, &[]);
382        assert!(all_path.is_some());
383        assert_eq!(all_path.unwrap().template, "application_charges");
384
385        // Create path
386        let create_path = get_path(ApplicationCharge::PATHS, ResourceOperation::Create, &[]);
387        assert!(create_path.is_some());
388        assert_eq!(create_path.unwrap().template, "application_charges");
389        assert_eq!(create_path.unwrap().http_method, HttpMethod::Post);
390
391        // No Update path
392        let update_path = get_path(
393            ApplicationCharge::PATHS,
394            ResourceOperation::Update,
395            &["id"],
396        );
397        assert!(update_path.is_none());
398
399        // No Delete path
400        let delete_path = get_path(
401            ApplicationCharge::PATHS,
402            ResourceOperation::Delete,
403            &["id"],
404        );
405        assert!(delete_path.is_none());
406
407        // No Count path
408        let count_path = get_path(ApplicationCharge::PATHS, ResourceOperation::Count, &[]);
409        assert!(count_path.is_none());
410    }
411
412    #[test]
413    fn test_application_charge_list_params() {
414        let params = ApplicationChargeListParams {
415            limit: Some(50),
416            since_id: Some(100),
417            fields: Some("id,name,price".to_string()),
418        };
419
420        let json = serde_json::to_value(&params).unwrap();
421        assert_eq!(json["limit"], 50);
422        assert_eq!(json["since_id"], 100);
423        assert_eq!(json["fields"], "id,name,price");
424
425        // Empty params should serialize to empty object
426        let empty_params = ApplicationChargeListParams::default();
427        let empty_json = serde_json::to_value(&empty_params).unwrap();
428        assert_eq!(empty_json, serde_json::json!({}));
429    }
430
431    #[test]
432    fn test_application_charge_constants() {
433        assert_eq!(ApplicationCharge::NAME, "ApplicationCharge");
434        assert_eq!(ApplicationCharge::PLURAL, "application_charges");
435    }
436
437    #[test]
438    fn test_application_charge_get_id() {
439        let charge_with_id = ApplicationCharge {
440            id: Some(12345),
441            ..Default::default()
442        };
443        assert_eq!(charge_with_id.get_id(), Some(12345));
444
445        let charge_without_id = ApplicationCharge::default();
446        assert_eq!(charge_without_id.get_id(), None);
447    }
448}