koprogo_api/domain/entities/
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
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10use super::ContributionType;
11
12/// Status of the call for funds
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14#[serde(rename_all = "lowercase")]
15pub enum CallForFundsStatus {
16    /// Draft - not yet sent
17    Draft,
18    /// Sent to owners
19    Sent,
20    /// Partially paid
21    Partial,
22    /// Fully paid by all owners
23    Completed,
24    /// Cancelled
25    Cancelled,
26}
27
28/// Call for Funds (Appel de Fonds Collectif)
29///
30/// Represents a payment request sent by the Syndic to all owners of a building
31/// Automatically generates individual OwnerContribution records based on ownership percentages
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
33pub struct CallForFunds {
34    pub id: Uuid,
35    pub organization_id: Uuid,
36    pub building_id: Uuid,
37
38    // Description
39    pub title: String,
40    pub description: String,
41
42    // Financial details
43    pub total_amount: f64, // Total amount to be collected from ALL owners
44
45    // Type
46    pub contribution_type: ContributionType,
47
48    // Dates
49    pub call_date: DateTime<Utc>,         // When the call is issued
50    pub due_date: DateTime<Utc>,          // Payment deadline
51    pub sent_date: Option<DateTime<Utc>>, // When actually sent to owners
52
53    // Status
54    pub status: CallForFundsStatus,
55
56    // Accounting
57    pub account_code: Option<String>, // PCMN code (classe 7)
58
59    // Metadata
60    pub notes: Option<String>,
61    pub created_at: DateTime<Utc>,
62    pub updated_at: DateTime<Utc>,
63    pub created_by: Option<Uuid>,
64}
65
66impl CallForFunds {
67    #[allow(clippy::too_many_arguments)]
68    pub fn new(
69        organization_id: Uuid,
70        building_id: Uuid,
71        title: String,
72        description: String,
73        total_amount: f64,
74        contribution_type: ContributionType,
75        call_date: DateTime<Utc>,
76        due_date: DateTime<Utc>,
77        account_code: Option<String>,
78    ) -> Result<Self, String> {
79        // Validate total amount is positive
80        if total_amount <= 0.0 {
81            return Err("Total amount must be positive".to_string());
82        }
83
84        // Validate title
85        if title.trim().is_empty() {
86            return Err("Title cannot be empty".to_string());
87        }
88
89        // Validate description
90        if description.trim().is_empty() {
91            return Err("Description cannot be empty".to_string());
92        }
93
94        // Validate dates
95        if due_date <= call_date {
96            return Err("Due date must be after call date".to_string());
97        }
98
99        Ok(Self {
100            id: Uuid::new_v4(),
101            organization_id,
102            building_id,
103            title,
104            description,
105            total_amount,
106            contribution_type,
107            call_date,
108            due_date,
109            sent_date: None,
110            status: CallForFundsStatus::Draft,
111            account_code,
112            notes: None,
113            created_at: Utc::now(),
114            updated_at: Utc::now(),
115            created_by: None,
116        })
117    }
118
119    /// Mark as sent to owners
120    pub fn mark_as_sent(&mut self) {
121        self.sent_date = Some(Utc::now());
122        self.status = CallForFundsStatus::Sent;
123        self.updated_at = Utc::now();
124    }
125
126    /// Mark as completed (all owners paid)
127    pub fn mark_as_completed(&mut self) {
128        self.status = CallForFundsStatus::Completed;
129        self.updated_at = Utc::now();
130    }
131
132    /// Mark as cancelled
133    pub fn cancel(&mut self) {
134        self.status = CallForFundsStatus::Cancelled;
135        self.updated_at = Utc::now();
136    }
137
138    /// Check if overdue (past due date and not completed)
139    pub fn is_overdue(&self) -> bool {
140        self.status != CallForFundsStatus::Completed
141            && self.status != CallForFundsStatus::Cancelled
142            && Utc::now() > self.due_date
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::domain::entities::ContributionType;
150
151    #[test]
152    fn test_create_call_for_funds_success() {
153        let call_date = Utc::now();
154        let due_date = call_date + chrono::Duration::days(30);
155
156        let call = CallForFunds::new(
157            Uuid::new_v4(),
158            Uuid::new_v4(),
159            "Appel de fonds Q1 2025".to_string(),
160            "Charges courantes trimestrielles".to_string(),
161            5000.0,
162            ContributionType::Regular,
163            call_date,
164            due_date,
165            Some("7000".to_string()),
166        );
167
168        assert!(call.is_ok());
169        let call = call.unwrap();
170        assert_eq!(call.total_amount, 5000.0);
171        assert_eq!(call.status, CallForFundsStatus::Draft);
172    }
173
174    #[test]
175    fn test_create_call_negative_amount() {
176        let call_date = Utc::now();
177        let due_date = call_date + chrono::Duration::days(30);
178
179        let call = CallForFunds::new(
180            Uuid::new_v4(),
181            Uuid::new_v4(),
182            "Test".to_string(),
183            "Test".to_string(),
184            -100.0,
185            ContributionType::Regular,
186            call_date,
187            due_date,
188            None,
189        );
190
191        assert!(call.is_err());
192        assert!(call.unwrap_err().contains("must be positive"));
193    }
194
195    #[test]
196    fn test_create_call_invalid_dates() {
197        let call_date = Utc::now();
198        let due_date = call_date - chrono::Duration::days(1); // Due date BEFORE call date
199
200        let call = CallForFunds::new(
201            Uuid::new_v4(),
202            Uuid::new_v4(),
203            "Test".to_string(),
204            "Test".to_string(),
205            100.0,
206            ContributionType::Regular,
207            call_date,
208            due_date,
209            None,
210        );
211
212        assert!(call.is_err());
213        assert!(call.unwrap_err().contains("Due date must be after"));
214    }
215
216    #[test]
217    fn test_mark_as_sent() {
218        let call_date = Utc::now();
219        let due_date = call_date + chrono::Duration::days(30);
220
221        let mut call = CallForFunds::new(
222            Uuid::new_v4(),
223            Uuid::new_v4(),
224            "Test".to_string(),
225            "Test".to_string(),
226            100.0,
227            ContributionType::Regular,
228            call_date,
229            due_date,
230            None,
231        )
232        .unwrap();
233
234        assert_eq!(call.status, CallForFundsStatus::Draft);
235        assert!(call.sent_date.is_none());
236
237        call.mark_as_sent();
238
239        assert_eq!(call.status, CallForFundsStatus::Sent);
240        assert!(call.sent_date.is_some());
241    }
242
243    #[test]
244    fn test_is_overdue() {
245        let call_date = Utc::now() - chrono::Duration::days(60);
246        let due_date = Utc::now() - chrono::Duration::days(30); // 30 days ago
247
248        let call = CallForFunds::new(
249            Uuid::new_v4(),
250            Uuid::new_v4(),
251            "Overdue call".to_string(),
252            "Test".to_string(),
253            100.0,
254            ContributionType::Regular,
255            call_date,
256            due_date,
257            None,
258        )
259        .unwrap();
260
261        assert!(call.is_overdue());
262    }
263}