1use chrono::{DateTime, Utc};
64use serde::{Deserialize, Serialize};
65
66use crate::clients::RestClient;
67use crate::rest::{ResourceError, ResourceOperation, ResourcePath, RestResource};
68use crate::HttpMethod;
69
70#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
74pub struct RefundLineItem {
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub id: Option<u64>,
78
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub quantity: Option<i32>,
82
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub line_item_id: Option<u64>,
86
87 #[serde(skip_serializing_if = "Option::is_none")]
89 pub location_id: Option<u64>,
90
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub restock_type: Option<String>,
94
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub subtotal: Option<String>,
98
99 #[serde(skip_serializing_if = "Option::is_none")]
101 pub total_tax: Option<String>,
102
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub subtotal_set: Option<serde_json::Value>,
106
107 #[serde(skip_serializing_if = "Option::is_none")]
109 pub total_tax_set: Option<serde_json::Value>,
110
111 #[serde(skip_serializing_if = "Option::is_none")]
113 pub line_item: Option<serde_json::Value>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
120pub struct OrderAdjustment {
121 #[serde(skip_serializing_if = "Option::is_none")]
123 pub id: Option<u64>,
124
125 #[serde(skip_serializing_if = "Option::is_none")]
127 pub order_id: Option<u64>,
128
129 #[serde(skip_serializing_if = "Option::is_none")]
131 pub refund_id: Option<u64>,
132
133 #[serde(skip_serializing_if = "Option::is_none")]
135 pub kind: Option<String>,
136
137 #[serde(skip_serializing_if = "Option::is_none")]
139 pub reason: Option<String>,
140
141 #[serde(skip_serializing_if = "Option::is_none")]
143 pub amount: Option<String>,
144
145 #[serde(skip_serializing_if = "Option::is_none")]
147 pub tax_amount: Option<String>,
148
149 #[serde(skip_serializing_if = "Option::is_none")]
151 pub amount_set: Option<serde_json::Value>,
152
153 #[serde(skip_serializing_if = "Option::is_none")]
155 pub tax_amount_set: Option<serde_json::Value>,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
160pub struct RefundShipping {
161 #[serde(skip_serializing_if = "Option::is_none")]
163 pub full_refund: Option<bool>,
164
165 #[serde(skip_serializing_if = "Option::is_none")]
167 pub amount: Option<String>,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
172pub struct RefundLineItemInput {
173 pub line_item_id: u64,
175
176 pub quantity: i32,
178
179 #[serde(skip_serializing_if = "Option::is_none")]
181 pub restock_type: Option<String>,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
186pub struct RefundShippingLine {
187 #[serde(skip_serializing_if = "Option::is_none")]
189 pub id: Option<u64>,
190
191 #[serde(skip_serializing_if = "Option::is_none")]
193 pub full_refund: Option<bool>,
194
195 #[serde(skip_serializing_if = "Option::is_none")]
197 pub amount: Option<String>,
198
199 #[serde(skip_serializing_if = "Option::is_none")]
201 pub amount_set: Option<serde_json::Value>,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
236pub struct RefundResource {
237 #[serde(skip_serializing)]
240 pub id: Option<u64>,
241
242 #[serde(skip_serializing_if = "Option::is_none")]
244 pub order_id: Option<u64>,
245
246 #[serde(skip_serializing_if = "Option::is_none")]
248 pub note: Option<String>,
249
250 #[serde(skip_serializing_if = "Option::is_none")]
252 pub user_id: Option<u64>,
253
254 #[serde(skip_serializing_if = "Option::is_none")]
256 pub restock: Option<bool>,
257
258 #[serde(skip_serializing_if = "Option::is_none")]
260 pub notify: Option<bool>,
261
262 #[serde(skip_serializing)]
265 pub processed_at: Option<DateTime<Utc>>,
266
267 #[serde(skip_serializing)]
270 pub created_at: Option<DateTime<Utc>>,
271
272 #[serde(skip_serializing_if = "Option::is_none")]
274 pub duties: Option<serde_json::Value>,
275
276 #[serde(skip_serializing_if = "Option::is_none")]
278 pub refund_duties: Option<serde_json::Value>,
279
280 #[serde(skip_serializing_if = "Option::is_none")]
282 pub refund_line_items: Option<serde_json::Value>,
283
284 #[serde(skip_serializing_if = "Option::is_none")]
286 pub refund_shipping_lines: Option<Vec<RefundShippingLine>>,
287
288 #[serde(skip_serializing_if = "Option::is_none")]
290 pub transactions: Option<serde_json::Value>,
291
292 #[serde(skip_serializing_if = "Option::is_none")]
294 pub order_adjustments: Option<Vec<OrderAdjustment>>,
295
296 #[serde(skip_serializing_if = "Option::is_none")]
298 pub shipping: Option<RefundShipping>,
299
300 #[serde(skip_serializing_if = "Option::is_none")]
302 pub currency: Option<String>,
303
304 #[serde(skip_serializing)]
307 pub admin_graphql_api_id: Option<String>,
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
312pub struct RefundCalculateParams {
313 #[serde(skip_serializing_if = "Option::is_none")]
315 pub shipping: Option<RefundShipping>,
316
317 #[serde(skip_serializing_if = "Option::is_none")]
319 pub refund_line_items: Option<Vec<RefundLineItemInput>>,
320
321 #[serde(skip_serializing_if = "Option::is_none")]
323 pub currency: Option<String>,
324}
325
326impl RefundResource {
327 pub async fn calculate(
360 client: &RestClient,
361 order_id: u64,
362 params: RefundCalculateParams,
363 ) -> Result<RefundResource, ResourceError> {
364 let path = format!("orders/{order_id}/refunds/calculate");
365
366 let body = serde_json::json!({
368 "refund": params
369 });
370
371 let response = client.post(&path, body, None).await?;
372
373 if !response.is_ok() {
374 return Err(ResourceError::from_http_response(
375 response.code,
376 &response.body,
377 Self::NAME,
378 Some(&order_id.to_string()),
379 response.request_id(),
380 ));
381 }
382
383 let refund: RefundResource = response
385 .body
386 .get("refund")
387 .ok_or_else(|| {
388 ResourceError::Http(crate::clients::HttpError::Response(
389 crate::clients::HttpResponseError {
390 code: response.code,
391 message: "Missing 'refund' in response".to_string(),
392 error_reference: response.request_id().map(ToString::to_string),
393 },
394 ))
395 })
396 .and_then(|v| {
397 serde_json::from_value(v.clone()).map_err(|e| {
398 ResourceError::Http(crate::clients::HttpError::Response(
399 crate::clients::HttpResponseError {
400 code: response.code,
401 message: format!("Failed to deserialize refund: {e}"),
402 error_reference: response.request_id().map(ToString::to_string),
403 },
404 ))
405 })
406 })?;
407
408 Ok(refund)
409 }
410
411 pub async fn count_with_parent<ParentId: std::fmt::Display + Send>(
418 _client: &RestClient,
419 _parent_id_name: &str,
420 _parent_id: ParentId,
421 _params: Option<RefundCountParams>,
422 ) -> Result<u64, ResourceError> {
423 Err(ResourceError::PathResolutionFailed {
424 resource: Self::NAME,
425 operation: "count",
426 })
427 }
428}
429
430impl RestResource for RefundResource {
431 type Id = u64;
432 type FindParams = RefundFindParams;
433 type AllParams = RefundListParams;
434 type CountParams = RefundCountParams;
435
436 const NAME: &'static str = "Refund";
437 const PLURAL: &'static str = "refunds";
438
439 const PATHS: &'static [ResourcePath] = &[
444 ResourcePath::new(
446 HttpMethod::Get,
447 ResourceOperation::Find,
448 &["order_id", "id"],
449 "orders/{order_id}/refunds/{id}",
450 ),
451 ResourcePath::new(
452 HttpMethod::Get,
453 ResourceOperation::All,
454 &["order_id"],
455 "orders/{order_id}/refunds",
456 ),
457 ResourcePath::new(
458 HttpMethod::Post,
459 ResourceOperation::Create,
460 &["order_id"],
461 "orders/{order_id}/refunds",
462 ),
463 ];
465
466 fn get_id(&self) -> Option<Self::Id> {
467 self.id
468 }
469}
470
471#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
473pub struct RefundFindParams {
474 #[serde(skip_serializing_if = "Option::is_none")]
476 pub fields: Option<String>,
477
478 #[serde(skip_serializing_if = "Option::is_none")]
480 pub in_shop_currency: Option<bool>,
481}
482
483#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
485pub struct RefundListParams {
486 #[serde(skip_serializing_if = "Option::is_none")]
488 pub limit: Option<u32>,
489
490 #[serde(skip_serializing_if = "Option::is_none")]
492 pub fields: Option<String>,
493
494 #[serde(skip_serializing_if = "Option::is_none")]
496 pub in_shop_currency: Option<bool>,
497}
498
499#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
503pub struct RefundCountParams {
504 }
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use crate::rest::{get_path, ResourceOperation};
511
512 #[test]
513 fn test_refund_nested_paths_require_order_id() {
514 let find_path = get_path(
518 RefundResource::PATHS,
519 ResourceOperation::Find,
520 &["order_id", "id"],
521 );
522 assert!(find_path.is_some());
523 assert_eq!(
524 find_path.unwrap().template,
525 "orders/{order_id}/refunds/{id}"
526 );
527
528 let find_without_order = get_path(RefundResource::PATHS, ResourceOperation::Find, &["id"]);
530 assert!(find_without_order.is_none());
531
532 let all_path = get_path(RefundResource::PATHS, ResourceOperation::All, &["order_id"]);
534 assert!(all_path.is_some());
535 assert_eq!(all_path.unwrap().template, "orders/{order_id}/refunds");
536
537 let all_without_order = get_path(RefundResource::PATHS, ResourceOperation::All, &[]);
539 assert!(all_without_order.is_none());
540
541 let create_path = get_path(
543 RefundResource::PATHS,
544 ResourceOperation::Create,
545 &["order_id"],
546 );
547 assert!(create_path.is_some());
548 assert_eq!(create_path.unwrap().template, "orders/{order_id}/refunds");
549
550 let count_path = get_path(
552 RefundResource::PATHS,
553 ResourceOperation::Count,
554 &["order_id"],
555 );
556 assert!(count_path.is_none());
557
558 let update_path = get_path(
560 RefundResource::PATHS,
561 ResourceOperation::Update,
562 &["order_id", "id"],
563 );
564 assert!(update_path.is_none());
565
566 let delete_path = get_path(
568 RefundResource::PATHS,
569 ResourceOperation::Delete,
570 &["order_id", "id"],
571 );
572 assert!(delete_path.is_none());
573 }
574
575 #[test]
576 fn test_refund_calculate_path_construction() {
577 let order_id = 450789469u64;
579 let expected_path = format!("orders/{order_id}/refunds/calculate");
580 assert_eq!(expected_path, "orders/450789469/refunds/calculate");
581 }
582
583 #[test]
584 fn test_refund_struct_serialization() {
585 let refund = RefundResource {
586 id: Some(123456),
587 order_id: Some(450789469),
588 note: Some("Customer requested refund".to_string()),
589 user_id: Some(799407056),
590 restock: Some(true),
591 notify: Some(true),
592 created_at: Some(
593 DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
594 .unwrap()
595 .with_timezone(&Utc),
596 ),
597 processed_at: Some(
598 DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
599 .unwrap()
600 .with_timezone(&Utc),
601 ),
602 admin_graphql_api_id: Some("gid://shopify/Refund/123456".to_string()),
603 ..Default::default()
604 };
605
606 let json = serde_json::to_string(&refund).unwrap();
607 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
608
609 assert_eq!(parsed["order_id"], 450789469);
611 assert_eq!(parsed["note"], "Customer requested refund");
612 assert_eq!(parsed["user_id"], 799407056);
613 assert_eq!(parsed["restock"], true);
614 assert_eq!(parsed["notify"], true);
615
616 assert!(parsed.get("id").is_none());
618 assert!(parsed.get("created_at").is_none());
619 assert!(parsed.get("processed_at").is_none());
620 assert!(parsed.get("admin_graphql_api_id").is_none());
621 }
622
623 #[test]
624 fn test_refund_deserialization_with_complex_nested_structures() {
625 let json = r#"{
626 "id": 123456,
627 "order_id": 450789469,
628 "note": "Customer requested refund",
629 "user_id": 799407056,
630 "restock": true,
631 "processed_at": "2024-01-15T10:30:00Z",
632 "created_at": "2024-01-15T10:30:00Z",
633 "refund_line_items": [
634 {
635 "id": 1,
636 "quantity": 1,
637 "line_item_id": 669751112,
638 "location_id": 655441491,
639 "restock_type": "return",
640 "subtotal": "199.99",
641 "total_tax": "15.00",
642 "line_item": {
643 "id": 669751112,
644 "title": "IPod Nano - 8GB"
645 }
646 }
647 ],
648 "transactions": [
649 {
650 "id": 389404469,
651 "order_id": 450789469,
652 "kind": "refund",
653 "amount": "214.99",
654 "status": "success"
655 }
656 ],
657 "order_adjustments": [
658 {
659 "id": 1,
660 "order_id": 450789469,
661 "refund_id": 123456,
662 "kind": "refund_discrepancy",
663 "reason": "Refund discrepancy",
664 "amount": "-0.01"
665 }
666 ],
667 "refund_shipping_lines": [
668 {
669 "id": 1,
670 "full_refund": true,
671 "amount": "5.00"
672 }
673 ],
674 "admin_graphql_api_id": "gid://shopify/Refund/123456"
675 }"#;
676
677 let refund: RefundResource = serde_json::from_str(json).unwrap();
678
679 assert_eq!(refund.id, Some(123456));
680 assert_eq!(refund.order_id, Some(450789469));
681 assert_eq!(refund.note, Some("Customer requested refund".to_string()));
682 assert_eq!(refund.user_id, Some(799407056));
683 assert_eq!(refund.restock, Some(true));
684 assert!(refund.processed_at.is_some());
685 assert!(refund.created_at.is_some());
686
687 assert!(refund.refund_line_items.is_some());
689 assert!(refund.transactions.is_some());
690
691 assert!(refund.order_adjustments.is_some());
693 let adjustments = refund.order_adjustments.unwrap();
694 assert_eq!(adjustments.len(), 1);
695 assert_eq!(adjustments[0].kind, Some("refund_discrepancy".to_string()));
696
697 assert!(refund.refund_shipping_lines.is_some());
699 let shipping_lines = refund.refund_shipping_lines.unwrap();
700 assert_eq!(shipping_lines.len(), 1);
701 assert_eq!(shipping_lines[0].full_refund, Some(true));
702 assert_eq!(shipping_lines[0].amount, Some("5.00".to_string()));
703 }
704
705 #[test]
706 fn test_refund_calculate_params_serialization() {
707 let params = RefundCalculateParams {
708 shipping: Some(RefundShipping {
709 full_refund: Some(true),
710 amount: None,
711 }),
712 refund_line_items: Some(vec![RefundLineItemInput {
713 line_item_id: 669751112,
714 quantity: 1,
715 restock_type: Some("return".to_string()),
716 }]),
717 currency: Some("USD".to_string()),
718 };
719
720 let json = serde_json::to_value(¶ms).unwrap();
721
722 assert!(json["shipping"]["full_refund"].as_bool().unwrap());
723 assert_eq!(json["refund_line_items"][0]["line_item_id"], 669751112);
724 assert_eq!(json["refund_line_items"][0]["quantity"], 1);
725 assert_eq!(json["refund_line_items"][0]["restock_type"], "return");
726 assert_eq!(json["currency"], "USD");
727 }
728
729 #[test]
730 fn test_refund_list_params_serialization() {
731 let params = RefundListParams {
732 limit: Some(50),
733 fields: Some("id,note,created_at".to_string()),
734 in_shop_currency: Some(true),
735 };
736
737 let json = serde_json::to_value(¶ms).unwrap();
738
739 assert_eq!(json["limit"], 50);
740 assert_eq!(json["fields"], "id,note,created_at");
741 assert_eq!(json["in_shop_currency"], true);
742
743 let empty_params = RefundListParams::default();
745 let empty_json = serde_json::to_value(&empty_params).unwrap();
746 assert_eq!(empty_json, serde_json::json!({}));
747 }
748
749 #[test]
750 fn test_refund_get_id_returns_correct_value() {
751 let refund_with_id = RefundResource {
753 id: Some(123456),
754 order_id: Some(450789469),
755 note: Some("Test refund".to_string()),
756 ..Default::default()
757 };
758 assert_eq!(refund_with_id.get_id(), Some(123456));
759
760 let refund_without_id = RefundResource {
762 id: None,
763 order_id: Some(450789469),
764 note: Some("New refund".to_string()),
765 ..Default::default()
766 };
767 assert_eq!(refund_without_id.get_id(), None);
768 }
769
770 #[test]
771 fn test_refund_constants() {
772 assert_eq!(RefundResource::NAME, "Refund");
773 assert_eq!(RefundResource::PLURAL, "refunds");
774 }
775
776 #[test]
777 fn test_refund_line_item_serialization() {
778 let line_item = RefundLineItem {
779 id: Some(1),
780 quantity: Some(1),
781 line_item_id: Some(669751112),
782 location_id: Some(655441491),
783 restock_type: Some("return".to_string()),
784 subtotal: Some("199.99".to_string()),
785 total_tax: Some("15.00".to_string()),
786 ..Default::default()
787 };
788
789 let json = serde_json::to_value(&line_item).unwrap();
790
791 assert_eq!(json["id"], 1);
792 assert_eq!(json["quantity"], 1);
793 assert_eq!(json["line_item_id"], 669751112);
794 assert_eq!(json["location_id"], 655441491);
795 assert_eq!(json["restock_type"], "return");
796 assert_eq!(json["subtotal"], "199.99");
797 assert_eq!(json["total_tax"], "15.00");
798 }
799
800 #[test]
801 fn test_order_adjustment_serialization() {
802 let adjustment = OrderAdjustment {
803 id: Some(1),
804 order_id: Some(450789469),
805 refund_id: Some(123456),
806 kind: Some("refund_discrepancy".to_string()),
807 reason: Some("Refund discrepancy".to_string()),
808 amount: Some("-0.01".to_string()),
809 tax_amount: Some("0.00".to_string()),
810 ..Default::default()
811 };
812
813 let json = serde_json::to_value(&adjustment).unwrap();
814
815 assert_eq!(json["id"], 1);
816 assert_eq!(json["order_id"], 450789469);
817 assert_eq!(json["refund_id"], 123456);
818 assert_eq!(json["kind"], "refund_discrepancy");
819 assert_eq!(json["reason"], "Refund discrepancy");
820 assert_eq!(json["amount"], "-0.01");
821 }
822}