koprogo_api/domain/entities/
owner_credit_balance.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Owner Credit Balance for Local Exchange Trading System (SEL)
6///
7/// Tracks time-based currency balance for each owner per building.
8/// Credits are earned by providing services and spent by receiving services.
9///
10/// Balance = credits_earned - credits_spent
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12pub struct OwnerCreditBalance {
13    pub owner_id: Uuid,
14    pub building_id: Uuid,
15    pub credits_earned: i32,         // Services provided (positive)
16    pub credits_spent: i32,          // Services received (positive)
17    pub balance: i32,                // earned - spent (can be negative)
18    pub total_exchanges: i32,        // Total number of completed exchanges
19    pub average_rating: Option<f32>, // Average rating received (1-5 stars)
20    pub created_at: DateTime<Utc>,
21    pub updated_at: DateTime<Utc>,
22}
23
24impl OwnerCreditBalance {
25    /// Create a new credit balance (starts at 0)
26    pub fn new(owner_id: Uuid, building_id: Uuid) -> Self {
27        let now = Utc::now();
28
29        OwnerCreditBalance {
30            owner_id,
31            building_id,
32            credits_earned: 0,
33            credits_spent: 0,
34            balance: 0,
35            total_exchanges: 0,
36            average_rating: None,
37            created_at: now,
38            updated_at: now,
39        }
40    }
41
42    /// Earn credits (when providing a service)
43    pub fn earn_credits(&mut self, amount: i32) -> Result<(), String> {
44        if amount <= 0 {
45            return Err("Credits to earn must be positive".to_string());
46        }
47
48        self.credits_earned += amount;
49        self.balance += amount;
50        self.updated_at = Utc::now();
51
52        Ok(())
53    }
54
55    /// Spend credits (when receiving a service)
56    pub fn spend_credits(&mut self, amount: i32) -> Result<(), String> {
57        if amount <= 0 {
58            return Err("Credits to spend must be positive".to_string());
59        }
60
61        // Note: We allow negative balance (community trust model)
62        // Some SEL systems don't allow negative balance, but we do for flexibility
63        self.credits_spent += amount;
64        self.balance -= amount;
65        self.updated_at = Utc::now();
66
67        Ok(())
68    }
69
70    /// Increment exchange counter
71    pub fn increment_exchanges(&mut self) {
72        self.total_exchanges += 1;
73        self.updated_at = Utc::now();
74    }
75
76    /// Update average rating
77    pub fn update_rating(&mut self, new_rating: f32) -> Result<(), String> {
78        if !(1.0..=5.0).contains(&new_rating) {
79            return Err("Rating must be between 1.0 and 5.0".to_string());
80        }
81
82        self.average_rating = Some(new_rating);
83        self.updated_at = Utc::now();
84
85        Ok(())
86    }
87
88    /// Check if owner has sufficient credits (for systems that enforce limits)
89    pub fn has_sufficient_credits(&self, required: i32) -> bool {
90        self.balance >= required
91    }
92
93    /// Get credit status
94    pub fn credit_status(&self) -> CreditStatus {
95        if self.balance > 0 {
96            CreditStatus::Positive
97        } else if self.balance < 0 {
98            CreditStatus::Negative
99        } else {
100            CreditStatus::Balanced
101        }
102    }
103
104    /// Check if owner is a new member (no exchanges yet)
105    pub fn is_new_member(&self) -> bool {
106        self.total_exchanges == 0
107    }
108
109    /// Get participation level
110    pub fn participation_level(&self) -> ParticipationLevel {
111        match self.total_exchanges {
112            0 => ParticipationLevel::New,
113            1..=5 => ParticipationLevel::Beginner,
114            6..=20 => ParticipationLevel::Active,
115            21..=50 => ParticipationLevel::Veteran,
116            _ => ParticipationLevel::Expert,
117        }
118    }
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
122pub enum CreditStatus {
123    Positive, // Balance > 0 (net provider)
124    Balanced, // Balance = 0
125    Negative, // Balance < 0 (net receiver)
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
129pub enum ParticipationLevel {
130    New,      // 0 exchanges
131    Beginner, // 1-5 exchanges
132    Active,   // 6-20 exchanges
133    Veteran,  // 21-50 exchanges
134    Expert,   // 51+ exchanges
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn test_new_credit_balance() {
143        let owner_id = Uuid::new_v4();
144        let building_id = Uuid::new_v4();
145
146        let balance = OwnerCreditBalance::new(owner_id, building_id);
147
148        assert_eq!(balance.owner_id, owner_id);
149        assert_eq!(balance.building_id, building_id);
150        assert_eq!(balance.credits_earned, 0);
151        assert_eq!(balance.credits_spent, 0);
152        assert_eq!(balance.balance, 0);
153        assert_eq!(balance.total_exchanges, 0);
154        assert!(balance.average_rating.is_none());
155        assert_eq!(balance.credit_status(), CreditStatus::Balanced);
156        assert!(balance.is_new_member());
157    }
158
159    #[test]
160    fn test_earn_credits() {
161        let owner_id = Uuid::new_v4();
162        let building_id = Uuid::new_v4();
163
164        let mut balance = OwnerCreditBalance::new(owner_id, building_id);
165
166        // Earn 5 credits
167        assert!(balance.earn_credits(5).is_ok());
168        assert_eq!(balance.credits_earned, 5);
169        assert_eq!(balance.balance, 5);
170        assert_eq!(balance.credit_status(), CreditStatus::Positive);
171
172        // Earn 3 more credits
173        assert!(balance.earn_credits(3).is_ok());
174        assert_eq!(balance.credits_earned, 8);
175        assert_eq!(balance.balance, 8);
176    }
177
178    #[test]
179    fn test_spend_credits() {
180        let owner_id = Uuid::new_v4();
181        let building_id = Uuid::new_v4();
182
183        let mut balance = OwnerCreditBalance::new(owner_id, building_id);
184
185        // Earn 10 credits first
186        balance.earn_credits(10).unwrap();
187
188        // Spend 6 credits
189        assert!(balance.spend_credits(6).is_ok());
190        assert_eq!(balance.credits_spent, 6);
191        assert_eq!(balance.balance, 4);
192        assert_eq!(balance.credit_status(), CreditStatus::Positive);
193
194        // Spend 5 more credits (goes negative)
195        assert!(balance.spend_credits(5).is_ok());
196        assert_eq!(balance.credits_spent, 11);
197        assert_eq!(balance.balance, -1);
198        assert_eq!(balance.credit_status(), CreditStatus::Negative);
199    }
200
201    #[test]
202    fn test_earn_credits_validation() {
203        let owner_id = Uuid::new_v4();
204        let building_id = Uuid::new_v4();
205
206        let mut balance = OwnerCreditBalance::new(owner_id, building_id);
207
208        // Cannot earn 0 credits
209        assert!(balance.earn_credits(0).is_err());
210
211        // Cannot earn negative credits
212        assert!(balance.earn_credits(-5).is_err());
213    }
214
215    #[test]
216    fn test_spend_credits_validation() {
217        let owner_id = Uuid::new_v4();
218        let building_id = Uuid::new_v4();
219
220        let mut balance = OwnerCreditBalance::new(owner_id, building_id);
221
222        // Cannot spend 0 credits
223        assert!(balance.spend_credits(0).is_err());
224
225        // Cannot spend negative credits
226        assert!(balance.spend_credits(-5).is_err());
227    }
228
229    #[test]
230    fn test_increment_exchanges() {
231        let owner_id = Uuid::new_v4();
232        let building_id = Uuid::new_v4();
233
234        let mut balance = OwnerCreditBalance::new(owner_id, building_id);
235
236        balance.increment_exchanges();
237        assert_eq!(balance.total_exchanges, 1);
238
239        balance.increment_exchanges();
240        assert_eq!(balance.total_exchanges, 2);
241    }
242
243    #[test]
244    fn test_update_rating() {
245        let owner_id = Uuid::new_v4();
246        let building_id = Uuid::new_v4();
247
248        let mut balance = OwnerCreditBalance::new(owner_id, building_id);
249
250        // Valid rating
251        assert!(balance.update_rating(4.5).is_ok());
252        assert_eq!(balance.average_rating, Some(4.5));
253
254        // Invalid rating (too low)
255        assert!(balance.update_rating(0.5).is_err());
256
257        // Invalid rating (too high)
258        assert!(balance.update_rating(5.5).is_err());
259    }
260
261    #[test]
262    fn test_has_sufficient_credits() {
263        let owner_id = Uuid::new_v4();
264        let building_id = Uuid::new_v4();
265
266        let mut balance = OwnerCreditBalance::new(owner_id, building_id);
267
268        balance.earn_credits(10).unwrap();
269
270        assert!(balance.has_sufficient_credits(5));
271        assert!(balance.has_sufficient_credits(10));
272        assert!(!balance.has_sufficient_credits(11));
273    }
274
275    #[test]
276    fn test_participation_levels() {
277        let owner_id = Uuid::new_v4();
278        let building_id = Uuid::new_v4();
279
280        let mut balance = OwnerCreditBalance::new(owner_id, building_id);
281
282        // New
283        assert_eq!(balance.participation_level(), ParticipationLevel::New);
284
285        // Beginner (1-5)
286        for _ in 0..3 {
287            balance.increment_exchanges();
288        }
289        assert_eq!(balance.participation_level(), ParticipationLevel::Beginner);
290
291        // Active (6-20)
292        for _ in 0..10 {
293            balance.increment_exchanges();
294        }
295        assert_eq!(balance.participation_level(), ParticipationLevel::Active);
296
297        // Veteran (21-50)
298        for _ in 0..15 {
299            balance.increment_exchanges();
300        }
301        assert_eq!(balance.participation_level(), ParticipationLevel::Veteran);
302
303        // Expert (51+)
304        for _ in 0..25 {
305            balance.increment_exchanges();
306        }
307        assert_eq!(balance.participation_level(), ParticipationLevel::Expert);
308    }
309
310    #[test]
311    fn test_credit_status() {
312        let owner_id = Uuid::new_v4();
313        let building_id = Uuid::new_v4();
314
315        let mut balance = OwnerCreditBalance::new(owner_id, building_id);
316
317        // Balanced
318        assert_eq!(balance.credit_status(), CreditStatus::Balanced);
319
320        // Positive
321        balance.earn_credits(5).unwrap();
322        assert_eq!(balance.credit_status(), CreditStatus::Positive);
323
324        // Negative
325        balance.spend_credits(10).unwrap();
326        assert_eq!(balance.credit_status(), CreditStatus::Negative);
327    }
328}