stateset-core 1.22.0

Core domain models and business logic for StateSet iCommerce
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
//! Loyalty program and rewards domain models
//!
//! Supports multi-tier loyalty programs with point earning, redemption,
//! and configurable reward catalogs.

use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use stateset_primitives::{
    CustomerId, LoyaltyAccountId, LoyaltyProgramId, LoyaltyTransactionId, RewardId,
};
use strum::{Display, EnumString};

/// Loyalty program status
#[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 {
    /// Program is active and accepting enrollments
    #[default]
    Active,
    /// Program is paused (no new enrollments, existing members keep benefits)
    Paused,
    /// Program has been retired
    Archived,
}

/// Loyalty transaction type
#[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 {
    /// Points earned from a purchase
    #[default]
    Earn,
    /// Points redeemed for a reward
    Redeem,
    /// Manual adjustment by admin
    Adjust,
    /// Points expired
    Expire,
    /// Bonus points (promotions, sign-up, etc.)
    Bonus,
    /// Points refunded from a cancelled redemption
    Refund,
}

/// Reward type
#[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 {
    /// Discount on next order (percentage or fixed)
    #[default]
    Discount,
    /// Free shipping on next order
    FreeShipping,
    /// Free product
    FreeProduct,
    /// Store credit issuance
    StoreCredit,
    /// Exclusive access to products or sales
    ExclusiveAccess,
}

/// A loyalty program definition
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoyaltyProgram {
    /// Unique program ID
    pub id: LoyaltyProgramId,
    /// Program name
    pub name: String,
    /// Program description
    pub description: Option<String>,
    /// Points earned per dollar spent
    pub points_per_dollar: u32,
    /// Program tiers (ordered by `min_points` ascending)
    pub tiers: Vec<LoyaltyTier>,
    /// Program status
    pub status: LoyaltyProgramStatus,
    /// When the program was created
    pub created_at: DateTime<Utc>,
    /// When the program was last updated
    pub updated_at: DateTime<Utc>,
}

/// A tier within a loyalty program
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoyaltyTier {
    /// Tier name (e.g., "Bronze", "Silver", "Gold", "Platinum")
    pub name: String,
    /// Minimum lifetime points to reach this tier
    pub min_points: u64,
    /// Points earning multiplier (e.g., 1.5 = 50% bonus)
    pub multiplier: f64,
    /// Perks/benefits at this tier
    pub perks: Vec<String>,
}

/// A customer's loyalty account
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoyaltyAccount {
    /// Unique account ID
    pub id: LoyaltyAccountId,
    /// Customer
    pub customer_id: CustomerId,
    /// Program this account belongs to
    pub program_id: LoyaltyProgramId,
    /// Current redeemable points balance
    pub points_balance: i64,
    /// Total lifetime points earned (determines tier)
    pub lifetime_points: u64,
    /// Current tier name
    pub tier: String,
    /// When the account was created (enrolled)
    pub created_at: DateTime<Utc>,
    /// When the account was last updated
    pub updated_at: DateTime<Utc>,
}

/// A loyalty points transaction
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoyaltyTransaction {
    /// Unique transaction ID
    pub id: LoyaltyTransactionId,
    /// Account this transaction belongs to
    pub account_id: LoyaltyAccountId,
    /// Points amount (positive for earn/bonus, negative for redeem)
    pub points: i64,
    /// Transaction type
    pub transaction_type: LoyaltyTransactionType,
    /// Optional reference (order ID, reward ID, etc.)
    pub reference_id: Option<String>,
    /// Optional description
    pub description: Option<String>,
    /// When the transaction occurred
    pub created_at: DateTime<Utc>,
}

/// A reward in the reward catalog
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Reward {
    /// Unique reward ID
    pub id: RewardId,
    /// Program this reward belongs to
    pub program_id: LoyaltyProgramId,
    /// Reward name
    pub name: String,
    /// Reward description
    pub description: Option<String>,
    /// Points cost to redeem
    pub points_cost: u64,
    /// Type of reward
    pub reward_type: RewardType,
    /// Monetary value of the reward (for discount/store credit types)
    pub value: Option<Decimal>,
    /// Whether this reward is currently available
    pub is_active: bool,
    /// When the reward was created
    pub created_at: DateTime<Utc>,
    /// When the reward was last updated
    pub updated_at: DateTime<Utc>,
}

/// Input for creating a loyalty program
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateLoyaltyProgram {
    /// Program name
    pub name: String,
    /// Description
    pub description: Option<String>,
    /// Points per dollar
    pub points_per_dollar: u32,
    /// Initial tiers
    pub tiers: Vec<LoyaltyTier>,
}

/// Input for enrolling a customer
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnrollCustomer {
    /// Customer to enroll
    pub customer_id: CustomerId,
    /// Program to enroll in
    pub program_id: LoyaltyProgramId,
}

/// Input for earning/redeeming points
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdjustPoints {
    /// Account to adjust
    pub account_id: LoyaltyAccountId,
    /// Points amount (positive or negative)
    pub points: i64,
    /// Transaction type
    pub transaction_type: LoyaltyTransactionType,
    /// Reference
    pub reference_id: Option<String>,
    /// Description
    pub description: Option<String>,
}

/// Input for creating a reward
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateReward {
    /// Program this reward belongs to
    pub program_id: LoyaltyProgramId,
    /// Reward name
    pub name: String,
    /// Description
    pub description: Option<String>,
    /// Points cost
    pub points_cost: u64,
    /// Reward type
    pub reward_type: RewardType,
    /// Monetary value
    pub value: Option<Decimal>,
}

/// Filter for listing loyalty accounts
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LoyaltyAccountFilter {
    /// Filter by customer
    pub customer_id: Option<CustomerId>,
    /// Filter by program
    pub program_id: Option<LoyaltyProgramId>,
    /// Filter by tier
    pub tier: Option<String>,
    /// Maximum results
    pub limit: Option<u32>,
    /// Offset for pagination
    pub offset: Option<u32>,
}

/// Filter for listing rewards
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RewardFilter {
    /// Filter by program
    pub program_id: Option<LoyaltyProgramId>,
    /// Filter by reward type
    pub reward_type: Option<RewardType>,
    /// Only active rewards
    pub is_active: Option<bool>,
    /// Maximum results
    pub limit: Option<u32>,
    /// Offset for pagination
    pub offset: Option<u32>,
}

impl LoyaltyProgram {
    /// Get the tier for a given lifetime points value
    #[must_use]
    pub fn tier_for_points(&self, lifetime_points: u64) -> Option<&LoyaltyTier> {
        self.tiers.iter().rev().find(|tier| lifetime_points >= tier.min_points)
    }

    /// Whether the program is accepting new enrollments
    #[must_use]
    pub fn is_active(&self) -> bool {
        self.status == LoyaltyProgramStatus::Active
    }
}

impl LoyaltyAccount {
    /// Whether the account has enough points to redeem a reward
    #[must_use]
    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(),
        }
    }

    // ---- tier_for_points ----

    #[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() {
        // All tiers have min_points > 0
        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());
    }

    // ---- is_active ----

    #[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());
    }

    // ---- can_redeem ----

    #[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));
    }

    // ---- enum Display / FromStr round-trips ----

    #[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}");
        }
    }

    // ---- Defaults ----

    #[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);
    }
}