koprogo_api/domain/entities/
individual_member.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Individual Member (non-copropriétaire)
6/// Issue #280: Energy group buying extensions — allows individuals to join energy campaigns
7/// Art. 22 RED II (Renewable Energy Directive II)
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9pub struct IndividualMember {
10    pub id: Uuid,
11    pub campaign_id: Uuid,
12    pub email: String,
13    pub postal_code: String,
14    pub has_gdpr_consent: bool,
15    pub consent_at: Option<DateTime<Utc>>,
16    pub annual_consumption_kwh: Option<f64>,
17    pub current_provider: Option<String>,
18    pub ean_code: Option<String>, // Belgian EAN identifier
19    pub unsubscribed_at: Option<DateTime<Utc>>,
20    pub created_at: DateTime<Utc>,
21}
22
23impl IndividualMember {
24    pub fn new(campaign_id: Uuid, email: String, postal_code: String) -> Result<Self, String> {
25        if email.is_empty() || !email.contains('@') {
26            return Err("Invalid email address".to_string());
27        }
28        if postal_code.is_empty() {
29            return Err("Postal code cannot be empty".to_string());
30        }
31
32        Ok(Self {
33            id: Uuid::new_v4(),
34            campaign_id,
35            email,
36            postal_code,
37            has_gdpr_consent: false,
38            consent_at: None,
39            annual_consumption_kwh: None,
40            current_provider: None,
41            ean_code: None,
42            unsubscribed_at: None,
43            created_at: Utc::now(),
44        })
45    }
46
47    /// Grant GDPR consent for campaign participation
48    pub fn grant_consent(&mut self) -> Result<(), String> {
49        if self.unsubscribed_at.is_some() {
50            return Err("Cannot grant consent to unsubscribed member".to_string());
51        }
52        self.has_gdpr_consent = true;
53        self.consent_at = Some(Utc::now());
54        Ok(())
55    }
56
57    /// Update consumption data
58    pub fn update_consumption(
59        &mut self,
60        kwh: f64,
61        provider: Option<String>,
62        ean: Option<String>,
63    ) -> Result<(), String> {
64        if kwh < 0.0 {
65            return Err("Annual consumption cannot be negative".to_string());
66        }
67        self.annual_consumption_kwh = Some(kwh);
68        self.current_provider = provider;
69        self.ean_code = ean;
70        Ok(())
71    }
72
73    /// Unsubscribe member from campaign (GDPR right to erasure prep)
74    pub fn unsubscribe(&mut self) -> Result<(), String> {
75        if self.unsubscribed_at.is_some() {
76            return Err("Member already unsubscribed".to_string());
77        }
78        self.unsubscribed_at = Some(Utc::now());
79        Ok(())
80    }
81
82    /// Check if member is active (not unsubscribed)
83    pub fn is_active(&self) -> bool {
84        self.unsubscribed_at.is_none()
85    }
86
87    /// Check if member has valid consent
88    pub fn has_valid_consent(&self) -> bool {
89        self.has_gdpr_consent && self.is_active()
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn test_individual_member_new_success() {
99        let campaign_id = Uuid::new_v4();
100        let member = IndividualMember::new(
101            campaign_id,
102            "user@example.com".to_string(),
103            "1000".to_string(),
104        );
105
106        assert!(member.is_ok());
107        let m = member.unwrap();
108        assert_eq!(m.email, "user@example.com");
109        assert_eq!(m.postal_code, "1000");
110        assert!(!m.has_gdpr_consent);
111        assert!(m.is_active());
112    }
113
114    #[test]
115    fn test_individual_member_invalid_email() {
116        let campaign_id = Uuid::new_v4();
117        let result = IndividualMember::new(campaign_id, "invalid".to_string(), "1000".to_string());
118        assert!(result.is_err());
119    }
120
121    #[test]
122    fn test_individual_member_empty_postal_code() {
123        let campaign_id = Uuid::new_v4();
124        let result =
125            IndividualMember::new(campaign_id, "user@example.com".to_string(), "".to_string());
126        assert!(result.is_err());
127    }
128
129    #[test]
130    fn test_grant_consent() {
131        let campaign_id = Uuid::new_v4();
132        let mut member = IndividualMember::new(
133            campaign_id,
134            "user@example.com".to_string(),
135            "1000".to_string(),
136        )
137        .unwrap();
138
139        assert!(!member.has_gdpr_consent);
140        let _ = member.grant_consent();
141        assert!(member.has_gdpr_consent);
142        assert!(member.consent_at.is_some());
143    }
144
145    #[test]
146    fn test_unsubscribe() {
147        let campaign_id = Uuid::new_v4();
148        let mut member = IndividualMember::new(
149            campaign_id,
150            "user@example.com".to_string(),
151            "1000".to_string(),
152        )
153        .unwrap();
154
155        assert!(member.is_active());
156        let _ = member.unsubscribe();
157        assert!(!member.is_active());
158        assert!(member.unsubscribed_at.is_some());
159    }
160
161    #[test]
162    fn test_has_valid_consent() {
163        let campaign_id = Uuid::new_v4();
164        let mut member = IndividualMember::new(
165            campaign_id,
166            "user@example.com".to_string(),
167            "1000".to_string(),
168        )
169        .unwrap();
170
171        assert!(!member.has_valid_consent());
172        let _ = member.grant_consent();
173        assert!(member.has_valid_consent());
174        let _ = member.unsubscribe();
175        assert!(!member.has_valid_consent());
176    }
177
178    #[test]
179    fn test_update_consumption() {
180        let campaign_id = Uuid::new_v4();
181        let mut member = IndividualMember::new(
182            campaign_id,
183            "user@example.com".to_string(),
184            "1000".to_string(),
185        )
186        .unwrap();
187
188        let result = member.update_consumption(
189            5000.0,
190            Some("Engie".to_string()),
191            Some("501234567890".to_string()),
192        );
193        assert!(result.is_ok());
194        assert_eq!(member.annual_consumption_kwh, Some(5000.0));
195        assert_eq!(member.current_provider, Some("Engie".to_string()));
196    }
197
198    #[test]
199    fn test_update_consumption_negative() {
200        let campaign_id = Uuid::new_v4();
201        let mut member = IndividualMember::new(
202            campaign_id,
203            "user@example.com".to_string(),
204            "1000".to_string(),
205        )
206        .unwrap();
207
208        let result = member.update_consumption(-100.0, None, None);
209        assert!(result.is_err());
210    }
211}