1use chrono::{DateTime, Utc};
2use rust_decimal::Decimal;
3use rust_decimal_macros::dec;
4use serde::{Deserialize, Serialize};
5use std::sync::atomic::{AtomicU64, Ordering};
6use uuid::Uuid;
7
8static ETAT_DATE_COUNTER: AtomicU64 = AtomicU64::new(0);
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, sqlx::Type, utoipa::ToSchema)]
13#[sqlx(type_name = "etat_date_status", rename_all = "snake_case")]
14#[serde(rename_all = "snake_case")]
15pub enum EtatDateStatus {
16 Requested, InProgress, Generated, Delivered, Expired, }
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, sqlx::Type, utoipa::ToSchema)]
25#[sqlx(type_name = "etat_date_language", rename_all = "snake_case")]
26#[serde(rename_all = "lowercase")]
27pub enum EtatDateLanguage {
28 Fr, Nl, De, }
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
63pub struct EtatDate {
64 pub id: Uuid,
65
66 pub acp_id: Uuid,
73
74 pub organization_id: Uuid,
76 pub building_id: Uuid,
77 pub unit_id: Uuid,
78
79 pub reference_date: DateTime<Utc>,
81
82 pub requested_date: DateTime<Utc>,
84
85 pub generated_date: Option<DateTime<Utc>>,
87
88 pub delivered_date: Option<DateTime<Utc>>,
90
91 pub status: EtatDateStatus,
93
94 pub language: EtatDateLanguage,
96
97 pub reference_number: String,
99
100 pub notary_name: String,
102 pub notary_email: String,
103 pub notary_phone: Option<String>,
104
105 pub building_name: String,
107 pub building_address: String,
108 pub unit_number: String,
109 pub unit_floor: Option<String>,
110 pub unit_area: Option<f64>,
111
112 pub ordinary_charges_quota: Decimal,
115 pub extraordinary_charges_quota: Decimal,
117
118 pub owner_balance: Decimal,
124 pub arrears_amount: Decimal,
126
127 pub monthly_provision_amount: Decimal,
130
131 pub total_balance: Decimal,
134
135 pub approved_works_unpaid: Decimal,
138
139 pub additional_data: serde_json::Value,
154
155 pub pdf_file_path: Option<String>,
157
158 pub created_at: DateTime<Utc>,
159 pub updated_at: DateTime<Utc>,
160}
161
162#[derive(Debug, Clone, PartialEq)]
169pub enum EtatDateError {
170 EmptyField(&'static str),
172 InvalidNotaryEmail,
174 QuotaOutOfRange(&'static str),
176 NegativeAmount(&'static str),
179 InvalidTransition {
181 from: EtatDateStatus,
182 to: &'static str,
183 },
184 EmptyPdfPath,
186 AdditionalDataNotObject,
188}
189
190impl std::fmt::Display for EtatDateError {
191 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192 match self {
193 Self::EmptyField(name) => write!(f, "{} cannot be empty", name),
194 Self::InvalidNotaryEmail => write!(f, "Invalid notary email"),
195 Self::QuotaOutOfRange(name) => {
196 write!(f, "{} must be between 0 and 100%", name)
197 }
198 Self::NegativeAmount(name) => write!(f, "{} cannot be negative", name),
199 Self::InvalidTransition { from, to } => {
200 write!(f, "Cannot mark as {}: current status is {:?}", to, from)
201 }
202 Self::EmptyPdfPath => write!(f, "PDF file path cannot be empty"),
203 Self::AdditionalDataNotObject => {
204 write!(f, "Additional data must be a JSON object")
205 }
206 }
207 }
208}
209
210impl std::error::Error for EtatDateError {}
211
212impl From<EtatDateError> for String {
216 fn from(e: EtatDateError) -> String {
217 e.to_string()
218 }
219}
220
221impl EtatDate {
222 #[allow(clippy::too_many_arguments)]
223 pub fn new(
224 acp_id: Uuid,
225 organization_id: Uuid,
226 building_id: Uuid,
227 unit_id: Uuid,
228 reference_date: DateTime<Utc>,
229 language: EtatDateLanguage,
230 notary_name: String,
231 notary_email: String,
232 notary_phone: Option<String>,
233 building_name: String,
234 building_address: String,
235 unit_number: String,
236 unit_floor: Option<String>,
237 unit_area: Option<f64>,
238 ordinary_charges_quota: Decimal,
239 extraordinary_charges_quota: Decimal,
240 ) -> Result<Self, EtatDateError> {
241 if notary_name.trim().is_empty() {
243 return Err(EtatDateError::EmptyField("Notary name"));
244 }
245 if notary_email.trim().is_empty() {
246 return Err(EtatDateError::EmptyField("Notary email"));
247 }
248 if !notary_email.contains('@') {
249 return Err(EtatDateError::InvalidNotaryEmail);
250 }
251 if building_name.trim().is_empty() {
252 return Err(EtatDateError::EmptyField("Building name"));
253 }
254 if building_address.trim().is_empty() {
255 return Err(EtatDateError::EmptyField("Building address"));
256 }
257 if unit_number.trim().is_empty() {
258 return Err(EtatDateError::EmptyField("Unit number"));
259 }
260
261 if ordinary_charges_quota < Decimal::ZERO || ordinary_charges_quota > dec!(100) {
263 return Err(EtatDateError::QuotaOutOfRange("Ordinary charges quota"));
264 }
265 if extraordinary_charges_quota < Decimal::ZERO || extraordinary_charges_quota > dec!(100) {
266 return Err(EtatDateError::QuotaOutOfRange(
267 "Extraordinary charges quota",
268 ));
269 }
270
271 let now = Utc::now();
272 let reference_number = Self::generate_reference_number(&building_id, &unit_id, &now);
273
274 Ok(Self {
275 id: Uuid::new_v4(),
276 acp_id,
277 organization_id,
278 building_id,
279 unit_id,
280 reference_date,
281 requested_date: now,
282 generated_date: None,
283 delivered_date: None,
284 status: EtatDateStatus::Requested,
285 language,
286 reference_number,
287 notary_name,
288 notary_email,
289 notary_phone,
290 building_name,
291 building_address,
292 unit_number,
293 unit_floor,
294 unit_area,
295 ordinary_charges_quota,
296 extraordinary_charges_quota,
297 owner_balance: Decimal::ZERO,
298 arrears_amount: Decimal::ZERO,
299 monthly_provision_amount: Decimal::ZERO,
300 total_balance: Decimal::ZERO,
301 approved_works_unpaid: Decimal::ZERO,
302 additional_data: serde_json::json!({}),
303 pdf_file_path: None,
304 created_at: now,
305 updated_at: now,
306 })
307 }
308
309 fn generate_reference_number(
312 building_id: &Uuid,
313 unit_id: &Uuid,
314 date: &DateTime<Utc>,
315 ) -> String {
316 let year = date.format("%Y");
317 let building_short = &building_id.to_string()[..8];
318 let unit_short = &unit_id.to_string()[..8];
319
320 let seq = ETAT_DATE_COUNTER.fetch_add(1, Ordering::Relaxed);
322 let unique_id = &Uuid::new_v4().to_string()[..8];
323
324 format!(
325 "ED-{}-{:03}-{}-BLD{}-U{}",
326 year,
327 seq % 1000,
328 unique_id,
329 building_short,
330 unit_short
331 )
332 }
333
334 pub fn mark_in_progress(&mut self) -> Result<(), EtatDateError> {
336 match self.status {
337 EtatDateStatus::Requested => {
338 self.status = EtatDateStatus::InProgress;
339 self.updated_at = Utc::now();
340 Ok(())
341 }
342 _ => Err(EtatDateError::InvalidTransition {
343 from: self.status.clone(),
344 to: "in progress",
345 }),
346 }
347 }
348
349 pub fn mark_generated(&mut self, pdf_file_path: String) -> Result<(), EtatDateError> {
351 if pdf_file_path.trim().is_empty() {
352 return Err(EtatDateError::EmptyPdfPath);
353 }
354
355 match self.status {
356 EtatDateStatus::InProgress => {
357 self.status = EtatDateStatus::Generated;
358 self.generated_date = Some(Utc::now());
359 self.pdf_file_path = Some(pdf_file_path);
360 self.updated_at = Utc::now();
361 Ok(())
362 }
363 _ => Err(EtatDateError::InvalidTransition {
364 from: self.status.clone(),
365 to: "generated",
366 }),
367 }
368 }
369
370 pub fn mark_delivered(&mut self) -> Result<(), EtatDateError> {
372 match self.status {
373 EtatDateStatus::Generated => {
374 self.status = EtatDateStatus::Delivered;
375 self.delivered_date = Some(Utc::now());
376 self.updated_at = Utc::now();
377 Ok(())
378 }
379 _ => Err(EtatDateError::InvalidTransition {
380 from: self.status.clone(),
381 to: "delivered",
382 }),
383 }
384 }
385
386 pub fn is_expired(&self) -> bool {
388 let now = Utc::now();
389 let expiration_date = self.reference_date + chrono::Duration::days(90); now > expiration_date
391 }
392
393 pub fn is_overdue(&self) -> bool {
408 if matches!(
409 self.status,
410 EtatDateStatus::Generated | EtatDateStatus::Delivered
411 ) {
412 return false; }
414
415 let now = Utc::now();
416 let deadline = self.requested_date + chrono::Duration::days(15);
417 now > deadline
418 }
419
420 pub fn days_since_request(&self) -> i64 {
422 let now = Utc::now();
423 (now - self.requested_date).num_days()
424 }
425
426 pub fn update_financial_data(
428 &mut self,
429 owner_balance: Decimal,
430 arrears_amount: Decimal,
431 monthly_provision_amount: Decimal,
432 total_balance: Decimal,
433 approved_works_unpaid: Decimal,
434 ) -> Result<(), EtatDateError> {
435 if arrears_amount < Decimal::ZERO {
437 return Err(EtatDateError::NegativeAmount("Arrears amount"));
438 }
439 if monthly_provision_amount < Decimal::ZERO {
440 return Err(EtatDateError::NegativeAmount("Monthly provision amount"));
441 }
442 if approved_works_unpaid < Decimal::ZERO {
443 return Err(EtatDateError::NegativeAmount("Approved works unpaid"));
444 }
445
446 self.owner_balance = owner_balance;
447 self.arrears_amount = arrears_amount;
448 self.monthly_provision_amount = monthly_provision_amount;
449 self.total_balance = total_balance;
450 self.approved_works_unpaid = approved_works_unpaid;
451 self.updated_at = Utc::now();
452
453 Ok(())
454 }
455
456 pub fn update_additional_data(&mut self, data: serde_json::Value) -> Result<(), EtatDateError> {
458 if !data.is_object() {
459 return Err(EtatDateError::AdditionalDataNotObject);
460 }
461
462 self.additional_data = data;
463 self.updated_at = Utc::now();
464 Ok(())
465 }
466}
467
468impl crate::domain::services::PieceDeGestion for EtatDate {
469 fn acp_id(&self) -> Uuid {
470 self.acp_id
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 #[test]
479 fn test_create_etat_date_success() {
480 let org_id = Uuid::new_v4();
481 let building_id = Uuid::new_v4();
482 let unit_id = Uuid::new_v4();
483 let ref_date = Utc::now();
484
485 let etat_date = EtatDate::new(
486 Uuid::new_v4(), org_id,
488 building_id,
489 unit_id,
490 ref_date,
491 EtatDateLanguage::Fr,
492 "Maître Dupont".to_string(),
493 "dupont@notaire.be".to_string(),
494 Some("+32 2 123 4567".to_string()),
495 "Résidence Les Jardins".to_string(),
496 "Rue de la Loi 123, 1000 Bruxelles".to_string(),
497 "101".to_string(),
498 Some("1".to_string()),
499 Some(100.0),
500 dec!(100), dec!(100), );
503
504 assert!(etat_date.is_ok());
505 let ed = etat_date.unwrap();
506 assert_eq!(ed.status, EtatDateStatus::Requested);
507 assert_eq!(ed.notary_name, "Maître Dupont");
508 assert!(ed.reference_number.starts_with("ED-"));
509 }
510
511 #[test]
512 fn test_create_etat_date_invalid_email() {
513 let org_id = Uuid::new_v4();
514 let building_id = Uuid::new_v4();
515 let unit_id = Uuid::new_v4();
516 let ref_date = Utc::now();
517
518 let result = EtatDate::new(
519 Uuid::new_v4(), org_id,
521 building_id,
522 unit_id,
523 ref_date,
524 EtatDateLanguage::Fr,
525 "Maître Dupont".to_string(),
526 "invalid-email".to_string(), None,
528 "Résidence Les Jardins".to_string(),
529 "Rue de la Loi 123".to_string(),
530 "101".to_string(),
531 None,
532 None,
533 dec!(100),
534 dec!(100),
535 );
536
537 assert!(matches!(
538 result.unwrap_err(),
539 EtatDateError::InvalidNotaryEmail
540 ));
541 }
542
543 #[test]
544 fn test_create_etat_date_invalid_quota() {
545 let org_id = Uuid::new_v4();
546 let building_id = Uuid::new_v4();
547 let unit_id = Uuid::new_v4();
548 let ref_date = Utc::now();
549
550 let result = EtatDate::new(
551 Uuid::new_v4(), org_id,
553 building_id,
554 unit_id,
555 ref_date,
556 EtatDateLanguage::Fr,
557 "Maître Dupont".to_string(),
558 "dupont@notaire.be".to_string(),
559 None,
560 "Résidence Les Jardins".to_string(),
561 "Rue de la Loi 123".to_string(),
562 "101".to_string(),
563 None,
564 None,
565 dec!(150), dec!(100),
567 );
568
569 assert!(matches!(
570 result.unwrap_err(),
571 EtatDateError::QuotaOutOfRange(_)
572 ));
573 }
574
575 #[test]
576 fn test_workflow_transitions() {
577 let org_id = Uuid::new_v4();
578 let building_id = Uuid::new_v4();
579 let unit_id = Uuid::new_v4();
580 let ref_date = Utc::now();
581
582 let mut ed = EtatDate::new(
583 Uuid::new_v4(), org_id,
585 building_id,
586 unit_id,
587 ref_date,
588 EtatDateLanguage::Fr,
589 "Maître Dupont".to_string(),
590 "dupont@notaire.be".to_string(),
591 None,
592 "Résidence Les Jardins".to_string(),
593 "Rue de la Loi 123".to_string(),
594 "101".to_string(),
595 None,
596 None,
597 dec!(100),
598 dec!(100),
599 )
600 .unwrap();
601
602 assert!(ed.mark_in_progress().is_ok());
604 assert_eq!(ed.status, EtatDateStatus::InProgress);
605
606 assert!(ed
608 .mark_generated("/path/to/etat_date_001.pdf".to_string())
609 .is_ok());
610 assert_eq!(ed.status, EtatDateStatus::Generated);
611 assert!(ed.generated_date.is_some());
612 assert!(ed.pdf_file_path.is_some());
613
614 assert!(ed.mark_delivered().is_ok());
616 assert_eq!(ed.status, EtatDateStatus::Delivered);
617 assert!(ed.delivered_date.is_some());
618 }
619
620 #[test]
621 fn test_invalid_workflow_transition() {
622 let org_id = Uuid::new_v4();
623 let building_id = Uuid::new_v4();
624 let unit_id = Uuid::new_v4();
625 let ref_date = Utc::now();
626
627 let mut ed = EtatDate::new(
628 Uuid::new_v4(), org_id,
630 building_id,
631 unit_id,
632 ref_date,
633 EtatDateLanguage::Fr,
634 "Maître Dupont".to_string(),
635 "dupont@notaire.be".to_string(),
636 None,
637 "Résidence Les Jardins".to_string(),
638 "Rue de la Loi 123".to_string(),
639 "101".to_string(),
640 None,
641 None,
642 dec!(100),
643 dec!(100),
644 )
645 .unwrap();
646
647 let result = ed.mark_delivered();
649 assert!(result.is_err());
650 }
651
652 #[test]
653 fn test_update_financial_data() {
654 let org_id = Uuid::new_v4();
655 let building_id = Uuid::new_v4();
656 let unit_id = Uuid::new_v4();
657 let ref_date = Utc::now();
658
659 let mut ed = EtatDate::new(
660 Uuid::new_v4(), org_id,
662 building_id,
663 unit_id,
664 ref_date,
665 EtatDateLanguage::Fr,
666 "Maître Dupont".to_string(),
667 "dupont@notaire.be".to_string(),
668 None,
669 "Résidence Les Jardins".to_string(),
670 "Rue de la Loi 123".to_string(),
671 "101".to_string(),
672 None,
673 None,
674 dec!(100),
675 dec!(100),
676 )
677 .unwrap();
678
679 let result = ed.update_financial_data(
680 dec!(-500.00), dec!(100.0), dec!(100.0), dec!(-500.00), dec!(100.0), );
686
687 assert!(result.is_ok());
688 assert_eq!(ed.owner_balance, dec!(-500.00));
689 assert_eq!(ed.arrears_amount, dec!(100.0));
690 }
691
692 #[test]
693 fn test_is_overdue() {
694 let org_id = Uuid::new_v4();
695 let building_id = Uuid::new_v4();
696 let unit_id = Uuid::new_v4();
697 let ref_date = Utc::now();
698
699 let mut ed = EtatDate::new(
700 Uuid::new_v4(), org_id,
702 building_id,
703 unit_id,
704 ref_date,
705 EtatDateLanguage::Fr,
706 "Maître Dupont".to_string(),
707 "dupont@notaire.be".to_string(),
708 None,
709 "Résidence Les Jardins".to_string(),
710 "Rue de la Loi 123".to_string(),
711 "101".to_string(),
712 None,
713 None,
714 dec!(100),
715 dec!(100),
716 )
717 .unwrap();
718
719 ed.requested_date = Utc::now() - chrono::Duration::days(16);
721
722 assert!(ed.is_overdue());
723 }
724
725 #[test]
752 fn test_delai_art_3_94_se_compte_en_jours_calendaires() {
753 let ed_neuf = || {
754 EtatDate::new(
755 Uuid::new_v4(), Uuid::new_v4(),
757 Uuid::new_v4(),
758 Uuid::new_v4(),
759 Utc::now(),
760 EtatDateLanguage::Fr,
761 "Maître Dupont".to_string(),
762 "dupont@notaire.be".to_string(),
763 None,
764 "Résidence Les Jardins".to_string(),
765 "Rue de la Loi 123".to_string(),
766 "101".to_string(),
767 None,
768 None,
769 dec!(100),
770 dec!(100),
771 )
772 .unwrap()
773 };
774
775 let mut avant = ed_neuf();
777 avant.requested_date = Utc::now() - chrono::Duration::days(14);
778 assert!(
779 !avant.is_overdue(),
780 "quatorze jours calendaires restent dans le délai légal"
781 );
782
783 let mut apres = ed_neuf();
785 apres.requested_date = Utc::now() - chrono::Duration::days(16);
786 assert!(
787 apres.is_overdue(),
788 "seize jours calendaires dépassent le délai légal"
789 );
790
791 let mut ouvrables = ed_neuf();
795 ouvrables.requested_date = Utc::now() - chrono::Duration::days(18);
796 assert!(
797 ouvrables.is_overdue(),
798 "le délai se compte en jours calendaires, pas en jours ouvrables"
799 );
800 }
801
802 #[test]
803 fn test_days_since_request() {
804 let org_id = Uuid::new_v4();
805 let building_id = Uuid::new_v4();
806 let unit_id = Uuid::new_v4();
807 let ref_date = Utc::now();
808
809 let mut ed = EtatDate::new(
810 Uuid::new_v4(), org_id,
812 building_id,
813 unit_id,
814 ref_date,
815 EtatDateLanguage::Fr,
816 "Maître Dupont".to_string(),
817 "dupont@notaire.be".to_string(),
818 None,
819 "Résidence Les Jardins".to_string(),
820 "Rue de la Loi 123".to_string(),
821 "101".to_string(),
822 None,
823 None,
824 dec!(100),
825 dec!(100),
826 )
827 .unwrap();
828
829 ed.requested_date = Utc::now() - chrono::Duration::days(5);
831
832 assert_eq!(ed.days_since_request(), 5);
833 }
834
835 fn sample() -> EtatDate {
841 EtatDate::new(
842 Uuid::new_v4(), Uuid::new_v4(),
844 Uuid::new_v4(),
845 Uuid::new_v4(),
846 Utc::now(),
847 EtatDateLanguage::Fr,
848 "Maître Dupont".to_string(),
849 "dupont@notaire.be".to_string(),
850 None,
851 "Résidence Les Jardins".to_string(),
852 "Rue de la Loi 123".to_string(),
853 "101".to_string(),
854 None,
855 None,
856 dec!(50),
857 dec!(50),
858 )
859 .unwrap()
860 }
861
862 #[test]
864 fn happy_update_financial_data_decimal_exact() {
865 let mut ed = sample();
866 ed.update_financial_data(
867 dec!(-1234.56),
868 dec!(789.01),
869 dec!(150.00),
870 dec!(-445.55),
871 dec!(2000.00),
872 )
873 .unwrap();
874 assert_eq!(ed.owner_balance, dec!(-1234.56));
875 assert_eq!(ed.total_balance, dec!(-445.55));
876 }
877
878 #[test]
881 fn edge_decimal_exactness_and_quota_boundary() {
882 let mut ed = sample();
883 ed.update_financial_data(
884 dec!(0.1) + dec!(0.2),
885 Decimal::ZERO,
886 Decimal::ZERO,
887 dec!(0.3),
888 Decimal::ZERO,
889 )
890 .unwrap();
891 assert_eq!(ed.owner_balance, dec!(0.3));
892 assert_eq!(ed.owner_balance, ed.total_balance);
893
894 let ok = EtatDate::new(
896 Uuid::new_v4(), Uuid::new_v4(),
898 Uuid::new_v4(),
899 Uuid::new_v4(),
900 Utc::now(),
901 EtatDateLanguage::Nl,
902 "N".to_string(),
903 "n@x.be".to_string(),
904 None,
905 "B".to_string(),
906 "A".to_string(),
907 "1".to_string(),
908 None,
909 None,
910 dec!(100),
911 dec!(0),
912 );
913 assert!(ok.is_ok());
914 }
915
916 #[test]
919 fn negative_amount_and_transition_rejected() {
920 let mut ed = sample();
921 assert!(matches!(
922 ed.update_financial_data(
923 Decimal::ZERO,
924 dec!(-1), Decimal::ZERO,
926 Decimal::ZERO,
927 Decimal::ZERO,
928 )
929 .unwrap_err(),
930 EtatDateError::NegativeAmount(_)
931 ));
932
933 assert!(matches!(
935 ed.mark_delivered().unwrap_err(),
936 EtatDateError::InvalidTransition { .. }
937 ));
938 }
939
940 #[test]
944 fn security_tampered_quota_rejected() {
945 let result = EtatDate::new(
946 Uuid::new_v4(), Uuid::new_v4(),
948 Uuid::new_v4(),
949 Uuid::new_v4(),
950 Utc::now(),
951 EtatDateLanguage::Fr,
952 "Maître Dupont".to_string(),
953 "dupont@notaire.be".to_string(),
954 None,
955 "Résidence".to_string(),
956 "Rue".to_string(),
957 "101".to_string(),
958 None,
959 None,
960 dec!(250), dec!(50),
962 );
963 assert!(matches!(
964 result.unwrap_err(),
965 EtatDateError::QuotaOutOfRange(_)
966 ));
967 }
968}