use std::marker::PhantomData;
use std::path::Path;
use base64::Engine;
use crate::client::PayrixClient;
use crate::entity::EntityType;
use crate::error::{Error, Result};
use crate::types::{
Chargeback, ChargebackCycle, ChargebackDocument, ChargebackDocumentType, ChargebackMessage,
ChargebackMessageType, ChargebackStatusValue, CreateChargebackDocument, CreateChargebackMessage,
PayrixId,
};
pub const MAX_DOCUMENTS: usize = 8;
pub const MAX_DOCUMENT_SIZE: usize = 1_048_576;
pub const MAX_TOTAL_SIZE: usize = 8_388_608;
mod private {
pub trait Sealed {}
}
pub trait ChargebackState: private::Sealed {
fn state_name() -> &'static str;
}
#[derive(Debug, Clone, Copy)]
pub struct Retrieval;
impl private::Sealed for Retrieval {}
impl ChargebackState for Retrieval {
fn state_name() -> &'static str {
"retrieval"
}
}
#[derive(Debug, Clone, Copy)]
pub struct First;
impl private::Sealed for First {}
impl ChargebackState for First {
fn state_name() -> &'static str {
"first"
}
}
#[derive(Debug, Clone, Copy)]
pub struct Representment;
impl private::Sealed for Representment {}
impl ChargebackState for Representment {
fn state_name() -> &'static str {
"representment"
}
}
#[derive(Debug, Clone, Copy)]
pub struct PreArbitration;
impl private::Sealed for PreArbitration {}
impl ChargebackState for PreArbitration {
fn state_name() -> &'static str {
"preArbitration"
}
}
#[derive(Debug, Clone, Copy)]
pub struct SecondChargeback;
impl private::Sealed for SecondChargeback {}
impl ChargebackState for SecondChargeback {
fn state_name() -> &'static str {
"secondChargeback"
}
}
#[derive(Debug, Clone, Copy)]
pub struct Arbitration;
impl private::Sealed for Arbitration {}
impl ChargebackState for Arbitration {
fn state_name() -> &'static str {
"arbitration"
}
}
#[derive(Debug, Clone, Copy)]
pub struct Terminal;
impl private::Sealed for Terminal {}
impl ChargebackState for Terminal {
fn state_name() -> &'static str {
"terminal"
}
}
#[derive(Debug, Clone)]
pub struct TypedChargeback<S: ChargebackState> {
inner: Chargeback,
_state: PhantomData<S>,
}
impl<S: ChargebackState> TypedChargeback<S> {
fn new(chargeback: Chargeback) -> Self {
Self {
inner: chargeback,
_state: PhantomData,
}
}
pub fn inner(&self) -> &Chargeback {
&self.inner
}
pub fn into_inner(self) -> Chargeback {
self.inner
}
pub fn id(&self) -> &PayrixId {
&self.inner.id
}
pub fn state_name(&self) -> &'static str {
S::state_name()
}
pub fn amount(&self) -> Option<i64> {
self.inner.total
}
pub fn reason_code(&self) -> Option<&str> {
self.inner.reason_code.as_deref()
}
pub fn reason(&self) -> Option<&str> {
self.inner.reason.as_deref()
}
pub fn reply_deadline(&self) -> Option<i32> {
self.inner.reply
}
pub fn is_actionable(&self) -> bool {
self.inner.actionable
}
pub fn merchant_id(&self) -> Option<&PayrixId> {
self.inner.merchant.as_ref()
}
pub fn transaction_id(&self) -> Option<&PayrixId> {
self.inner.txn.as_ref()
}
}
#[derive(Debug, Clone)]
pub struct EvidenceDocument {
pub name: String,
pub content: Vec<u8>,
pub mime_type: String,
}
impl EvidenceDocument {
pub fn new(name: impl Into<String>, content: Vec<u8>, mime_type: impl Into<String>) -> Self {
Self {
name: name.into(),
content,
mime_type: mime_type.into(),
}
}
pub fn size(&self) -> usize {
self.content.len()
}
pub fn validate(&self) -> Result<()> {
if self.content.len() > MAX_DOCUMENT_SIZE {
return Err(Error::Validation(format!(
"Document '{}' exceeds maximum size of {} bytes (actual: {} bytes)",
self.name,
MAX_DOCUMENT_SIZE,
self.content.len()
)));
}
let valid_types = [
"image/tiff",
"image/tif",
"application/pdf",
"image/png",
"image/jpeg",
"image/jpg",
"image/gif",
];
if !valid_types.contains(&self.mime_type.as_str()) {
return Err(Error::Validation(format!(
"Document '{}' has unsupported MIME type '{}'. Supported: {:?}",
self.name, self.mime_type, valid_types
)));
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct Evidence {
pub message: String,
pub documents: Vec<EvidenceDocument>,
}
impl Evidence {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
documents: Vec::new(),
}
}
pub fn with_document(
mut self,
name: impl Into<String>,
content: Vec<u8>,
mime_type: impl Into<String>,
) -> Self {
self.documents
.push(EvidenceDocument::new(name, content, mime_type));
self
}
pub fn with_evidence_document(mut self, doc: EvidenceDocument) -> Self {
self.documents.push(doc);
self
}
pub fn total_size(&self) -> usize {
self.documents.iter().map(|d| d.size()).sum()
}
pub fn validate(&self) -> Result<()> {
if self.message.trim().is_empty() {
return Err(Error::Validation(
"Evidence message cannot be empty".to_string(),
));
}
if self.documents.len() > MAX_DOCUMENTS {
return Err(Error::Validation(format!(
"Too many documents: {} (maximum: {})",
self.documents.len(),
MAX_DOCUMENTS
)));
}
let total_size = self.total_size();
if total_size > MAX_TOTAL_SIZE {
return Err(Error::Validation(format!(
"Total document size {} bytes exceeds maximum of {} bytes",
total_size, MAX_TOTAL_SIZE
)));
}
for doc in &self.documents {
doc.validate()?;
}
Ok(())
}
}
pub fn evidence_from_bytes(filename: &str, content: Vec<u8>) -> Result<EvidenceDocument> {
let mime_type = mime_type_from_extension(filename)?;
let doc = EvidenceDocument::new(filename, content, mime_type);
doc.validate()?;
Ok(doc)
}
pub fn evidence_from_path(path: impl AsRef<Path>) -> Result<EvidenceDocument> {
let path = path.as_ref();
let filename = path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| Error::Validation("Invalid file path".to_string()))?;
let content = std::fs::read(path).map_err(|e| Error::Io(e.to_string()))?;
evidence_from_bytes(filename, content)
}
pub fn evidence_from_base64_url(filename: &str, data_url: &str) -> Result<EvidenceDocument> {
let data_url = data_url
.strip_prefix("data:")
.ok_or_else(|| Error::Validation("Invalid data URL: must start with 'data:'".to_string()))?;
let (header, data) = data_url.split_once(',').ok_or_else(|| {
Error::Validation("Invalid data URL: missing comma separator".to_string())
})?;
let parts: Vec<&str> = header.split(';').collect();
let mime_type = parts.first().unwrap_or(&"application/octet-stream");
let is_base64 = parts.iter().any(|p| *p == "base64");
if !is_base64 {
return Err(Error::Validation(
"Only base64-encoded data URLs are supported".to_string(),
));
}
use base64::{engine::general_purpose::STANDARD, Engine};
let content = STANDARD.decode(data).map_err(|e| {
Error::Validation(format!("Invalid base64 in data URL: {}", e))
})?;
let doc = EvidenceDocument::new(filename, content, *mime_type);
doc.validate()?;
Ok(doc)
}
fn mime_type_from_extension(filename: &str) -> Result<&'static str> {
let ext = filename
.rsplit('.')
.next()
.map(|e| e.to_lowercase())
.unwrap_or_default();
match ext.as_str() {
"pdf" => Ok("application/pdf"),
"tiff" | "tif" => Ok("image/tiff"),
"png" => Ok("image/png"),
"jpg" | "jpeg" => Ok("image/jpeg"),
"gif" => Ok("image/gif"),
_ => Err(Error::Validation(format!(
"Unsupported file extension '{}'. Supported: pdf, tiff, tif, png, jpg, jpeg, gif",
ext
))),
}
}
impl TypedChargeback<First> {
pub async fn represent(
self,
client: &PayrixClient,
evidence: Evidence,
) -> Result<TypedChargeback<Representment>> {
evidence.validate()?;
if !self.inner.actionable {
return Err(Error::Validation(
"Chargeback is not currently actionable".to_string(),
));
}
let message = CreateChargebackMessage {
chargeback: self.inner.id.to_string(),
message_type: Some(ChargebackMessageType::Represent),
subject: Some("Representment".to_string()),
message: Some(evidence.message),
};
let response: ChargebackMessage = client
.create(EntityType::ChargebackMessages, &message)
.await?;
for doc in evidence.documents {
let document_type = mime_type_to_document_type(&doc.mime_type);
let encoded_content = base64::engine::general_purpose::STANDARD.encode(&doc.content);
let new_doc = CreateChargebackDocument {
chargeback: self.inner.id.to_string(),
chargeback_message: Some(response.id.to_string()),
name: Some(doc.name),
document_type: Some(document_type),
mime_type: Some(doc.mime_type),
description: None,
data: Some(encoded_content),
};
let _doc_response: ChargebackDocument = client
.create(EntityType::ChargebackDocuments, &new_doc)
.await?;
}
let updated: Chargeback = client
.get_one(EntityType::Chargebacks, self.inner.id.as_str())
.await?
.ok_or_else(|| Error::NotFound("Chargeback not found after update".to_string()))?;
Ok(TypedChargeback::new(updated))
}
pub async fn accept_liability(self, client: &PayrixClient) -> Result<TypedChargeback<Terminal>> {
if !self.inner.actionable {
return Err(Error::Validation(
"Chargeback is not currently actionable".to_string(),
));
}
let message = CreateChargebackMessage {
chargeback: self.inner.id.to_string(),
message_type: Some(ChargebackMessageType::AcceptLiability),
subject: Some("Accept Liability".to_string()),
message: Some("Merchant accepts liability for this chargeback".to_string()),
};
let _response: ChargebackMessage = client
.create(EntityType::ChargebackMessages, &message)
.await?;
let updated: Chargeback = client
.get_one(EntityType::Chargebacks, self.inner.id.as_str())
.await?
.ok_or_else(|| Error::NotFound("Chargeback not found after update".to_string()))?;
Ok(TypedChargeback::new(updated))
}
}
impl TypedChargeback<PreArbitration> {
pub async fn request_arbitration(
self,
client: &PayrixClient,
) -> Result<TypedChargeback<Arbitration>> {
if !self.inner.actionable {
return Err(Error::Validation(
"Chargeback is not currently actionable".to_string(),
));
}
let message = CreateChargebackMessage {
chargeback: self.inner.id.to_string(),
message_type: Some(ChargebackMessageType::RequestArbitration),
subject: Some("Request Arbitration".to_string()),
message: Some("Merchant requests card network arbitration".to_string()),
};
let _response: ChargebackMessage = client
.create(EntityType::ChargebackMessages, &message)
.await?;
let updated: Chargeback = client
.get_one(EntityType::Chargebacks, self.inner.id.as_str())
.await?
.ok_or_else(|| Error::NotFound("Chargeback not found after update".to_string()))?;
Ok(TypedChargeback::new(updated))
}
pub async fn represent(
self,
client: &PayrixClient,
evidence: Evidence,
) -> Result<TypedChargeback<Representment>> {
evidence.validate()?;
if !self.inner.actionable {
return Err(Error::Validation(
"Chargeback is not currently actionable".to_string(),
));
}
let message = CreateChargebackMessage {
chargeback: self.inner.id.to_string(),
message_type: Some(ChargebackMessageType::Represent),
subject: Some("Pre-Arbitration Response".to_string()),
message: Some(evidence.message),
};
let response: ChargebackMessage = client
.create(EntityType::ChargebackMessages, &message)
.await?;
for doc in evidence.documents {
let document_type = mime_type_to_document_type(&doc.mime_type);
let encoded_content = base64::engine::general_purpose::STANDARD.encode(&doc.content);
let new_doc = CreateChargebackDocument {
chargeback: self.inner.id.to_string(),
chargeback_message: Some(response.id.to_string()),
name: Some(doc.name),
document_type: Some(document_type),
mime_type: Some(doc.mime_type),
description: None,
data: Some(encoded_content),
};
let _doc_response: ChargebackDocument = client
.create(EntityType::ChargebackDocuments, &new_doc)
.await?;
}
let updated: Chargeback = client
.get_one(EntityType::Chargebacks, self.inner.id.as_str())
.await?
.ok_or_else(|| Error::NotFound("Chargeback not found after update".to_string()))?;
Ok(TypedChargeback::new(updated))
}
pub async fn accept_liability(self, client: &PayrixClient) -> Result<TypedChargeback<Terminal>> {
if !self.inner.actionable {
return Err(Error::Validation(
"Chargeback is not currently actionable".to_string(),
));
}
let message = CreateChargebackMessage {
chargeback: self.inner.id.to_string(),
message_type: Some(ChargebackMessageType::AcceptLiability),
subject: Some("Accept Liability".to_string()),
message: Some("Merchant accepts liability for this chargeback".to_string()),
};
let _response: ChargebackMessage = client
.create(EntityType::ChargebackMessages, &message)
.await?;
let updated: Chargeback = client
.get_one(EntityType::Chargebacks, self.inner.id.as_str())
.await?
.ok_or_else(|| Error::NotFound("Chargeback not found after update".to_string()))?;
Ok(TypedChargeback::new(updated))
}
}
impl TypedChargeback<SecondChargeback> {
pub async fn represent(
self,
client: &PayrixClient,
evidence: Evidence,
) -> Result<TypedChargeback<Representment>> {
evidence.validate()?;
if !self.inner.actionable {
return Err(Error::Validation(
"Chargeback is not currently actionable".to_string(),
));
}
let message = CreateChargebackMessage {
chargeback: self.inner.id.to_string(),
message_type: Some(ChargebackMessageType::Represent),
subject: Some("Second Chargeback Representment".to_string()),
message: Some(evidence.message),
};
let response: ChargebackMessage = client
.create(EntityType::ChargebackMessages, &message)
.await?;
for doc in evidence.documents {
let document_type = mime_type_to_document_type(&doc.mime_type);
let encoded_content = base64::engine::general_purpose::STANDARD.encode(&doc.content);
let new_doc = CreateChargebackDocument {
chargeback: self.inner.id.to_string(),
chargeback_message: Some(response.id.to_string()),
name: Some(doc.name),
document_type: Some(document_type),
mime_type: Some(doc.mime_type),
description: None,
data: Some(encoded_content),
};
let _doc_response: ChargebackDocument = client
.create(EntityType::ChargebackDocuments, &new_doc)
.await?;
}
let updated: Chargeback = client
.get_one(EntityType::Chargebacks, self.inner.id.as_str())
.await?
.ok_or_else(|| Error::NotFound("Chargeback not found after update".to_string()))?;
Ok(TypedChargeback::new(updated))
}
pub async fn accept_liability(self, client: &PayrixClient) -> Result<TypedChargeback<Terminal>> {
if !self.inner.actionable {
return Err(Error::Validation(
"Chargeback is not currently actionable".to_string(),
));
}
let message = CreateChargebackMessage {
chargeback: self.inner.id.to_string(),
message_type: Some(ChargebackMessageType::AcceptLiability),
subject: Some("Accept Liability".to_string()),
message: Some("Merchant accepts liability for this chargeback".to_string()),
};
let _response: ChargebackMessage = client
.create(EntityType::ChargebackMessages, &message)
.await?;
let updated: Chargeback = client
.get_one(EntityType::Chargebacks, self.inner.id.as_str())
.await?
.ok_or_else(|| Error::NotFound("Chargeback not found after update".to_string()))?;
Ok(TypedChargeback::new(updated))
}
}
#[derive(Debug, Clone)]
pub enum ActiveDispute {
Retrieval(TypedChargeback<Retrieval>),
First(TypedChargeback<First>),
Representment(TypedChargeback<Representment>),
PreArbitration(TypedChargeback<PreArbitration>),
SecondChargeback(TypedChargeback<SecondChargeback>),
Arbitration(TypedChargeback<Arbitration>),
}
impl ActiveDispute {
pub fn id(&self) -> &PayrixId {
match self {
Self::Retrieval(c) => c.id(),
Self::First(c) => c.id(),
Self::Representment(c) => c.id(),
Self::PreArbitration(c) => c.id(),
Self::SecondChargeback(c) => c.id(),
Self::Arbitration(c) => c.id(),
}
}
pub fn state_name(&self) -> &'static str {
match self {
Self::Retrieval(_) => Retrieval::state_name(),
Self::First(_) => First::state_name(),
Self::Representment(_) => Representment::state_name(),
Self::PreArbitration(_) => PreArbitration::state_name(),
Self::SecondChargeback(_) => SecondChargeback::state_name(),
Self::Arbitration(_) => Arbitration::state_name(),
}
}
pub fn inner(&self) -> &Chargeback {
match self {
Self::Retrieval(c) => c.inner(),
Self::First(c) => c.inner(),
Self::Representment(c) => c.inner(),
Self::PreArbitration(c) => c.inner(),
Self::SecondChargeback(c) => c.inner(),
Self::Arbitration(c) => c.inner(),
}
}
}
#[derive(Debug, Clone)]
pub enum ChargebackDispute {
Active(ActiveDispute),
Terminal(TypedChargeback<Terminal>),
}
impl ChargebackDispute {
pub async fn load(client: &PayrixClient, id: &str) -> Result<Self> {
let chargeback: Chargeback = client
.get_one(EntityType::Chargebacks, id)
.await?
.ok_or_else(|| Error::NotFound(format!("Chargeback not found: {}", id)))?;
Ok(Self::from_chargeback(chargeback))
}
pub fn from_chargeback(chargeback: Chargeback) -> Self {
if let Some(status) = &chargeback.status {
match status {
ChargebackStatusValue::Closed
| ChargebackStatusValue::Won
| ChargebackStatusValue::Lost => {
return Self::Terminal(TypedChargeback::new(chargeback));
}
_ => {}
}
}
if let Some(cycle) = &chargeback.cycle {
match cycle {
ChargebackCycle::ArbitrationWon
| ChargebackCycle::ArbitrationLost
| ChargebackCycle::ArbitrationSplit
| ChargebackCycle::Reversal => {
return Self::Terminal(TypedChargeback::new(chargeback));
}
_ => {}
}
}
let active = match chargeback.cycle {
Some(ChargebackCycle::Retrieval) => {
ActiveDispute::Retrieval(TypedChargeback::new(chargeback))
}
Some(ChargebackCycle::First) => ActiveDispute::First(TypedChargeback::new(chargeback)),
Some(ChargebackCycle::Representment) => {
ActiveDispute::Representment(TypedChargeback::new(chargeback))
}
Some(ChargebackCycle::PreArbitration)
| Some(ChargebackCycle::IssuerDeclinedPreArbitration)
| Some(ChargebackCycle::ResponseToIssuerPreArbitration)
| Some(ChargebackCycle::MerchantDeclinedPreArbitration) => {
ActiveDispute::PreArbitration(TypedChargeback::new(chargeback))
}
Some(ChargebackCycle::Arbitration)
| Some(ChargebackCycle::PreCompliance)
| Some(ChargebackCycle::Compliance) => {
ActiveDispute::Arbitration(TypedChargeback::new(chargeback))
}
None => ActiveDispute::First(TypedChargeback::new(chargeback)),
_ => ActiveDispute::First(TypedChargeback::new(chargeback)),
};
Self::Active(active)
}
pub async fn refresh(&self, client: &PayrixClient) -> Result<Self> {
Self::load(client, self.id().as_str()).await
}
pub fn id(&self) -> &PayrixId {
match self {
Self::Active(active) => active.id(),
Self::Terminal(terminal) => terminal.id(),
}
}
pub fn state_name(&self) -> &'static str {
match self {
Self::Active(active) => active.state_name(),
Self::Terminal(_) => Terminal::state_name(),
}
}
pub fn inner(&self) -> &Chargeback {
match self {
Self::Active(active) => active.inner(),
Self::Terminal(terminal) => terminal.inner(),
}
}
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Terminal(_))
}
pub fn is_active(&self) -> bool {
matches!(self, Self::Active(_))
}
}
pub async fn get_actionable_disputes(
client: &PayrixClient,
merchant_id: &str,
) -> Result<Vec<ChargebackDispute>> {
let search = format!(
"merchant[equals]={}&status[equals]=open&actionable[equals]=1",
merchant_id
);
let chargebacks: Vec<Chargeback> = client.search(EntityType::Chargebacks, &search).await?;
Ok(chargebacks
.into_iter()
.map(ChargebackDispute::from_chargeback)
.collect())
}
pub async fn get_disputes_by_cycle(
client: &PayrixClient,
merchant_id: &str,
cycle: ChargebackCycle,
) -> Result<Vec<ChargebackDispute>> {
let cycle_str = match cycle {
ChargebackCycle::Retrieval => "retrieval",
ChargebackCycle::First => "first",
ChargebackCycle::Representment => "representment",
ChargebackCycle::PreArbitration => "preArbitration",
ChargebackCycle::Arbitration => "arbitration",
_ => return Ok(Vec::new()), };
let search = format!(
"merchant[equals]={}&cycle[equals]={}",
merchant_id, cycle_str
);
let chargebacks: Vec<Chargeback> = client.search(EntityType::Chargebacks, &search).await?;
Ok(chargebacks
.into_iter()
.map(ChargebackDispute::from_chargeback)
.collect())
}
pub async fn get_disputes_for_transaction(
client: &PayrixClient,
transaction_id: &str,
) -> Result<Vec<ChargebackDispute>> {
let search = format!("txn[equals]={}", transaction_id);
let chargebacks: Vec<Chargeback> = client.search(EntityType::Chargebacks, &search).await?;
Ok(chargebacks
.into_iter()
.map(ChargebackDispute::from_chargeback)
.collect())
}
fn mime_type_to_document_type(mime_type: &str) -> ChargebackDocumentType {
match mime_type.to_lowercase().as_str() {
"application/pdf" => ChargebackDocumentType::Pdf,
"image/tiff" | "image/tif" => ChargebackDocumentType::Tiff,
"image/png" => ChargebackDocumentType::Png,
"image/jpeg" | "image/jpg" => ChargebackDocumentType::Jpg,
"image/gif" => ChargebackDocumentType::Image,
"text/plain" => ChargebackDocumentType::Text,
_ if mime_type.starts_with("image/") => ChargebackDocumentType::Image,
_ => ChargebackDocumentType::Other,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_state_names() {
assert_eq!(Retrieval::state_name(), "retrieval");
assert_eq!(First::state_name(), "first");
assert_eq!(Representment::state_name(), "representment");
assert_eq!(PreArbitration::state_name(), "preArbitration");
assert_eq!(SecondChargeback::state_name(), "secondChargeback");
assert_eq!(Arbitration::state_name(), "arbitration");
assert_eq!(Terminal::state_name(), "terminal");
}
#[test]
fn test_evidence_creation() {
let evidence = Evidence::new("Test message");
assert_eq!(evidence.message, "Test message");
assert!(evidence.documents.is_empty());
}
#[test]
fn test_evidence_with_document() {
let evidence = Evidence::new("Test message")
.with_document("receipt.pdf", vec![1, 2, 3], "application/pdf");
assert_eq!(evidence.documents.len(), 1);
assert_eq!(evidence.documents[0].name, "receipt.pdf");
assert_eq!(evidence.documents[0].mime_type, "application/pdf");
}
#[test]
fn test_evidence_validation_empty_message() {
let evidence = Evidence::new("");
let result = evidence.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("cannot be empty"));
}
#[test]
fn test_evidence_validation_whitespace_message() {
let evidence = Evidence::new(" ");
let result = evidence.validate();
assert!(result.is_err());
}
#[test]
fn test_evidence_validation_too_many_documents() {
let mut evidence = Evidence::new("Test");
for i in 0..9 {
evidence = evidence.with_document(
format!("doc{}.pdf", i),
vec![1],
"application/pdf",
);
}
let result = evidence.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Too many documents"));
}
#[test]
fn test_evidence_validation_document_too_large() {
let large_content = vec![0u8; MAX_DOCUMENT_SIZE + 1];
let evidence = Evidence::new("Test").with_document("large.pdf", large_content, "application/pdf");
let result = evidence.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("exceeds maximum size"));
}
#[test]
fn test_evidence_validation_invalid_mime_type() {
let evidence = Evidence::new("Test").with_document("file.exe", vec![1], "application/exe");
let result = evidence.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("unsupported MIME type"));
}
#[test]
fn test_evidence_validation_valid() {
let evidence = Evidence::new("Valid evidence message")
.with_document("receipt.pdf", vec![1, 2, 3], "application/pdf")
.with_document("photo.png", vec![4, 5, 6], "image/png");
assert!(evidence.validate().is_ok());
}
#[test]
fn test_evidence_total_size() {
let evidence = Evidence::new("Test")
.with_document("a.pdf", vec![1, 2, 3], "application/pdf")
.with_document("b.pdf", vec![4, 5, 6, 7], "application/pdf");
assert_eq!(evidence.total_size(), 7);
}
#[test]
fn test_mime_type_from_extension() {
assert_eq!(mime_type_from_extension("file.pdf").unwrap(), "application/pdf");
assert_eq!(mime_type_from_extension("file.PDF").unwrap(), "application/pdf");
assert_eq!(mime_type_from_extension("file.tiff").unwrap(), "image/tiff");
assert_eq!(mime_type_from_extension("file.tif").unwrap(), "image/tiff");
assert_eq!(mime_type_from_extension("file.png").unwrap(), "image/png");
assert_eq!(mime_type_from_extension("file.jpg").unwrap(), "image/jpeg");
assert_eq!(mime_type_from_extension("file.jpeg").unwrap(), "image/jpeg");
assert_eq!(mime_type_from_extension("file.gif").unwrap(), "image/gif");
}
#[test]
fn test_mime_type_unsupported() {
assert!(mime_type_from_extension("file.exe").is_err());
assert!(mime_type_from_extension("file.doc").is_err());
}
#[test]
fn test_evidence_from_bytes() {
let doc = evidence_from_bytes("receipt.pdf", vec![1, 2, 3]).unwrap();
assert_eq!(doc.name, "receipt.pdf");
assert_eq!(doc.mime_type, "application/pdf");
}
#[test]
fn test_evidence_from_base64_url() {
let doc = evidence_from_base64_url("file.pdf", "data:application/pdf;base64,dGVzdA==").unwrap();
assert_eq!(doc.name, "file.pdf");
assert_eq!(doc.mime_type, "application/pdf");
assert_eq!(doc.content, b"test");
}
#[test]
fn test_evidence_from_base64_url_invalid() {
assert!(evidence_from_base64_url("file.pdf", "application/pdf;base64,dGVzdA==").is_err());
assert!(evidence_from_base64_url("file.pdf", "data:application/pdf;base64").is_err());
assert!(evidence_from_base64_url("file.pdf", "data:application/pdf,notbase64").is_err());
}
fn make_test_chargeback(cycle: Option<ChargebackCycle>, status: Option<ChargebackStatusValue>) -> Chargeback {
Chargeback {
id: "t1_chb_12345678901234567890123".parse().unwrap(),
created: None,
modified: None,
creator: None,
modifier: None,
merchant: None,
txn: None,
mid: None,
description: None,
total: Some(10000),
represented_total: None,
cycle,
currency: Some("USD".to_string()),
platform: None,
payment_method: None,
reference: None,
reason: Some("Disputed charge".to_string()),
reason_code: Some("4853".to_string()),
issued: None,
received: None,
reply: Some(20240130),
bank_ref: None,
chargeback_ref: None,
status,
last_status_change: None,
actionable: true,
shadow: false,
inactive: false,
frozen: false,
#[cfg(not(feature = "sqlx"))]
assessments: None,
#[cfg(not(feature = "sqlx"))]
chargeback_documents: None,
#[cfg(not(feature = "sqlx"))]
chargeback_messages: None,
#[cfg(not(feature = "sqlx"))]
chargeback_statuses: None,
#[cfg(not(feature = "sqlx"))]
entries: None,
#[cfg(not(feature = "sqlx"))]
pending_entry: None,
}
}
#[test]
fn test_from_chargeback_first() {
let cb = make_test_chargeback(Some(ChargebackCycle::First), Some(ChargebackStatusValue::Open));
let dispute = ChargebackDispute::from_chargeback(cb);
assert!(dispute.is_active());
assert!(!dispute.is_terminal());
assert_eq!(dispute.state_name(), "first");
if let ChargebackDispute::Active(ActiveDispute::First(_)) = dispute {
} else {
panic!("Expected First state");
}
}
#[test]
fn test_from_chargeback_pre_arbitration() {
let cb = make_test_chargeback(Some(ChargebackCycle::PreArbitration), Some(ChargebackStatusValue::Open));
let dispute = ChargebackDispute::from_chargeback(cb);
assert!(dispute.is_active());
assert_eq!(dispute.state_name(), "preArbitration");
if let ChargebackDispute::Active(ActiveDispute::PreArbitration(_)) = dispute {
} else {
panic!("Expected PreArbitration state");
}
}
#[test]
fn test_from_chargeback_terminal_won() {
let cb = make_test_chargeback(Some(ChargebackCycle::ArbitrationWon), Some(ChargebackStatusValue::Won));
let dispute = ChargebackDispute::from_chargeback(cb);
assert!(dispute.is_terminal());
assert!(!dispute.is_active());
assert_eq!(dispute.state_name(), "terminal");
}
#[test]
fn test_from_chargeback_terminal_closed() {
let cb = make_test_chargeback(Some(ChargebackCycle::First), Some(ChargebackStatusValue::Closed));
let dispute = ChargebackDispute::from_chargeback(cb);
assert!(dispute.is_terminal());
}
#[test]
fn test_typed_chargeback_accessors() {
let cb = make_test_chargeback(Some(ChargebackCycle::First), Some(ChargebackStatusValue::Open));
let dispute = ChargebackDispute::from_chargeback(cb);
assert_eq!(dispute.inner().total, Some(10000));
assert_eq!(dispute.inner().reason_code.as_deref(), Some("4853"));
assert_eq!(dispute.inner().reply, Some(20240130));
}
#[test]
fn test_active_dispute_id() {
let cb = make_test_chargeback(Some(ChargebackCycle::First), Some(ChargebackStatusValue::Open));
let id = cb.id.clone();
let dispute = ChargebackDispute::from_chargeback(cb);
assert_eq!(dispute.id().as_str(), id.as_str());
}
#[test]
fn test_from_mock_data_chargebacks() {
let mock_json = r#"{
"response": {
"data": [
{
"id": "t1_chb_6616a9f7c19a47bea938957",
"created": "2024-04-10 11:02:15.8016",
"modified": "2024-06-20 13:34:48.1638",
"creator": "t1_log_618afcdc2543bcabeaf184e",
"modifier": "t1_log_657202cb80bcfc9df78676f",
"merchant": "t1_mer_65f097a2848a4ceae39b6ee",
"txn": "t1_txn_6616a938dab5e92858d4e0a",
"description": "",
"total": 30000,
"representedTotal": null,
"cycle": "first",
"currency": "USD",
"ref": "j3JeC74OWBL000065",
"reason": "Missing Signature",
"reasonCode": "F14",
"issued": 20240409,
"received": null,
"reply": 20241231,
"bankRef": null,
"chargebackRef": null,
"status": "closed",
"inactive": 0,
"frozen": 0,
"lastStatusChange": "t1_chs_66746838179377f5d203381",
"actionable": 1,
"paymentMethod": 4,
"shadow": 0
},
{
"id": "t1_chb_6616a9de06fd751e5ae91e5",
"created": "2024-04-10 11:01:50.0337",
"modified": "2024-04-10 11:01:50.1415",
"creator": "t1_log_618afcdc2543bcabeaf184e",
"modifier": "t1_log_618afcdc2543bcabeaf184e",
"merchant": "t1_mer_65f097a2848a4ceae39b6ee",
"txn": "t1_txn_6616a925e796be0ebf69dd9",
"description": "",
"total": 20000,
"representedTotal": null,
"cycle": "first",
"currency": "USD",
"ref": "j3JeC74OWBL000064",
"reason": "Missing Signature",
"reasonCode": "F14",
"issued": 20240409,
"received": null,
"reply": 20241231,
"bankRef": null,
"chargebackRef": null,
"status": "open",
"inactive": 0,
"frozen": 0,
"lastStatusChange": "t1_chs_6616a9de0caecb0efb2c2e6",
"actionable": 1,
"paymentMethod": 4,
"shadow": 0
},
{
"id": "t1_chb_6616a9b87fce852bab31384",
"created": "2024-04-10 11:01:12.5285",
"modified": "2024-04-10 21:00:01.8517",
"creator": "t1_log_618afcdc2543bcabeaf184e",
"modifier": "t1_log_64ee6855b97877780a5bfef",
"merchant": "t1_mer_65f097a2848a4ceae39b6ee",
"txn": "t1_txn_6616a9113625edd8552a81d",
"description": "",
"total": 10000,
"representedTotal": null,
"cycle": "first",
"currency": "USD",
"ref": "j3JeC74OWBL000063",
"reason": "Missing Signature",
"reasonCode": "F14",
"issued": 20240409,
"received": null,
"reply": 20241231,
"bankRef": null,
"chargebackRef": null,
"status": "lost",
"inactive": 0,
"frozen": 0,
"lastStatusChange": "t1_chs_66173611ab3e030eb8b9b4e",
"actionable": 1,
"paymentMethod": 4,
"shadow": 0
}
],
"details": {
"requestId": 1,
"totals": [],
"page": {
"current": 1,
"last": 1,
"hasMore": false
}
},
"errors": []
}
}"#;
#[derive(serde::Deserialize)]
struct Response {
response: ResponseBody,
}
#[derive(serde::Deserialize)]
struct ResponseBody {
data: Vec<Chargeback>,
}
let response: Response = serde_json::from_str(mock_json).expect("Failed to parse mock JSON");
let chargebacks = response.response.data;
assert_eq!(chargebacks.len(), 3);
let dispute1 = ChargebackDispute::from_chargeback(chargebacks[0].clone());
assert_eq!(dispute1.id().as_str(), "t1_chb_6616a9f7c19a47bea938957");
assert!(dispute1.is_terminal(), "Closed chargeback should be Terminal");
assert_eq!(dispute1.inner().total, Some(30000));
assert_eq!(dispute1.inner().reason_code.as_deref(), Some("F14"));
let dispute2 = ChargebackDispute::from_chargeback(chargebacks[1].clone());
assert_eq!(dispute2.id().as_str(), "t1_chb_6616a9de06fd751e5ae91e5");
assert!(dispute2.is_active(), "Open chargeback should be Active");
assert_eq!(dispute2.state_name(), "first");
if let ChargebackDispute::Active(ActiveDispute::First(first)) = &dispute2 {
assert_eq!(first.inner().total, Some(20000));
assert!(first.inner().actionable, "Should be actionable");
} else {
panic!("Expected Active(First) state for open chargeback");
}
let dispute3 = ChargebackDispute::from_chargeback(chargebacks[2].clone());
assert_eq!(dispute3.id().as_str(), "t1_chb_6616a9b87fce852bab31384");
assert!(dispute3.is_terminal(), "Lost chargeback should be Terminal");
assert_eq!(dispute3.inner().total, Some(10000));
}
#[test]
fn test_mock_data_chargeback_fields() {
let cb_json = r#"{
"id": "t1_chb_6616a9de06fd751e5ae91e5",
"created": "2024-04-10 11:01:50.0337",
"modified": "2024-04-10 11:01:50.1415",
"creator": "t1_log_618afcdc2543bcabeaf184e",
"modifier": "t1_log_618afcdc2543bcabeaf184e",
"merchant": "t1_mer_65f097a2848a4ceae39b6ee",
"txn": "t1_txn_6616a925e796be0ebf69dd9",
"description": "",
"total": 20000,
"representedTotal": null,
"cycle": "first",
"currency": "USD",
"ref": "j3JeC74OWBL000064",
"reason": "Missing Signature",
"reasonCode": "F14",
"issued": 20240409,
"received": null,
"reply": 20241231,
"bankRef": null,
"chargebackRef": null,
"status": "open",
"inactive": 0,
"frozen": 0,
"lastStatusChange": "t1_chs_6616a9de0caecb0efb2c2e6",
"actionable": 1,
"paymentMethod": 4,
"shadow": 0
}"#;
let cb: Chargeback = serde_json::from_str(cb_json).expect("Failed to parse chargeback");
assert_eq!(cb.id.as_str(), "t1_chb_6616a9de06fd751e5ae91e5");
assert_eq!(cb.merchant.as_ref().map(|m| m.as_str()), Some("t1_mer_65f097a2848a4ceae39b6ee"));
assert_eq!(cb.txn.as_ref().map(|t| t.as_str()), Some("t1_txn_6616a925e796be0ebf69dd9"));
assert_eq!(cb.total, Some(20000));
assert_eq!(cb.cycle, Some(ChargebackCycle::First));
assert_eq!(cb.status, Some(ChargebackStatusValue::Open));
assert_eq!(cb.reason.as_deref(), Some("Missing Signature"));
assert_eq!(cb.reason_code.as_deref(), Some("F14"));
assert_eq!(cb.reply, Some(20241231));
assert!(cb.actionable);
assert!(!cb.inactive);
assert!(!cb.frozen);
assert!(!cb.shadow);
let dispute = ChargebackDispute::from_chargeback(cb);
assert!(dispute.is_active());
assert_eq!(dispute.state_name(), "first");
}
#[test]
fn test_mime_type_to_document_type() {
assert!(matches!(mime_type_to_document_type("application/pdf"), ChargebackDocumentType::Pdf));
assert!(matches!(mime_type_to_document_type("APPLICATION/PDF"), ChargebackDocumentType::Pdf));
assert!(matches!(mime_type_to_document_type("image/tiff"), ChargebackDocumentType::Tiff));
assert!(matches!(mime_type_to_document_type("image/tif"), ChargebackDocumentType::Tiff));
assert!(matches!(mime_type_to_document_type("image/png"), ChargebackDocumentType::Png));
assert!(matches!(mime_type_to_document_type("image/jpeg"), ChargebackDocumentType::Jpg));
assert!(matches!(mime_type_to_document_type("image/jpg"), ChargebackDocumentType::Jpg));
assert!(matches!(mime_type_to_document_type("image/gif"), ChargebackDocumentType::Image));
assert!(matches!(mime_type_to_document_type("image/webp"), ChargebackDocumentType::Image));
assert!(matches!(mime_type_to_document_type("text/plain"), ChargebackDocumentType::Text));
assert!(matches!(mime_type_to_document_type("application/octet-stream"), ChargebackDocumentType::Other));
}
}