Skip to main content

stateset_core/models/
loyalty.rs

1//! Loyalty program and rewards domain models
2//!
3//! Supports multi-tier loyalty programs with point earning, redemption,
4//! and configurable reward catalogs.
5
6use chrono::{DateTime, Utc};
7use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9use stateset_primitives::{
10    CustomerId, LoyaltyAccountId, LoyaltyProgramId, LoyaltyTransactionId, RewardId,
11};
12use strum::{Display, EnumString};
13
14/// Loyalty program status
15#[derive(
16    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
17)]
18#[serde(rename_all = "snake_case")]
19#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
20#[non_exhaustive]
21pub enum LoyaltyProgramStatus {
22    /// Program is active and accepting enrollments
23    #[default]
24    Active,
25    /// Program is paused (no new enrollments, existing members keep benefits)
26    Paused,
27    /// Program has been retired
28    Archived,
29}
30
31/// Loyalty transaction type
32#[derive(
33    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
34)]
35#[serde(rename_all = "snake_case")]
36#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
37#[non_exhaustive]
38pub enum LoyaltyTransactionType {
39    /// Points earned from a purchase
40    #[default]
41    Earn,
42    /// Points redeemed for a reward
43    Redeem,
44    /// Manual adjustment by admin
45    Adjust,
46    /// Points expired
47    Expire,
48    /// Bonus points (promotions, sign-up, etc.)
49    Bonus,
50    /// Points refunded from a cancelled redemption
51    Refund,
52}
53
54/// Reward type
55#[derive(
56    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
57)]
58#[serde(rename_all = "snake_case")]
59#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
60#[non_exhaustive]
61pub enum RewardType {
62    /// Discount on next order (percentage or fixed)
63    #[default]
64    Discount,
65    /// Free shipping on next order
66    FreeShipping,
67    /// Free product
68    FreeProduct,
69    /// Store credit issuance
70    StoreCredit,
71    /// Exclusive access to products or sales
72    ExclusiveAccess,
73}
74
75/// A loyalty program definition
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct LoyaltyProgram {
78    /// Unique program ID
79    pub id: LoyaltyProgramId,
80    /// Program name
81    pub name: String,
82    /// Program description
83    pub description: Option<String>,
84    /// Points earned per dollar spent
85    pub points_per_dollar: u32,
86    /// Program tiers (ordered by `min_points` ascending)
87    pub tiers: Vec<LoyaltyTier>,
88    /// Program status
89    pub status: LoyaltyProgramStatus,
90    /// When the program was created
91    pub created_at: DateTime<Utc>,
92    /// When the program was last updated
93    pub updated_at: DateTime<Utc>,
94}
95
96/// A tier within a loyalty program
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct LoyaltyTier {
99    /// Tier name (e.g., "Bronze", "Silver", "Gold", "Platinum")
100    pub name: String,
101    /// Minimum lifetime points to reach this tier
102    pub min_points: u64,
103    /// Points earning multiplier (e.g., 1.5 = 50% bonus)
104    pub multiplier: f64,
105    /// Perks/benefits at this tier
106    pub perks: Vec<String>,
107}
108
109/// A customer's loyalty account
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct LoyaltyAccount {
112    /// Unique account ID
113    pub id: LoyaltyAccountId,
114    /// Customer
115    pub customer_id: CustomerId,
116    /// Program this account belongs to
117    pub program_id: LoyaltyProgramId,
118    /// Current redeemable points balance
119    pub points_balance: i64,
120    /// Total lifetime points earned (determines tier)
121    pub lifetime_points: u64,
122    /// Current tier name
123    pub tier: String,
124    /// When the account was created (enrolled)
125    pub created_at: DateTime<Utc>,
126    /// When the account was last updated
127    pub updated_at: DateTime<Utc>,
128}
129
130/// A loyalty points transaction
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct LoyaltyTransaction {
133    /// Unique transaction ID
134    pub id: LoyaltyTransactionId,
135    /// Account this transaction belongs to
136    pub account_id: LoyaltyAccountId,
137    /// Points amount (positive for earn/bonus, negative for redeem)
138    pub points: i64,
139    /// Transaction type
140    pub transaction_type: LoyaltyTransactionType,
141    /// Optional reference (order ID, reward ID, etc.)
142    pub reference_id: Option<String>,
143    /// Optional description
144    pub description: Option<String>,
145    /// When the transaction occurred
146    pub created_at: DateTime<Utc>,
147}
148
149/// A reward in the reward catalog
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct Reward {
152    /// Unique reward ID
153    pub id: RewardId,
154    /// Program this reward belongs to
155    pub program_id: LoyaltyProgramId,
156    /// Reward name
157    pub name: String,
158    /// Reward description
159    pub description: Option<String>,
160    /// Points cost to redeem
161    pub points_cost: u64,
162    /// Type of reward
163    pub reward_type: RewardType,
164    /// Monetary value of the reward (for discount/store credit types)
165    pub value: Option<Decimal>,
166    /// Whether this reward is currently available
167    pub is_active: bool,
168    /// When the reward was created
169    pub created_at: DateTime<Utc>,
170    /// When the reward was last updated
171    pub updated_at: DateTime<Utc>,
172}
173
174/// Input for creating a loyalty program
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct CreateLoyaltyProgram {
177    /// Program name
178    pub name: String,
179    /// Description
180    pub description: Option<String>,
181    /// Points per dollar
182    pub points_per_dollar: u32,
183    /// Initial tiers
184    pub tiers: Vec<LoyaltyTier>,
185}
186
187/// Input for enrolling a customer
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct EnrollCustomer {
190    /// Customer to enroll
191    pub customer_id: CustomerId,
192    /// Program to enroll in
193    pub program_id: LoyaltyProgramId,
194}
195
196/// Input for earning/redeeming points
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct AdjustPoints {
199    /// Account to adjust
200    pub account_id: LoyaltyAccountId,
201    /// Points amount (positive or negative)
202    pub points: i64,
203    /// Transaction type
204    pub transaction_type: LoyaltyTransactionType,
205    /// Reference
206    pub reference_id: Option<String>,
207    /// Description
208    pub description: Option<String>,
209}
210
211/// Input for creating a reward
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct CreateReward {
214    /// Program this reward belongs to
215    pub program_id: LoyaltyProgramId,
216    /// Reward name
217    pub name: String,
218    /// Description
219    pub description: Option<String>,
220    /// Points cost
221    pub points_cost: u64,
222    /// Reward type
223    pub reward_type: RewardType,
224    /// Monetary value
225    pub value: Option<Decimal>,
226}
227
228/// Filter for listing loyalty accounts
229#[derive(Debug, Clone, Serialize, Deserialize, Default)]
230pub struct LoyaltyAccountFilter {
231    /// Filter by customer
232    pub customer_id: Option<CustomerId>,
233    /// Filter by program
234    pub program_id: Option<LoyaltyProgramId>,
235    /// Filter by tier
236    pub tier: Option<String>,
237    /// Maximum results
238    pub limit: Option<u32>,
239    /// Offset for pagination
240    pub offset: Option<u32>,
241}
242
243/// Filter for listing rewards
244#[derive(Debug, Clone, Serialize, Deserialize, Default)]
245pub struct RewardFilter {
246    /// Filter by program
247    pub program_id: Option<LoyaltyProgramId>,
248    /// Filter by reward type
249    pub reward_type: Option<RewardType>,
250    /// Only active rewards
251    pub is_active: Option<bool>,
252    /// Maximum results
253    pub limit: Option<u32>,
254    /// Offset for pagination
255    pub offset: Option<u32>,
256}
257
258impl LoyaltyProgram {
259    /// Get the tier for a given lifetime points value
260    #[must_use]
261    pub fn tier_for_points(&self, lifetime_points: u64) -> Option<&LoyaltyTier> {
262        self.tiers.iter().rev().find(|tier| lifetime_points >= tier.min_points)
263    }
264
265    /// Whether the program is accepting new enrollments
266    #[must_use]
267    pub fn is_active(&self) -> bool {
268        self.status == LoyaltyProgramStatus::Active
269    }
270}
271
272impl LoyaltyAccount {
273    /// Whether the account has enough points to redeem a reward
274    #[must_use]
275    pub const fn can_redeem(&self, points_cost: u64) -> bool {
276        self.points_balance >= 0 && (self.points_balance as u64) >= points_cost
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use chrono::Utc;
284    use stateset_primitives::{CustomerId, LoyaltyAccountId, LoyaltyProgramId};
285
286    fn make_program_with_tiers() -> LoyaltyProgram {
287        LoyaltyProgram {
288            id: LoyaltyProgramId::new(),
289            name: "Test Program".to_string(),
290            description: None,
291            points_per_dollar: 1,
292            tiers: vec![
293                LoyaltyTier {
294                    name: "Bronze".to_string(),
295                    min_points: 0,
296                    multiplier: 1.0,
297                    perks: vec![],
298                },
299                LoyaltyTier {
300                    name: "Silver".to_string(),
301                    min_points: 500,
302                    multiplier: 1.5,
303                    perks: vec![],
304                },
305                LoyaltyTier {
306                    name: "Gold".to_string(),
307                    min_points: 2000,
308                    multiplier: 2.0,
309                    perks: vec![],
310                },
311            ],
312            status: LoyaltyProgramStatus::Active,
313            created_at: Utc::now(),
314            updated_at: Utc::now(),
315        }
316    }
317
318    fn make_account(points_balance: i64) -> LoyaltyAccount {
319        LoyaltyAccount {
320            id: LoyaltyAccountId::new(),
321            customer_id: CustomerId::new(),
322            program_id: LoyaltyProgramId::new(),
323            points_balance,
324            lifetime_points: points_balance.max(0) as u64,
325            tier: "Bronze".to_string(),
326            created_at: Utc::now(),
327            updated_at: Utc::now(),
328        }
329    }
330
331    // ---- tier_for_points ----
332
333    #[test]
334    fn tier_for_points_returns_bronze_at_zero() {
335        let program = make_program_with_tiers();
336        let tier = program.tier_for_points(0).unwrap();
337        assert_eq!(tier.name, "Bronze");
338    }
339
340    #[test]
341    fn tier_for_points_returns_silver_at_500() {
342        let program = make_program_with_tiers();
343        let tier = program.tier_for_points(500).unwrap();
344        assert_eq!(tier.name, "Silver");
345    }
346
347    #[test]
348    fn tier_for_points_returns_highest_tier_at_large_value() {
349        let program = make_program_with_tiers();
350        let tier = program.tier_for_points(10_000).unwrap();
351        assert_eq!(tier.name, "Gold");
352    }
353
354    #[test]
355    fn tier_for_points_returns_none_for_empty_tiers() {
356        let program = LoyaltyProgram { tiers: vec![], ..make_program_with_tiers() };
357        assert!(program.tier_for_points(0).is_none());
358    }
359
360    #[test]
361    fn tier_for_points_returns_none_when_below_minimum() {
362        // All tiers have min_points > 0
363        let program = LoyaltyProgram {
364            tiers: vec![
365                LoyaltyTier {
366                    name: "Silver".to_string(),
367                    min_points: 500,
368                    multiplier: 1.5,
369                    perks: vec![],
370                },
371                LoyaltyTier {
372                    name: "Gold".to_string(),
373                    min_points: 2000,
374                    multiplier: 2.0,
375                    perks: vec![],
376                },
377            ],
378            ..make_program_with_tiers()
379        };
380        assert!(program.tier_for_points(0).is_none());
381    }
382
383    // ---- is_active ----
384
385    #[test]
386    fn program_is_active_when_active() {
387        let program = make_program_with_tiers();
388        assert!(program.is_active());
389    }
390
391    #[test]
392    fn program_is_not_active_when_paused() {
393        let program =
394            LoyaltyProgram { status: LoyaltyProgramStatus::Paused, ..make_program_with_tiers() };
395        assert!(!program.is_active());
396    }
397
398    #[test]
399    fn program_is_not_active_when_archived() {
400        let program =
401            LoyaltyProgram { status: LoyaltyProgramStatus::Archived, ..make_program_with_tiers() };
402        assert!(!program.is_active());
403    }
404
405    // ---- can_redeem ----
406
407    #[test]
408    fn can_redeem_with_sufficient_points() {
409        let account = make_account(1000);
410        assert!(account.can_redeem(500));
411    }
412
413    #[test]
414    fn can_redeem_with_exact_points() {
415        let account = make_account(500);
416        assert!(account.can_redeem(500));
417    }
418
419    #[test]
420    fn cannot_redeem_with_insufficient_points() {
421        let account = make_account(100);
422        assert!(!account.can_redeem(500));
423    }
424
425    #[test]
426    fn cannot_redeem_with_negative_balance() {
427        let account = make_account(-100);
428        assert!(!account.can_redeem(0));
429    }
430
431    // ---- enum Display / FromStr round-trips ----
432
433    #[test]
434    fn loyalty_program_status_display_fromstr_roundtrip() {
435        for status in [
436            LoyaltyProgramStatus::Active,
437            LoyaltyProgramStatus::Paused,
438            LoyaltyProgramStatus::Archived,
439        ] {
440            let s = status.to_string();
441            let parsed: LoyaltyProgramStatus = s.parse().unwrap();
442            assert_eq!(parsed, status, "round-trip failed for {s}");
443        }
444    }
445
446    #[test]
447    fn loyalty_transaction_type_display_fromstr_roundtrip() {
448        for tx_type in [
449            LoyaltyTransactionType::Earn,
450            LoyaltyTransactionType::Redeem,
451            LoyaltyTransactionType::Adjust,
452            LoyaltyTransactionType::Expire,
453            LoyaltyTransactionType::Bonus,
454            LoyaltyTransactionType::Refund,
455        ] {
456            let s = tx_type.to_string();
457            let parsed: LoyaltyTransactionType = s.parse().unwrap();
458            assert_eq!(parsed, tx_type, "round-trip failed for {s}");
459        }
460    }
461
462    #[test]
463    fn reward_type_display_fromstr_roundtrip() {
464        for reward_type in [
465            RewardType::Discount,
466            RewardType::FreeShipping,
467            RewardType::FreeProduct,
468            RewardType::StoreCredit,
469            RewardType::ExclusiveAccess,
470        ] {
471            let s = reward_type.to_string();
472            let parsed: RewardType = s.parse().unwrap();
473            assert_eq!(parsed, reward_type, "round-trip failed for {s}");
474        }
475    }
476
477    // ---- Defaults ----
478
479    #[test]
480    fn loyalty_program_status_default_is_active() {
481        assert_eq!(LoyaltyProgramStatus::default(), LoyaltyProgramStatus::Active);
482    }
483
484    #[test]
485    fn loyalty_transaction_type_default_is_earn() {
486        assert_eq!(LoyaltyTransactionType::default(), LoyaltyTransactionType::Earn);
487    }
488
489    #[test]
490    fn reward_type_default_is_discount() {
491        assert_eq!(RewardType::default(), RewardType::Discount);
492    }
493}