1use chrono::{DateTime, Duration, Utc};
2use rust_decimal::Decimal;
3use rust_decimal_macros::dec;
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13pub enum AgeRequestStatus {
14 Draft,
16 Open,
18 Reached,
20 Submitted,
22 Accepted,
24 Expired,
26 Rejected,
28 Withdrawn,
30}
31
32impl AgeRequestStatus {
33 pub fn from_db_string(s: &str) -> Result<Self, String> {
34 match s {
35 "draft" => Ok(Self::Draft),
36 "open" => Ok(Self::Open),
37 "reached" => Ok(Self::Reached),
38 "submitted" => Ok(Self::Submitted),
39 "accepted" => Ok(Self::Accepted),
40 "expired" => Ok(Self::Expired),
41 "rejected" => Ok(Self::Rejected),
42 "withdrawn" => Ok(Self::Withdrawn),
43 _ => Err(format!("Unknown age_request_status: {}", s)),
44 }
45 }
46
47 pub fn to_db_str(&self) -> &'static str {
48 match self {
49 Self::Draft => "draft",
50 Self::Open => "open",
51 Self::Reached => "reached",
52 Self::Submitted => "submitted",
53 Self::Accepted => "accepted",
54 Self::Expired => "expired",
55 Self::Rejected => "rejected",
56 Self::Withdrawn => "withdrawn",
57 }
58 }
59
60 pub fn is_terminal(&self) -> bool {
62 matches!(
63 self,
64 Self::Accepted | Self::Expired | Self::Rejected | Self::Withdrawn
65 )
66 }
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
71pub struct AgeRequestCosignatory {
72 pub id: Uuid,
73 pub age_request_id: Uuid,
74 pub owner_id: Uuid,
75 pub shares_pct: Decimal,
77 pub signed_at: DateTime<Utc>,
78}
79
80impl AgeRequestCosignatory {
81 pub fn new(age_request_id: Uuid, owner_id: Uuid, shares_pct: Decimal) -> Result<Self, String> {
82 if shares_pct <= Decimal::ZERO || shares_pct > Decimal::ONE {
83 return Err(format!(
84 "shares_pct doit être entre 0 et 1, reçu: {}",
85 shares_pct
86 ));
87 }
88 Ok(Self {
89 id: Uuid::new_v4(),
90 age_request_id,
91 owner_id,
92 shares_pct,
93 signed_at: Utc::now(),
94 })
95 }
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
109pub struct AgeRequest {
110 pub id: Uuid,
111 pub organization_id: Uuid,
112 pub building_id: Uuid,
113
114 pub title: String,
116
117 pub description: Option<String>,
119
120 pub status: AgeRequestStatus,
122
123 pub created_by: Uuid,
125
126 pub cosignatories: Vec<AgeRequestCosignatory>,
128
129 pub total_shares_pct: Decimal,
131
132 pub threshold_pct: Decimal,
134
135 pub threshold_reached: bool,
137
138 pub threshold_reached_at: Option<DateTime<Utc>>,
140
141 pub submitted_to_syndic_at: Option<DateTime<Utc>>,
143
144 pub syndic_deadline_at: Option<DateTime<Utc>>,
146
147 pub syndic_response_at: Option<DateTime<Utc>>,
149
150 pub syndic_notes: Option<String>,
152
153 pub auto_convocation_triggered: bool,
155
156 pub meeting_id: Option<Uuid>,
158
159 pub concertation_poll_id: Option<Uuid>,
161
162 pub created_at: DateTime<Utc>,
163 pub updated_at: DateTime<Utc>,
164}
165
166impl AgeRequest {
167 pub const SYNDIC_DEADLINE_DAYS: i64 = 15;
169
170 pub const DEFAULT_THRESHOLD_PCT: Decimal = dec!(0.20);
172
173 pub fn new(
175 organization_id: Uuid,
176 building_id: Uuid,
177 title: String,
178 description: Option<String>,
179 created_by: Uuid,
180 ) -> Result<Self, String> {
181 if title.trim().is_empty() {
182 return Err("Le titre de la demande d'AGE ne peut pas être vide".to_string());
183 }
184 if title.len() > 255 {
185 return Err("Le titre ne peut pas dépasser 255 caractères".to_string());
186 }
187
188 let now = Utc::now();
189 Ok(Self {
190 id: Uuid::new_v4(),
191 organization_id,
192 building_id,
193 title: title.trim().to_string(),
194 description,
195 status: AgeRequestStatus::Draft,
196 created_by,
197 cosignatories: Vec::new(),
198 total_shares_pct: Decimal::ZERO,
199 threshold_pct: Self::DEFAULT_THRESHOLD_PCT,
200 threshold_reached: false,
201 threshold_reached_at: None,
202 submitted_to_syndic_at: None,
203 syndic_deadline_at: None,
204 syndic_response_at: None,
205 syndic_notes: None,
206 auto_convocation_triggered: false,
207 meeting_id: None,
208 concertation_poll_id: None,
209 created_at: now,
210 updated_at: now,
211 })
212 }
213
214 pub fn open(&mut self) -> Result<(), String> {
216 if self.status != AgeRequestStatus::Draft {
217 return Err(format!(
218 "Impossible d'ouvrir une demande en statut {:?}",
219 self.status
220 ));
221 }
222 self.status = AgeRequestStatus::Open;
223 self.updated_at = Utc::now();
224 Ok(())
225 }
226
227 pub fn add_cosignatory(&mut self, owner_id: Uuid, shares_pct: Decimal) -> Result<bool, String> {
230 if self.status != AgeRequestStatus::Draft && self.status != AgeRequestStatus::Open {
231 return Err(format!(
232 "Impossible d'ajouter un cosignataire en statut {:?}",
233 self.status
234 ));
235 }
236
237 if self.cosignatories.iter().any(|c| c.owner_id == owner_id) {
239 return Err("Ce copropriétaire a déjà signé cette demande".to_string());
240 }
241
242 let cosignatory = AgeRequestCosignatory::new(self.id, owner_id, shares_pct)?;
243 self.cosignatories.push(cosignatory);
244
245 self.total_shares_pct = self.cosignatories.iter().map(|c| c.shares_pct).sum();
247 self.updated_at = Utc::now();
248
249 let newly_reached = !self.threshold_reached && self.total_shares_pct >= self.threshold_pct;
251
252 if newly_reached {
253 self.threshold_reached = true;
254 self.threshold_reached_at = Some(Utc::now());
255 self.status = AgeRequestStatus::Reached;
256 }
257
258 Ok(newly_reached)
259 }
260
261 pub fn remove_cosignatory(&mut self, owner_id: Uuid) -> Result<(), String> {
263 if self.status != AgeRequestStatus::Draft
264 && self.status != AgeRequestStatus::Open
265 && self.status != AgeRequestStatus::Reached
266 {
267 return Err(format!(
268 "Impossible de retirer un cosignataire en statut {:?}",
269 self.status
270 ));
271 }
272
273 let before_len = self.cosignatories.len();
274 self.cosignatories.retain(|c| c.owner_id != owner_id);
275
276 if self.cosignatories.len() == before_len {
277 return Err("Ce copropriétaire n'a pas signé cette demande".to_string());
278 }
279
280 self.total_shares_pct = self.cosignatories.iter().map(|c| c.shares_pct).sum();
282 self.updated_at = Utc::now();
283
284 if self.threshold_reached && self.total_shares_pct < self.threshold_pct {
286 self.threshold_reached = false;
287 self.threshold_reached_at = None;
288 self.status = AgeRequestStatus::Open; }
290
291 Ok(())
292 }
293
294 pub fn submit_to_syndic(&mut self) -> Result<(), String> {
296 if self.status != AgeRequestStatus::Reached {
297 return Err(format!(
298 "La demande doit être en statut Reached pour être soumise (statut actuel: {:?}). \
299 Le seuil d'1/5 des quotes-parts doit être atteint.",
300 self.status
301 ));
302 }
303
304 let now = Utc::now();
305 self.status = AgeRequestStatus::Submitted;
306 self.submitted_to_syndic_at = Some(now);
307 self.syndic_deadline_at = Some(now + Duration::days(Self::SYNDIC_DEADLINE_DAYS));
308 self.updated_at = now;
309 Ok(())
310 }
311
312 pub fn accept_by_syndic(&mut self, notes: Option<String>) -> Result<(), String> {
314 if self.status != AgeRequestStatus::Submitted {
315 return Err(format!(
316 "La demande doit être en statut Submitted pour être acceptée (statut actuel: {:?})",
317 self.status
318 ));
319 }
320 let now = Utc::now();
321 self.status = AgeRequestStatus::Accepted;
322 self.syndic_response_at = Some(now);
323 self.syndic_notes = notes;
324 self.updated_at = now;
325 Ok(())
326 }
327
328 pub fn reject_by_syndic(&mut self, reason: String) -> Result<(), String> {
330 if self.status != AgeRequestStatus::Submitted {
331 return Err(format!(
332 "La demande doit être en statut Submitted pour être rejetée (statut actuel: {:?})",
333 self.status
334 ));
335 }
336 if reason.trim().is_empty() {
337 return Err("Un motif de refus est obligatoire".to_string());
338 }
339 let now = Utc::now();
340 self.status = AgeRequestStatus::Rejected;
341 self.syndic_response_at = Some(now);
342 self.syndic_notes = Some(reason);
343 self.updated_at = now;
344 Ok(())
345 }
346
347 pub fn trigger_auto_convocation(&mut self) -> Result<(), String> {
349 if self.status != AgeRequestStatus::Submitted {
350 return Err(format!(
351 "La demande doit être en statut Submitted (statut actuel: {:?})",
352 self.status
353 ));
354 }
355
356 if let Some(deadline) = self.syndic_deadline_at {
358 if Utc::now() < deadline {
359 return Err(format!(
360 "Le délai syndic n'est pas encore dépassé (expire le {})",
361 deadline.format("%d/%m/%Y")
362 ));
363 }
364 }
365
366 self.status = AgeRequestStatus::Expired;
367 self.auto_convocation_triggered = true;
368 self.updated_at = Utc::now();
369 Ok(())
370 }
371
372 pub fn withdraw(&mut self, requester_id: Uuid) -> Result<(), String> {
374 if self.status.is_terminal() {
375 return Err(format!(
376 "Impossible de retirer une demande en statut {:?}",
377 self.status
378 ));
379 }
380 if self.created_by != requester_id {
382 return Err("Seul l'initiateur peut retirer cette demande".to_string());
383 }
384 self.status = AgeRequestStatus::Withdrawn;
385 self.updated_at = Utc::now();
386 Ok(())
387 }
388
389 pub fn set_meeting(&mut self, meeting_id: Uuid) {
391 self.meeting_id = Some(meeting_id);
392 self.updated_at = Utc::now();
393 }
394
395 pub fn set_concertation_poll(&mut self, poll_id: Uuid) {
397 self.concertation_poll_id = Some(poll_id);
398 self.updated_at = Utc::now();
399 }
400
401 pub fn is_deadline_expired(&self) -> bool {
403 self.syndic_deadline_at
404 .map(|d| Utc::now() > d)
405 .unwrap_or(false)
406 }
407
408 pub fn shares_pct_missing(&self) -> Decimal {
410 if self.threshold_reached {
411 Decimal::ZERO
412 } else {
413 (self.threshold_pct - self.total_shares_pct).max(Decimal::ZERO)
414 }
415 }
416
417 pub fn calculate_progress_percentage(&self, _building_total_shares: f64) -> f64 {
428 use rust_decimal::prelude::ToPrimitive;
429 if self.threshold_pct == Decimal::ZERO {
432 return 0.0;
433 }
434 let progress = (self.total_shares_pct / self.threshold_pct) * dec!(100);
435 progress.min(dec!(100)).to_f64().unwrap_or(0.0)
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442
443 fn make_request() -> AgeRequest {
444 AgeRequest::new(
445 Uuid::new_v4(),
446 Uuid::new_v4(),
447 "Remplacement toiture - AGE urgente".to_string(),
448 Some("La toiture présente des infiltrations importantes.".to_string()),
449 Uuid::new_v4(),
450 )
451 .unwrap()
452 }
453
454 #[test]
455 fn test_new_age_request_is_draft() {
456 let req = make_request();
457 assert_eq!(req.status, AgeRequestStatus::Draft);
458 assert_eq!(req.total_shares_pct, Decimal::ZERO);
459 assert!(!req.threshold_reached);
460 assert_eq!(req.threshold_pct, AgeRequest::DEFAULT_THRESHOLD_PCT);
461 assert!(req.cosignatories.is_empty());
462 }
463
464 #[test]
465 fn test_empty_title_rejected() {
466 let result = AgeRequest::new(
467 Uuid::new_v4(),
468 Uuid::new_v4(),
469 " ".to_string(),
470 None,
471 Uuid::new_v4(),
472 );
473 assert!(result.is_err());
474 }
475
476 #[test]
477 fn test_open_transitions_from_draft() {
478 let mut req = make_request();
479 req.open().unwrap();
480 assert_eq!(req.status, AgeRequestStatus::Open);
481 }
482
483 #[test]
484 fn test_open_fails_if_not_draft() {
485 let mut req = make_request();
486 req.status = AgeRequestStatus::Reached;
487 assert!(req.open().is_err());
488 }
489
490 #[test]
491 fn test_add_cosignatory_accumulates_shares() {
492 let mut req = make_request();
493 req.open().unwrap();
494
495 let owner1 = Uuid::new_v4();
496 let newly_reached = req.add_cosignatory(owner1, dec!(0.10)).unwrap();
497 assert!(!newly_reached);
498 assert_eq!(req.total_shares_pct, dec!(0.10));
499 assert_eq!(req.status, AgeRequestStatus::Open);
500 }
501
502 #[test]
503 fn test_threshold_reached_at_20_percent() {
504 let mut req = make_request();
505 req.open().unwrap();
506
507 let o1 = Uuid::new_v4();
509 let reached = req.add_cosignatory(o1, dec!(0.10)).unwrap();
510 assert!(!reached);
511 assert_eq!(req.status, AgeRequestStatus::Open);
512
513 let o2 = Uuid::new_v4();
515 let reached = req.add_cosignatory(o2, dec!(0.12)).unwrap();
516 assert!(reached);
517 assert_eq!(req.status, AgeRequestStatus::Reached);
518 assert!(req.threshold_reached);
519 assert!(req.threshold_reached_at.is_some());
520 assert_eq!(req.total_shares_pct, dec!(0.22));
521 }
522
523 #[test]
524 fn test_duplicate_cosignatory_rejected() {
525 let mut req = make_request();
526 req.open().unwrap();
527 let owner = Uuid::new_v4();
528 req.add_cosignatory(owner, dec!(0.10)).unwrap();
529 let result = req.add_cosignatory(owner, dec!(0.05));
530 assert!(result.is_err());
531 }
532
533 #[test]
534 fn test_remove_cosignatory_reverts_status() {
535 let mut req = make_request();
536 req.open().unwrap();
537
538 let o1 = Uuid::new_v4();
539 let o2 = Uuid::new_v4();
540 req.add_cosignatory(o1, dec!(0.15)).unwrap();
541 req.add_cosignatory(o2, dec!(0.10)).unwrap(); assert_eq!(req.status, AgeRequestStatus::Reached);
544
545 req.remove_cosignatory(o2).unwrap();
547 assert_eq!(req.status, AgeRequestStatus::Open);
548 assert!(!req.threshold_reached);
549 }
550
551 #[test]
552 fn test_submit_to_syndic() {
553 let mut req = make_request();
554 req.open().unwrap();
555 let o1 = Uuid::new_v4();
556 req.add_cosignatory(o1, dec!(0.25)).unwrap(); req.submit_to_syndic().unwrap();
559 assert_eq!(req.status, AgeRequestStatus::Submitted);
560 assert!(req.submitted_to_syndic_at.is_some());
561 assert!(req.syndic_deadline_at.is_some());
562
563 let diff = req.syndic_deadline_at.unwrap() - req.submitted_to_syndic_at.unwrap();
565 assert_eq!(diff.num_days(), AgeRequest::SYNDIC_DEADLINE_DAYS);
566 }
567
568 #[test]
569 fn test_submit_fails_if_not_reached() {
570 let mut req = make_request();
571 req.open().unwrap();
572 assert!(req.submit_to_syndic().is_err());
574 }
575
576 #[test]
577 fn test_accept_by_syndic() {
578 let mut req = make_request();
579 req.open().unwrap();
580 req.add_cosignatory(Uuid::new_v4(), dec!(0.25)).unwrap();
581 req.submit_to_syndic().unwrap();
582 req.accept_by_syndic(Some("Convocation dans 3 semaines".to_string()))
583 .unwrap();
584 assert_eq!(req.status, AgeRequestStatus::Accepted);
585 assert!(req.syndic_response_at.is_some());
586 }
587
588 #[test]
589 fn test_reject_requires_reason() {
590 let mut req = make_request();
591 req.open().unwrap();
592 req.add_cosignatory(Uuid::new_v4(), dec!(0.25)).unwrap();
593 req.submit_to_syndic().unwrap();
594 assert!(req.reject_by_syndic(" ".to_string()).is_err());
595 req.reject_by_syndic("Demande insuffisamment motivée".to_string())
596 .unwrap();
597 assert_eq!(req.status, AgeRequestStatus::Rejected);
598 }
599
600 #[test]
601 fn test_withdraw_by_initiator_only() {
602 let mut req = make_request();
603 req.open().unwrap();
604
605 let other = Uuid::new_v4();
606 assert!(req.withdraw(other).is_err());
607
608 let initiator = req.created_by;
609 req.withdraw(initiator).unwrap();
610 assert_eq!(req.status, AgeRequestStatus::Withdrawn);
611 }
612
613 #[test]
614 fn test_shares_pct_missing() {
615 let mut req = make_request();
616 req.open().unwrap();
617
618 assert_eq!(req.shares_pct_missing(), dec!(0.20));
620
621 req.add_cosignatory(Uuid::new_v4(), dec!(0.12)).unwrap();
622 assert_eq!(req.shares_pct_missing(), dec!(0.08));
624
625 req.add_cosignatory(Uuid::new_v4(), dec!(0.10)).unwrap();
626 assert_eq!(req.shares_pct_missing(), Decimal::ZERO);
628 }
629
630 #[test]
631 fn test_status_is_terminal() {
632 assert!(AgeRequestStatus::Accepted.is_terminal());
633 assert!(AgeRequestStatus::Expired.is_terminal());
634 assert!(AgeRequestStatus::Rejected.is_terminal());
635 assert!(AgeRequestStatus::Withdrawn.is_terminal());
636 assert!(!AgeRequestStatus::Draft.is_terminal());
637 assert!(!AgeRequestStatus::Open.is_terminal());
638 assert!(!AgeRequestStatus::Reached.is_terminal());
639 assert!(!AgeRequestStatus::Submitted.is_terminal());
640 }
641
642 #[test]
643 fn test_calculate_progress_percentage() {
644 let mut req = make_request();
645 req.open().unwrap();
646
647 assert_eq!(req.calculate_progress_percentage(1.0), 0.0);
649
650 req.add_cosignatory(Uuid::new_v4(), dec!(0.05)).unwrap();
652 assert!((req.calculate_progress_percentage(1.0) - 25.0).abs() < 1e-9);
653
654 req.add_cosignatory(Uuid::new_v4(), dec!(0.05)).unwrap();
656 assert!((req.calculate_progress_percentage(1.0) - 50.0).abs() < 1e-9);
657
658 req.add_cosignatory(Uuid::new_v4(), dec!(0.10)).unwrap();
660 assert_eq!(req.calculate_progress_percentage(1.0), 100.0);
661 }
662}