koprogo_api/domain/entities/
local_exchange.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Local Exchange Trading System (SEL) - Système d'Échange Local
6///
7/// Enables co-owners to exchange services, objects, and shared purchases
8/// using time-based currency (1 hour = 1 credit).
9///
10/// Belgian Legal Context:
11/// - SELs are legal and recognized in Belgium
12/// - No taxation if non-commercial (barter)
13/// - Must not replace professional services (insurance issues)
14/// - Clear T&Cs required (liability disclaimer)
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16pub struct LocalExchange {
17    pub id: Uuid,
18    pub building_id: Uuid,
19    pub provider_id: Uuid,          // Owner offering the exchange
20    pub requester_id: Option<Uuid>, // Owner requesting (None if still offered)
21    pub exchange_type: ExchangeType,
22    pub title: String,
23    pub description: String,
24    pub credits: i32, // Time in hours (or custom unit)
25    pub status: ExchangeStatus,
26    pub offered_at: DateTime<Utc>,
27    pub requested_at: Option<DateTime<Utc>>,
28    pub started_at: Option<DateTime<Utc>>,
29    pub completed_at: Option<DateTime<Utc>>,
30    pub cancelled_at: Option<DateTime<Utc>>,
31    pub cancellation_reason: Option<String>,
32    pub provider_rating: Option<i32>,  // 1-5 stars from requester
33    pub requester_rating: Option<i32>, // 1-5 stars from provider
34    pub created_at: DateTime<Utc>,
35    pub updated_at: DateTime<Utc>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
39#[serde(rename_all = "PascalCase")]
40pub enum ExchangeType {
41    Service,        // Skills (plumbing, gardening, tutoring, etc.)
42    ObjectLoan,     // Temporary loan (tools, books, equipment)
43    SharedPurchase, // Co-buying (bulk food, equipment rental)
44}
45
46impl ExchangeType {
47    pub fn to_sql(&self) -> &'static str {
48        match self {
49            ExchangeType::Service => "Service",
50            ExchangeType::ObjectLoan => "ObjectLoan",
51            ExchangeType::SharedPurchase => "SharedPurchase",
52        }
53    }
54
55    pub fn from_sql(s: &str) -> Result<Self, String> {
56        match s {
57            "Service" => Ok(ExchangeType::Service),
58            "ObjectLoan" => Ok(ExchangeType::ObjectLoan),
59            "SharedPurchase" => Ok(ExchangeType::SharedPurchase),
60            _ => Err(format!("Invalid exchange type: {}", s)),
61        }
62    }
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
66#[serde(rename_all = "PascalCase")]
67pub enum ExchangeStatus {
68    Offered,    // Available for anyone to request
69    Requested,  // Someone claimed it (pending provider acceptance)
70    InProgress, // Exchange is happening
71    Completed,  // Both parties confirmed completion
72    Cancelled,  // Exchange was cancelled
73}
74
75impl ExchangeStatus {
76    pub fn to_sql(&self) -> &'static str {
77        match self {
78            ExchangeStatus::Offered => "Offered",
79            ExchangeStatus::Requested => "Requested",
80            ExchangeStatus::InProgress => "InProgress",
81            ExchangeStatus::Completed => "Completed",
82            ExchangeStatus::Cancelled => "Cancelled",
83        }
84    }
85
86    pub fn from_sql(s: &str) -> Result<Self, String> {
87        match s {
88            "Offered" => Ok(ExchangeStatus::Offered),
89            "Requested" => Ok(ExchangeStatus::Requested),
90            "InProgress" => Ok(ExchangeStatus::InProgress),
91            "Completed" => Ok(ExchangeStatus::Completed),
92            "Cancelled" => Ok(ExchangeStatus::Cancelled),
93            _ => Err(format!("Invalid exchange status: {}", s)),
94        }
95    }
96}
97
98impl LocalExchange {
99    /// Create a new exchange offer
100    pub fn new(
101        building_id: Uuid,
102        provider_id: Uuid,
103        exchange_type: ExchangeType,
104        title: String,
105        description: String,
106        credits: i32,
107    ) -> Result<Self, String> {
108        // Validation
109        if title.trim().is_empty() {
110            return Err("Title cannot be empty".to_string());
111        }
112
113        if title.len() > 255 {
114            return Err("Title cannot exceed 255 characters".to_string());
115        }
116
117        if description.trim().is_empty() {
118            return Err("Description cannot be empty".to_string());
119        }
120
121        if description.len() > 2000 {
122            return Err("Description cannot exceed 2000 characters".to_string());
123        }
124
125        if credits <= 0 {
126            return Err("Credits must be positive".to_string());
127        }
128
129        if credits > 100 {
130            return Err("Credits cannot exceed 100 (maximum 100 hours)".to_string());
131        }
132
133        let now = Utc::now();
134
135        Ok(LocalExchange {
136            id: Uuid::new_v4(),
137            building_id,
138            provider_id,
139            requester_id: None,
140            exchange_type,
141            title: title.trim().to_string(),
142            description: description.trim().to_string(),
143            credits,
144            status: ExchangeStatus::Offered,
145            offered_at: now,
146            requested_at: None,
147            started_at: None,
148            completed_at: None,
149            cancelled_at: None,
150            cancellation_reason: None,
151            provider_rating: None,
152            requester_rating: None,
153            created_at: now,
154            updated_at: now,
155        })
156    }
157
158    /// Request an exchange (transition: Offered → Requested)
159    pub fn request(&mut self, requester_id: Uuid) -> Result<(), String> {
160        if self.status != ExchangeStatus::Offered {
161            return Err(format!(
162                "Cannot request exchange in status {:?}",
163                self.status
164            ));
165        }
166
167        if self.provider_id == requester_id {
168            return Err("Provider cannot request their own exchange".to_string());
169        }
170
171        self.requester_id = Some(requester_id);
172        self.status = ExchangeStatus::Requested;
173        self.requested_at = Some(Utc::now());
174        self.updated_at = Utc::now();
175
176        Ok(())
177    }
178
179    /// Start an exchange (transition: Requested → InProgress)
180    pub fn start(&mut self, actor_id: Uuid) -> Result<(), String> {
181        if self.status != ExchangeStatus::Requested {
182            return Err(format!("Cannot start exchange in status {:?}", self.status));
183        }
184
185        // Only provider can start the exchange
186        if self.provider_id != actor_id {
187            return Err("Only the provider can start the exchange".to_string());
188        }
189
190        self.status = ExchangeStatus::InProgress;
191        self.started_at = Some(Utc::now());
192        self.updated_at = Utc::now();
193
194        Ok(())
195    }
196
197    /// Complete an exchange (transition: InProgress → Completed)
198    /// Both provider and requester must confirm completion
199    pub fn complete(&mut self, actor_id: Uuid) -> Result<(), String> {
200        if self.status != ExchangeStatus::InProgress {
201            return Err(format!(
202                "Cannot complete exchange in status {:?}",
203                self.status
204            ));
205        }
206
207        // Only provider or requester can complete
208        if self.provider_id != actor_id && self.requester_id != Some(actor_id) {
209            return Err("Only provider or requester can complete the exchange".to_string());
210        }
211
212        self.status = ExchangeStatus::Completed;
213        self.completed_at = Some(Utc::now());
214        self.updated_at = Utc::now();
215
216        Ok(())
217    }
218
219    /// Cancel an exchange
220    pub fn cancel(&mut self, actor_id: Uuid, reason: Option<String>) -> Result<(), String> {
221        // Cannot cancel completed exchanges
222        if self.status == ExchangeStatus::Completed {
223            return Err("Cannot cancel a completed exchange".to_string());
224        }
225
226        if self.status == ExchangeStatus::Cancelled {
227            return Err("Exchange is already cancelled".to_string());
228        }
229
230        // Only provider or requester can cancel
231        if self.provider_id != actor_id && self.requester_id != Some(actor_id) {
232            return Err("Only provider or requester can cancel the exchange".to_string());
233        }
234
235        self.status = ExchangeStatus::Cancelled;
236        self.cancelled_at = Some(Utc::now());
237        self.cancellation_reason = reason;
238        self.updated_at = Utc::now();
239
240        Ok(())
241    }
242
243    /// Rate the provider (by requester)
244    pub fn rate_provider(&mut self, requester_id: Uuid, rating: i32) -> Result<(), String> {
245        if self.status != ExchangeStatus::Completed {
246            return Err("Can only rate completed exchanges".to_string());
247        }
248
249        if self.requester_id != Some(requester_id) {
250            return Err("Only the requester can rate the provider".to_string());
251        }
252
253        if !(1..=5).contains(&rating) {
254            return Err("Rating must be between 1 and 5".to_string());
255        }
256
257        self.provider_rating = Some(rating);
258        self.updated_at = Utc::now();
259
260        Ok(())
261    }
262
263    /// Rate the requester (by provider)
264    pub fn rate_requester(&mut self, provider_id: Uuid, rating: i32) -> Result<(), String> {
265        if self.status != ExchangeStatus::Completed {
266            return Err("Can only rate completed exchanges".to_string());
267        }
268
269        if self.provider_id != provider_id {
270            return Err("Only the provider can rate the requester".to_string());
271        }
272
273        if !(1..=5).contains(&rating) {
274            return Err("Rating must be between 1 and 5".to_string());
275        }
276
277        self.requester_rating = Some(rating);
278        self.updated_at = Utc::now();
279
280        Ok(())
281    }
282
283    /// Check if the exchange is active (not completed or cancelled)
284    pub fn is_active(&self) -> bool {
285        matches!(
286            self.status,
287            ExchangeStatus::Offered | ExchangeStatus::Requested | ExchangeStatus::InProgress
288        )
289    }
290
291    /// Check if ratings are complete
292    pub fn has_mutual_ratings(&self) -> bool {
293        self.provider_rating.is_some() && self.requester_rating.is_some()
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn test_create_exchange_success() {
303        let building_id = Uuid::new_v4();
304        let provider_id = Uuid::new_v4();
305
306        let exchange = LocalExchange::new(
307            building_id,
308            provider_id,
309            ExchangeType::Service,
310            "Gardening help".to_string(),
311            "I can help with planting and weeding".to_string(),
312            2,
313        );
314
315        assert!(exchange.is_ok());
316        let exchange = exchange.unwrap();
317        assert_eq!(exchange.building_id, building_id);
318        assert_eq!(exchange.provider_id, provider_id);
319        assert_eq!(exchange.status, ExchangeStatus::Offered);
320        assert_eq!(exchange.credits, 2);
321        assert!(exchange.requester_id.is_none());
322    }
323
324    #[test]
325    fn test_create_exchange_validation() {
326        let building_id = Uuid::new_v4();
327        let provider_id = Uuid::new_v4();
328
329        // Empty title
330        let result = LocalExchange::new(
331            building_id,
332            provider_id,
333            ExchangeType::Service,
334            "".to_string(),
335            "Description".to_string(),
336            2,
337        );
338        assert!(result.is_err());
339
340        // Negative credits
341        let result = LocalExchange::new(
342            building_id,
343            provider_id,
344            ExchangeType::Service,
345            "Title".to_string(),
346            "Description".to_string(),
347            -1,
348        );
349        assert!(result.is_err());
350
351        // Too many credits
352        let result = LocalExchange::new(
353            building_id,
354            provider_id,
355            ExchangeType::Service,
356            "Title".to_string(),
357            "Description".to_string(),
358            101,
359        );
360        assert!(result.is_err());
361    }
362
363    #[test]
364    fn test_exchange_workflow() {
365        let building_id = Uuid::new_v4();
366        let provider_id = Uuid::new_v4();
367        let requester_id = Uuid::new_v4();
368
369        let mut exchange = LocalExchange::new(
370            building_id,
371            provider_id,
372            ExchangeType::Service,
373            "Babysitting".to_string(),
374            "Can watch kids for 3 hours".to_string(),
375            3,
376        )
377        .unwrap();
378
379        // Request
380        assert!(exchange.request(requester_id).is_ok());
381        assert_eq!(exchange.status, ExchangeStatus::Requested);
382        assert_eq!(exchange.requester_id, Some(requester_id));
383
384        // Start
385        assert!(exchange.start(provider_id).is_ok());
386        assert_eq!(exchange.status, ExchangeStatus::InProgress);
387
388        // Complete
389        assert!(exchange.complete(provider_id).is_ok());
390        assert_eq!(exchange.status, ExchangeStatus::Completed);
391        assert!(exchange.completed_at.is_some());
392    }
393
394    #[test]
395    fn test_cannot_request_own_exchange() {
396        let building_id = Uuid::new_v4();
397        let provider_id = Uuid::new_v4();
398
399        let mut exchange = LocalExchange::new(
400            building_id,
401            provider_id,
402            ExchangeType::Service,
403            "Service".to_string(),
404            "Description".to_string(),
405            2,
406        )
407        .unwrap();
408
409        let result = exchange.request(provider_id);
410        assert!(result.is_err());
411    }
412
413    #[test]
414    fn test_cancel_exchange() {
415        let building_id = Uuid::new_v4();
416        let provider_id = Uuid::new_v4();
417        let requester_id = Uuid::new_v4();
418
419        let mut exchange = LocalExchange::new(
420            building_id,
421            provider_id,
422            ExchangeType::Service,
423            "Service".to_string(),
424            "Description".to_string(),
425            2,
426        )
427        .unwrap();
428
429        exchange.request(requester_id).unwrap();
430
431        // Requester cancels
432        assert!(exchange
433            .cancel(requester_id, Some("Changed my mind".to_string()))
434            .is_ok());
435        assert_eq!(exchange.status, ExchangeStatus::Cancelled);
436        assert!(exchange.cancelled_at.is_some());
437        assert_eq!(
438            exchange.cancellation_reason,
439            Some("Changed my mind".to_string())
440        );
441    }
442
443    #[test]
444    fn test_cannot_cancel_completed_exchange() {
445        let building_id = Uuid::new_v4();
446        let provider_id = Uuid::new_v4();
447        let requester_id = Uuid::new_v4();
448
449        let mut exchange = LocalExchange::new(
450            building_id,
451            provider_id,
452            ExchangeType::Service,
453            "Service".to_string(),
454            "Description".to_string(),
455            2,
456        )
457        .unwrap();
458
459        exchange.request(requester_id).unwrap();
460        exchange.start(provider_id).unwrap();
461        exchange.complete(provider_id).unwrap();
462
463        let result = exchange.cancel(provider_id, None);
464        assert!(result.is_err());
465    }
466
467    #[test]
468    fn test_ratings() {
469        let building_id = Uuid::new_v4();
470        let provider_id = Uuid::new_v4();
471        let requester_id = Uuid::new_v4();
472
473        let mut exchange = LocalExchange::new(
474            building_id,
475            provider_id,
476            ExchangeType::Service,
477            "Service".to_string(),
478            "Description".to_string(),
479            2,
480        )
481        .unwrap();
482
483        exchange.request(requester_id).unwrap();
484        exchange.start(provider_id).unwrap();
485        exchange.complete(provider_id).unwrap();
486
487        // Rate provider
488        assert!(exchange.rate_provider(requester_id, 5).is_ok());
489        assert_eq!(exchange.provider_rating, Some(5));
490
491        // Rate requester
492        assert!(exchange.rate_requester(provider_id, 4).is_ok());
493        assert_eq!(exchange.requester_rating, Some(4));
494
495        assert!(exchange.has_mutual_ratings());
496    }
497
498    #[test]
499    fn test_rating_validation() {
500        let building_id = Uuid::new_v4();
501        let provider_id = Uuid::new_v4();
502        let requester_id = Uuid::new_v4();
503
504        let mut exchange = LocalExchange::new(
505            building_id,
506            provider_id,
507            ExchangeType::Service,
508            "Service".to_string(),
509            "Description".to_string(),
510            2,
511        )
512        .unwrap();
513
514        exchange.request(requester_id).unwrap();
515        exchange.start(provider_id).unwrap();
516        exchange.complete(provider_id).unwrap();
517
518        // Invalid rating (too low)
519        assert!(exchange.rate_provider(requester_id, 0).is_err());
520
521        // Invalid rating (too high)
522        assert!(exchange.rate_provider(requester_id, 6).is_err());
523
524        // Wrong actor
525        assert!(exchange.rate_provider(provider_id, 5).is_err());
526    }
527
528    #[test]
529    fn test_exchange_type_sql_conversion() {
530        assert_eq!(ExchangeType::Service.to_sql(), "Service");
531        assert_eq!(ExchangeType::ObjectLoan.to_sql(), "ObjectLoan");
532        assert_eq!(ExchangeType::SharedPurchase.to_sql(), "SharedPurchase");
533
534        assert_eq!(
535            ExchangeType::from_sql("Service").unwrap(),
536            ExchangeType::Service
537        );
538        assert_eq!(
539            ExchangeType::from_sql("ObjectLoan").unwrap(),
540            ExchangeType::ObjectLoan
541        );
542        assert_eq!(
543            ExchangeType::from_sql("SharedPurchase").unwrap(),
544            ExchangeType::SharedPurchase
545        );
546    }
547
548    #[test]
549    fn test_exchange_status_sql_conversion() {
550        assert_eq!(ExchangeStatus::Offered.to_sql(), "Offered");
551        assert_eq!(ExchangeStatus::Requested.to_sql(), "Requested");
552        assert_eq!(ExchangeStatus::InProgress.to_sql(), "InProgress");
553        assert_eq!(ExchangeStatus::Completed.to_sql(), "Completed");
554        assert_eq!(ExchangeStatus::Cancelled.to_sql(), "Cancelled");
555
556        assert_eq!(
557            ExchangeStatus::from_sql("Offered").unwrap(),
558            ExchangeStatus::Offered
559        );
560        assert_eq!(
561            ExchangeStatus::from_sql("Requested").unwrap(),
562            ExchangeStatus::Requested
563        );
564        assert_eq!(
565            ExchangeStatus::from_sql("InProgress").unwrap(),
566            ExchangeStatus::InProgress
567        );
568        assert_eq!(
569            ExchangeStatus::from_sql("Completed").unwrap(),
570            ExchangeStatus::Completed
571        );
572        assert_eq!(
573            ExchangeStatus::from_sql("Cancelled").unwrap(),
574            ExchangeStatus::Cancelled
575        );
576    }
577}