1use 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#[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 #[default]
24 Active,
25 Paused,
27 Archived,
29}
30
31#[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 #[default]
41 Earn,
42 Redeem,
44 Adjust,
46 Expire,
48 Bonus,
50 Refund,
52}
53
54#[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 #[default]
64 Discount,
65 FreeShipping,
67 FreeProduct,
69 StoreCredit,
71 ExclusiveAccess,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct LoyaltyProgram {
78 pub id: LoyaltyProgramId,
80 pub name: String,
82 pub description: Option<String>,
84 pub points_per_dollar: u32,
86 pub tiers: Vec<LoyaltyTier>,
88 pub status: LoyaltyProgramStatus,
90 pub created_at: DateTime<Utc>,
92 pub updated_at: DateTime<Utc>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct LoyaltyTier {
99 pub name: String,
101 pub min_points: u64,
103 pub multiplier: f64,
105 pub perks: Vec<String>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct LoyaltyAccount {
112 pub id: LoyaltyAccountId,
114 pub customer_id: CustomerId,
116 pub program_id: LoyaltyProgramId,
118 pub points_balance: i64,
120 pub lifetime_points: u64,
122 pub tier: String,
124 pub created_at: DateTime<Utc>,
126 pub updated_at: DateTime<Utc>,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct LoyaltyTransaction {
133 pub id: LoyaltyTransactionId,
135 pub account_id: LoyaltyAccountId,
137 pub points: i64,
139 pub transaction_type: LoyaltyTransactionType,
141 pub reference_id: Option<String>,
143 pub description: Option<String>,
145 pub created_at: DateTime<Utc>,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct Reward {
152 pub id: RewardId,
154 pub program_id: LoyaltyProgramId,
156 pub name: String,
158 pub description: Option<String>,
160 pub points_cost: u64,
162 pub reward_type: RewardType,
164 pub value: Option<Decimal>,
166 pub is_active: bool,
168 pub created_at: DateTime<Utc>,
170 pub updated_at: DateTime<Utc>,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct CreateLoyaltyProgram {
177 pub name: String,
179 pub description: Option<String>,
181 pub points_per_dollar: u32,
183 pub tiers: Vec<LoyaltyTier>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct EnrollCustomer {
190 pub customer_id: CustomerId,
192 pub program_id: LoyaltyProgramId,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct AdjustPoints {
199 pub account_id: LoyaltyAccountId,
201 pub points: i64,
203 pub transaction_type: LoyaltyTransactionType,
205 pub reference_id: Option<String>,
207 pub description: Option<String>,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct CreateReward {
214 pub program_id: LoyaltyProgramId,
216 pub name: String,
218 pub description: Option<String>,
220 pub points_cost: u64,
222 pub reward_type: RewardType,
224 pub value: Option<Decimal>,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize, Default)]
230pub struct LoyaltyAccountFilter {
231 pub customer_id: Option<CustomerId>,
233 pub program_id: Option<LoyaltyProgramId>,
235 pub tier: Option<String>,
237 pub limit: Option<u32>,
239 pub offset: Option<u32>,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize, Default)]
245pub struct RewardFilter {
246 pub program_id: Option<LoyaltyProgramId>,
248 pub reward_type: Option<RewardType>,
250 pub is_active: Option<bool>,
252 pub limit: Option<u32>,
254 pub offset: Option<u32>,
256}
257
258impl LoyaltyProgram {
259 #[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 #[must_use]
267 pub fn is_active(&self) -> bool {
268 self.status == LoyaltyProgramStatus::Active
269 }
270}
271
272impl LoyaltyAccount {
273 #[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 #[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 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 #[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 #[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 #[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 #[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}