use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::clients::RestClient;
use crate::rest::{
build_path, get_path, ResourceError, ResourceOperation, ResourcePath, RestResource,
};
use crate::HttpMethod;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum TransactionKind {
#[default]
Authorization,
Capture,
Sale,
Void,
Refund,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum TransactionStatus {
#[default]
Pending,
Failure,
Success,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct PaymentDetails {
#[serde(skip_serializing_if = "Option::is_none")]
pub credit_card_bin: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub avs_result_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cvv_result_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub credit_card_number: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub credit_card_company: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub credit_card_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub credit_card_wallet: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub credit_card_expiration_month: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub credit_card_expiration_year: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub buyer_action_info: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct CurrencyExchangeAdjustment {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub original_amount: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub final_amount: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub currency: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub adjustment: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct Transaction {
#[serde(skip_serializing)]
pub id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub order_id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<TransactionKind>,
#[serde(skip_serializing_if = "Option::is_none")]
pub amount: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<TransactionStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gateway: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub authorization: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub authorization_expires_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub currency: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub test: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub location_id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub device_id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_name: Option<String>,
#[serde(skip_serializing)]
pub processed_at: Option<DateTime<Utc>>,
#[serde(skip_serializing)]
pub created_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub receipt: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub payment_details: Option<PaymentDetails>,
#[serde(skip_serializing_if = "Option::is_none")]
pub currency_exchange_adjustment: Option<CurrencyExchangeAdjustment>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total_unsettled_set: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manual_payment_gateway: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub amount_rounding: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub payments_refund_attributes: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extended_authorization_attributes: Option<serde_json::Value>,
#[serde(skip_serializing)]
pub admin_graphql_api_id: Option<String>,
}
impl Transaction {
pub async fn count_with_parent<ParentId: std::fmt::Display + Send>(
client: &RestClient,
parent_id_name: &str,
parent_id: ParentId,
params: Option<TransactionCountParams>,
) -> Result<u64, ResourceError> {
let mut ids: HashMap<&str, String> = HashMap::new();
ids.insert(parent_id_name, parent_id.to_string());
let available_ids: Vec<&str> = ids.keys().copied().collect();
let path = get_path(Self::PATHS, ResourceOperation::Count, &available_ids).ok_or(
ResourceError::PathResolutionFailed {
resource: Self::NAME,
operation: "count",
},
)?;
let url = build_path(path.template, &ids);
let query = params
.map(|p| {
let value = serde_json::to_value(&p).map_err(|e| {
ResourceError::Http(crate::clients::HttpError::Response(
crate::clients::HttpResponseError {
code: 400,
message: format!("Failed to serialize params: {e}"),
error_reference: None,
},
))
})?;
let mut query = HashMap::new();
if let serde_json::Value::Object(map) = value {
for (key, val) in map {
match val {
serde_json::Value::String(s) => {
query.insert(key, s);
}
serde_json::Value::Number(n) => {
query.insert(key, n.to_string());
}
serde_json::Value::Bool(b) => {
query.insert(key, b.to_string());
}
_ => {}
}
}
}
Ok::<_, ResourceError>(query)
})
.transpose()?
.filter(|q| !q.is_empty());
let response = client.get(&url, query).await?;
if !response.is_ok() {
return Err(ResourceError::from_http_response(
response.code,
&response.body,
Self::NAME,
None,
response.request_id(),
));
}
let count = response
.body
.get("count")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| {
ResourceError::Http(crate::clients::HttpError::Response(
crate::clients::HttpResponseError {
code: response.code,
message: "Missing 'count' in response".to_string(),
error_reference: response.request_id().map(ToString::to_string),
},
))
})?;
Ok(count)
}
}
impl RestResource for Transaction {
type Id = u64;
type FindParams = TransactionFindParams;
type AllParams = TransactionListParams;
type CountParams = TransactionCountParams;
const NAME: &'static str = "Transaction";
const PLURAL: &'static str = "transactions";
const PATHS: &'static [ResourcePath] = &[
ResourcePath::new(
HttpMethod::Get,
ResourceOperation::Find,
&["order_id", "id"],
"orders/{order_id}/transactions/{id}",
),
ResourcePath::new(
HttpMethod::Get,
ResourceOperation::All,
&["order_id"],
"orders/{order_id}/transactions",
),
ResourcePath::new(
HttpMethod::Get,
ResourceOperation::Count,
&["order_id"],
"orders/{order_id}/transactions/count",
),
ResourcePath::new(
HttpMethod::Post,
ResourceOperation::Create,
&["order_id"],
"orders/{order_id}/transactions",
),
];
fn get_id(&self) -> Option<Self::Id> {
self.id
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct TransactionFindParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub fields: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub in_shop_currency: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct TransactionListParams {
#[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 fields: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub in_shop_currency: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct TransactionCountParams {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rest::{get_path, ResourceOperation};
#[test]
fn test_transaction_kind_enum_serialization() {
assert_eq!(
serde_json::to_string(&TransactionKind::Authorization).unwrap(),
"\"authorization\""
);
assert_eq!(
serde_json::to_string(&TransactionKind::Capture).unwrap(),
"\"capture\""
);
assert_eq!(
serde_json::to_string(&TransactionKind::Sale).unwrap(),
"\"sale\""
);
assert_eq!(
serde_json::to_string(&TransactionKind::Void).unwrap(),
"\"void\""
);
assert_eq!(
serde_json::to_string(&TransactionKind::Refund).unwrap(),
"\"refund\""
);
let auth: TransactionKind = serde_json::from_str("\"authorization\"").unwrap();
let capture: TransactionKind = serde_json::from_str("\"capture\"").unwrap();
let sale: TransactionKind = serde_json::from_str("\"sale\"").unwrap();
let void_txn: TransactionKind = serde_json::from_str("\"void\"").unwrap();
let refund: TransactionKind = serde_json::from_str("\"refund\"").unwrap();
assert_eq!(auth, TransactionKind::Authorization);
assert_eq!(capture, TransactionKind::Capture);
assert_eq!(sale, TransactionKind::Sale);
assert_eq!(void_txn, TransactionKind::Void);
assert_eq!(refund, TransactionKind::Refund);
assert_eq!(TransactionKind::default(), TransactionKind::Authorization);
}
#[test]
fn test_transaction_status_enum_serialization() {
assert_eq!(
serde_json::to_string(&TransactionStatus::Pending).unwrap(),
"\"pending\""
);
assert_eq!(
serde_json::to_string(&TransactionStatus::Failure).unwrap(),
"\"failure\""
);
assert_eq!(
serde_json::to_string(&TransactionStatus::Success).unwrap(),
"\"success\""
);
assert_eq!(
serde_json::to_string(&TransactionStatus::Error).unwrap(),
"\"error\""
);
let success: TransactionStatus = serde_json::from_str("\"success\"").unwrap();
let failure: TransactionStatus = serde_json::from_str("\"failure\"").unwrap();
assert_eq!(success, TransactionStatus::Success);
assert_eq!(failure, TransactionStatus::Failure);
assert_eq!(TransactionStatus::default(), TransactionStatus::Pending);
}
#[test]
fn test_transaction_nested_paths_require_order_id() {
let find_path = get_path(Transaction::PATHS, ResourceOperation::Find, &["order_id", "id"]);
assert!(find_path.is_some());
assert_eq!(
find_path.unwrap().template,
"orders/{order_id}/transactions/{id}"
);
let find_without_order = get_path(Transaction::PATHS, ResourceOperation::Find, &["id"]);
assert!(find_without_order.is_none());
let all_path = get_path(Transaction::PATHS, ResourceOperation::All, &["order_id"]);
assert!(all_path.is_some());
assert_eq!(
all_path.unwrap().template,
"orders/{order_id}/transactions"
);
let all_without_order = get_path(Transaction::PATHS, ResourceOperation::All, &[]);
assert!(all_without_order.is_none());
let count_path = get_path(Transaction::PATHS, ResourceOperation::Count, &["order_id"]);
assert!(count_path.is_some());
assert_eq!(
count_path.unwrap().template,
"orders/{order_id}/transactions/count"
);
let create_path = get_path(Transaction::PATHS, ResourceOperation::Create, &["order_id"]);
assert!(create_path.is_some());
assert_eq!(
create_path.unwrap().template,
"orders/{order_id}/transactions"
);
let update_path = get_path(
Transaction::PATHS,
ResourceOperation::Update,
&["order_id", "id"],
);
assert!(update_path.is_none());
let delete_path = get_path(
Transaction::PATHS,
ResourceOperation::Delete,
&["order_id", "id"],
);
assert!(delete_path.is_none());
}
#[test]
fn test_transaction_struct_serialization() {
let transaction = Transaction {
id: Some(389404469),
order_id: Some(450789469),
kind: Some(TransactionKind::Capture),
amount: Some("199.99".to_string()),
status: Some(TransactionStatus::Success),
gateway: Some("bogus".to_string()),
message: Some("Transaction successful".to_string()),
currency: Some("USD".to_string()),
test: Some(true),
parent_id: Some(389404468),
created_at: Some(
DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
.unwrap()
.with_timezone(&Utc),
),
admin_graphql_api_id: Some("gid://shopify/OrderTransaction/389404469".to_string()),
..Default::default()
};
let json = serde_json::to_string(&transaction).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["order_id"], 450789469);
assert_eq!(parsed["kind"], "capture");
assert_eq!(parsed["amount"], "199.99");
assert_eq!(parsed["status"], "success");
assert_eq!(parsed["gateway"], "bogus");
assert_eq!(parsed["message"], "Transaction successful");
assert_eq!(parsed["currency"], "USD");
assert_eq!(parsed["test"], true);
assert_eq!(parsed["parent_id"], 389404468);
assert!(parsed.get("id").is_none());
assert!(parsed.get("created_at").is_none());
assert!(parsed.get("processed_at").is_none());
assert!(parsed.get("admin_graphql_api_id").is_none());
}
#[test]
fn test_transaction_deserialization_from_api_response() {
let json = r#"{
"id": 389404469,
"order_id": 450789469,
"kind": "capture",
"amount": "199.99",
"status": "success",
"gateway": "bogus",
"message": "Bogus Gateway: Forced success",
"error_code": null,
"authorization": "ch_1234567890",
"authorization_expires_at": "2024-01-22T10:30:00Z",
"currency": "USD",
"test": true,
"parent_id": 389404468,
"location_id": 655441491,
"user_id": 799407056,
"source_name": "web",
"processed_at": "2024-01-15T10:30:00Z",
"created_at": "2024-01-15T10:30:00Z",
"payment_details": {
"credit_card_bin": "424242",
"credit_card_number": "xxxx xxxx xxxx 4242",
"credit_card_company": "Visa",
"credit_card_name": "John Doe"
},
"receipt": {
"testcase": true,
"authorization": "ch_1234567890"
},
"admin_graphql_api_id": "gid://shopify/OrderTransaction/389404469"
}"#;
let transaction: Transaction = serde_json::from_str(json).unwrap();
assert_eq!(transaction.id, Some(389404469));
assert_eq!(transaction.order_id, Some(450789469));
assert_eq!(transaction.kind, Some(TransactionKind::Capture));
assert_eq!(transaction.amount, Some("199.99".to_string()));
assert_eq!(transaction.status, Some(TransactionStatus::Success));
assert_eq!(transaction.gateway, Some("bogus".to_string()));
assert_eq!(
transaction.authorization,
Some("ch_1234567890".to_string())
);
assert!(transaction.authorization_expires_at.is_some());
assert_eq!(transaction.currency, Some("USD".to_string()));
assert_eq!(transaction.test, Some(true));
assert_eq!(transaction.parent_id, Some(389404468));
assert_eq!(transaction.location_id, Some(655441491));
assert_eq!(transaction.user_id, Some(799407056));
assert!(transaction.processed_at.is_some());
assert!(transaction.created_at.is_some());
assert!(transaction.payment_details.is_some());
assert!(transaction.receipt.is_some());
let payment_details = transaction.payment_details.unwrap();
assert_eq!(payment_details.credit_card_bin, Some("424242".to_string()));
assert_eq!(payment_details.credit_card_company, Some("Visa".to_string()));
}
#[test]
fn test_transaction_list_params_serialization() {
let params = TransactionListParams {
limit: Some(50),
since_id: Some(100),
fields: Some("id,kind,amount".to_string()),
in_shop_currency: Some(true),
};
let json = serde_json::to_value(¶ms).unwrap();
assert_eq!(json["limit"], 50);
assert_eq!(json["since_id"], 100);
assert_eq!(json["fields"], "id,kind,amount");
assert_eq!(json["in_shop_currency"], true);
let empty_params = TransactionListParams::default();
let empty_json = serde_json::to_value(&empty_params).unwrap();
assert_eq!(empty_json, serde_json::json!({}));
}
#[test]
fn test_transaction_find_params_serialization() {
let params = TransactionFindParams {
fields: Some("id,kind,amount".to_string()),
in_shop_currency: Some(true),
};
let json = serde_json::to_value(¶ms).unwrap();
assert_eq!(json["fields"], "id,kind,amount");
assert_eq!(json["in_shop_currency"], true);
}
#[test]
fn test_transaction_get_id_returns_correct_value() {
let txn_with_id = Transaction {
id: Some(389404469),
order_id: Some(450789469),
kind: Some(TransactionKind::Capture),
..Default::default()
};
assert_eq!(txn_with_id.get_id(), Some(389404469));
let txn_without_id = Transaction {
id: None,
order_id: Some(450789469),
kind: Some(TransactionKind::Capture),
..Default::default()
};
assert_eq!(txn_without_id.get_id(), None);
}
#[test]
fn test_transaction_constants() {
assert_eq!(Transaction::NAME, "Transaction");
assert_eq!(Transaction::PLURAL, "transactions");
}
}