Skip to main content

koprogo_api/domain/comptabilite/
call_for_funds.rs

1// Domain Entity: Call for Funds (Appel de Fonds)
2//
3// Represents a collective payment request sent by the Syndic to all owners
4// This is the "master" entity that generates individual OwnerContribution records
5//
6// MONETARY: total_amount uses rust_decimal::Decimal (cf. ADR-0007).
7
8use chrono::{DateTime, Utc};
9use rust_decimal::Decimal;
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13use super::ContributionType;
14
15/// Status of the call for funds
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17#[serde(rename_all = "lowercase")]
18pub enum CallForFundsStatus {
19    /// Draft - not yet sent
20    Draft,
21    /// Sent to owners
22    Sent,
23    /// Partially paid
24    Partial,
25    /// Fully paid by all owners
26    Completed,
27    /// Cancelled
28    Cancelled,
29}
30
31/// Call for Funds (Appel de Fonds Collectif)
32///
33/// Represents a payment request sent by the Syndic to all owners of a building
34/// Automatically generates individual OwnerContribution records based on ownership percentages
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
36pub struct CallForFunds {
37    pub id: Uuid,
38
39    /// L'ACP au nom de laquelle les fonds sont appelés.
40    ///
41    /// Art. 3.86 § 3 : le patrimoine de l'ACP « est constitué par des apports
42    /// périodiques des copropriétaires décidés par l'assemblée générale ». Le
43    /// syndic lance l'appel, l'ACP en est créancière. Le mandat s'éteint, la
44    /// créance reste. Cf. ADR-0045.
45    pub acp_id: Uuid,
46
47    /// Le syndic qui a lancé l'appel, conservé comme trace d'auteur.
48    ///
49    /// N'entre dans aucun prédicat d'autorisation.
50    pub organization_id: Uuid,
51    pub building_id: Uuid,
52
53    // Description
54    pub title: String,
55    pub description: String,
56
57    // Financial details
58    pub total_amount: Decimal, // Total amount to be collected from ALL owners
59
60    /// Part du montant appelé qui alimentera le fonds de réserve.
61    ///
62    /// Art. 3.86 § 3, alinéa 7 : « Le syndic communique à toutes les parties
63    /// concernées **lors de l'appel de fonds** quelle part sera affectée au
64    /// fonds de réserve. »
65    ///
66    /// L'obligation n'est pas d'affichage : le fonds de réserve est la part
67    /// qu'un copropriétaire **ne récupère pas** en vendant son lot (elle suit
68    /// le lot, pas le vendeur). Il doit donc savoir ce qu'il y verse au moment
69    /// où on le lui réclame, et non le découvrir à la mutation.
70    ///
71    /// Zéro est licite et explicite : toutes les charges n'alimentent pas le
72    /// fonds. Ce qui ne l'est pas, c'est de ne rien dire.
73    pub reserve_fund_share: Decimal,
74
75    // Type
76    pub contribution_type: ContributionType,
77
78    // Dates
79    pub call_date: DateTime<Utc>,         // When the call is issued
80    pub due_date: DateTime<Utc>,          // Payment deadline
81    pub sent_date: Option<DateTime<Utc>>, // When actually sent to owners
82
83    // Status
84    pub status: CallForFundsStatus,
85
86    // Accounting
87    pub account_code: Option<String>, // PCMN code (classe 7)
88
89    // Metadata
90    pub notes: Option<String>,
91    pub created_at: DateTime<Utc>,
92    pub updated_at: DateTime<Utc>,
93    pub created_by: Option<Uuid>,
94
95    /// Le fonds alimenté par cet appel (roulement/réserve/affecté).
96    ///
97    /// Issue #635 — posé après construction (à l'image de `created_by`),
98    /// jamais comme argument de constructeur : aucune des dizaines de sites
99    /// d'appel existants de `CallForFunds::new` n'a besoin de connaître le
100    /// fonds au moment de la création de l'appel.
101    pub fund_id: Option<Uuid>,
102}
103
104/// Domain-typed validation error for calls for funds (appel de fonds).
105///
106/// Pure domain type — no infra/application dependency (hexagonal purity).
107/// Précédent `JournalEntryError`/`ChargeDistributionError` (#433 / WP-A6
108/// EXP-008) → 400 validation, jamais 500 Internal.
109#[derive(Debug, Clone, PartialEq)]
110pub enum CallForFundsError {
111    /// Montant total non strictement positif.
112    NonPositiveTotalAmount,
113    /// Titre vide.
114    EmptyTitle,
115    /// Description vide.
116    EmptyDescription,
117    /// Date d'échéance ≤ date d'appel (fenêtre de paiement invalide).
118    DueDateNotAfterCallDate,
119    /// Part affectée au fonds de réserve négative (Art. 3.86 § 3 al. 7).
120    NegativeReserveShare,
121    /// Part affectée au fonds de réserve supérieure au montant appelé.
122    ReserveShareExceedsTotal,
123}
124
125impl std::fmt::Display for CallForFundsError {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        match self {
128            Self::NegativeReserveShare => write!(
129                f,
130                "La part affectée au fonds de réserve ne peut pas être négative (Art. 3.86 § 3)"
131            ),
132            Self::ReserveShareExceedsTotal => write!(
133                f,
134                "La part affectée au fonds de réserve dépasse le montant appelé (Art. 3.86 § 3)"
135            ),
136            Self::NonPositiveTotalAmount => write!(f, "Total amount must be positive"),
137            Self::EmptyTitle => write!(f, "Title cannot be empty"),
138            Self::EmptyDescription => write!(f, "Description cannot be empty"),
139            Self::DueDateNotAfterCallDate => {
140                write!(f, "Due date must be after call date")
141            }
142        }
143    }
144}
145
146impl std::error::Error for CallForFundsError {}
147
148/// Bridge : use-cases/ports `Result<_, String>` inchangés (cascade
149/// String→AppError = slice large différée, précédent WP-A3/A4/A5).
150impl From<CallForFundsError> for String {
151    fn from(e: CallForFundsError) -> String {
152        e.to_string()
153    }
154}
155
156impl CallForFunds {
157    #[allow(clippy::too_many_arguments)]
158    pub fn new(
159        acp_id: Uuid,
160        organization_id: Uuid,
161        building_id: Uuid,
162        title: String,
163        description: String,
164        total_amount: Decimal,
165        contribution_type: ContributionType,
166        call_date: DateTime<Utc>,
167        due_date: DateTime<Utc>,
168        account_code: Option<String>,
169        reserve_fund_share: Decimal,
170    ) -> Result<Self, CallForFundsError> {
171        // Validate total amount is positive
172        if total_amount <= Decimal::ZERO {
173            return Err(CallForFundsError::NonPositiveTotalAmount);
174        }
175
176        // Validate title
177        if title.trim().is_empty() {
178            return Err(CallForFundsError::EmptyTitle);
179        }
180
181        // Validate description
182        if description.trim().is_empty() {
183            return Err(CallForFundsError::EmptyDescription);
184        }
185
186        // Validate dates
187        if due_date <= call_date {
188            return Err(CallForFundsError::DueDateNotAfterCallDate);
189        }
190
191        // Art. 3.86 § 3 al. 7 — la part de réserve est bornée par l'appel.
192        // Une part négative retirerait du fonds de réserve par un appel de
193        // fonds, sans décision d'assemblée ; une part supérieure au total
194        // affecterait de l'argent qu'on ne réclame pas.
195        if reserve_fund_share < Decimal::ZERO {
196            return Err(CallForFundsError::NegativeReserveShare);
197        }
198        if reserve_fund_share > total_amount {
199            return Err(CallForFundsError::ReserveShareExceedsTotal);
200        }
201
202        Ok(Self {
203            id: Uuid::new_v4(),
204            acp_id,
205            organization_id,
206            building_id,
207            title,
208            description,
209            total_amount,
210            reserve_fund_share,
211            contribution_type,
212            call_date,
213            due_date,
214            sent_date: None,
215            status: CallForFundsStatus::Draft,
216            account_code,
217            notes: None,
218            created_at: Utc::now(),
219            updated_at: Utc::now(),
220            created_by: None,
221            fund_id: None,
222        })
223    }
224
225    /// Rattache cet appel au fonds qu'il alimente (issue #635).
226    pub fn attach_to_fund(&mut self, fund_id: Uuid) {
227        self.fund_id = Some(fund_id);
228        self.updated_at = Utc::now();
229    }
230
231    /// Mark as sent to owners
232    pub fn mark_as_sent(&mut self) {
233        self.sent_date = Some(Utc::now());
234        self.status = CallForFundsStatus::Sent;
235        self.updated_at = Utc::now();
236    }
237
238    /// Mark as completed (all owners paid)
239    pub fn mark_as_completed(&mut self) {
240        self.status = CallForFundsStatus::Completed;
241        self.updated_at = Utc::now();
242    }
243
244    /// Mark as cancelled
245    pub fn cancel(&mut self) {
246        self.status = CallForFundsStatus::Cancelled;
247        self.updated_at = Utc::now();
248    }
249
250    /// Check if overdue (past due date and not completed)
251    pub fn is_overdue(&self) -> bool {
252        self.status != CallForFundsStatus::Completed
253            && self.status != CallForFundsStatus::Cancelled
254            && Utc::now() > self.due_date
255    }
256}
257
258impl crate::domain::services::PieceDeGestion for CallForFunds {
259    fn acp_id(&self) -> Uuid {
260        self.acp_id
261    }
262}
263
264#[cfg(test)]
265mod tests_art_3_86_fonds_de_reserve {
266    use super::*;
267    use chrono::Duration;
268    use rust_decimal_macros::dec;
269
270    fn appel(total: Decimal, part_reserve: Decimal) -> Result<CallForFunds, CallForFundsError> {
271        let maintenant = Utc::now();
272        CallForFunds::new(
273            Uuid::new_v4(),
274            Uuid::new_v4(),
275            Uuid::new_v4(),
276            "Provision T1 2026".to_string(),
277            "Charges ordinaires".to_string(),
278            total,
279            ContributionType::Regular,
280            maintenant,
281            maintenant + Duration::days(30),
282            None,
283            part_reserve,
284        )
285    }
286
287    /// Art. 3.86 § 3, alinéa 7 :
288    ///
289    /// > « Le syndic communique à toutes les parties concernées **lors de
290    /// > l'appel de fonds** quelle part sera affectée au fonds de réserve. »
291    ///
292    /// Ce n'est pas une formalité d'affichage. Le fonds de réserve est la part
293    /// qu'un copropriétaire ne récupère pas en vendant son lot : il doit savoir
294    /// ce qu'il y verse au moment où on le lui réclame, pas à la mutation.
295    #[test]
296    fn happy_lappel_porte_la_part_affectee_au_fonds_de_reserve() {
297        let appel = appel(dec!(12000), dec!(3000)).expect("appel valide");
298
299        assert_eq!(appel.reserve_fund_share, dec!(3000));
300        assert_eq!(
301            appel.total_amount - appel.reserve_fund_share,
302            dec!(9000),
303            "le reste alimente le fonds de roulement et les charges courantes"
304        );
305    }
306
307    /// Un appel sans part de réserve reste licite : toutes les charges ne
308    /// l'alimentent pas. Ce qui ne l'est pas, c'est de ne pas le dire.
309    #[test]
310    fn happy_une_part_nulle_est_licite_et_explicite() {
311        let appel = appel(dec!(12000), Decimal::ZERO).expect("appel valide");
312        assert_eq!(appel.reserve_fund_share, Decimal::ZERO);
313    }
314
315    /// @negative — on ne peut pas affecter au fonds de réserve plus que ce
316    /// qu'on appelle.
317    #[test]
318    fn negative_la_part_de_reserve_ne_depasse_pas_le_total() {
319        let erreur = appel(dec!(12000), dec!(12001)).expect_err("doit refuser");
320        assert_eq!(erreur, CallForFundsError::ReserveShareExceedsTotal);
321    }
322
323    /// @edge — la part peut valoir exactement le total : un appel dédié à la
324    /// constitution du fonds de réserve est un cas réel (Art. 3.86 § 3 al. 4,
325    /// obligation de constitution à cinq ans).
326    #[test]
327    fn edge_la_part_peut_valoir_le_total() {
328        let appel = appel(dec!(12000), dec!(12000)).expect("appel valide");
329        assert_eq!(appel.reserve_fund_share, appel.total_amount);
330    }
331
332    /// @security — une part négative retirerait du fonds de réserve par un
333    /// appel de fonds, sans décision d'assemblée.
334    #[test]
335    fn security_une_part_negative_est_refusee() {
336        let erreur = appel(dec!(12000), dec!(-1)).expect_err("doit refuser");
337        assert_eq!(erreur, CallForFundsError::NegativeReserveShare);
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use crate::domain::entities::ContributionType;
345
346    #[test]
347    fn test_create_call_for_funds_success() {
348        let call_date = Utc::now();
349        let due_date = call_date + chrono::Duration::days(30);
350
351        let call = CallForFunds::new(
352            Uuid::new_v4(), // acp_id
353            Uuid::new_v4(),
354            Uuid::new_v4(),
355            "Appel de fonds Q1 2025".to_string(),
356            "Charges courantes trimestrielles".to_string(),
357            rust_decimal_macros::dec!(5000),
358            ContributionType::Regular,
359            call_date,
360            due_date,
361            Some("7000".to_string()),
362            Decimal::ZERO, // part fonds de réserve
363        );
364
365        assert!(call.is_ok());
366        let call = call.unwrap();
367        assert_eq!(call.total_amount, rust_decimal_macros::dec!(5000));
368        assert_eq!(call.status, CallForFundsStatus::Draft);
369    }
370
371    #[test]
372    fn test_create_call_negative_amount() {
373        let call_date = Utc::now();
374        let due_date = call_date + chrono::Duration::days(30);
375
376        let call = CallForFunds::new(
377            Uuid::new_v4(), // acp_id
378            Uuid::new_v4(),
379            Uuid::new_v4(),
380            "Test".to_string(),
381            "Test".to_string(),
382            rust_decimal_macros::dec!(-100),
383            ContributionType::Regular,
384            call_date,
385            due_date,
386            None,
387            Decimal::ZERO, // part fonds de réserve
388        );
389
390        assert!(matches!(
391            call.unwrap_err(),
392            CallForFundsError::NonPositiveTotalAmount
393        ));
394    }
395
396    #[test]
397    fn test_create_call_invalid_dates() {
398        let call_date = Utc::now();
399        let due_date = call_date - chrono::Duration::days(1); // Due date BEFORE call date
400
401        let call = CallForFunds::new(
402            Uuid::new_v4(), // acp_id
403            Uuid::new_v4(),
404            Uuid::new_v4(),
405            "Test".to_string(),
406            "Test".to_string(),
407            rust_decimal_macros::dec!(100),
408            ContributionType::Regular,
409            call_date,
410            due_date,
411            None,
412            Decimal::ZERO, // part fonds de réserve
413        );
414
415        assert!(matches!(
416            call.unwrap_err(),
417            CallForFundsError::DueDateNotAfterCallDate
418        ));
419    }
420
421    /// Issue #635 — rattachement d'un appel au fonds qu'il alimente.
422    #[test]
423    fn happy_attach_to_fund_rattache_lappel_au_fonds() {
424        let call_date = Utc::now();
425        let due_date = call_date + chrono::Duration::days(30);
426        let mut call = CallForFunds::new(
427            Uuid::new_v4(),
428            Uuid::new_v4(),
429            Uuid::new_v4(),
430            "Provision travaux".to_string(),
431            "Alimente le fonds affecté toiture".to_string(),
432            rust_decimal_macros::dec!(1000),
433            ContributionType::Regular,
434            call_date,
435            due_date,
436            None,
437            Decimal::ZERO,
438        )
439        .unwrap();
440        assert_eq!(call.fund_id, None);
441
442        let fund_id = Uuid::new_v4();
443        call.attach_to_fund(fund_id);
444        assert_eq!(call.fund_id, Some(fund_id));
445    }
446
447    #[test]
448    fn test_mark_as_sent() {
449        let call_date = Utc::now();
450        let due_date = call_date + chrono::Duration::days(30);
451
452        let mut call = CallForFunds::new(
453            Uuid::new_v4(), // acp_id
454            Uuid::new_v4(),
455            Uuid::new_v4(),
456            "Test".to_string(),
457            "Test".to_string(),
458            rust_decimal_macros::dec!(100),
459            ContributionType::Regular,
460            call_date,
461            due_date,
462            None,
463            Decimal::ZERO, // part fonds de réserve
464        )
465        .unwrap();
466
467        assert_eq!(call.status, CallForFundsStatus::Draft);
468        assert!(call.sent_date.is_none());
469
470        call.mark_as_sent();
471
472        assert_eq!(call.status, CallForFundsStatus::Sent);
473        assert!(call.sent_date.is_some());
474    }
475
476    #[test]
477    fn test_is_overdue() {
478        let call_date = Utc::now() - chrono::Duration::days(60);
479        let due_date = Utc::now() - chrono::Duration::days(30); // 30 days ago
480
481        let call = CallForFunds::new(
482            Uuid::new_v4(), // acp_id
483            Uuid::new_v4(),
484            Uuid::new_v4(),
485            "Overdue call".to_string(),
486            "Test".to_string(),
487            rust_decimal_macros::dec!(100),
488            ContributionType::Regular,
489            call_date,
490            due_date,
491            None,
492            Decimal::ZERO, // part fonds de réserve
493        )
494        .unwrap();
495
496        assert!(call.is_overdue());
497    }
498
499    // ------------------------------------------------------------------------
500    // 4 catégories #433/WP-A6 EXP-008 — erreur typée (CRITICAL.md #3).
501    // Entité déjà Decimal (ADR-0007) ; ce WP type l'erreur domaine.
502    // ------------------------------------------------------------------------
503
504    fn mk(amount: Decimal, days: i64) -> Result<CallForFunds, CallForFundsError> {
505        let call_date = Utc::now();
506        CallForFunds::new(
507            Uuid::new_v4(), // acp_id
508            Uuid::new_v4(),
509            Uuid::new_v4(),
510            "Appel".to_string(),
511            "Charges".to_string(),
512            amount,
513            ContributionType::Regular,
514            call_date,
515            call_date + chrono::Duration::days(days),
516            None,
517            Decimal::ZERO, // part fonds de réserve
518        )
519    }
520
521    /// @happy — appel nominal : total_amount Decimal exact.
522    #[test]
523    fn happy_total_amount_decimal_exact() {
524        let c = mk(rust_decimal_macros::dec!(9876.54), 30).unwrap();
525        assert_eq!(c.total_amount, rust_decimal_macros::dec!(9876.54));
526        assert_eq!(c.status, CallForFundsStatus::Draft);
527    }
528
529    /// @edge — montant minimal strictement positif accepté ; exactitude
530    /// Decimal sur cumul (0.1+0.2=0.3, f64 échoue).
531    #[test]
532    fn edge_min_positive_and_decimal_exactness() {
533        assert!(mk(rust_decimal_macros::dec!(0.01), 1).is_ok());
534        let c = mk(
535            rust_decimal_macros::dec!(0.1) + rust_decimal_macros::dec!(0.2),
536            7,
537        )
538        .unwrap();
539        assert_eq!(c.total_amount, rust_decimal_macros::dec!(0.3));
540    }
541
542    /// @negative — total ≤ 0, titre/description vides, échéance ≤ appel
543    /// rejetés (erreurs typées, pas de panic).
544    #[test]
545    fn negative_invalid_inputs_rejected() {
546        assert!(matches!(
547            mk(Decimal::ZERO, 30).unwrap_err(),
548            CallForFundsError::NonPositiveTotalAmount
549        ));
550        assert!(matches!(
551            mk(rust_decimal_macros::dec!(100), -1).unwrap_err(),
552            CallForFundsError::DueDateNotAfterCallDate
553        ));
554    }
555
556    /// @security — un appel de fonds falsifié à montant nul/négatif
557    /// (collecte fantôme) ne peut jamais être créé.
558    #[test]
559    fn security_tampered_nonpositive_amount_rejected() {
560        assert!(matches!(
561            mk(rust_decimal_macros::dec!(-1), 30).unwrap_err(),
562            CallForFundsError::NonPositiveTotalAmount
563        ));
564    }
565}