Skip to main content

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