use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use stateset_primitives::{CurrencyCode, GiftCardId, GiftCardTransactionId};
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 GiftCardStatus {
#[default]
Active,
Depleted,
Expired,
Disabled,
}
#[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 GiftCardTransactionType {
#[default]
Charge,
Refund,
Adjustment,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GiftCard {
pub id: GiftCardId,
pub code: String,
pub initial_balance: Decimal,
pub current_balance: Decimal,
pub currency: CurrencyCode,
pub status: GiftCardStatus,
pub recipient_email: Option<String>,
pub sender_name: Option<String>,
pub message: Option<String>,
pub expires_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl GiftCard {
#[must_use]
pub fn is_active(&self) -> bool {
self.status == GiftCardStatus::Active
}
#[must_use]
pub fn is_expired(&self) -> bool {
if let Some(expires_at) = self.expires_at { Utc::now() > expires_at } else { false }
}
#[must_use]
pub fn can_charge(&self, amount: Decimal) -> bool {
self.is_active() && self.current_balance >= amount
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GiftCardTransaction {
pub id: GiftCardTransactionId,
pub gift_card_id: GiftCardId,
pub amount: Decimal,
pub balance_after: Decimal,
pub transaction_type: GiftCardTransactionType,
pub reference_id: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateGiftCard {
pub code: Option<String>,
pub initial_balance: Decimal,
pub currency: CurrencyCode,
pub recipient_email: Option<String>,
pub sender_name: Option<String>,
pub message: Option<String>,
pub expires_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UpdateGiftCard {
pub status: Option<GiftCardStatus>,
pub recipient_email: Option<String>,
pub expires_at: Option<Option<DateTime<Utc>>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GiftCardFilter {
pub status: Option<GiftCardStatus>,
pub code: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, Utc};
use rust_decimal_macros::dec;
use stateset_primitives::GiftCardId;
fn make_test_gift_card() -> GiftCard {
GiftCard {
id: GiftCardId::new(),
code: "TEST-1234".to_string(),
initial_balance: dec!(100.00),
current_balance: dec!(100.00),
currency: CurrencyCode::USD,
status: GiftCardStatus::Active,
recipient_email: None,
sender_name: None,
message: None,
expires_at: None,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn is_active_returns_true_when_active() {
let gc = make_test_gift_card();
assert!(gc.is_active());
}
#[test]
fn is_active_returns_false_when_disabled() {
let gc = GiftCard { status: GiftCardStatus::Disabled, ..make_test_gift_card() };
assert!(!gc.is_active());
}
#[test]
fn is_active_returns_false_when_depleted() {
let gc = GiftCard { status: GiftCardStatus::Depleted, ..make_test_gift_card() };
assert!(!gc.is_active());
}
#[test]
fn is_active_returns_false_when_expired_status() {
let gc = GiftCard { status: GiftCardStatus::Expired, ..make_test_gift_card() };
assert!(!gc.is_active());
}
#[test]
fn is_expired_returns_true_for_past_date() {
let gc =
GiftCard { expires_at: Some(Utc::now() - Duration::hours(1)), ..make_test_gift_card() };
assert!(gc.is_expired());
}
#[test]
fn is_expired_returns_false_for_future_date() {
let gc =
GiftCard { expires_at: Some(Utc::now() + Duration::days(30)), ..make_test_gift_card() };
assert!(!gc.is_expired());
}
#[test]
fn is_expired_returns_false_when_no_expiry() {
let gc = make_test_gift_card();
assert!(!gc.is_expired());
}
#[test]
fn can_charge_succeeds_with_sufficient_balance() {
let gc = GiftCard {
status: GiftCardStatus::Active,
current_balance: dec!(50.00),
..make_test_gift_card()
};
assert!(gc.can_charge(dec!(25.00)));
}
#[test]
fn can_charge_succeeds_with_exact_balance() {
let gc = GiftCard {
status: GiftCardStatus::Active,
current_balance: dec!(25.00),
..make_test_gift_card()
};
assert!(gc.can_charge(dec!(25.00)));
}
#[test]
fn can_charge_fails_with_insufficient_balance() {
let gc = GiftCard {
status: GiftCardStatus::Active,
current_balance: dec!(10.00),
..make_test_gift_card()
};
assert!(!gc.can_charge(dec!(25.00)));
}
#[test]
fn can_charge_fails_when_inactive() {
let gc = GiftCard {
status: GiftCardStatus::Disabled,
current_balance: dec!(100.00),
..make_test_gift_card()
};
assert!(!gc.can_charge(dec!(10.00)));
}
#[test]
fn gift_card_status_display_fromstr_roundtrip() {
for status in [
GiftCardStatus::Active,
GiftCardStatus::Depleted,
GiftCardStatus::Expired,
GiftCardStatus::Disabled,
] {
let s = status.to_string();
let parsed: GiftCardStatus = s.parse().unwrap();
assert_eq!(parsed, status, "round-trip failed for {s}");
}
}
#[test]
fn gift_card_transaction_type_display_fromstr_roundtrip() {
for tx_type in [
GiftCardTransactionType::Charge,
GiftCardTransactionType::Refund,
GiftCardTransactionType::Adjustment,
] {
let s = tx_type.to_string();
let parsed: GiftCardTransactionType = s.parse().unwrap();
assert_eq!(parsed, tx_type, "round-trip failed for {s}");
}
}
#[test]
fn gift_card_status_default_is_active() {
assert_eq!(GiftCardStatus::default(), GiftCardStatus::Active);
}
#[test]
fn gift_card_transaction_type_default_is_charge() {
assert_eq!(GiftCardTransactionType::default(), GiftCardTransactionType::Charge);
}
}