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 =
518 get_path(RefundResource::PATHS, ResourceOperation::Find, &["order_id", "id"]);
519 assert!(find_path.is_some());
520 assert_eq!(
521 find_path.unwrap().template,
522 "orders/{order_id}/refunds/{id}"
523 );
524
525 let find_without_order = get_path(RefundResource::PATHS, ResourceOperation::Find, &["id"]);
527 assert!(find_without_order.is_none());
528
529 let all_path = get_path(RefundResource::PATHS, ResourceOperation::All, &["order_id"]);
531 assert!(all_path.is_some());
532 assert_eq!(all_path.unwrap().template, "orders/{order_id}/refunds");
533
534 let all_without_order = get_path(RefundResource::PATHS, ResourceOperation::All, &[]);
536 assert!(all_without_order.is_none());
537
538 let create_path = get_path(RefundResource::PATHS, ResourceOperation::Create, &["order_id"]);
540 assert!(create_path.is_some());
541 assert_eq!(create_path.unwrap().template, "orders/{order_id}/refunds");
542
543 let count_path = get_path(RefundResource::PATHS, ResourceOperation::Count, &["order_id"]);
545 assert!(count_path.is_none());
546
547 let update_path = get_path(
549 RefundResource::PATHS,
550 ResourceOperation::Update,
551 &["order_id", "id"],
552 );
553 assert!(update_path.is_none());
554
555 let delete_path = get_path(
557 RefundResource::PATHS,
558 ResourceOperation::Delete,
559 &["order_id", "id"],
560 );
561 assert!(delete_path.is_none());
562 }
563
564 #[test]
565 fn test_refund_calculate_path_construction() {
566 let order_id = 450789469u64;
568 let expected_path = format!("orders/{order_id}/refunds/calculate");
569 assert_eq!(expected_path, "orders/450789469/refunds/calculate");
570 }
571
572 #[test]
573 fn test_refund_struct_serialization() {
574 let refund = RefundResource {
575 id: Some(123456),
576 order_id: Some(450789469),
577 note: Some("Customer requested refund".to_string()),
578 user_id: Some(799407056),
579 restock: Some(true),
580 notify: Some(true),
581 created_at: Some(
582 DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
583 .unwrap()
584 .with_timezone(&Utc),
585 ),
586 processed_at: Some(
587 DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
588 .unwrap()
589 .with_timezone(&Utc),
590 ),
591 admin_graphql_api_id: Some("gid://shopify/Refund/123456".to_string()),
592 ..Default::default()
593 };
594
595 let json = serde_json::to_string(&refund).unwrap();
596 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
597
598 assert_eq!(parsed["order_id"], 450789469);
600 assert_eq!(parsed["note"], "Customer requested refund");
601 assert_eq!(parsed["user_id"], 799407056);
602 assert_eq!(parsed["restock"], true);
603 assert_eq!(parsed["notify"], true);
604
605 assert!(parsed.get("id").is_none());
607 assert!(parsed.get("created_at").is_none());
608 assert!(parsed.get("processed_at").is_none());
609 assert!(parsed.get("admin_graphql_api_id").is_none());
610 }
611
612 #[test]
613 fn test_refund_deserialization_with_complex_nested_structures() {
614 let json = r#"{
615 "id": 123456,
616 "order_id": 450789469,
617 "note": "Customer requested refund",
618 "user_id": 799407056,
619 "restock": true,
620 "processed_at": "2024-01-15T10:30:00Z",
621 "created_at": "2024-01-15T10:30:00Z",
622 "refund_line_items": [
623 {
624 "id": 1,
625 "quantity": 1,
626 "line_item_id": 669751112,
627 "location_id": 655441491,
628 "restock_type": "return",
629 "subtotal": "199.99",
630 "total_tax": "15.00",
631 "line_item": {
632 "id": 669751112,
633 "title": "IPod Nano - 8GB"
634 }
635 }
636 ],
637 "transactions": [
638 {
639 "id": 389404469,
640 "order_id": 450789469,
641 "kind": "refund",
642 "amount": "214.99",
643 "status": "success"
644 }
645 ],
646 "order_adjustments": [
647 {
648 "id": 1,
649 "order_id": 450789469,
650 "refund_id": 123456,
651 "kind": "refund_discrepancy",
652 "reason": "Refund discrepancy",
653 "amount": "-0.01"
654 }
655 ],
656 "refund_shipping_lines": [
657 {
658 "id": 1,
659 "full_refund": true,
660 "amount": "5.00"
661 }
662 ],
663 "admin_graphql_api_id": "gid://shopify/Refund/123456"
664 }"#;
665
666 let refund: RefundResource = serde_json::from_str(json).unwrap();
667
668 assert_eq!(refund.id, Some(123456));
669 assert_eq!(refund.order_id, Some(450789469));
670 assert_eq!(refund.note, Some("Customer requested refund".to_string()));
671 assert_eq!(refund.user_id, Some(799407056));
672 assert_eq!(refund.restock, Some(true));
673 assert!(refund.processed_at.is_some());
674 assert!(refund.created_at.is_some());
675
676 assert!(refund.refund_line_items.is_some());
678 assert!(refund.transactions.is_some());
679
680 assert!(refund.order_adjustments.is_some());
682 let adjustments = refund.order_adjustments.unwrap();
683 assert_eq!(adjustments.len(), 1);
684 assert_eq!(adjustments[0].kind, Some("refund_discrepancy".to_string()));
685
686 assert!(refund.refund_shipping_lines.is_some());
688 let shipping_lines = refund.refund_shipping_lines.unwrap();
689 assert_eq!(shipping_lines.len(), 1);
690 assert_eq!(shipping_lines[0].full_refund, Some(true));
691 assert_eq!(shipping_lines[0].amount, Some("5.00".to_string()));
692 }
693
694 #[test]
695 fn test_refund_calculate_params_serialization() {
696 let params = RefundCalculateParams {
697 shipping: Some(RefundShipping {
698 full_refund: Some(true),
699 amount: None,
700 }),
701 refund_line_items: Some(vec![
702 RefundLineItemInput {
703 line_item_id: 669751112,
704 quantity: 1,
705 restock_type: Some("return".to_string()),
706 },
707 ]),
708 currency: Some("USD".to_string()),
709 };
710
711 let json = serde_json::to_value(¶ms).unwrap();
712
713 assert!(json["shipping"]["full_refund"].as_bool().unwrap());
714 assert_eq!(json["refund_line_items"][0]["line_item_id"], 669751112);
715 assert_eq!(json["refund_line_items"][0]["quantity"], 1);
716 assert_eq!(json["refund_line_items"][0]["restock_type"], "return");
717 assert_eq!(json["currency"], "USD");
718 }
719
720 #[test]
721 fn test_refund_list_params_serialization() {
722 let params = RefundListParams {
723 limit: Some(50),
724 fields: Some("id,note,created_at".to_string()),
725 in_shop_currency: Some(true),
726 };
727
728 let json = serde_json::to_value(¶ms).unwrap();
729
730 assert_eq!(json["limit"], 50);
731 assert_eq!(json["fields"], "id,note,created_at");
732 assert_eq!(json["in_shop_currency"], true);
733
734 let empty_params = RefundListParams::default();
736 let empty_json = serde_json::to_value(&empty_params).unwrap();
737 assert_eq!(empty_json, serde_json::json!({}));
738 }
739
740 #[test]
741 fn test_refund_get_id_returns_correct_value() {
742 let refund_with_id = RefundResource {
744 id: Some(123456),
745 order_id: Some(450789469),
746 note: Some("Test refund".to_string()),
747 ..Default::default()
748 };
749 assert_eq!(refund_with_id.get_id(), Some(123456));
750
751 let refund_without_id = RefundResource {
753 id: None,
754 order_id: Some(450789469),
755 note: Some("New refund".to_string()),
756 ..Default::default()
757 };
758 assert_eq!(refund_without_id.get_id(), None);
759 }
760
761 #[test]
762 fn test_refund_constants() {
763 assert_eq!(RefundResource::NAME, "Refund");
764 assert_eq!(RefundResource::PLURAL, "refunds");
765 }
766
767 #[test]
768 fn test_refund_line_item_serialization() {
769 let line_item = RefundLineItem {
770 id: Some(1),
771 quantity: Some(1),
772 line_item_id: Some(669751112),
773 location_id: Some(655441491),
774 restock_type: Some("return".to_string()),
775 subtotal: Some("199.99".to_string()),
776 total_tax: Some("15.00".to_string()),
777 ..Default::default()
778 };
779
780 let json = serde_json::to_value(&line_item).unwrap();
781
782 assert_eq!(json["id"], 1);
783 assert_eq!(json["quantity"], 1);
784 assert_eq!(json["line_item_id"], 669751112);
785 assert_eq!(json["location_id"], 655441491);
786 assert_eq!(json["restock_type"], "return");
787 assert_eq!(json["subtotal"], "199.99");
788 assert_eq!(json["total_tax"], "15.00");
789 }
790
791 #[test]
792 fn test_order_adjustment_serialization() {
793 let adjustment = OrderAdjustment {
794 id: Some(1),
795 order_id: Some(450789469),
796 refund_id: Some(123456),
797 kind: Some("refund_discrepancy".to_string()),
798 reason: Some("Refund discrepancy".to_string()),
799 amount: Some("-0.01".to_string()),
800 tax_amount: Some("0.00".to_string()),
801 ..Default::default()
802 };
803
804 let json = serde_json::to_value(&adjustment).unwrap();
805
806 assert_eq!(json["id"], 1);
807 assert_eq!(json["order_id"], 450789469);
808 assert_eq!(json["refund_id"], 123456);
809 assert_eq!(json["kind"], "refund_discrepancy");
810 assert_eq!(json["reason"], "Refund discrepancy");
811 assert_eq!(json["amount"], "-0.01");
812 }
813}