1use std::collections::HashMap;
48
49use chrono::{DateTime, Utc};
50use serde::{Deserialize, Serialize};
51
52use crate::clients::RestClient;
53use crate::rest::{
54 build_path, get_path, ResourceError, ResourceOperation, ResourcePath, RestResource,
55};
56use crate::HttpMethod;
57
58#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
62#[serde(rename_all = "snake_case")]
63pub enum TransactionKind {
64 #[default]
66 Authorization,
67 Capture,
69 Sale,
71 Void,
73 Refund,
75}
76
77#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
81#[serde(rename_all = "snake_case")]
82pub enum TransactionStatus {
83 #[default]
85 Pending,
86 Failure,
88 Success,
90 Error,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
98pub struct PaymentDetails {
99 #[serde(skip_serializing_if = "Option::is_none")]
101 pub credit_card_bin: Option<String>,
102
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub avs_result_code: Option<String>,
106
107 #[serde(skip_serializing_if = "Option::is_none")]
109 pub cvv_result_code: Option<String>,
110
111 #[serde(skip_serializing_if = "Option::is_none")]
113 pub credit_card_number: Option<String>,
114
115 #[serde(skip_serializing_if = "Option::is_none")]
117 pub credit_card_company: Option<String>,
118
119 #[serde(skip_serializing_if = "Option::is_none")]
121 pub credit_card_name: Option<String>,
122
123 #[serde(skip_serializing_if = "Option::is_none")]
125 pub credit_card_wallet: Option<String>,
126
127 #[serde(skip_serializing_if = "Option::is_none")]
129 pub credit_card_expiration_month: Option<i32>,
130
131 #[serde(skip_serializing_if = "Option::is_none")]
133 pub credit_card_expiration_year: Option<i32>,
134
135 #[serde(skip_serializing_if = "Option::is_none")]
137 pub buyer_action_info: Option<serde_json::Value>,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
142pub struct CurrencyExchangeAdjustment {
143 #[serde(skip_serializing_if = "Option::is_none")]
145 pub id: Option<u64>,
146
147 #[serde(skip_serializing_if = "Option::is_none")]
149 pub original_amount: Option<String>,
150
151 #[serde(skip_serializing_if = "Option::is_none")]
153 pub final_amount: Option<String>,
154
155 #[serde(skip_serializing_if = "Option::is_none")]
157 pub currency: Option<String>,
158
159 #[serde(skip_serializing_if = "Option::is_none")]
161 pub adjustment: Option<String>,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
193pub struct Transaction {
194 #[serde(skip_serializing)]
197 pub id: Option<u64>,
198
199 #[serde(skip_serializing_if = "Option::is_none")]
201 pub order_id: Option<u64>,
202
203 #[serde(skip_serializing_if = "Option::is_none")]
205 pub kind: Option<TransactionKind>,
206
207 #[serde(skip_serializing_if = "Option::is_none")]
209 pub amount: Option<String>,
210
211 #[serde(skip_serializing_if = "Option::is_none")]
213 pub status: Option<TransactionStatus>,
214
215 #[serde(skip_serializing_if = "Option::is_none")]
217 pub gateway: Option<String>,
218
219 #[serde(skip_serializing_if = "Option::is_none")]
221 pub message: Option<String>,
222
223 #[serde(skip_serializing_if = "Option::is_none")]
225 pub error_code: Option<String>,
226
227 #[serde(skip_serializing_if = "Option::is_none")]
229 pub authorization: Option<String>,
230
231 #[serde(skip_serializing_if = "Option::is_none")]
233 pub authorization_expires_at: Option<DateTime<Utc>>,
234
235 #[serde(skip_serializing_if = "Option::is_none")]
237 pub currency: Option<String>,
238
239 #[serde(skip_serializing_if = "Option::is_none")]
241 pub test: Option<bool>,
242
243 #[serde(skip_serializing_if = "Option::is_none")]
245 pub parent_id: Option<u64>,
246
247 #[serde(skip_serializing_if = "Option::is_none")]
249 pub location_id: Option<u64>,
250
251 #[serde(skip_serializing_if = "Option::is_none")]
253 pub device_id: Option<u64>,
254
255 #[serde(skip_serializing_if = "Option::is_none")]
257 pub user_id: Option<u64>,
258
259 #[serde(skip_serializing_if = "Option::is_none")]
261 pub source_name: Option<String>,
262
263 #[serde(skip_serializing)]
266 pub processed_at: Option<DateTime<Utc>>,
267
268 #[serde(skip_serializing)]
271 pub created_at: Option<DateTime<Utc>>,
272
273 #[serde(skip_serializing_if = "Option::is_none")]
275 pub receipt: Option<serde_json::Value>,
276
277 #[serde(skip_serializing_if = "Option::is_none")]
279 pub payment_details: Option<PaymentDetails>,
280
281 #[serde(skip_serializing_if = "Option::is_none")]
283 pub currency_exchange_adjustment: Option<CurrencyExchangeAdjustment>,
284
285 #[serde(skip_serializing_if = "Option::is_none")]
287 pub total_unsettled_set: Option<serde_json::Value>,
288
289 #[serde(skip_serializing_if = "Option::is_none")]
291 pub manual_payment_gateway: Option<bool>,
292
293 #[serde(skip_serializing_if = "Option::is_none")]
295 pub amount_rounding: Option<serde_json::Value>,
296
297 #[serde(skip_serializing_if = "Option::is_none")]
299 pub payments_refund_attributes: Option<serde_json::Value>,
300
301 #[serde(skip_serializing_if = "Option::is_none")]
303 pub extended_authorization_attributes: Option<serde_json::Value>,
304
305 #[serde(skip_serializing)]
308 pub admin_graphql_api_id: Option<String>,
309}
310
311impl Transaction {
312 pub async fn count_with_parent<ParentId: std::fmt::Display + Send>(
336 client: &RestClient,
337 parent_id_name: &str,
338 parent_id: ParentId,
339 params: Option<TransactionCountParams>,
340 ) -> Result<u64, ResourceError> {
341 let mut ids: HashMap<&str, String> = HashMap::new();
342 ids.insert(parent_id_name, parent_id.to_string());
343
344 let available_ids: Vec<&str> = ids.keys().copied().collect();
345 let path = get_path(Self::PATHS, ResourceOperation::Count, &available_ids).ok_or(
346 ResourceError::PathResolutionFailed {
347 resource: Self::NAME,
348 operation: "count",
349 },
350 )?;
351
352 let url = build_path(path.template, &ids);
353
354 let query = params
356 .map(|p| {
357 let value = serde_json::to_value(&p).map_err(|e| {
358 ResourceError::Http(crate::clients::HttpError::Response(
359 crate::clients::HttpResponseError {
360 code: 400,
361 message: format!("Failed to serialize params: {e}"),
362 error_reference: None,
363 },
364 ))
365 })?;
366
367 let mut query = HashMap::new();
368 if let serde_json::Value::Object(map) = value {
369 for (key, val) in map {
370 match val {
371 serde_json::Value::String(s) => {
372 query.insert(key, s);
373 }
374 serde_json::Value::Number(n) => {
375 query.insert(key, n.to_string());
376 }
377 serde_json::Value::Bool(b) => {
378 query.insert(key, b.to_string());
379 }
380 _ => {}
381 }
382 }
383 }
384 Ok::<_, ResourceError>(query)
385 })
386 .transpose()?
387 .filter(|q| !q.is_empty());
388
389 let response = client.get(&url, query).await?;
390
391 if !response.is_ok() {
392 return Err(ResourceError::from_http_response(
393 response.code,
394 &response.body,
395 Self::NAME,
396 None,
397 response.request_id(),
398 ));
399 }
400
401 let count = response
403 .body
404 .get("count")
405 .and_then(serde_json::Value::as_u64)
406 .ok_or_else(|| {
407 ResourceError::Http(crate::clients::HttpError::Response(
408 crate::clients::HttpResponseError {
409 code: response.code,
410 message: "Missing 'count' in response".to_string(),
411 error_reference: response.request_id().map(ToString::to_string),
412 },
413 ))
414 })?;
415
416 Ok(count)
417 }
418}
419
420impl RestResource for Transaction {
421 type Id = u64;
422 type FindParams = TransactionFindParams;
423 type AllParams = TransactionListParams;
424 type CountParams = TransactionCountParams;
425
426 const NAME: &'static str = "Transaction";
427 const PLURAL: &'static str = "transactions";
428
429 const PATHS: &'static [ResourcePath] = &[
434 ResourcePath::new(
436 HttpMethod::Get,
437 ResourceOperation::Find,
438 &["order_id", "id"],
439 "orders/{order_id}/transactions/{id}",
440 ),
441 ResourcePath::new(
442 HttpMethod::Get,
443 ResourceOperation::All,
444 &["order_id"],
445 "orders/{order_id}/transactions",
446 ),
447 ResourcePath::new(
448 HttpMethod::Get,
449 ResourceOperation::Count,
450 &["order_id"],
451 "orders/{order_id}/transactions/count",
452 ),
453 ResourcePath::new(
454 HttpMethod::Post,
455 ResourceOperation::Create,
456 &["order_id"],
457 "orders/{order_id}/transactions",
458 ),
459 ];
461
462 fn get_id(&self) -> Option<Self::Id> {
463 self.id
464 }
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
469pub struct TransactionFindParams {
470 #[serde(skip_serializing_if = "Option::is_none")]
472 pub fields: Option<String>,
473
474 #[serde(skip_serializing_if = "Option::is_none")]
476 pub in_shop_currency: Option<bool>,
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
481pub struct TransactionListParams {
482 #[serde(skip_serializing_if = "Option::is_none")]
484 pub limit: Option<u32>,
485
486 #[serde(skip_serializing_if = "Option::is_none")]
488 pub since_id: Option<u64>,
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)]
501pub struct TransactionCountParams {
502 }
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508 use crate::rest::{get_path, ResourceOperation};
509
510 #[test]
511 fn test_transaction_kind_enum_serialization() {
512 assert_eq!(
514 serde_json::to_string(&TransactionKind::Authorization).unwrap(),
515 "\"authorization\""
516 );
517 assert_eq!(
518 serde_json::to_string(&TransactionKind::Capture).unwrap(),
519 "\"capture\""
520 );
521 assert_eq!(
522 serde_json::to_string(&TransactionKind::Sale).unwrap(),
523 "\"sale\""
524 );
525 assert_eq!(
526 serde_json::to_string(&TransactionKind::Void).unwrap(),
527 "\"void\""
528 );
529 assert_eq!(
530 serde_json::to_string(&TransactionKind::Refund).unwrap(),
531 "\"refund\""
532 );
533
534 let auth: TransactionKind = serde_json::from_str("\"authorization\"").unwrap();
536 let capture: TransactionKind = serde_json::from_str("\"capture\"").unwrap();
537 let sale: TransactionKind = serde_json::from_str("\"sale\"").unwrap();
538 let void_txn: TransactionKind = serde_json::from_str("\"void\"").unwrap();
539 let refund: TransactionKind = serde_json::from_str("\"refund\"").unwrap();
540
541 assert_eq!(auth, TransactionKind::Authorization);
542 assert_eq!(capture, TransactionKind::Capture);
543 assert_eq!(sale, TransactionKind::Sale);
544 assert_eq!(void_txn, TransactionKind::Void);
545 assert_eq!(refund, TransactionKind::Refund);
546
547 assert_eq!(TransactionKind::default(), TransactionKind::Authorization);
549 }
550
551 #[test]
552 fn test_transaction_status_enum_serialization() {
553 assert_eq!(
555 serde_json::to_string(&TransactionStatus::Pending).unwrap(),
556 "\"pending\""
557 );
558 assert_eq!(
559 serde_json::to_string(&TransactionStatus::Failure).unwrap(),
560 "\"failure\""
561 );
562 assert_eq!(
563 serde_json::to_string(&TransactionStatus::Success).unwrap(),
564 "\"success\""
565 );
566 assert_eq!(
567 serde_json::to_string(&TransactionStatus::Error).unwrap(),
568 "\"error\""
569 );
570
571 let success: TransactionStatus = serde_json::from_str("\"success\"").unwrap();
573 let failure: TransactionStatus = serde_json::from_str("\"failure\"").unwrap();
574
575 assert_eq!(success, TransactionStatus::Success);
576 assert_eq!(failure, TransactionStatus::Failure);
577
578 assert_eq!(TransactionStatus::default(), TransactionStatus::Pending);
580 }
581
582 #[test]
583 fn test_transaction_nested_paths_require_order_id() {
584 let find_path = get_path(Transaction::PATHS, ResourceOperation::Find, &["order_id", "id"]);
588 assert!(find_path.is_some());
589 assert_eq!(
590 find_path.unwrap().template,
591 "orders/{order_id}/transactions/{id}"
592 );
593
594 let find_without_order = get_path(Transaction::PATHS, ResourceOperation::Find, &["id"]);
596 assert!(find_without_order.is_none());
597
598 let all_path = get_path(Transaction::PATHS, ResourceOperation::All, &["order_id"]);
600 assert!(all_path.is_some());
601 assert_eq!(
602 all_path.unwrap().template,
603 "orders/{order_id}/transactions"
604 );
605
606 let all_without_order = get_path(Transaction::PATHS, ResourceOperation::All, &[]);
608 assert!(all_without_order.is_none());
609
610 let count_path = get_path(Transaction::PATHS, ResourceOperation::Count, &["order_id"]);
612 assert!(count_path.is_some());
613 assert_eq!(
614 count_path.unwrap().template,
615 "orders/{order_id}/transactions/count"
616 );
617
618 let create_path = get_path(Transaction::PATHS, ResourceOperation::Create, &["order_id"]);
620 assert!(create_path.is_some());
621 assert_eq!(
622 create_path.unwrap().template,
623 "orders/{order_id}/transactions"
624 );
625
626 let update_path = get_path(
628 Transaction::PATHS,
629 ResourceOperation::Update,
630 &["order_id", "id"],
631 );
632 assert!(update_path.is_none());
633
634 let delete_path = get_path(
636 Transaction::PATHS,
637 ResourceOperation::Delete,
638 &["order_id", "id"],
639 );
640 assert!(delete_path.is_none());
641 }
642
643 #[test]
644 fn test_transaction_struct_serialization() {
645 let transaction = Transaction {
646 id: Some(389404469),
647 order_id: Some(450789469),
648 kind: Some(TransactionKind::Capture),
649 amount: Some("199.99".to_string()),
650 status: Some(TransactionStatus::Success),
651 gateway: Some("bogus".to_string()),
652 message: Some("Transaction successful".to_string()),
653 currency: Some("USD".to_string()),
654 test: Some(true),
655 parent_id: Some(389404468),
656 created_at: Some(
657 DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
658 .unwrap()
659 .with_timezone(&Utc),
660 ),
661 admin_graphql_api_id: Some("gid://shopify/OrderTransaction/389404469".to_string()),
662 ..Default::default()
663 };
664
665 let json = serde_json::to_string(&transaction).unwrap();
666 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
667
668 assert_eq!(parsed["order_id"], 450789469);
670 assert_eq!(parsed["kind"], "capture");
671 assert_eq!(parsed["amount"], "199.99");
672 assert_eq!(parsed["status"], "success");
673 assert_eq!(parsed["gateway"], "bogus");
674 assert_eq!(parsed["message"], "Transaction successful");
675 assert_eq!(parsed["currency"], "USD");
676 assert_eq!(parsed["test"], true);
677 assert_eq!(parsed["parent_id"], 389404468);
678
679 assert!(parsed.get("id").is_none());
681 assert!(parsed.get("created_at").is_none());
682 assert!(parsed.get("processed_at").is_none());
683 assert!(parsed.get("admin_graphql_api_id").is_none());
684 }
685
686 #[test]
687 fn test_transaction_deserialization_from_api_response() {
688 let json = r#"{
689 "id": 389404469,
690 "order_id": 450789469,
691 "kind": "capture",
692 "amount": "199.99",
693 "status": "success",
694 "gateway": "bogus",
695 "message": "Bogus Gateway: Forced success",
696 "error_code": null,
697 "authorization": "ch_1234567890",
698 "authorization_expires_at": "2024-01-22T10:30:00Z",
699 "currency": "USD",
700 "test": true,
701 "parent_id": 389404468,
702 "location_id": 655441491,
703 "user_id": 799407056,
704 "source_name": "web",
705 "processed_at": "2024-01-15T10:30:00Z",
706 "created_at": "2024-01-15T10:30:00Z",
707 "payment_details": {
708 "credit_card_bin": "424242",
709 "credit_card_number": "xxxx xxxx xxxx 4242",
710 "credit_card_company": "Visa",
711 "credit_card_name": "John Doe"
712 },
713 "receipt": {
714 "testcase": true,
715 "authorization": "ch_1234567890"
716 },
717 "admin_graphql_api_id": "gid://shopify/OrderTransaction/389404469"
718 }"#;
719
720 let transaction: Transaction = serde_json::from_str(json).unwrap();
721
722 assert_eq!(transaction.id, Some(389404469));
723 assert_eq!(transaction.order_id, Some(450789469));
724 assert_eq!(transaction.kind, Some(TransactionKind::Capture));
725 assert_eq!(transaction.amount, Some("199.99".to_string()));
726 assert_eq!(transaction.status, Some(TransactionStatus::Success));
727 assert_eq!(transaction.gateway, Some("bogus".to_string()));
728 assert_eq!(
729 transaction.authorization,
730 Some("ch_1234567890".to_string())
731 );
732 assert!(transaction.authorization_expires_at.is_some());
733 assert_eq!(transaction.currency, Some("USD".to_string()));
734 assert_eq!(transaction.test, Some(true));
735 assert_eq!(transaction.parent_id, Some(389404468));
736 assert_eq!(transaction.location_id, Some(655441491));
737 assert_eq!(transaction.user_id, Some(799407056));
738 assert!(transaction.processed_at.is_some());
739 assert!(transaction.created_at.is_some());
740 assert!(transaction.payment_details.is_some());
741 assert!(transaction.receipt.is_some());
742
743 let payment_details = transaction.payment_details.unwrap();
744 assert_eq!(payment_details.credit_card_bin, Some("424242".to_string()));
745 assert_eq!(payment_details.credit_card_company, Some("Visa".to_string()));
746 }
747
748 #[test]
749 fn test_transaction_list_params_serialization() {
750 let params = TransactionListParams {
751 limit: Some(50),
752 since_id: Some(100),
753 fields: Some("id,kind,amount".to_string()),
754 in_shop_currency: Some(true),
755 };
756
757 let json = serde_json::to_value(¶ms).unwrap();
758
759 assert_eq!(json["limit"], 50);
760 assert_eq!(json["since_id"], 100);
761 assert_eq!(json["fields"], "id,kind,amount");
762 assert_eq!(json["in_shop_currency"], true);
763
764 let empty_params = TransactionListParams::default();
766 let empty_json = serde_json::to_value(&empty_params).unwrap();
767 assert_eq!(empty_json, serde_json::json!({}));
768 }
769
770 #[test]
771 fn test_transaction_find_params_serialization() {
772 let params = TransactionFindParams {
773 fields: Some("id,kind,amount".to_string()),
774 in_shop_currency: Some(true),
775 };
776
777 let json = serde_json::to_value(¶ms).unwrap();
778
779 assert_eq!(json["fields"], "id,kind,amount");
780 assert_eq!(json["in_shop_currency"], true);
781 }
782
783 #[test]
784 fn test_transaction_get_id_returns_correct_value() {
785 let txn_with_id = Transaction {
787 id: Some(389404469),
788 order_id: Some(450789469),
789 kind: Some(TransactionKind::Capture),
790 ..Default::default()
791 };
792 assert_eq!(txn_with_id.get_id(), Some(389404469));
793
794 let txn_without_id = Transaction {
796 id: None,
797 order_id: Some(450789469),
798 kind: Some(TransactionKind::Capture),
799 ..Default::default()
800 };
801 assert_eq!(txn_without_id.get_id(), None);
802 }
803
804 #[test]
805 fn test_transaction_constants() {
806 assert_eq!(Transaction::NAME, "Transaction");
807 assert_eq!(Transaction::PLURAL, "transactions");
808 }
809}