#![cfg(feature = "sqlite")]
use rust_decimal_macros::dec;
use stateset_core::{
CommerceError, CreatePayment, CreateRefund, PaymentMethodType, PaymentRepository,
};
use stateset_db::SqliteDatabase;
fn db() -> SqliteDatabase {
SqliteDatabase::in_memory().expect("create in-memory sqlite db")
}
fn completed_payment(db: &SqliteDatabase, amount: rust_decimal::Decimal) -> stateset_core::Payment {
let payment = db
.payments()
.create(CreatePayment {
payment_method: PaymentMethodType::CreditCard,
amount,
..Default::default()
})
.expect("create payment");
db.payments().mark_completed(payment.id).expect("mark payment completed")
}
#[test]
fn refund_amount_must_be_positive() {
let db = db();
let payment = completed_payment(&db, dec!(100.00));
let err = db
.payments()
.create_refund(CreateRefund {
payment_id: payment.id,
amount: Some(dec!(0.00)),
..Default::default()
})
.expect_err("zero-amount refund must be rejected");
assert!(matches!(err, CommerceError::ValidationError(_)), "got {err:?}");
let err = db
.payments()
.create_refund(CreateRefund {
payment_id: payment.id,
amount: Some(dec!(-5.00)),
..Default::default()
})
.expect_err("negative-amount refund must be rejected");
assert!(matches!(err, CommerceError::ValidationError(_)), "got {err:?}");
}
#[test]
fn refund_exceeding_remaining_is_rejected() {
let db = db();
let payment = completed_payment(&db, dec!(100.00));
let err = db
.payments()
.create_refund(CreateRefund {
payment_id: payment.id,
amount: Some(dec!(150.00)),
..Default::default()
})
.expect_err("refund larger than payment must be rejected");
assert!(matches!(err, CommerceError::ValidationError(_)), "got {err:?}");
assert!(db.payments().get_refunds(payment.id).expect("list refunds").is_empty());
let reloaded = db.payments().get(payment.id).expect("get").expect("payment present");
assert_eq!(reloaded.amount_refunded, dec!(0));
}
#[test]
fn over_refund_across_two_refunds_is_rejected() {
let db = db();
let payment = completed_payment(&db, dec!(100.00));
let r1 = db
.payments()
.create_refund(CreateRefund {
payment_id: payment.id,
amount: Some(dec!(60.00)),
..Default::default()
})
.expect("first refund");
db.payments().complete_refund(r1.id).expect("complete first refund");
let err = db
.payments()
.create_refund(CreateRefund {
payment_id: payment.id,
amount: Some(dec!(60.00)),
..Default::default()
})
.expect_err("second refund exceeding remaining must be rejected");
assert!(matches!(err, CommerceError::ValidationError(_)), "got {err:?}");
}
#[test]
fn refunding_pending_payment_is_rejected() {
let db = db();
let payment = db
.payments()
.create(CreatePayment {
payment_method: PaymentMethodType::CreditCard,
amount: dec!(100.00),
..Default::default()
})
.expect("create payment");
let err = db
.payments()
.create_refund(CreateRefund {
payment_id: payment.id,
amount: Some(dec!(10.00)),
..Default::default()
})
.expect_err("refunding a pending payment must be rejected");
assert!(matches!(err, CommerceError::ValidationError(_)), "got {err:?}");
}
#[test]
fn refunding_failed_payment_is_rejected() {
let db = db();
let payment = db
.payments()
.create(CreatePayment {
payment_method: PaymentMethodType::CreditCard,
amount: dec!(100.00),
..Default::default()
})
.expect("create payment");
db.payments().mark_failed(payment.id, "card declined", Some("declined")).expect("mark failed");
let err = db
.payments()
.create_refund(CreateRefund { payment_id: payment.id, amount: None, ..Default::default() })
.expect_err("refunding a failed payment must be rejected");
assert!(matches!(err, CommerceError::ValidationError(_)), "got {err:?}");
}
#[test]
fn exact_full_refund_succeeds_and_flips_to_refunded() {
let db = db();
let payment = completed_payment(&db, dec!(100.00));
let refund = db
.payments()
.create_refund(CreateRefund { payment_id: payment.id, amount: None, ..Default::default() })
.expect("full refund");
assert_eq!(refund.amount, dec!(100.00));
db.payments().complete_refund(refund.id).expect("complete full refund");
let reloaded = db.payments().get(payment.id).expect("get").expect("payment present");
assert_eq!(reloaded.amount_refunded, dec!(100.00));
assert_eq!(reloaded.status, stateset_core::PaymentTransactionStatus::Refunded);
}
#[test]
fn two_partial_refunds_sum_to_exact_decimal_and_flip_to_refunded() {
let db = db();
let payment = completed_payment(&db, dec!(0.30));
let r1 = db
.payments()
.create_refund(CreateRefund {
payment_id: payment.id,
amount: Some(dec!(0.10)),
..Default::default()
})
.expect("first partial refund");
db.payments().complete_refund(r1.id).expect("complete first partial refund");
let mid = db.payments().get(payment.id).expect("get").expect("payment present");
assert_eq!(mid.amount_refunded, dec!(0.10));
assert_eq!(mid.status, stateset_core::PaymentTransactionStatus::PartiallyRefunded);
let r2 = db
.payments()
.create_refund(CreateRefund {
payment_id: payment.id,
amount: Some(dec!(0.20)),
..Default::default()
})
.expect("second partial refund");
db.payments().complete_refund(r2.id).expect("complete second partial refund");
let reloaded = db.payments().get(payment.id).expect("get").expect("payment present");
assert_eq!(reloaded.amount_refunded, dec!(0.30));
assert_eq!(
reloaded.amount_refunded.to_string(),
"0.30",
"amount_refunded must be exact decimal text, not a float-coerced value"
);
assert_eq!(reloaded.status, stateset_core::PaymentTransactionStatus::Refunded);
}
#[test]
fn second_pending_refund_exceeding_remaining_is_rejected_before_completion() {
let db = db();
let payment = completed_payment(&db, dec!(100.00));
let r1 = db
.payments()
.create_refund(CreateRefund {
payment_id: payment.id,
amount: Some(dec!(60.00)),
..Default::default()
})
.expect("first pending refund");
let mid = db.payments().get(payment.id).expect("get").expect("payment present");
assert_eq!(mid.amount_refunded, dec!(0));
let err = db
.payments()
.create_refund(CreateRefund {
payment_id: payment.id,
amount: Some(dec!(60.00)),
..Default::default()
})
.expect_err("second pending refund exceeding remaining must be rejected");
assert!(matches!(err, CommerceError::ValidationError(_)), "got {err:?}");
let refunds = db.payments().get_refunds(payment.id).expect("list refunds");
assert_eq!(refunds.len(), 1, "over-refunding second pending refund must not persist");
assert_eq!(refunds[0].id, r1.id);
}
#[test]
fn second_pending_refund_within_remaining_is_allowed() {
let db = db();
let payment = completed_payment(&db, dec!(100.00));
db.payments()
.create_refund(CreateRefund {
payment_id: payment.id,
amount: Some(dec!(40.00)),
..Default::default()
})
.expect("first pending refund");
db.payments()
.create_refund(CreateRefund {
payment_id: payment.id,
amount: Some(dec!(50.00)),
..Default::default()
})
.expect("second pending refund within remaining");
let err = db
.payments()
.create_refund(CreateRefund {
payment_id: payment.id,
amount: Some(dec!(20.00)),
..Default::default()
})
.expect_err("third refund exceeding reserved remaining must be rejected");
assert!(matches!(err, CommerceError::ValidationError(_)), "got {err:?}");
let refunds = db.payments().get_refunds(payment.id).expect("list refunds");
assert_eq!(refunds.len(), 2, "only the two fitting refunds should persist");
}
#[test]
fn failed_refund_releases_its_reservation() {
let db = db();
let payment = completed_payment(&db, dec!(100.00));
let r1 = db
.payments()
.create_refund(CreateRefund {
payment_id: payment.id,
amount: Some(dec!(100.00)),
..Default::default()
})
.expect("full pending refund");
let err = db
.payments()
.create_refund(CreateRefund {
payment_id: payment.id,
amount: Some(dec!(10.00)),
..Default::default()
})
.expect_err("balance fully reserved by pending refund");
assert!(matches!(err, CommerceError::ValidationError(_)), "got {err:?}");
db.payments().fail_refund(r1.id, "processor declined").expect("fail refund");
let r2 = db
.payments()
.create_refund(CreateRefund {
payment_id: payment.id,
amount: Some(dec!(100.00)),
..Default::default()
})
.expect("refund after failed reservation released");
db.payments().complete_refund(r2.id).expect("complete refund");
let reloaded = db.payments().get(payment.id).expect("get").expect("payment present");
assert_eq!(reloaded.amount_refunded, dec!(100.00));
assert_eq!(reloaded.status, stateset_core::PaymentTransactionStatus::Refunded);
}
#[test]
fn complete_refund_is_idempotent() {
let db = db();
let payment = completed_payment(&db, dec!(100.00));
let refund = db
.payments()
.create_refund(CreateRefund {
payment_id: payment.id,
amount: Some(dec!(50.00)),
..Default::default()
})
.expect("create refund");
db.payments().complete_refund(refund.id).expect("first completion");
let once = db.payments().get(payment.id).expect("get").expect("present");
assert_eq!(once.amount_refunded, dec!(50.00));
assert_eq!(once.status, stateset_core::PaymentTransactionStatus::PartiallyRefunded);
db.payments().complete_refund(refund.id).expect("second completion is idempotent");
let twice = db.payments().get(payment.id).expect("get").expect("present");
assert_eq!(
twice.amount_refunded,
dec!(50.00),
"duplicate complete_refund must NOT double-count into amount_refunded"
);
assert_eq!(
twice.status,
stateset_core::PaymentTransactionStatus::PartiallyRefunded,
"payment must not flip to fully Refunded on a duplicate completion"
);
}
#[test]
fn complete_refund_rejects_failed_refund() {
let db = db();
let payment = completed_payment(&db, dec!(100.00));
let refund = db
.payments()
.create_refund(CreateRefund {
payment_id: payment.id,
amount: Some(dec!(40.00)),
..Default::default()
})
.expect("create refund");
db.payments().fail_refund(refund.id, "processor declined").expect("fail refund");
let err =
db.payments().complete_refund(refund.id).expect_err("a failed refund cannot be completed");
assert!(matches!(err, CommerceError::ValidationError(_)), "got {err:?}");
let reloaded = db.payments().get(payment.id).expect("get").expect("present");
assert_eq!(
reloaded.amount_refunded,
dec!(0.00),
"a failed refund must not fold its amount into the payment"
);
}