konnektoren_core/marketplace/
coupon.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use thiserror::Error;
4use uuid::Uuid;
5
6#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
7pub struct Coupon {
8    pub id: Uuid,                       // Unique identifier for the coupon
9    pub code: String,                   // User-friendly coupon code (e.g., "SPRING25")
10    pub challenge_ids: Vec<String>,     // IDs of challenges this coupon applies to
11    pub max_uses: u32,                  // Maximum number of times this coupon can be used
12    pub uses_remaining: u32,            // Number of uses left
13    pub expiration_date: DateTime<Utc>, // Date and time the coupon expires
14    pub used_by: Vec<String>, // Trace IDs of requests that used this coupon.  This will be a trace ID in practice.
15}
16
17#[derive(Debug, Error)]
18pub enum CouponRedemptionError {
19    #[error("Coupon expired on {0}")]
20    Expired(DateTime<Utc>),
21    #[error("Coupon already used with this trace ID")]
22    AlreadyUsed,
23    #[error("Invalid challenge ID for this coupon")]
24    InvalidChallenge,
25    #[error("Coupon has no uses remaining")]
26    NoUsesRemaining,
27}
28
29impl Coupon {
30    pub fn new(
31        code: String,
32        challenge_ids: Vec<String>,
33        max_uses: u32,
34        expiration_date: DateTime<Utc>,
35    ) -> Self {
36        Coupon {
37            id: Uuid::new_v4(),
38            code,
39            challenge_ids,
40            max_uses,
41            uses_remaining: max_uses,
42            expiration_date,
43            used_by: Vec::new(),
44        }
45    }
46
47    pub fn is_valid(&self, challenge_id: &str, user_id: &str) -> bool {
48        // Check for expiration
49        if self.expiration_date < Utc::now() {
50            return false;
51        }
52
53        // Check if uses remaining
54        if self.uses_remaining == 0 {
55            return false;
56        }
57
58        // Check if challenge is valid for this coupon
59        if !self.challenge_ids.contains(&challenge_id.to_string()) {
60            return false;
61        }
62
63        // Check if user_id (trace_id) already exists
64        if self.used_by.contains(&user_id.to_string()) {
65            return false;
66        }
67
68        true
69    }
70
71    pub fn redeem(&mut self, user_id: String) -> Result<(), CouponRedemptionError> {
72        if self.expiration_date < Utc::now() {
73            return Err(CouponRedemptionError::Expired(self.expiration_date));
74        }
75        if self.uses_remaining == 0 {
76            return Err(CouponRedemptionError::NoUsesRemaining);
77        }
78        if self.used_by.contains(&user_id) {
79            return Err(CouponRedemptionError::AlreadyUsed);
80        }
81        self.uses_remaining -= 1;
82        self.used_by.push(user_id);
83        Ok(())
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use chrono::Duration;
91
92    #[test]
93    fn test_coupon_creation() {
94        let expiration_date = Utc::now() + Duration::days(7);
95        let coupon = Coupon::new(
96            "TEST1234".to_string(),
97            vec!["challenge1".to_string(), "challenge2".to_string()],
98            2,
99            expiration_date,
100        );
101        assert_eq!(coupon.code, "TEST1234");
102        assert_eq!(coupon.max_uses, 2);
103        assert_eq!(coupon.uses_remaining, 2);
104    }
105
106    #[test]
107    fn test_coupon_redemption() {
108        let expiration_date = Utc::now() + Duration::days(7);
109        let mut coupon = Coupon::new(
110            "TEST1234".to_string(),
111            vec!["challenge1".to_string()],
112            1,
113            expiration_date,
114        );
115        let trace_id = "trace123".to_string();
116
117        assert!(coupon.redeem(trace_id.clone()).is_ok());
118        assert!(coupon.redeem(trace_id.clone()).is_err()); // Already used
119        assert_eq!(coupon.uses_remaining, 0);
120
121        let expiration_date_past = Utc::now() - Duration::days(1);
122        let mut coupon_past = Coupon::new(
123            "TEST1235".to_string(),
124            vec!["challenge1".to_string()],
125            1,
126            expiration_date_past,
127        );
128        assert!(coupon_past.redeem(trace_id.clone()).is_err()); // Expired
129    }
130
131    #[test]
132    fn test_coupon_redemption_expired() {
133        let expiration_date_past = Utc::now() - Duration::days(1);
134        let mut coupon = Coupon::new(
135            "TEST1235".to_string(),
136            vec!["challenge1".to_string()],
137            1,
138            expiration_date_past,
139        );
140        let trace_id = "trace123".to_string();
141        match coupon.redeem(trace_id) {
142            Err(CouponRedemptionError::Expired(date)) => {
143                assert_eq!(date, expiration_date_past);
144            }
145            _ => panic!("Expected Expired error"),
146        }
147    }
148}