1use chrono::{DateTime, Utc};
24use rust_decimal::Decimal;
25use serde::{Deserialize, Serialize};
26use uuid::Uuid;
27
28use crate::domain::copropriete::resolution::{MajorityType, ResolutionStatus};
29
30#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
33#[serde(rename_all = "snake_case")]
34pub enum FundKind {
35 WorkingCapital,
37 Reserve,
40 Earmarked,
43}
44
45#[derive(Debug, Clone, PartialEq)]
48pub enum FundError {
49 EmptyName,
51 EarmarkedRequiresPurpose,
53 EarmarkedRequiresPositiveTarget,
55 NonEarmarkedCannotHavePurpose,
57 NonEarmarkedCannotHaveTarget,
59 NonPositiveContribution,
61 ExpensePurposeMismatch {
63 fund_purpose: String,
64 expense_work_ref: String,
65 },
66 InsufficientMajorityForCreation {
69 required: MajorityType,
70 used: MajorityType,
71 },
72 OnlyEarmarkedFundsCanBeReassigned,
74 ReassignmentRequiresAdoptedResolution,
76 NonPositiveReassignmentAmount,
78 ReassignmentAmountExceedsBalance,
80}
81
82impl std::fmt::Display for FundError {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 match self {
85 Self::EmptyName => write!(f, "Le nom du fonds ne peut pas être vide"),
86 Self::EarmarkedRequiresPurpose => write!(
87 f,
88 "Un fonds affecté doit porter un objet précis (travail ciblé)"
89 ),
90 Self::EarmarkedRequiresPositiveTarget => write!(
91 f,
92 "Un fonds affecté doit porter un objectif d'épargne strictement positif"
93 ),
94 Self::NonEarmarkedCannotHavePurpose => write!(
95 f,
96 "Seul un fonds affecté peut porter un objet précis \
97 (roulement/réserve n'en portent pas)"
98 ),
99 Self::NonEarmarkedCannotHaveTarget => write!(
100 f,
101 "Seul un fonds affecté peut porter un objectif d'épargne \
102 (roulement/réserve n'en portent pas)"
103 ),
104 Self::NonPositiveContribution => {
105 write!(f, "Un versement au fonds doit être strictement positif")
106 }
107 Self::ExpensePurposeMismatch {
108 fund_purpose,
109 expense_work_ref,
110 } => write!(
111 f,
112 "Dépense refusée : le fonds est affecté à « {fund_purpose} », \
113 pas à « {expense_work_ref} »"
114 ),
115 Self::InsufficientMajorityForCreation { required, used } => write!(
116 f,
117 "La création d'un fonds affecté exige la majorité {required:?} \
118 (Art. 3.88, gros travaux) ; majorité obtenue : {used:?}"
119 ),
120 Self::OnlyEarmarkedFundsCanBeReassigned => write!(
121 f,
122 "Seul un fonds affecté peut être réaffecté (le fonds de réserve \
123 légal reste gouverné par sa propre règle de renonciation 4/5)"
124 ),
125 Self::ReassignmentRequiresAdoptedResolution => write!(
126 f,
127 "La réaffectation d'un fonds exige une décision d'AG adoptée"
128 ),
129 Self::NonPositiveReassignmentAmount => {
130 write!(f, "Le montant réaffecté doit être strictement positif")
131 }
132 Self::ReassignmentAmountExceedsBalance => {
133 write!(f, "Le montant réaffecté dépasse le solde du fonds")
134 }
135 }
136 }
137}
138
139impl std::error::Error for FundError {}
140
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
143pub struct Fund {
144 pub id: Uuid,
145 pub acp_id: Uuid,
146 pub kind: FundKind,
147 pub name: String,
148 pub purpose: Option<String>,
150 pub target_amount: Option<Decimal>,
152 pub balance: Decimal,
153 pub created_at: DateTime<Utc>,
154 pub updated_at: DateTime<Utc>,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
160pub struct FundReassignment {
161 pub id: Uuid,
162 pub fund_id: Uuid,
163 pub previous_purpose: String,
164 pub new_purpose: String,
165 pub amount: Decimal,
166 pub resolution_id: Uuid,
168 pub reassigned_at: DateTime<Utc>,
169}
170
171impl Fund {
172 pub fn new(
175 acp_id: Uuid,
176 kind: FundKind,
177 name: String,
178 purpose: Option<String>,
179 target_amount: Option<Decimal>,
180 ) -> Result<Self, FundError> {
181 let name = name.trim().to_string();
182 if name.is_empty() {
183 return Err(FundError::EmptyName);
184 }
185
186 match kind {
187 FundKind::Earmarked => {
188 let purpose_trimmed = purpose.as_deref().unwrap_or("").trim().to_string();
189 if purpose_trimmed.is_empty() {
190 return Err(FundError::EarmarkedRequiresPurpose);
191 }
192 match target_amount {
193 Some(t) if t > Decimal::ZERO => {}
194 _ => return Err(FundError::EarmarkedRequiresPositiveTarget),
195 }
196 }
197 FundKind::WorkingCapital | FundKind::Reserve => {
198 if purpose.is_some() {
199 return Err(FundError::NonEarmarkedCannotHavePurpose);
200 }
201 if target_amount.is_some() {
202 return Err(FundError::NonEarmarkedCannotHaveTarget);
203 }
204 }
205 }
206
207 let now = Utc::now();
208 Ok(Self {
209 id: Uuid::new_v4(),
210 acp_id,
211 kind,
212 name,
213 purpose,
214 target_amount,
215 balance: Decimal::ZERO,
216 created_at: now,
217 updated_at: now,
218 })
219 }
220
221 pub fn new_earmarked(
228 acp_id: Uuid,
229 name: String,
230 purpose: String,
231 target_amount: Decimal,
232 majority_used: MajorityType,
233 ) -> Result<Self, FundError> {
234 if !majority_used.satisfait(&MajorityType::TwoThirds) {
235 return Err(FundError::InsufficientMajorityForCreation {
236 required: MajorityType::TwoThirds,
237 used: majority_used,
238 });
239 }
240 Self::new(
241 acp_id,
242 FundKind::Earmarked,
243 name,
244 Some(purpose),
245 Some(target_amount),
246 )
247 }
248
249 pub fn contribute(&mut self, amount: Decimal) -> Result<(), FundError> {
252 if amount <= Decimal::ZERO {
253 return Err(FundError::NonPositiveContribution);
254 }
255 self.balance += amount;
256 self.updated_at = Utc::now();
257 Ok(())
258 }
259
260 pub fn progress(&self) -> Option<Decimal> {
264 self.target_amount
265 .filter(|t| *t > Decimal::ZERO)
266 .map(|t| self.balance / t)
267 }
268
269 pub fn reliquat(&self) -> Option<Decimal> {
275 let target = self.target_amount?;
276 (self.balance > target).then_some(self.balance - target)
277 }
278
279 pub fn assert_expense_matches_purpose(
288 &self,
289 expense_work_ref: &str,
290 ag_override: Option<ResolutionStatus>,
291 ) -> Result<(), FundError> {
292 if ag_override == Some(ResolutionStatus::Adopted) {
293 return Ok(());
294 }
295 if self.kind != FundKind::Earmarked {
296 return Ok(());
297 }
298 match &self.purpose {
299 Some(purpose) if purpose == expense_work_ref => Ok(()),
300 Some(purpose) => Err(FundError::ExpensePurposeMismatch {
301 fund_purpose: purpose.clone(),
302 expense_work_ref: expense_work_ref.to_string(),
303 }),
304 None => Ok(()),
307 }
308 }
309
310 pub fn reassign(
321 &mut self,
322 new_purpose: String,
323 amount: Option<Decimal>,
324 resolution_id: Uuid,
325 resolution_status: ResolutionStatus,
326 ) -> Result<FundReassignment, FundError> {
327 if self.kind != FundKind::Earmarked {
328 return Err(FundError::OnlyEarmarkedFundsCanBeReassigned);
329 }
330 if resolution_status != ResolutionStatus::Adopted {
331 return Err(FundError::ReassignmentRequiresAdoptedResolution);
332 }
333 let moved = amount.unwrap_or(self.balance);
334 if moved <= Decimal::ZERO {
335 return Err(FundError::NonPositiveReassignmentAmount);
336 }
337 if moved > self.balance {
338 return Err(FundError::ReassignmentAmountExceedsBalance);
339 }
340
341 let previous_purpose = self.purpose.clone().unwrap_or_default();
342 self.balance -= moved;
343
344 if self.balance == Decimal::ZERO {
348 self.purpose = Some(new_purpose.clone());
349 }
350 self.updated_at = Utc::now();
351
352 Ok(FundReassignment {
353 id: Uuid::new_v4(),
354 fund_id: self.id,
355 previous_purpose,
356 new_purpose,
357 amount: moved,
358 resolution_id,
359 reassigned_at: Utc::now(),
360 })
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 use rust_decimal_macros::dec;
368
369 fn earmarked(name: &str, purpose: &str, target: Decimal) -> Fund {
370 Fund::new_earmarked(
371 Uuid::new_v4(),
372 name.to_string(),
373 purpose.to_string(),
374 target,
375 MajorityType::TwoThirds,
376 )
377 .expect("fonds affecté valide")
378 }
379
380 #[test]
385 fn happy_fonds_affecte_progresse_et_reste_distinct() {
386 let acp_id = Uuid::new_v4();
387 let mut toiture = Fund::new_earmarked(
388 acp_id,
389 "Réfection toiture".to_string(),
390 "Réfection toiture".to_string(),
391 dec!(50000),
392 MajorityType::TwoThirds,
393 )
394 .expect("vote valide");
395 let mut roulement = Fund::new(
396 acp_id,
397 FundKind::WorkingCapital,
398 "Roulement".to_string(),
399 None,
400 None,
401 )
402 .unwrap();
403 let mut reserve = Fund::new(
404 acp_id,
405 FundKind::Reserve,
406 "Réserve légale".to_string(),
407 None,
408 None,
409 )
410 .unwrap();
411
412 toiture.contribute(dec!(12500)).unwrap();
413 roulement.contribute(dec!(3000)).unwrap();
414 reserve.contribute(dec!(7000)).unwrap();
415
416 assert_eq!(toiture.balance, dec!(12500));
417 assert_eq!(toiture.progress(), Some(dec!(0.25)));
418 assert_eq!(roulement.balance, dec!(3000));
420 assert_eq!(reserve.balance, dec!(7000));
421 assert_ne!(toiture.balance, roulement.balance);
422 assert_ne!(toiture.balance, reserve.balance);
423 }
424
425 #[test]
427 fn happy_reliquat_apres_objectif_atteint() {
428 let mut fonds = earmarked("Façade", "Ravalement façade", dec!(10000));
429 fonds.contribute(dec!(11500)).unwrap();
430 assert_eq!(fonds.reliquat(), Some(dec!(1500)));
431 }
432
433 #[test]
436 fn happy_reassignation_par_decision_ag_adoptee() {
437 let mut fonds = earmarked("Ascenseur", "Remplacement ascenseur", dec!(20000));
438 fonds.contribute(dec!(20000)).unwrap();
439 let resolution_id = Uuid::new_v4();
440
441 let audit = fonds
442 .reassign(
443 "Ravalement façade".to_string(),
444 None,
445 resolution_id,
446 ResolutionStatus::Adopted,
447 )
448 .expect("réaffectation autorisée par l'AG");
449
450 assert_eq!(audit.fund_id, fonds.id);
451 assert_eq!(audit.previous_purpose, "Remplacement ascenseur");
452 assert_eq!(audit.new_purpose, "Ravalement façade");
453 assert_eq!(audit.amount, dec!(20000));
454 assert_eq!(audit.resolution_id, resolution_id);
455 assert_eq!(fonds.purpose, Some("Ravalement façade".to_string()));
456 assert_eq!(fonds.balance, Decimal::ZERO);
457 }
458
459 #[test]
464 fn negative_depense_hors_objet_est_refusee() {
465 let fonds = earmarked("Toiture", "Réfection toiture", dec!(50000));
466 let erreur = fonds
467 .assert_expense_matches_purpose("Ravalement façade", None)
468 .expect_err("doit refuser");
469 assert_eq!(
470 erreur,
471 FundError::ExpensePurposeMismatch {
472 fund_purpose: "Réfection toiture".to_string(),
473 expense_work_ref: "Ravalement façade".to_string(),
474 }
475 );
476 }
477
478 #[test]
479 fn negative_reassignation_sans_resolution_adoptee_refusee() {
480 let mut fonds = earmarked("Toiture", "Réfection toiture", dec!(50000));
481 fonds.contribute(dec!(1000)).unwrap();
482
483 for statut_non_adopte in [ResolutionStatus::Pending, ResolutionStatus::Rejected] {
484 let erreur = fonds
485 .clone()
486 .reassign(
487 "Autre objet".to_string(),
488 None,
489 Uuid::new_v4(),
490 statut_non_adopte,
491 )
492 .expect_err("doit refuser sans décision adoptée");
493 assert_eq!(erreur, FundError::ReassignmentRequiresAdoptedResolution);
494 }
495 }
496
497 #[test]
498 fn negative_contribution_non_positive_refusee() {
499 let mut fonds = Fund::new(
500 Uuid::new_v4(),
501 FundKind::WorkingCapital,
502 "Roulement".to_string(),
503 None,
504 None,
505 )
506 .unwrap();
507 assert_eq!(
508 fonds.contribute(Decimal::ZERO).unwrap_err(),
509 FundError::NonPositiveContribution
510 );
511 assert_eq!(
512 fonds.contribute(dec!(-1)).unwrap_err(),
513 FundError::NonPositiveContribution
514 );
515 }
516
517 #[test]
524 fn edge_fonds_de_reserve_legal_reste_distinct_et_sans_objet() {
525 let reserve = Fund::new(
526 Uuid::new_v4(),
527 FundKind::Reserve,
528 "Réserve légale".to_string(),
529 None,
530 None,
531 )
532 .expect("fonds de réserve valide, sans vote qualifié requis ici");
533 assert_eq!(reserve.kind, FundKind::Reserve);
534 assert_eq!(reserve.purpose, None);
535 assert_eq!(reserve.target_amount, None);
536
537 let refuse_avec_objet = Fund::new(
538 Uuid::new_v4(),
539 FundKind::Reserve,
540 "Réserve légale".to_string(),
541 Some("Chantier".to_string()),
542 None,
543 )
544 .expect_err("la réserve légale ne porte pas d'objet de thésaurisation");
545 assert_eq!(refuse_avec_objet, FundError::NonEarmarkedCannotHavePurpose);
546 }
547
548 #[test]
550 fn edge_reassignation_partielle_laisse_un_reliquat_sous_lancien_objet() {
551 let mut fonds = earmarked("Toiture", "Réfection toiture", dec!(50000));
552 fonds.contribute(dec!(30000)).unwrap();
553
554 let audit = fonds
555 .reassign(
556 "Ravalement façade".to_string(),
557 Some(dec!(10000)),
558 Uuid::new_v4(),
559 ResolutionStatus::Adopted,
560 )
561 .expect("réaffectation partielle autorisée");
562
563 assert_eq!(audit.amount, dec!(10000));
564 assert_eq!(fonds.balance, dec!(20000));
565 assert_eq!(fonds.purpose, Some("Réfection toiture".to_string()));
567 }
568
569 #[test]
570 fn edge_reliquat_absent_avant_objectif_atteint() {
571 let mut fonds = earmarked("Toiture", "Réfection toiture", dec!(50000));
572 fonds.contribute(dec!(10000)).unwrap();
573 assert_eq!(fonds.reliquat(), None);
574 }
575
576 #[test]
581 fn security_creation_fonds_affecte_exige_majorite_des_deux_tiers() {
582 let refus = Fund::new_earmarked(
583 Uuid::new_v4(),
584 "Toiture".to_string(),
585 "Réfection toiture".to_string(),
586 dec!(50000),
587 MajorityType::Absolute,
588 )
589 .expect_err("la majorité simple ne suffit pas");
590 assert_eq!(
591 refus,
592 FundError::InsufficientMajorityForCreation {
593 required: MajorityType::TwoThirds,
594 used: MajorityType::Absolute,
595 }
596 );
597
598 for majorite_suffisante in [
599 MajorityType::TwoThirds,
600 MajorityType::FourFifths,
601 MajorityType::Unanimity,
602 ] {
603 assert!(Fund::new_earmarked(
604 Uuid::new_v4(),
605 "Toiture".to_string(),
606 "Réfection toiture".to_string(),
607 dec!(50000),
608 majorite_suffisante,
609 )
610 .is_ok());
611 }
612 }
613
614 #[test]
617 fn security_ag_override_autorise_une_depense_hors_objet() {
618 let fonds = earmarked("Toiture", "Réfection toiture", dec!(50000));
619 let resultat = fonds
620 .assert_expense_matches_purpose("Ravalement façade", Some(ResolutionStatus::Adopted));
621 assert!(resultat.is_ok());
622
623 let refuse = fonds
625 .assert_expense_matches_purpose("Ravalement façade", Some(ResolutionStatus::Pending))
626 .expect_err("doit refuser sans décision adoptée");
627 assert!(matches!(refuse, FundError::ExpensePurposeMismatch { .. }));
628 }
629
630 #[test]
634 fn security_seul_un_fonds_affecte_peut_etre_reassigne() {
635 let mut roulement = Fund::new(
636 Uuid::new_v4(),
637 FundKind::WorkingCapital,
638 "Roulement".to_string(),
639 None,
640 None,
641 )
642 .unwrap();
643 let erreur = roulement
644 .reassign(
645 "Autre objet".to_string(),
646 None,
647 Uuid::new_v4(),
648 ResolutionStatus::Adopted,
649 )
650 .expect_err("le roulement n'est pas réaffectable");
651 assert_eq!(erreur, FundError::OnlyEarmarkedFundsCanBeReassigned);
652 }
653}