Skip to main content

koprogo_api/domain/economie_circulaire/
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, utoipa::ToSchema)]
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, utoipa::ToSchema)]
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    /// Annule un échange PAR MODÉRATION (syndic ou `community.moderator`),
244    /// sans être partie prenante (ni `provider_id` ni `requester_id`).
245    ///
246    /// Story 5.3 (#587), INV-4 — l'autorisation de modérer et l'obligation
247    /// d'un motif non vide sont vérifiées en AMONT, côté application
248    /// (`LocalExchangeUseCases`), qui seule connaît le rôle de l'appelant :
249    /// le domaine ne doit pas importer `UserRole` pour rester agnostique de
250    /// l'identité. Ce point d'entrée ne revérifie donc que les invariants
251    /// d'état déjà appliqués par `cancel()` (pas de double vérification
252    /// d'appartenance, contrairement à `cancel()`).
253    pub fn moderate_cancel(&mut self, reason: String) -> Result<(), String> {
254        if self.status == ExchangeStatus::Completed {
255            return Err("Cannot cancel a completed exchange".to_string());
256        }
257
258        if self.status == ExchangeStatus::Cancelled {
259            return Err("Exchange is already cancelled".to_string());
260        }
261
262        self.status = ExchangeStatus::Cancelled;
263        self.cancelled_at = Some(Utc::now());
264        self.cancellation_reason = Some(reason);
265        self.updated_at = Utc::now();
266
267        Ok(())
268    }
269
270    /// Rate the provider (by requester)
271    pub fn rate_provider(&mut self, requester_id: Uuid, rating: i32) -> Result<(), String> {
272        if self.status != ExchangeStatus::Completed {
273            return Err("Can only rate completed exchanges".to_string());
274        }
275
276        if self.requester_id != Some(requester_id) {
277            return Err("Only the requester can rate the provider".to_string());
278        }
279
280        if !(1..=5).contains(&rating) {
281            return Err("Rating must be between 1 and 5".to_string());
282        }
283
284        self.provider_rating = Some(rating);
285        self.updated_at = Utc::now();
286
287        Ok(())
288    }
289
290    /// Rate the requester (by provider)
291    pub fn rate_requester(&mut self, provider_id: Uuid, rating: i32) -> Result<(), String> {
292        if self.status != ExchangeStatus::Completed {
293            return Err("Can only rate completed exchanges".to_string());
294        }
295
296        if self.provider_id != provider_id {
297            return Err("Only the provider can rate the requester".to_string());
298        }
299
300        if !(1..=5).contains(&rating) {
301            return Err("Rating must be between 1 and 5".to_string());
302        }
303
304        self.requester_rating = Some(rating);
305        self.updated_at = Utc::now();
306
307        Ok(())
308    }
309
310    /// Check if the exchange is active (not completed or cancelled)
311    pub fn is_active(&self) -> bool {
312        matches!(
313            self.status,
314            ExchangeStatus::Offered | ExchangeStatus::Requested | ExchangeStatus::InProgress
315        )
316    }
317
318    /// Check if ratings are complete
319    pub fn has_mutual_ratings(&self) -> bool {
320        self.provider_rating.is_some() && self.requester_rating.is_some()
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn test_create_exchange_success() {
330        let building_id = Uuid::new_v4();
331        let provider_id = Uuid::new_v4();
332
333        let exchange = LocalExchange::new(
334            building_id,
335            provider_id,
336            ExchangeType::Service,
337            "Gardening help".to_string(),
338            "I can help with planting and weeding".to_string(),
339            2,
340        );
341
342        assert!(exchange.is_ok());
343        let exchange = exchange.unwrap();
344        assert_eq!(exchange.building_id, building_id);
345        assert_eq!(exchange.provider_id, provider_id);
346        assert_eq!(exchange.status, ExchangeStatus::Offered);
347        assert_eq!(exchange.credits, 2);
348        assert!(exchange.requester_id.is_none());
349    }
350
351    #[test]
352    fn test_create_exchange_validation() {
353        let building_id = Uuid::new_v4();
354        let provider_id = Uuid::new_v4();
355
356        // Empty title
357        let result = LocalExchange::new(
358            building_id,
359            provider_id,
360            ExchangeType::Service,
361            "".to_string(),
362            "Description".to_string(),
363            2,
364        );
365        assert!(result.is_err());
366
367        // Negative credits
368        let result = LocalExchange::new(
369            building_id,
370            provider_id,
371            ExchangeType::Service,
372            "Title".to_string(),
373            "Description".to_string(),
374            -1,
375        );
376        assert!(result.is_err());
377
378        // Too many credits
379        let result = LocalExchange::new(
380            building_id,
381            provider_id,
382            ExchangeType::Service,
383            "Title".to_string(),
384            "Description".to_string(),
385            101,
386        );
387        assert!(result.is_err());
388    }
389
390    #[test]
391    fn test_exchange_workflow() {
392        let building_id = Uuid::new_v4();
393        let provider_id = Uuid::new_v4();
394        let requester_id = Uuid::new_v4();
395
396        let mut exchange = LocalExchange::new(
397            building_id,
398            provider_id,
399            ExchangeType::Service,
400            "Babysitting".to_string(),
401            "Can watch kids for 3 hours".to_string(),
402            3,
403        )
404        .unwrap();
405
406        // Request
407        assert!(exchange.request(requester_id).is_ok());
408        assert_eq!(exchange.status, ExchangeStatus::Requested);
409        assert_eq!(exchange.requester_id, Some(requester_id));
410
411        // Start
412        assert!(exchange.start(provider_id).is_ok());
413        assert_eq!(exchange.status, ExchangeStatus::InProgress);
414
415        // Complete
416        assert!(exchange.complete(provider_id).is_ok());
417        assert_eq!(exchange.status, ExchangeStatus::Completed);
418        assert!(exchange.completed_at.is_some());
419    }
420
421    #[test]
422    fn test_cannot_request_own_exchange() {
423        let building_id = Uuid::new_v4();
424        let provider_id = Uuid::new_v4();
425
426        let mut exchange = LocalExchange::new(
427            building_id,
428            provider_id,
429            ExchangeType::Service,
430            "Service".to_string(),
431            "Description".to_string(),
432            2,
433        )
434        .unwrap();
435
436        let result = exchange.request(provider_id);
437        assert!(result.is_err());
438    }
439
440    #[test]
441    fn test_cancel_exchange() {
442        let building_id = Uuid::new_v4();
443        let provider_id = Uuid::new_v4();
444        let requester_id = Uuid::new_v4();
445
446        let mut exchange = LocalExchange::new(
447            building_id,
448            provider_id,
449            ExchangeType::Service,
450            "Service".to_string(),
451            "Description".to_string(),
452            2,
453        )
454        .unwrap();
455
456        exchange.request(requester_id).unwrap();
457
458        // Requester cancels
459        assert!(exchange
460            .cancel(requester_id, Some("Changed my mind".to_string()))
461            .is_ok());
462        assert_eq!(exchange.status, ExchangeStatus::Cancelled);
463        assert!(exchange.cancelled_at.is_some());
464        assert_eq!(
465            exchange.cancellation_reason,
466            Some("Changed my mind".to_string())
467        );
468    }
469
470    // ------------------------------------------------------------------------
471    // Story 5.3 (#587), INV-4 — moderate_cancel
472    // ------------------------------------------------------------------------
473
474    #[test]
475    fn happy_moderate_cancel_par_un_tiers_a_l_echange() {
476        let building_id = Uuid::new_v4();
477        let provider_id = Uuid::new_v4();
478        let requester_id = Uuid::new_v4();
479
480        let mut exchange = LocalExchange::new(
481            building_id,
482            provider_id,
483            ExchangeType::Service,
484            "Service".to_string(),
485            "Description".to_string(),
486            2,
487        )
488        .unwrap();
489        exchange.request(requester_id).unwrap();
490
491        // Ni provider ni requester : un `cancel()` classique refuserait.
492        // `moderate_cancel` l'autorise, l'autorisation ayant déjà été
493        // vérifiée côté application.
494        let result = exchange.moderate_cancel("Litige signalé par un voisin".to_string());
495        assert!(result.is_ok(), "moderate_cancel failed: {:?}", result.err());
496        assert_eq!(exchange.status, ExchangeStatus::Cancelled);
497        assert_eq!(
498            exchange.cancellation_reason,
499            Some("Litige signalé par un voisin".to_string())
500        );
501    }
502
503    #[test]
504    fn negative_moderate_cancel_refuse_un_echange_deja_complete() {
505        let building_id = Uuid::new_v4();
506        let provider_id = Uuid::new_v4();
507        let requester_id = Uuid::new_v4();
508
509        let mut exchange = LocalExchange::new(
510            building_id,
511            provider_id,
512            ExchangeType::Service,
513            "Service".to_string(),
514            "Description".to_string(),
515            2,
516        )
517        .unwrap();
518        exchange.request(requester_id).unwrap();
519        exchange.start(provider_id).unwrap();
520        exchange.complete(provider_id).unwrap();
521
522        let result = exchange.moderate_cancel("Motif".to_string());
523        assert!(result.is_err());
524        assert!(result.unwrap_err().contains("Cannot cancel a completed"));
525    }
526
527    #[test]
528    fn edge_moderate_cancel_refuse_un_echange_deja_annule() {
529        let building_id = Uuid::new_v4();
530        let provider_id = Uuid::new_v4();
531
532        let mut exchange = LocalExchange::new(
533            building_id,
534            provider_id,
535            ExchangeType::Service,
536            "Service".to_string(),
537            "Description".to_string(),
538            2,
539        )
540        .unwrap();
541        exchange
542            .moderate_cancel("Premier motif".to_string())
543            .unwrap();
544
545        let result = exchange.moderate_cancel("Second motif".to_string());
546        assert!(result.is_err());
547        assert!(result.unwrap_err().contains("already cancelled"));
548    }
549
550    #[test]
551    fn test_cannot_cancel_completed_exchange() {
552        let building_id = Uuid::new_v4();
553        let provider_id = Uuid::new_v4();
554        let requester_id = Uuid::new_v4();
555
556        let mut exchange = LocalExchange::new(
557            building_id,
558            provider_id,
559            ExchangeType::Service,
560            "Service".to_string(),
561            "Description".to_string(),
562            2,
563        )
564        .unwrap();
565
566        exchange.request(requester_id).unwrap();
567        exchange.start(provider_id).unwrap();
568        exchange.complete(provider_id).unwrap();
569
570        let result = exchange.cancel(provider_id, None);
571        assert!(result.is_err());
572    }
573
574    #[test]
575    fn test_ratings() {
576        let building_id = Uuid::new_v4();
577        let provider_id = Uuid::new_v4();
578        let requester_id = Uuid::new_v4();
579
580        let mut exchange = LocalExchange::new(
581            building_id,
582            provider_id,
583            ExchangeType::Service,
584            "Service".to_string(),
585            "Description".to_string(),
586            2,
587        )
588        .unwrap();
589
590        exchange.request(requester_id).unwrap();
591        exchange.start(provider_id).unwrap();
592        exchange.complete(provider_id).unwrap();
593
594        // Rate provider
595        assert!(exchange.rate_provider(requester_id, 5).is_ok());
596        assert_eq!(exchange.provider_rating, Some(5));
597
598        // Rate requester
599        assert!(exchange.rate_requester(provider_id, 4).is_ok());
600        assert_eq!(exchange.requester_rating, Some(4));
601
602        assert!(exchange.has_mutual_ratings());
603    }
604
605    #[test]
606    fn test_rating_validation() {
607        let building_id = Uuid::new_v4();
608        let provider_id = Uuid::new_v4();
609        let requester_id = Uuid::new_v4();
610
611        let mut exchange = LocalExchange::new(
612            building_id,
613            provider_id,
614            ExchangeType::Service,
615            "Service".to_string(),
616            "Description".to_string(),
617            2,
618        )
619        .unwrap();
620
621        exchange.request(requester_id).unwrap();
622        exchange.start(provider_id).unwrap();
623        exchange.complete(provider_id).unwrap();
624
625        // Invalid rating (too low)
626        assert!(exchange.rate_provider(requester_id, 0).is_err());
627
628        // Invalid rating (too high)
629        assert!(exchange.rate_provider(requester_id, 6).is_err());
630
631        // Wrong actor
632        assert!(exchange.rate_provider(provider_id, 5).is_err());
633    }
634
635    #[test]
636    fn test_exchange_type_sql_conversion() {
637        assert_eq!(ExchangeType::Service.to_sql(), "Service");
638        assert_eq!(ExchangeType::ObjectLoan.to_sql(), "ObjectLoan");
639        assert_eq!(ExchangeType::SharedPurchase.to_sql(), "SharedPurchase");
640
641        assert_eq!(
642            ExchangeType::from_sql("Service").unwrap(),
643            ExchangeType::Service
644        );
645        assert_eq!(
646            ExchangeType::from_sql("ObjectLoan").unwrap(),
647            ExchangeType::ObjectLoan
648        );
649        assert_eq!(
650            ExchangeType::from_sql("SharedPurchase").unwrap(),
651            ExchangeType::SharedPurchase
652        );
653    }
654
655    #[test]
656    fn test_exchange_status_sql_conversion() {
657        assert_eq!(ExchangeStatus::Offered.to_sql(), "Offered");
658        assert_eq!(ExchangeStatus::Requested.to_sql(), "Requested");
659        assert_eq!(ExchangeStatus::InProgress.to_sql(), "InProgress");
660        assert_eq!(ExchangeStatus::Completed.to_sql(), "Completed");
661        assert_eq!(ExchangeStatus::Cancelled.to_sql(), "Cancelled");
662
663        assert_eq!(
664            ExchangeStatus::from_sql("Offered").unwrap(),
665            ExchangeStatus::Offered
666        );
667        assert_eq!(
668            ExchangeStatus::from_sql("Requested").unwrap(),
669            ExchangeStatus::Requested
670        );
671        assert_eq!(
672            ExchangeStatus::from_sql("InProgress").unwrap(),
673            ExchangeStatus::InProgress
674        );
675        assert_eq!(
676            ExchangeStatus::from_sql("Completed").unwrap(),
677            ExchangeStatus::Completed
678        );
679        assert_eq!(
680            ExchangeStatus::from_sql("Cancelled").unwrap(),
681            ExchangeStatus::Cancelled
682        );
683    }
684}