use serde::{Deserialize, Serialize};
use super::{
batch::Platform, bool_from_int_default_false, deserialize_optional_i32, deserialize_string_or_int,
BatchStatus, ChargebackCycle, ChargebackPaymentMethod, ChargebackStatusValue, Member,
Payment, PaymentMethod, PayrixId, Plan, PlanSchedule, PlanType, PlanUm, Subscription,
SubscriptionOrigin, TokenStatus, Transaction, TransactionStatus, TransactionType,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenExpanded {
pub id: PayrixId,
#[serde(default)]
pub created: Option<String>,
#[serde(default)]
pub modified: Option<String>,
#[serde(default)]
pub creator: Option<PayrixId>,
#[serde(default)]
pub modifier: Option<PayrixId>,
#[serde(default)]
pub token: Option<String>,
#[serde(default)]
pub status: Option<TokenStatus>,
#[serde(default)]
pub expiration: Option<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub custom: Option<String>,
#[serde(default, with = "bool_from_int_default_false")]
pub inactive: bool,
#[serde(default, with = "bool_from_int_default_false")]
pub frozen: bool,
#[serde(default)]
pub entry_mode: Option<i32>,
#[serde(default)]
pub origin: Option<String>,
#[serde(default)]
pub omnitoken: Option<String>,
#[serde(default)]
pub auth_token_customer: Option<String>,
#[serde(default)]
pub payment: Option<Payment>,
#[serde(default)]
pub customer: Option<PayrixId>,
}
impl TokenExpanded {
pub fn payment_method(&self) -> Option<PaymentMethod> {
self.payment.as_ref().and_then(|p| p.method)
}
pub fn card_display(&self) -> Option<String> {
self.payment.as_ref().map(|p| p.display())
}
pub fn customer_id(&self) -> Option<&str> {
self.customer.as_ref().map(|c| c.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TransactionExpanded {
pub id: PayrixId,
#[serde(default)]
pub created: Option<String>,
#[serde(default)]
pub modified: Option<String>,
#[serde(default)]
pub creator: Option<PayrixId>,
#[serde(default)]
pub modifier: Option<PayrixId>,
#[serde(default, rename = "type")]
pub txn_type: Option<TransactionType>,
#[serde(default)]
pub status: Option<TransactionStatus>,
#[serde(default)]
pub origin: Option<i32>,
#[serde(default)]
pub total: Option<i64>,
#[serde(default)]
pub approved: Option<i64>,
#[serde(default)]
pub original_approved: Option<i64>,
#[serde(default)]
pub refunded: Option<i64>,
#[serde(default)]
pub reserved: Option<i64>,
#[serde(default)]
pub authorization: Option<String>,
#[serde(default)]
pub auth_code: Option<String>,
#[serde(default)]
pub currency: Option<String>,
#[serde(default)]
pub descriptor: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub cof_type: Option<String>,
#[serde(default)]
pub expiration: Option<String>,
#[serde(default)]
pub cvv: Option<i32>,
#[serde(default)]
pub platform: Option<String>,
#[serde(default, deserialize_with = "deserialize_string_or_int")]
pub captured: Option<String>,
#[serde(default, deserialize_with = "deserialize_string_or_int")]
pub settled: Option<String>,
#[serde(default, deserialize_with = "deserialize_string_or_int")]
pub returned: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_i32")]
pub funded: Option<i32>,
#[serde(default)]
pub first: Option<String>,
#[serde(default)]
pub middle: Option<String>,
#[serde(default)]
pub last: Option<String>,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub phone: Option<String>,
#[serde(default)]
pub address1: Option<String>,
#[serde(default)]
pub address2: Option<String>,
#[serde(default)]
pub city: Option<String>,
#[serde(default)]
pub state: Option<String>,
#[serde(default)]
pub zip: Option<String>,
#[serde(default)]
pub country: Option<String>,
#[serde(default, with = "bool_from_int_default_false")]
pub inactive: bool,
#[serde(default, with = "bool_from_int_default_false")]
pub frozen: bool,
#[serde(default)]
pub funding_enabled: Option<i32>,
#[serde(default)]
pub batch: Option<PayrixId>,
#[serde(default)]
pub fortxn: Option<PayrixId>,
#[serde(default)]
pub fromtxn: Option<PayrixId>,
#[serde(default)]
pub payment: Option<Payment>,
#[serde(default)]
pub token: Option<TokenExpanded>,
#[serde(default)]
pub merchant: Option<PayrixId>,
#[serde(default)]
pub subscription: Option<Subscription>,
}
impl TransactionExpanded {
pub fn amount_dollars(&self) -> f64 {
self.total.unwrap_or(0) as f64 / 100.0
}
pub fn approved_dollars(&self) -> f64 {
self.approved.unwrap_or(0) as f64 / 100.0
}
pub fn payment_display(&self) -> Option<String> {
self.payment.as_ref().map(|p| p.display())
}
pub fn customer_name(&self) -> Option<String> {
let first = self.first.as_deref().unwrap_or("");
let last = self.last.as_deref().unwrap_or("");
let name = format!("{} {}", first, last).trim().to_string();
if name.is_empty() {
None
} else {
Some(name)
}
}
pub fn customer_id(&self) -> Option<&str> {
self.token.as_ref().and_then(|t| t.customer_id())
}
pub fn is_approved(&self) -> bool {
matches!(self.status, Some(TransactionStatus::Captured))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomerExpanded {
pub id: PayrixId,
#[serde(default)]
pub created: Option<String>,
#[serde(default)]
pub first: Option<String>,
#[serde(default)]
pub last: Option<String>,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub merchant: Option<PayrixId>,
#[serde(default, with = "bool_from_int_default_false")]
pub inactive: bool,
#[serde(default)]
pub tokens: Option<Vec<TokenExpanded>>,
#[serde(default)]
pub invoices: Option<Vec<serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubscriptionExpanded {
pub id: PayrixId,
#[serde(default)]
pub created: Option<String>,
#[serde(default)]
pub modified: Option<String>,
#[serde(default)]
pub creator: Option<PayrixId>,
#[serde(default)]
pub modifier: Option<PayrixId>,
#[serde(default)]
pub statement_entity: Option<PayrixId>,
#[serde(default)]
pub first_txn: Option<PayrixId>,
#[serde(default)]
pub start: Option<i32>,
#[serde(default)]
pub finish: Option<i32>,
#[serde(default)]
pub tax: Option<i64>,
#[serde(default)]
pub descriptor: Option<String>,
#[serde(default)]
pub txn_description: Option<String>,
#[serde(default)]
pub order: Option<String>,
#[serde(default)]
pub origin: Option<SubscriptionOrigin>,
#[serde(default)]
pub authentication: Option<String>,
#[serde(default)]
pub authentication_id: Option<String>,
#[serde(default)]
pub failures: Option<i32>,
#[serde(default)]
pub max_failures: Option<i32>,
#[serde(default, with = "bool_from_int_default_false")]
pub inactive: bool,
#[serde(default, with = "bool_from_int_default_false")]
pub frozen: bool,
#[serde(default)]
pub plan: Option<Plan>,
}
impl SubscriptionExpanded {
pub fn plan_amount_dollars(&self) -> Option<f64> {
self.plan
.as_ref()
.and_then(|p| p.amount)
.map(|a| a as f64 / 100.0)
}
pub fn plan_name(&self) -> Option<&str> {
self.plan.as_ref().and_then(|p| p.name.as_deref())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlanExpanded {
pub id: PayrixId,
#[serde(default)]
pub created: Option<String>,
#[serde(default)]
pub modified: Option<String>,
#[serde(default)]
pub creator: Option<PayrixId>,
#[serde(default)]
pub modifier: Option<PayrixId>,
#[serde(default)]
pub billing: Option<PayrixId>,
#[serde(default, rename = "type")]
pub plan_type: Option<PlanType>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub txn_description: Option<String>,
#[serde(default)]
pub order: Option<String>,
#[serde(default)]
pub schedule: Option<PlanSchedule>,
#[serde(default)]
pub schedule_factor: Option<i32>,
#[serde(default)]
pub um: Option<PlanUm>,
#[serde(default)]
pub amount: Option<i64>,
#[serde(default)]
pub max_failures: Option<i32>,
#[serde(default, with = "bool_from_int_default_false")]
pub inactive: bool,
#[serde(default, with = "bool_from_int_default_false")]
pub frozen: bool,
#[serde(default)]
pub merchant: Option<PayrixId>,
#[serde(default)]
pub subscriptions: Option<Vec<Subscription>>,
}
impl PlanExpanded {
pub fn amount_dollars(&self) -> f64 {
self.amount.unwrap_or(0) as f64 / 100.0
}
pub fn subscription_count(&self) -> usize {
self.subscriptions
.as_ref()
.map(|s| s.iter().filter(|sub| !sub.inactive).count())
.unwrap_or(0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChargebackExpanded {
pub id: PayrixId,
#[serde(default)]
pub created: Option<String>,
#[serde(default)]
pub modified: Option<String>,
#[serde(default)]
pub creator: Option<PayrixId>,
#[serde(default)]
pub modifier: Option<PayrixId>,
#[serde(default)]
pub mid: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub total: Option<i64>,
#[serde(default)]
pub represented_total: Option<i64>,
#[serde(default)]
pub cycle: Option<ChargebackCycle>,
#[serde(default)]
pub currency: Option<String>,
#[serde(default)]
pub platform: Option<String>,
#[serde(default)]
pub payment_method: Option<ChargebackPaymentMethod>,
#[serde(default, rename = "ref")]
pub reference: Option<String>,
#[serde(default)]
pub reason: Option<String>,
#[serde(default)]
pub reason_code: Option<String>,
#[serde(default)]
pub issued: Option<i32>,
#[serde(default)]
pub received: Option<i32>,
#[serde(default)]
pub reply: Option<i32>,
#[serde(default)]
pub bank_ref: Option<String>,
#[serde(default)]
pub chargeback_ref: Option<String>,
#[serde(default)]
pub status: Option<ChargebackStatusValue>,
#[serde(default)]
pub last_status_change: Option<PayrixId>,
#[serde(default, with = "bool_from_int_default_false")]
pub actionable: bool,
#[serde(default, with = "bool_from_int_default_false")]
pub shadow: bool,
#[serde(default, with = "bool_from_int_default_false")]
pub inactive: bool,
#[serde(default, with = "bool_from_int_default_false")]
pub frozen: bool,
#[serde(default)]
pub txn: Option<Transaction>,
#[serde(default)]
pub merchant: Option<PayrixId>,
}
impl ChargebackExpanded {
pub fn amount_dollars(&self) -> f64 {
self.total.unwrap_or(0) as f64 / 100.0
}
pub fn original_transaction_amount(&self) -> Option<f64> {
self.txn.as_ref().and_then(|t| t.total).map(|a| a as f64 / 100.0)
}
pub fn is_actionable(&self) -> bool {
self.actionable && matches!(self.status, Some(ChargebackStatusValue::Open))
}
pub fn merchant_id(&self) -> Option<&str> {
self.merchant.as_ref().map(|m| m.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchExpanded {
pub id: PayrixId,
#[serde(default)]
pub created: Option<String>,
#[serde(default)]
pub modified: Option<String>,
#[serde(default)]
pub creator: Option<PayrixId>,
#[serde(default)]
pub modifier: Option<PayrixId>,
#[serde(default)]
pub date: Option<String>,
#[serde(default)]
pub processing_date: Option<String>,
#[serde(default)]
pub processing_id: Option<String>,
#[serde(default)]
pub platform: Option<Platform>,
#[serde(default)]
pub status: Option<BatchStatus>,
#[serde(default, rename = "ref")]
pub reference: Option<String>,
#[serde(default)]
pub client_ref: Option<String>,
#[serde(default)]
pub close_time: Option<String>,
#[serde(default, with = "bool_from_int_default_false")]
pub inactive: bool,
#[serde(default, with = "bool_from_int_default_false")]
pub frozen: bool,
#[serde(default)]
pub merchant: Option<PayrixId>,
#[serde(default)]
pub txns: Option<Vec<Transaction>>,
}
impl BatchExpanded {
pub fn transaction_count(&self) -> usize {
self.txns.as_ref().map(|t| t.len()).unwrap_or(0)
}
pub fn total_amount_dollars(&self) -> f64 {
self.txns
.as_ref()
.map(|txns| txns.iter().filter_map(|t| t.total).sum::<i64>())
.unwrap_or(0) as f64
/ 100.0
}
pub fn is_open(&self) -> bool {
matches!(self.status, Some(BatchStatus::Open))
}
pub fn merchant_id(&self) -> Option<&str> {
self.merchant.as_ref().map(|m| m.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MerchantExpanded {
pub id: PayrixId,
#[serde(default)]
pub created: Option<String>,
#[serde(default)]
pub modified: Option<String>,
#[serde(default)]
pub creator: Option<PayrixId>,
#[serde(default)]
pub modifier: Option<PayrixId>,
#[serde(default)]
pub dba: Option<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub entity: Option<PayrixId>,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub phone: Option<String>,
#[serde(default)]
pub website: Option<String>,
#[serde(default)]
pub address1: Option<String>,
#[serde(default)]
pub address2: Option<String>,
#[serde(default)]
pub city: Option<String>,
#[serde(default)]
pub state: Option<String>,
#[serde(default)]
pub zip: Option<String>,
#[serde(default)]
pub country: Option<String>,
#[serde(default)]
pub timezone: Option<String>,
#[serde(default)]
pub mcc: Option<String>,
#[serde(default)]
pub status: Option<i32>,
#[serde(default, with = "bool_from_int_default_false")]
pub inactive: bool,
#[serde(default, with = "bool_from_int_default_false")]
pub frozen: bool,
#[serde(default)]
pub members: Option<Vec<Member>>,
}
impl MerchantExpanded {
pub fn member_count(&self) -> usize {
self.members.as_ref().map(|m| m.len()).unwrap_or(0)
}
pub fn primary_member(&self) -> Option<&Member> {
self.members
.as_ref()
.and_then(|members| members.iter().find(|m| m.primary))
}
pub fn total_ownership_percent(&self) -> f64 {
self.members
.as_ref()
.map(|members| members.iter().filter_map(|m| m.ownership).sum::<i32>())
.unwrap_or(0) as f64
/ 100.0
}
pub fn display_name(&self) -> String {
self.dba
.as_ref()
.or(self.name.as_ref())
.cloned()
.unwrap_or_else(|| "Unknown".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_token_expanded_with_minimal_fields() {
let json = json!({
"id": "t1_tok_test123456789012345678"
});
let token: TokenExpanded = serde_json::from_value(json).unwrap();
assert!(token.id.as_str().starts_with("t1_tok_"));
assert!(token.payment.is_none());
assert!(token.customer.is_none());
assert!(!token.inactive);
assert!(!token.frozen);
}
#[test]
fn test_token_expanded_with_expansions() {
let json = json!({
"id": "t1_tok_694380e35c8eca506eb3856",
"token": "36720364621c45c3227c95022e527839",
"status": "ready",
"expiration": "1229",
"inactive": 1,
"frozen": 0,
"payment": {
"bin": "411111",
"method": 2,
"number": "1111"
},
"customer": "t1_cus_694380e3086a60cfae6d1eb"
});
let token: TokenExpanded = serde_json::from_value(json).unwrap();
assert!(token.id.as_str().starts_with("t1_tok_"));
assert!(token.token.is_some());
assert_eq!(token.status, Some(TokenStatus::Ready));
assert!(token.inactive);
assert!(token.payment.is_some());
let payment = token.payment.as_ref().unwrap();
assert_eq!(payment.method, Some(PaymentMethod::Visa));
assert_eq!(payment.bin.as_deref(), Some("411111"));
assert!(token.customer.is_some());
assert!(token.customer_id().unwrap().starts_with("t1_cus_"));
assert_eq!(token.payment_method(), Some(PaymentMethod::Visa));
assert!(token.customer_id().is_some());
}
#[test]
fn test_token_expanded_handles_unknown_fields() {
let json = json!({
"id": "t1_tok_test123456789012345678",
"status": "ready",
"future_field": "should be ignored",
"another_unknown": 12345
});
let result: Result<TokenExpanded, _> = serde_json::from_value(json);
assert!(result.is_ok(), "Should handle unknown fields gracefully");
let token = result.unwrap();
assert_eq!(token.status, Some(TokenStatus::Ready));
}
#[test]
fn test_transaction_expanded_with_minimal_fields() {
let json = json!({
"id": "t1_txn_test123456789012345678"
});
let txn: TransactionExpanded = serde_json::from_value(json).unwrap();
assert!(txn.id.as_str().starts_with("t1_txn_"));
assert!(txn.payment.is_none());
assert!(txn.token.is_none());
assert!(txn.merchant.is_none());
assert_eq!(txn.amount_dollars(), 0.0);
}
#[test]
fn test_transaction_expanded_with_all_expansions() {
let json = json!({
"id": "t1_txn_694380e3e1bd4ad74cdf956",
"type": 1,
"status": 3,
"total": 1000,
"approved": 1000,
"currency": "USD",
"first": "John",
"last": "Doe",
"payment": {
"bin": "411111",
"method": 2,
"number": "1111"
},
"token": {
"id": "t1_tok_694380e35c8eca506eb3856",
"token": "abc123",
"customer": "t1_cus_694380e3086a60cfae6d1eb"
},
"merchant": "t1_mer_test123456789012345678"
});
let txn: TransactionExpanded = serde_json::from_value(json).unwrap();
assert!(txn.id.as_str().starts_with("t1_txn_"));
assert_eq!(txn.total, Some(1000));
assert_eq!(txn.amount_dollars(), 10.0);
assert!(txn.payment.is_some());
assert_eq!(txn.payment.as_ref().unwrap().method, Some(PaymentMethod::Visa));
assert!(txn.token.is_some());
let token = txn.token.as_ref().unwrap();
assert!(token.customer.is_some());
assert!(token.customer_id().unwrap().starts_with("t1_cus_"));
assert!(txn.merchant.is_some());
assert!(txn.merchant.as_ref().unwrap().as_str().starts_with("t1_mer_"));
assert_eq!(txn.customer_name(), Some("John Doe".to_string()));
assert!(txn.payment_display().is_some());
}
#[test]
fn test_transaction_expanded_amount_calculation() {
let json = json!({
"id": "t1_txn_test123456789012345678",
"total": 12345
});
let txn: TransactionExpanded = serde_json::from_value(json).unwrap();
assert_eq!(txn.amount_dollars(), 123.45);
}
#[test]
fn test_customer_expanded_with_tokens_array() {
let json = json!({
"id": "t1_cus_test123456789012345678",
"first": "Jane",
"last": "Smith",
"tokens": [
{"id": "t1_tok_111111111111111111111", "status": "ready"},
{"id": "t1_tok_222222222222222222222", "status": "pending"}
]
});
let customer: CustomerExpanded = serde_json::from_value(json).unwrap();
assert!(customer.id.as_str().starts_with("t1_cus_"));
assert_eq!(customer.first.as_deref(), Some("Jane"));
assert!(customer.tokens.is_some());
let tokens = customer.tokens.as_ref().unwrap();
assert_eq!(tokens.len(), 2);
for token in tokens {
assert!(token.id.as_str().starts_with("t1_tok_"));
}
}
#[test]
fn test_subscription_expanded_with_plan() {
let json = json!({
"id": "t1_sbn_test123456789012345678",
"start": 20250101,
"plan": {
"id": "t1_pln_test123456789012345678",
"name": "Monthly Plan",
"amount": 1999
}
});
let sub: SubscriptionExpanded = serde_json::from_value(json).unwrap();
assert!(sub.id.as_str().starts_with("t1_sbn_"));
assert!(sub.plan.is_some());
let plan = sub.plan.as_ref().unwrap();
assert_eq!(plan.name.as_deref(), Some("Monthly Plan"));
assert_eq!(plan.amount, Some(1999));
assert_eq!(sub.plan_amount_dollars(), Some(19.99));
assert_eq!(sub.plan_name(), Some("Monthly Plan"));
}
#[test]
fn test_plan_expanded_with_subscriptions() {
let json = json!({
"id": "t1_pln_test123456789012345678",
"name": "Premium Plan",
"amount": 4999,
"subscriptions": [
{"id": "t1_sbn_111111111111111111111", "inactive": 0},
{"id": "t1_sbn_222222222222222222222", "inactive": 1}
]
});
let plan: PlanExpanded = serde_json::from_value(json).unwrap();
assert!(plan.id.as_str().starts_with("t1_pln_"));
assert_eq!(plan.amount_dollars(), 49.99);
assert_eq!(plan.subscription_count(), 1); }
#[test]
fn test_chargeback_expanded_with_transaction() {
let json = json!({
"id": "t1_chb_test123456789012345678",
"status": "open",
"total": 5000,
"cycle": "first",
"actionable": 1,
"txn": {
"id": "t1_txn_test123456789012345678",
"type": 1,
"total": 5000
}
});
let cb: ChargebackExpanded = serde_json::from_value(json).unwrap();
assert!(cb.id.as_str().starts_with("t1_chb_"));
assert_eq!(cb.amount_dollars(), 50.0);
assert!(cb.is_actionable());
assert!(cb.txn.is_some());
assert_eq!(cb.original_transaction_amount(), Some(50.0));
}
#[test]
fn test_batch_expanded_with_transactions() {
let json = json!({
"id": "t1_bat_test123456789012345678",
"status": "open",
"txns": [
{"id": "t1_txn_111111111111111111111", "type": 1, "total": 1000},
{"id": "t1_txn_222222222222222222222", "type": 1, "total": 2000}
]
});
let batch: BatchExpanded = serde_json::from_value(json).unwrap();
assert!(batch.id.as_str().starts_with("t1_bat_"));
assert!(batch.is_open());
assert_eq!(batch.transaction_count(), 2);
assert_eq!(batch.total_amount_dollars(), 30.0);
}
#[test]
fn test_merchant_expanded_with_members() {
let json = json!({
"id": "t1_mer_test123456789012345678",
"dba": "Test Business",
"name": "Test Business Inc",
"members": [
{"id": "t1_mem_111111111111111111111", "first": "John", "ownership": 6000},
{"id": "t1_mem_222222222222222222222", "first": "Jane", "ownership": 4000}
]
});
let merchant: MerchantExpanded = serde_json::from_value(json).unwrap();
assert!(merchant.id.as_str().starts_with("t1_mer_"));
assert_eq!(merchant.display_name(), "Test Business");
assert_eq!(merchant.member_count(), 2);
assert_eq!(merchant.total_ownership_percent(), 100.0);
}
}