1use chrono::{DateTime, Utc};
9use rust_decimal::Decimal;
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13use super::ContributionType;
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17#[serde(rename_all = "lowercase")]
18pub enum CallForFundsStatus {
19 Draft,
21 Sent,
23 Partial,
25 Completed,
27 Cancelled,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
36pub struct CallForFunds {
37 pub id: Uuid,
38
39 pub acp_id: Uuid,
46
47 pub organization_id: Uuid,
51 pub building_id: Uuid,
52
53 pub title: String,
55 pub description: String,
56
57 pub total_amount: Decimal, pub reserve_fund_share: Decimal,
74
75 pub contribution_type: ContributionType,
77
78 pub call_date: DateTime<Utc>, pub due_date: DateTime<Utc>, pub sent_date: Option<DateTime<Utc>>, pub status: CallForFundsStatus,
85
86 pub account_code: Option<String>, pub notes: Option<String>,
91 pub created_at: DateTime<Utc>,
92 pub updated_at: DateTime<Utc>,
93 pub created_by: Option<Uuid>,
94
95 pub fund_id: Option<Uuid>,
102}
103
104#[derive(Debug, Clone, PartialEq)]
110pub enum CallForFundsError {
111 NonPositiveTotalAmount,
113 EmptyTitle,
115 EmptyDescription,
117 DueDateNotAfterCallDate,
119 NegativeReserveShare,
121 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
148impl 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 if total_amount <= Decimal::ZERO {
173 return Err(CallForFundsError::NonPositiveTotalAmount);
174 }
175
176 if title.trim().is_empty() {
178 return Err(CallForFundsError::EmptyTitle);
179 }
180
181 if description.trim().is_empty() {
183 return Err(CallForFundsError::EmptyDescription);
184 }
185
186 if due_date <= call_date {
188 return Err(CallForFundsError::DueDateNotAfterCallDate);
189 }
190
191 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 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 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 pub fn mark_as_completed(&mut self) {
240 self.status = CallForFundsStatus::Completed;
241 self.updated_at = Utc::now();
242 }
243
244 pub fn cancel(&mut self) {
246 self.status = CallForFundsStatus::Cancelled;
247 self.updated_at = Utc::now();
248 }
249
250 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 #[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 #[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 #[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 #[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 #[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(), 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, );
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(), 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, );
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); let call = CallForFunds::new(
402 Uuid::new_v4(), 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, );
414
415 assert!(matches!(
416 call.unwrap_err(),
417 CallForFundsError::DueDateNotAfterCallDate
418 ));
419 }
420
421 #[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(), 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, )
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); let call = CallForFunds::new(
482 Uuid::new_v4(), 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, )
494 .unwrap();
495
496 assert!(call.is_overdue());
497 }
498
499 fn mk(amount: Decimal, days: i64) -> Result<CallForFunds, CallForFundsError> {
505 let call_date = Utc::now();
506 CallForFunds::new(
507 Uuid::new_v4(), 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, )
519 }
520
521 #[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 #[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 #[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 #[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}