use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use stateset_primitives::{
CustomerId, LoyaltyAccountId, LoyaltyProgramId, LoyaltyTransactionId, RewardId,
};
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 LoyaltyProgramStatus {
#[default]
Active,
Paused,
Archived,
}
#[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 LoyaltyTransactionType {
#[default]
Earn,
Redeem,
Adjust,
Expire,
Bonus,
Refund,
}
#[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 RewardType {
#[default]
Discount,
FreeShipping,
FreeProduct,
StoreCredit,
ExclusiveAccess,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoyaltyProgram {
pub id: LoyaltyProgramId,
pub name: String,
pub description: Option<String>,
pub points_per_dollar: u32,
pub tiers: Vec<LoyaltyTier>,
pub status: LoyaltyProgramStatus,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoyaltyTier {
pub name: String,
pub min_points: u64,
pub multiplier: f64,
pub perks: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoyaltyAccount {
pub id: LoyaltyAccountId,
pub customer_id: CustomerId,
pub program_id: LoyaltyProgramId,
pub points_balance: i64,
pub lifetime_points: u64,
pub tier: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoyaltyTransaction {
pub id: LoyaltyTransactionId,
pub account_id: LoyaltyAccountId,
pub points: i64,
pub transaction_type: LoyaltyTransactionType,
pub reference_id: Option<String>,
pub description: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Reward {
pub id: RewardId,
pub program_id: LoyaltyProgramId,
pub name: String,
pub description: Option<String>,
pub points_cost: u64,
pub reward_type: RewardType,
pub value: Option<Decimal>,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateLoyaltyProgram {
pub name: String,
pub description: Option<String>,
pub points_per_dollar: u32,
pub tiers: Vec<LoyaltyTier>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnrollCustomer {
pub customer_id: CustomerId,
pub program_id: LoyaltyProgramId,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdjustPoints {
pub account_id: LoyaltyAccountId,
pub points: i64,
pub transaction_type: LoyaltyTransactionType,
pub reference_id: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateReward {
pub program_id: LoyaltyProgramId,
pub name: String,
pub description: Option<String>,
pub points_cost: u64,
pub reward_type: RewardType,
pub value: Option<Decimal>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LoyaltyAccountFilter {
pub customer_id: Option<CustomerId>,
pub program_id: Option<LoyaltyProgramId>,
pub tier: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RewardFilter {
pub program_id: Option<LoyaltyProgramId>,
pub reward_type: Option<RewardType>,
pub is_active: Option<bool>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
impl LoyaltyProgram {
pub fn tier_for_points(&self, lifetime_points: u64) -> Option<&LoyaltyTier> {
self.tiers.iter().rev().find(|tier| lifetime_points >= tier.min_points)
}
pub fn is_active(&self) -> bool {
self.status == LoyaltyProgramStatus::Active
}
}
impl LoyaltyAccount {
pub const fn can_redeem(&self, points_cost: u64) -> bool {
self.points_balance >= 0 && (self.points_balance as u64) >= points_cost
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use stateset_primitives::{CustomerId, LoyaltyAccountId, LoyaltyProgramId};
fn make_program_with_tiers() -> LoyaltyProgram {
LoyaltyProgram {
id: LoyaltyProgramId::new(),
name: "Test Program".to_string(),
description: None,
points_per_dollar: 1,
tiers: vec![
LoyaltyTier {
name: "Bronze".to_string(),
min_points: 0,
multiplier: 1.0,
perks: vec![],
},
LoyaltyTier {
name: "Silver".to_string(),
min_points: 500,
multiplier: 1.5,
perks: vec![],
},
LoyaltyTier {
name: "Gold".to_string(),
min_points: 2000,
multiplier: 2.0,
perks: vec![],
},
],
status: LoyaltyProgramStatus::Active,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
fn make_account(points_balance: i64) -> LoyaltyAccount {
LoyaltyAccount {
id: LoyaltyAccountId::new(),
customer_id: CustomerId::new(),
program_id: LoyaltyProgramId::new(),
points_balance,
lifetime_points: points_balance.max(0) as u64,
tier: "Bronze".to_string(),
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn tier_for_points_returns_bronze_at_zero() {
let program = make_program_with_tiers();
let tier = program.tier_for_points(0).unwrap();
assert_eq!(tier.name, "Bronze");
}
#[test]
fn tier_for_points_returns_silver_at_500() {
let program = make_program_with_tiers();
let tier = program.tier_for_points(500).unwrap();
assert_eq!(tier.name, "Silver");
}
#[test]
fn tier_for_points_returns_highest_tier_at_large_value() {
let program = make_program_with_tiers();
let tier = program.tier_for_points(10_000).unwrap();
assert_eq!(tier.name, "Gold");
}
#[test]
fn tier_for_points_returns_none_for_empty_tiers() {
let program = LoyaltyProgram { tiers: vec![], ..make_program_with_tiers() };
assert!(program.tier_for_points(0).is_none());
}
#[test]
fn tier_for_points_returns_none_when_below_minimum() {
let program = LoyaltyProgram {
tiers: vec![
LoyaltyTier {
name: "Silver".to_string(),
min_points: 500,
multiplier: 1.5,
perks: vec![],
},
LoyaltyTier {
name: "Gold".to_string(),
min_points: 2000,
multiplier: 2.0,
perks: vec![],
},
],
..make_program_with_tiers()
};
assert!(program.tier_for_points(0).is_none());
}
#[test]
fn program_is_active_when_active() {
let program = make_program_with_tiers();
assert!(program.is_active());
}
#[test]
fn program_is_not_active_when_paused() {
let program =
LoyaltyProgram { status: LoyaltyProgramStatus::Paused, ..make_program_with_tiers() };
assert!(!program.is_active());
}
#[test]
fn program_is_not_active_when_archived() {
let program =
LoyaltyProgram { status: LoyaltyProgramStatus::Archived, ..make_program_with_tiers() };
assert!(!program.is_active());
}
#[test]
fn can_redeem_with_sufficient_points() {
let account = make_account(1000);
assert!(account.can_redeem(500));
}
#[test]
fn can_redeem_with_exact_points() {
let account = make_account(500);
assert!(account.can_redeem(500));
}
#[test]
fn cannot_redeem_with_insufficient_points() {
let account = make_account(100);
assert!(!account.can_redeem(500));
}
#[test]
fn cannot_redeem_with_negative_balance() {
let account = make_account(-100);
assert!(!account.can_redeem(0));
}
#[test]
fn loyalty_program_status_display_fromstr_roundtrip() {
for status in [
LoyaltyProgramStatus::Active,
LoyaltyProgramStatus::Paused,
LoyaltyProgramStatus::Archived,
] {
let s = status.to_string();
let parsed: LoyaltyProgramStatus = s.parse().unwrap();
assert_eq!(parsed, status, "round-trip failed for {s}");
}
}
#[test]
fn loyalty_transaction_type_display_fromstr_roundtrip() {
for tx_type in [
LoyaltyTransactionType::Earn,
LoyaltyTransactionType::Redeem,
LoyaltyTransactionType::Adjust,
LoyaltyTransactionType::Expire,
LoyaltyTransactionType::Bonus,
LoyaltyTransactionType::Refund,
] {
let s = tx_type.to_string();
let parsed: LoyaltyTransactionType = s.parse().unwrap();
assert_eq!(parsed, tx_type, "round-trip failed for {s}");
}
}
#[test]
fn reward_type_display_fromstr_roundtrip() {
for reward_type in [
RewardType::Discount,
RewardType::FreeShipping,
RewardType::FreeProduct,
RewardType::StoreCredit,
RewardType::ExclusiveAccess,
] {
let s = reward_type.to_string();
let parsed: RewardType = s.parse().unwrap();
assert_eq!(parsed, reward_type, "round-trip failed for {s}");
}
}
#[test]
fn loyalty_program_status_default_is_active() {
assert_eq!(LoyaltyProgramStatus::default(), LoyaltyProgramStatus::Active);
}
#[test]
fn loyalty_transaction_type_default_is_earn() {
assert_eq!(LoyaltyTransactionType::default(), LoyaltyTransactionType::Earn);
}
#[test]
fn reward_type_default_is_discount() {
assert_eq!(RewardType::default(), RewardType::Discount);
}
}