use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::clients::RestClient;
use crate::rest::{ResourceError, ResourceOperation, ResourcePath, RestResource};
use crate::HttpMethod;
use super::common::{Address, NoteAttribute, ShippingLine, TaxLine};
use super::customer::Customer;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum DraftOrderStatus {
#[default]
Open,
InvoiceSent,
Completed,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct AppliedDiscount {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub value_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub amount: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct DraftOrderLineItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub variant_id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub product_id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub variant_title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sku: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vendor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quantity: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub requires_shipping: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub taxable: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gift_card: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fulfillment_service: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub grams: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tax_lines: Option<Vec<TaxLine>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub properties: Option<Vec<serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub custom: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub price: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub applied_discount: Option<AppliedDiscount>,
#[serde(skip_serializing_if = "Option::is_none")]
pub admin_graphql_api_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct DraftOrderInvoice {
#[serde(skip_serializing_if = "Option::is_none")]
pub to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub from: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subject: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bcc: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct DraftOrderCompleteParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub payment_pending: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct DraftOrder {
#[serde(skip_serializing)]
pub id: Option<u64>,
#[serde(skip_serializing)]
pub order_id: Option<u64>,
#[serde(skip_serializing)]
pub name: Option<String>,
#[serde(skip_serializing)]
pub invoice_sent_at: Option<DateTime<Utc>>,
#[serde(skip_serializing)]
pub invoice_url: Option<String>,
#[serde(skip_serializing)]
pub completed_at: Option<DateTime<Utc>>,
#[serde(skip_serializing)]
pub created_at: Option<DateTime<Utc>>,
#[serde(skip_serializing)]
pub updated_at: Option<DateTime<Utc>>,
#[serde(skip_serializing)]
pub admin_graphql_api_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<DraftOrderStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub currency: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tax_exempt: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tax_exemptions: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub taxes_included: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total_tax: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subtotal_price: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total_price: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub note_attributes: Option<Vec<NoteAttribute>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub customer_id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub use_customer_default_address: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub line_items: Option<Vec<DraftOrderLineItem>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub shipping_address: Option<Address>,
#[serde(skip_serializing_if = "Option::is_none")]
pub billing_address: Option<Address>,
#[serde(skip_serializing_if = "Option::is_none")]
pub customer: Option<Customer>,
#[serde(skip_serializing_if = "Option::is_none")]
pub shipping_line: Option<ShippingLine>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tax_lines: Option<Vec<TaxLine>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub applied_discount: Option<AppliedDiscount>,
}
impl RestResource for DraftOrder {
type Id = u64;
type FindParams = DraftOrderFindParams;
type AllParams = DraftOrderListParams;
type CountParams = DraftOrderCountParams;
const NAME: &'static str = "DraftOrder";
const PLURAL: &'static str = "draft_orders";
const PATHS: &'static [ResourcePath] = &[
ResourcePath::new(
HttpMethod::Get,
ResourceOperation::Find,
&["id"],
"draft_orders/{id}",
),
ResourcePath::new(HttpMethod::Get, ResourceOperation::All, &[], "draft_orders"),
ResourcePath::new(
HttpMethod::Get,
ResourceOperation::Count,
&[],
"draft_orders/count",
),
ResourcePath::new(
HttpMethod::Post,
ResourceOperation::Create,
&[],
"draft_orders",
),
ResourcePath::new(
HttpMethod::Put,
ResourceOperation::Update,
&["id"],
"draft_orders/{id}",
),
ResourcePath::new(
HttpMethod::Delete,
ResourceOperation::Delete,
&["id"],
"draft_orders/{id}",
),
];
fn get_id(&self) -> Option<Self::Id> {
self.id
}
}
impl DraftOrder {
pub async fn complete(
&self,
client: &RestClient,
params: Option<DraftOrderCompleteParams>,
) -> Result<Self, ResourceError> {
let id = self.get_id().ok_or(ResourceError::PathResolutionFailed {
resource: Self::NAME,
operation: "complete",
})?;
let query_string = params
.as_ref()
.and_then(|p| p.payment_pending)
.map(|pp| format!("?payment_pending={}", pp))
.unwrap_or_default();
let path = format!("draft_orders/{id}/complete{query_string}");
let body = serde_json::json!({});
let response = client.put(&path, body, None).await?;
if !response.is_ok() {
return Err(ResourceError::from_http_response(
response.code,
&response.body,
Self::NAME,
Some(&id.to_string()),
response.request_id(),
));
}
let draft_order: Self = response
.body
.get("draft_order")
.ok_or_else(|| {
ResourceError::Http(crate::clients::HttpError::Response(
crate::clients::HttpResponseError {
code: response.code,
message: "Missing 'draft_order' in response".to_string(),
error_reference: response.request_id().map(ToString::to_string),
},
))
})
.and_then(|v| {
serde_json::from_value(v.clone()).map_err(|e| {
ResourceError::Http(crate::clients::HttpError::Response(
crate::clients::HttpResponseError {
code: response.code,
message: format!("Failed to deserialize draft_order: {e}"),
error_reference: response.request_id().map(ToString::to_string),
},
))
})
})?;
Ok(draft_order)
}
pub async fn send_invoice(
&self,
client: &RestClient,
invoice: DraftOrderInvoice,
) -> Result<Self, ResourceError> {
let id = self.get_id().ok_or(ResourceError::PathResolutionFailed {
resource: Self::NAME,
operation: "send_invoice",
})?;
let path = format!("draft_orders/{id}/send_invoice");
let body = serde_json::json!({
"draft_order_invoice": invoice
});
let response = client.post(&path, body, None).await?;
if !response.is_ok() {
return Err(ResourceError::from_http_response(
response.code,
&response.body,
Self::NAME,
Some(&id.to_string()),
response.request_id(),
));
}
let draft_order: Self = response
.body
.get("draft_order")
.ok_or_else(|| {
ResourceError::Http(crate::clients::HttpError::Response(
crate::clients::HttpResponseError {
code: response.code,
message: "Missing 'draft_order' in response".to_string(),
error_reference: response.request_id().map(ToString::to_string),
},
))
})
.and_then(|v| {
serde_json::from_value(v.clone()).map_err(|e| {
ResourceError::Http(crate::clients::HttpError::Response(
crate::clients::HttpResponseError {
code: response.code,
message: format!("Failed to deserialize draft_order: {e}"),
error_reference: response.request_id().map(ToString::to_string),
},
))
})
})?;
Ok(draft_order)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct DraftOrderFindParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub fields: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct DraftOrderListParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub since_id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at_min: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at_max: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at_min: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at_max: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ids: Option<Vec<u64>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<DraftOrderStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fields: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub page_info: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct DraftOrderCountParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub since_id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<DraftOrderStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at_min: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at_max: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at_min: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at_max: Option<DateTime<Utc>>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rest::{get_path, ResourceOperation};
#[test]
fn test_draft_order_struct_serialization() {
let draft_order = DraftOrder {
id: Some(123456789),
order_id: Some(987654321),
name: Some("#D1".to_string()),
status: Some(DraftOrderStatus::Open),
email: Some("customer@example.com".to_string()),
currency: Some("USD".to_string()),
tax_exempt: Some(false),
taxes_included: Some(true),
total_tax: Some("10.00".to_string()),
subtotal_price: Some("100.00".to_string()),
total_price: Some("110.00".to_string()),
note: Some("Wholesale order".to_string()),
tags: Some("vip, wholesale".to_string()),
created_at: Some(
DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
.unwrap()
.with_timezone(&Utc),
),
..Default::default()
};
let json = serde_json::to_string(&draft_order).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["status"], "open");
assert_eq!(parsed["email"], "customer@example.com");
assert_eq!(parsed["currency"], "USD");
assert_eq!(parsed["tax_exempt"], false);
assert_eq!(parsed["taxes_included"], true);
assert_eq!(parsed["total_tax"], "10.00");
assert_eq!(parsed["subtotal_price"], "100.00");
assert_eq!(parsed["total_price"], "110.00");
assert_eq!(parsed["note"], "Wholesale order");
assert_eq!(parsed["tags"], "vip, wholesale");
assert!(parsed.get("id").is_none());
assert!(parsed.get("order_id").is_none());
assert!(parsed.get("name").is_none());
assert!(parsed.get("created_at").is_none());
assert!(parsed.get("updated_at").is_none());
assert!(parsed.get("admin_graphql_api_id").is_none());
}
#[test]
fn test_draft_order_deserialization_from_api_response() {
let json_str = r##"{
"id": 994118539,
"order_id": null,
"name": "#D2",
"status": "open",
"email": "bob.norman@example.com",
"currency": "USD",
"invoice_sent_at": null,
"invoice_url": "https://jsmith.myshopify.com/548380009/invoices/994118539/dcc0adb7c08e3be1",
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z",
"tax_exempt": false,
"taxes_included": false,
"total_tax": "11.94",
"subtotal_price": "398.00",
"total_price": "409.94",
"line_items": [
{
"id": 994118540,
"variant_id": 39072856,
"product_id": 632910392,
"title": "IPod Nano - 8GB",
"variant_title": "green",
"sku": "IPOD2008GREEN",
"vendor": "Apple",
"quantity": 1,
"requires_shipping": true,
"taxable": true,
"gift_card": false,
"price": "199.00"
}
],
"shipping_address": {
"first_name": "Bob",
"last_name": "Norman",
"address1": "Chestnut Street 92",
"city": "Louisville",
"province": "Kentucky",
"country": "United States",
"zip": "40202"
},
"billing_address": {
"first_name": "Bob",
"last_name": "Norman",
"address1": "Chestnut Street 92",
"city": "Louisville",
"province": "Kentucky",
"country": "United States",
"zip": "40202"
},
"note": "Test draft order",
"admin_graphql_api_id": "gid://shopify/DraftOrder/994118539"
}"##;
let draft_order: DraftOrder = serde_json::from_str(json_str).unwrap();
assert_eq!(draft_order.id, Some(994118539));
assert_eq!(draft_order.order_id, None);
assert_eq!(draft_order.name.as_deref(), Some("#D2"));
assert_eq!(draft_order.status, Some(DraftOrderStatus::Open));
assert_eq!(
draft_order.email.as_deref(),
Some("bob.norman@example.com")
);
assert_eq!(draft_order.currency.as_deref(), Some("USD"));
assert_eq!(draft_order.total_tax.as_deref(), Some("11.94"));
assert_eq!(draft_order.subtotal_price.as_deref(), Some("398.00"));
assert_eq!(draft_order.total_price.as_deref(), Some("409.94"));
assert!(draft_order.created_at.is_some());
assert!(draft_order.updated_at.is_some());
let line_items = draft_order.line_items.unwrap();
assert_eq!(line_items.len(), 1);
assert_eq!(line_items[0].id, Some(994118540));
assert_eq!(line_items[0].title.as_deref(), Some("IPod Nano - 8GB"));
assert_eq!(line_items[0].quantity, Some(1));
assert_eq!(line_items[0].price.as_deref(), Some("199.00"));
let shipping = draft_order.shipping_address.unwrap();
assert_eq!(shipping.first_name.as_deref(), Some("Bob"));
assert_eq!(shipping.city.as_deref(), Some("Louisville"));
let billing = draft_order.billing_address.unwrap();
assert_eq!(billing.first_name.as_deref(), Some("Bob"));
}
#[test]
fn test_draft_order_status_enum_serialization() {
let open_str = serde_json::to_string(&DraftOrderStatus::Open).unwrap();
assert_eq!(open_str, "\"open\"");
let invoice_sent_str = serde_json::to_string(&DraftOrderStatus::InvoiceSent).unwrap();
assert_eq!(invoice_sent_str, "\"invoice_sent\"");
let completed_str = serde_json::to_string(&DraftOrderStatus::Completed).unwrap();
assert_eq!(completed_str, "\"completed\"");
let open: DraftOrderStatus = serde_json::from_str("\"open\"").unwrap();
let invoice_sent: DraftOrderStatus = serde_json::from_str("\"invoice_sent\"").unwrap();
let completed: DraftOrderStatus = serde_json::from_str("\"completed\"").unwrap();
assert_eq!(open, DraftOrderStatus::Open);
assert_eq!(invoice_sent, DraftOrderStatus::InvoiceSent);
assert_eq!(completed, DraftOrderStatus::Completed);
assert_eq!(DraftOrderStatus::default(), DraftOrderStatus::Open);
}
#[test]
fn test_draft_order_path_constants() {
let find_path = get_path(DraftOrder::PATHS, ResourceOperation::Find, &["id"]);
assert!(find_path.is_some());
assert_eq!(find_path.unwrap().template, "draft_orders/{id}");
assert_eq!(find_path.unwrap().http_method, HttpMethod::Get);
let all_path = get_path(DraftOrder::PATHS, ResourceOperation::All, &[]);
assert!(all_path.is_some());
assert_eq!(all_path.unwrap().template, "draft_orders");
let count_path = get_path(DraftOrder::PATHS, ResourceOperation::Count, &[]);
assert!(count_path.is_some());
assert_eq!(count_path.unwrap().template, "draft_orders/count");
let create_path = get_path(DraftOrder::PATHS, ResourceOperation::Create, &[]);
assert!(create_path.is_some());
assert_eq!(create_path.unwrap().template, "draft_orders");
assert_eq!(create_path.unwrap().http_method, HttpMethod::Post);
let update_path = get_path(DraftOrder::PATHS, ResourceOperation::Update, &["id"]);
assert!(update_path.is_some());
assert_eq!(update_path.unwrap().template, "draft_orders/{id}");
assert_eq!(update_path.unwrap().http_method, HttpMethod::Put);
let delete_path = get_path(DraftOrder::PATHS, ResourceOperation::Delete, &["id"]);
assert!(delete_path.is_some());
assert_eq!(delete_path.unwrap().template, "draft_orders/{id}");
assert_eq!(delete_path.unwrap().http_method, HttpMethod::Delete);
assert_eq!(DraftOrder::NAME, "DraftOrder");
assert_eq!(DraftOrder::PLURAL, "draft_orders");
}
#[test]
fn test_complete_method_signature() {
fn _assert_complete_signature<F, Fut>(f: F)
where
F: Fn(&DraftOrder, &RestClient, Option<DraftOrderCompleteParams>) -> Fut,
Fut: std::future::Future<Output = Result<DraftOrder, ResourceError>>,
{
let _ = f;
}
let params = DraftOrderCompleteParams {
payment_pending: Some(true),
};
assert_eq!(params.payment_pending, Some(true));
let draft_without_id = DraftOrder::default();
assert!(draft_without_id.get_id().is_none());
}
#[test]
fn test_send_invoice_method_signature() {
fn _assert_send_invoice_signature<F, Fut>(f: F)
where
F: Fn(&DraftOrder, &RestClient, DraftOrderInvoice) -> Fut,
Fut: std::future::Future<Output = Result<DraftOrder, ResourceError>>,
{
let _ = f;
}
let invoice = DraftOrderInvoice {
to: Some("customer@example.com".to_string()),
from: Some("store@example.com".to_string()),
subject: Some("Your order".to_string()),
custom_message: Some("Thanks!".to_string()),
bcc: Some(vec!["admin@example.com".to_string()]),
};
let json = serde_json::to_value(&invoice).unwrap();
assert_eq!(json["to"], "customer@example.com");
assert_eq!(json["from"], "store@example.com");
assert_eq!(json["subject"], "Your order");
assert_eq!(json["custom_message"], "Thanks!");
assert_eq!(json["bcc"][0], "admin@example.com");
}
#[test]
fn test_draft_order_line_item_with_applied_discount() {
let line_item = DraftOrderLineItem {
id: Some(123),
variant_id: Some(456),
product_id: Some(789),
title: Some("Test Product".to_string()),
quantity: Some(2),
price: Some("50.00".to_string()),
taxable: Some(true),
applied_discount: Some(AppliedDiscount {
title: Some("10% Off".to_string()),
description: Some("Wholesale discount".to_string()),
value: Some("10".to_string()),
value_type: Some("percentage".to_string()),
amount: Some("10.00".to_string()),
}),
..Default::default()
};
let json = serde_json::to_value(&line_item).unwrap();
assert_eq!(json["id"], 123);
assert_eq!(json["title"], "Test Product");
assert_eq!(json["quantity"], 2);
assert!(json.get("applied_discount").is_some());
assert_eq!(json["applied_discount"]["title"], "10% Off");
assert_eq!(json["applied_discount"]["value"], "10");
assert_eq!(json["applied_discount"]["value_type"], "percentage");
}
#[test]
fn test_draft_order_invoice_serialization() {
let invoice = DraftOrderInvoice {
to: Some("customer@example.com".to_string()),
subject: Some("Invoice for your order".to_string()),
custom_message: Some("Thank you for shopping with us!".to_string()),
..Default::default()
};
let json = serde_json::to_value(&invoice).unwrap();
assert_eq!(json["to"], "customer@example.com");
assert_eq!(json["subject"], "Invoice for your order");
assert_eq!(json["custom_message"], "Thank you for shopping with us!");
assert!(json.get("from").is_none());
assert!(json.get("bcc").is_none());
let empty_invoice = DraftOrderInvoice::default();
let empty_json = serde_json::to_value(&empty_invoice).unwrap();
assert_eq!(empty_json, serde_json::json!({}));
}
#[test]
fn test_draft_order_get_id_returns_correct_value() {
let draft_with_id = DraftOrder {
id: Some(994118539),
name: Some("#D2".to_string()),
..Default::default()
};
assert_eq!(draft_with_id.get_id(), Some(994118539));
let draft_without_id = DraftOrder {
id: None,
email: Some("customer@example.com".to_string()),
..Default::default()
};
assert_eq!(draft_without_id.get_id(), None);
}
}