use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use stateset_primitives::{CurrencyCode, CustomerId, StoreCreditId, StoreCreditTransactionId};
use strum::{Display, EnumString};
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[non_exhaustive]
pub enum StoreCreditStatus {
#[default]
Active,
Depleted,
Expired,
Voided,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[non_exhaustive]
pub enum StoreCreditReason {
#[default]
Return,
Loyalty,
Compensation,
Promotion,
Manual,
GiftCard,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[non_exhaustive]
pub enum StoreCreditTransactionType {
#[default]
Issue,
Apply,
Adjust,
Void,
Expire,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoreCredit {
pub id: StoreCreditId,
pub customer_id: CustomerId,
pub original_balance: Decimal,
pub current_balance: Decimal,
pub currency: CurrencyCode,
pub status: StoreCreditStatus,
pub reason: StoreCreditReason,
pub reference_id: Option<String>,
pub note: Option<String>,
pub expires_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoreCreditTransaction {
pub id: StoreCreditTransactionId,
pub store_credit_id: StoreCreditId,
pub amount: Decimal,
pub balance_after: Decimal,
pub transaction_type: StoreCreditTransactionType,
pub reference_id: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateStoreCredit {
pub customer_id: CustomerId,
pub amount: Decimal,
pub currency: CurrencyCode,
pub reason: StoreCreditReason,
pub reference_id: Option<String>,
pub note: Option<String>,
pub expires_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdjustStoreCredit {
pub amount: Decimal,
pub note: Option<String>,
pub reference_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StoreCreditFilter {
pub customer_id: Option<CustomerId>,
pub status: Option<StoreCreditStatus>,
pub reason: Option<StoreCreditReason>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
impl StoreCredit {
#[must_use]
pub fn is_active(&self) -> bool {
self.status == StoreCreditStatus::Active && self.current_balance > Decimal::ZERO
}
#[must_use]
pub fn is_expired(&self) -> bool {
self.expires_at.is_some_and(|exp| exp < Utc::now())
}
#[must_use]
pub fn can_apply(&self, amount: Decimal) -> bool {
self.is_active()
&& !self.is_expired()
&& self.current_balance >= amount
&& amount > Decimal::ZERO
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, Utc};
use rust_decimal_macros::dec;
use stateset_primitives::{CustomerId, StoreCreditId};
fn make_store_credit(
status: StoreCreditStatus,
current_balance: Decimal,
expires_at: Option<DateTime<Utc>>,
) -> StoreCredit {
StoreCredit {
id: StoreCreditId::new(),
customer_id: CustomerId::new(),
original_balance: dec!(50.00),
current_balance,
currency: CurrencyCode::USD,
status,
reason: StoreCreditReason::Return,
reference_id: None,
note: None,
expires_at,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn is_active_returns_true_when_active_with_balance() {
let sc = make_store_credit(StoreCreditStatus::Active, dec!(25.00), None);
assert!(sc.is_active());
}
#[test]
fn is_active_returns_false_when_depleted_status() {
let sc = make_store_credit(StoreCreditStatus::Depleted, dec!(0.00), None);
assert!(!sc.is_active());
}
#[test]
fn is_active_returns_false_when_voided() {
let sc = make_store_credit(StoreCreditStatus::Voided, dec!(25.00), None);
assert!(!sc.is_active());
}
#[test]
fn is_active_returns_false_when_balance_is_zero() {
let sc = make_store_credit(StoreCreditStatus::Active, dec!(0.00), None);
assert!(!sc.is_active());
}
#[test]
fn is_expired_returns_true_for_past_expiry() {
let sc = make_store_credit(
StoreCreditStatus::Active,
dec!(25.00),
Some(Utc::now() - Duration::hours(1)),
);
assert!(sc.is_expired());
}
#[test]
fn is_expired_returns_false_for_future_expiry() {
let sc = make_store_credit(
StoreCreditStatus::Active,
dec!(25.00),
Some(Utc::now() + Duration::days(30)),
);
assert!(!sc.is_expired());
}
#[test]
fn is_expired_returns_false_when_no_expiry() {
let sc = make_store_credit(StoreCreditStatus::Active, dec!(25.00), None);
assert!(!sc.is_expired());
}
#[test]
fn can_apply_succeeds_with_sufficient_non_expired_active_credit() {
let sc = make_store_credit(StoreCreditStatus::Active, dec!(50.00), None);
assert!(sc.can_apply(dec!(25.00)));
}
#[test]
fn can_apply_fails_with_insufficient_balance() {
let sc = make_store_credit(StoreCreditStatus::Active, dec!(10.00), None);
assert!(!sc.can_apply(dec!(25.00)));
}
#[test]
fn can_apply_fails_when_expired() {
let sc = make_store_credit(
StoreCreditStatus::Active,
dec!(50.00),
Some(Utc::now() - Duration::hours(1)),
);
assert!(!sc.can_apply(dec!(25.00)));
}
#[test]
fn can_apply_fails_when_inactive() {
let sc = make_store_credit(StoreCreditStatus::Voided, dec!(50.00), None);
assert!(!sc.can_apply(dec!(10.00)));
}
#[test]
fn can_apply_fails_for_zero_amount() {
let sc = make_store_credit(StoreCreditStatus::Active, dec!(50.00), None);
assert!(!sc.can_apply(dec!(0.00)));
}
#[test]
fn store_credit_status_display_fromstr_roundtrip() {
for status in [
StoreCreditStatus::Active,
StoreCreditStatus::Depleted,
StoreCreditStatus::Expired,
StoreCreditStatus::Voided,
] {
let s = status.to_string();
let parsed: StoreCreditStatus = s.parse().unwrap();
assert_eq!(parsed, status, "round-trip failed for {s}");
}
}
#[test]
fn store_credit_reason_display_fromstr_roundtrip() {
for reason in [
StoreCreditReason::Return,
StoreCreditReason::Loyalty,
StoreCreditReason::Compensation,
StoreCreditReason::Promotion,
StoreCreditReason::Manual,
StoreCreditReason::GiftCard,
] {
let s = reason.to_string();
let parsed: StoreCreditReason = s.parse().unwrap();
assert_eq!(parsed, reason, "round-trip failed for {s}");
}
}
#[test]
fn store_credit_transaction_type_display_fromstr_roundtrip() {
for tx_type in [
StoreCreditTransactionType::Issue,
StoreCreditTransactionType::Apply,
StoreCreditTransactionType::Adjust,
StoreCreditTransactionType::Void,
StoreCreditTransactionType::Expire,
] {
let s = tx_type.to_string();
let parsed: StoreCreditTransactionType = s.parse().unwrap();
assert_eq!(parsed, tx_type, "round-trip failed for {s}");
}
}
#[test]
fn store_credit_status_default_is_active() {
assert_eq!(StoreCreditStatus::default(), StoreCreditStatus::Active);
}
#[test]
fn store_credit_reason_default_is_return() {
assert_eq!(StoreCreditReason::default(), StoreCreditReason::Return);
}
#[test]
fn store_credit_transaction_type_default_is_issue() {
assert_eq!(StoreCreditTransactionType::default(), StoreCreditTransactionType::Issue);
}
}