use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::state_machine::DisputeStatus;
pub const EVIDENCE_DEADLINE_SECS: i64 = 72 * 60 * 60;
pub const REVIEW_DEADLINE_SECS: i64 = 7 * 24 * 60 * 60;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum DisputeCategory {
NonDelivery,
PoorQuality,
NotAsDescribed,
Overcharged,
Unauthorized,
Other,
}
impl std::fmt::Display for DisputeCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NonDelivery => write!(f, "non_delivery"),
Self::PoorQuality => write!(f, "poor_quality"),
Self::NotAsDescribed => write!(f, "not_as_described"),
Self::Overcharged => write!(f, "overcharged"),
Self::Unauthorized => write!(f, "unauthorized"),
Self::Other => write!(f, "other"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum EvidenceType {
Screenshot,
TransactionLog,
Communication,
DeliveryProof,
Other,
}
impl std::fmt::Display for EvidenceType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Screenshot => write!(f, "screenshot"),
Self::TransactionLog => write!(f, "transaction_log"),
Self::Communication => write!(f, "communication"),
Self::DeliveryProof => write!(f, "delivery_proof"),
Self::Other => write!(f, "other"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ResolutionType {
FullRefund,
PartialRefund,
ReleaseToSeller,
Split,
Escalated,
}
impl std::fmt::Display for ResolutionType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::FullRefund => write!(f, "full_refund"),
Self::PartialRefund => write!(f, "partial_refund"),
Self::ReleaseToSeller => write!(f, "release_to_seller"),
Self::Split => write!(f, "split"),
Self::Escalated => write!(f, "escalated"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DisputeRecord {
pub id: Uuid,
pub escrow_id: Uuid,
pub filed_by: String,
pub filed_against: String,
pub status: DisputeStatus,
pub category: DisputeCategory,
pub description: String,
pub amount: Option<Decimal>,
pub evidence_deadline: DateTime<Utc>,
pub review_deadline: DateTime<Utc>,
pub resolution: Option<ResolutionType>,
pub resolution_notes: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub resolved_at: Option<DateTime<Utc>>,
}
impl DisputeRecord {
pub fn new(
escrow_id: Uuid,
filed_by: impl Into<String>,
filed_against: impl Into<String>,
category: DisputeCategory,
description: impl Into<String>,
) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4(),
escrow_id,
filed_by: filed_by.into(),
filed_against: filed_against.into(),
status: DisputeStatus::Filed,
category,
description: description.into(),
amount: None,
evidence_deadline: now + chrono::Duration::seconds(EVIDENCE_DEADLINE_SECS),
review_deadline: now + chrono::Duration::seconds(REVIEW_DEADLINE_SECS),
resolution: None,
resolution_notes: None,
created_at: now,
updated_at: now,
resolved_at: None,
}
}
#[must_use]
pub const fn with_amount(mut self, amount: Decimal) -> Self {
self.amount = Some(amount);
self
}
#[must_use]
pub fn is_evidence_deadline_passed(&self) -> bool {
Utc::now() > self.evidence_deadline
}
#[must_use]
pub fn is_review_deadline_passed(&self) -> bool {
Utc::now() > self.review_deadline
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EscrowAction {
pub resolution_type: ResolutionType,
pub refund_amount: Option<Decimal>,
pub release_amount: Option<Decimal>,
pub hold: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolutionOutcome {
pub resolution_type: ResolutionType,
pub escrow_action: EscrowAction,
}
#[must_use]
pub fn resolution_to_escrow_action(resolution: ResolutionType, amount: Decimal) -> EscrowAction {
match resolution {
ResolutionType::FullRefund => EscrowAction {
resolution_type: resolution,
refund_amount: Some(amount),
release_amount: None,
hold: false,
},
ResolutionType::PartialRefund => {
let half = (amount / Decimal::from(2)).round_dp(6);
EscrowAction {
resolution_type: resolution,
refund_amount: Some(half),
release_amount: Some(amount - half),
hold: false,
}
}
ResolutionType::ReleaseToSeller => EscrowAction {
resolution_type: resolution,
refund_amount: None,
release_amount: Some(amount),
hold: false,
},
ResolutionType::Split => {
let half = (amount / Decimal::from(2)).round_dp(6);
EscrowAction {
resolution_type: resolution,
refund_amount: Some(half),
release_amount: Some(amount - half),
hold: false,
}
}
ResolutionType::Escalated => EscrowAction {
resolution_type: resolution,
refund_amount: None,
release_amount: None,
hold: true,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn dispute_category_display() {
assert_eq!(DisputeCategory::NonDelivery.to_string(), "non_delivery");
assert_eq!(DisputeCategory::PoorQuality.to_string(), "poor_quality");
assert_eq!(DisputeCategory::NotAsDescribed.to_string(), "not_as_described");
assert_eq!(DisputeCategory::Overcharged.to_string(), "overcharged");
assert_eq!(DisputeCategory::Unauthorized.to_string(), "unauthorized");
assert_eq!(DisputeCategory::Other.to_string(), "other");
}
#[test]
fn evidence_type_display() {
assert_eq!(EvidenceType::Screenshot.to_string(), "screenshot");
assert_eq!(EvidenceType::TransactionLog.to_string(), "transaction_log");
assert_eq!(EvidenceType::Communication.to_string(), "communication");
assert_eq!(EvidenceType::DeliveryProof.to_string(), "delivery_proof");
assert_eq!(EvidenceType::Other.to_string(), "other");
}
#[test]
fn resolution_type_display() {
assert_eq!(ResolutionType::FullRefund.to_string(), "full_refund");
assert_eq!(ResolutionType::PartialRefund.to_string(), "partial_refund");
assert_eq!(ResolutionType::ReleaseToSeller.to_string(), "release_to_seller");
assert_eq!(ResolutionType::Split.to_string(), "split");
assert_eq!(ResolutionType::Escalated.to_string(), "escalated");
}
#[test]
fn dispute_record_creation() {
let record = DisputeRecord::new(
Uuid::new_v4(),
"0xBuyer",
"0xSeller",
DisputeCategory::NonDelivery,
"Item never arrived",
);
assert_eq!(record.status, DisputeStatus::Filed);
assert_eq!(record.category, DisputeCategory::NonDelivery);
assert!(record.resolution.is_none());
}
#[test]
fn dispute_record_with_amount() {
let record = DisputeRecord::new(
Uuid::new_v4(),
"0xBuyer",
"0xSeller",
DisputeCategory::Overcharged,
"Charged double",
)
.with_amount(dec!(100));
assert_eq!(record.amount, Some(dec!(100)));
}
#[test]
fn resolution_full_refund() {
let action = resolution_to_escrow_action(ResolutionType::FullRefund, dec!(100));
assert_eq!(action.refund_amount, Some(dec!(100)));
assert_eq!(action.release_amount, None);
assert!(!action.hold);
}
#[test]
fn resolution_release_to_seller() {
let action = resolution_to_escrow_action(ResolutionType::ReleaseToSeller, dec!(100));
assert_eq!(action.refund_amount, None);
assert_eq!(action.release_amount, Some(dec!(100)));
assert!(!action.hold);
}
#[test]
fn resolution_partial_refund() {
let action = resolution_to_escrow_action(ResolutionType::PartialRefund, dec!(100));
assert_eq!(action.refund_amount, Some(dec!(50)));
assert_eq!(action.release_amount, Some(dec!(50)));
assert!(!action.hold);
}
#[test]
fn resolution_split() {
let action = resolution_to_escrow_action(ResolutionType::Split, dec!(100));
assert_eq!(action.refund_amount, Some(dec!(50)));
assert_eq!(action.release_amount, Some(dec!(50)));
assert!(!action.hold);
}
#[test]
fn resolution_split_odd_amount() {
let action = resolution_to_escrow_action(ResolutionType::Split, dec!(101));
let refund = action.refund_amount.unwrap();
let release = action.release_amount.unwrap();
assert_eq!(refund + release, dec!(101));
}
#[test]
fn resolution_escalated() {
let action = resolution_to_escrow_action(ResolutionType::Escalated, dec!(100));
assert_eq!(action.refund_amount, None);
assert_eq!(action.release_amount, None);
assert!(action.hold);
}
#[test]
fn deadline_constants() {
assert_eq!(EVIDENCE_DEADLINE_SECS, 259_200); assert_eq!(REVIEW_DEADLINE_SECS, 604_800); }
#[test]
fn dispute_category_serde_roundtrip() {
let json = serde_json::to_string(&DisputeCategory::NotAsDescribed).unwrap();
assert_eq!(json, "\"not_as_described\"");
let parsed: DisputeCategory = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, DisputeCategory::NotAsDescribed);
}
}