1use super::fenetre_ag_ordinaire::FenetreAgOrdinaire;
27use chrono::{DateTime, Utc};
28use rust_decimal::Decimal;
29use rust_decimal_macros::dec;
30use serde::{Deserialize, Serialize};
31use thiserror::Error;
32use uuid::Uuid;
33
34pub const DEFAULT_TOTAL_TANTIEMES: i32 = 1000;
39
40pub const RESERVE_FUND_RATE: Decimal = dec!(0.05);
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct AcpMetrics {
54 pub units_count: i32,
56 pub declared_units_total: i32,
58 pub quota_sum: Decimal,
60 pub buildings_count: i32,
62}
63
64impl AcpMetrics {
65 pub fn empty() -> Self {
67 Self {
68 units_count: 0,
69 declared_units_total: 0,
70 quota_sum: Decimal::ZERO,
71 buildings_count: 0,
72 }
73 }
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, utoipa::ToSchema)]
79#[serde(rename_all = "snake_case")]
80pub enum AcpLegalStatus {
81 #[default]
83 CoproprieteBelge,
84}
85
86impl AcpLegalStatus {
87 pub fn as_db_str(&self) -> &'static str {
89 match self {
90 Self::CoproprieteBelge => "copropriete_belge",
91 }
92 }
93
94 pub fn from_db_str(s: &str) -> Self {
99 match s {
100 "copropriete_belge" => Self::CoproprieteBelge,
101 _ => Self::CoproprieteBelge,
102 }
103 }
104}
105
106#[derive(Error, Debug, Clone, PartialEq, Eq)]
111pub enum AcpError {
112 #[error("ACP name cannot be empty")]
113 NameEmpty,
114 #[error("ACP name must be at least 2 characters long, got {0}")]
115 NameTooShort(usize),
116 #[error("ACP name must be at most 160 characters long, got {0}")]
117 NameTooLong(usize),
118 #[error("ACP address street cannot be empty")]
119 AddressStreetEmpty,
120 #[error("ACP postal code cannot be empty")]
121 PostalCodeEmpty,
122 #[error("ACP city cannot be empty")]
123 CityEmpty,
124 #[error("ACP total_tantiemes (acte de base) must be greater than 0, got {0}")]
125 TotalTantiemesInvalid(i32),
126 #[error("ACP fund balance cannot be negative, got {0}")]
127 NegativeFundBalance(Decimal),
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
134pub struct Acp {
135 pub id: Uuid,
136 pub organization_id: Option<Uuid>,
138 pub name: String,
139 pub slug: String,
140 pub legal_status: AcpLegalStatus,
141 pub total_tantiemes: i32,
145 pub bce_number: Option<String>,
147 pub address_street: String,
148 pub address_postal_code: String,
149 pub address_city: String,
150 #[serde(default)]
154 pub fenetre_ag_ordinaire: Option<FenetreAgOrdinaire>,
164
165 pub reception_provisoire_parties_communes: Option<chrono::NaiveDate>,
177
178 pub premiere_cession_de_lot: Option<chrono::NaiveDate>,
184
185 pub transcription_statuts: Option<chrono::NaiveDate>,
193
194 pub reserve_fund_balance: Decimal,
195 #[serde(default)]
198 pub working_capital_balance: Decimal,
199 #[serde(default)]
202 pub reserve_fund_waived: bool,
203 pub created_at: DateTime<Utc>,
204 pub updated_at: DateTime<Utc>,
205}
206
207impl Acp {
208 pub fn new(
218 organization_id: Option<Uuid>,
219 name: String,
220 address_street: String,
221 address_postal_code: String,
222 address_city: String,
223 bce_number: Option<String>,
224 ) -> Result<Self, AcpError> {
225 let name = name.trim().to_string();
226 if name.is_empty() {
227 return Err(AcpError::NameEmpty);
228 }
229 let name_len = name.chars().count();
230 if name_len < 2 {
231 return Err(AcpError::NameTooShort(name_len));
232 }
233 if name_len > 160 {
234 return Err(AcpError::NameTooLong(name_len));
235 }
236
237 let address_street = address_street.trim().to_string();
238 if address_street.is_empty() {
239 return Err(AcpError::AddressStreetEmpty);
240 }
241 let address_postal_code = address_postal_code.trim().to_string();
242 if address_postal_code.is_empty() {
243 return Err(AcpError::PostalCodeEmpty);
244 }
245 let address_city = address_city.trim().to_string();
246 if address_city.is_empty() {
247 return Err(AcpError::CityEmpty);
248 }
249
250 let slug = generate_slug(&name);
251 let now = Utc::now();
252
253 Ok(Self {
254 id: Uuid::new_v4(),
255 organization_id,
256 name,
257 slug,
258 legal_status: AcpLegalStatus::default(),
259 total_tantiemes: DEFAULT_TOTAL_TANTIEMES,
260 bce_number,
261 address_street,
262 address_postal_code,
263 address_city,
264 fenetre_ag_ordinaire: None,
265 reception_provisoire_parties_communes: None,
266 premiere_cession_de_lot: None,
267 transcription_statuts: None,
268 reserve_fund_balance: Decimal::ZERO,
269 working_capital_balance: Decimal::ZERO,
270 reserve_fund_waived: false,
271 created_at: now,
272 updated_at: now,
273 })
274 }
275
276 pub fn set_organization(&mut self, organization_id: Option<Uuid>) {
280 self.organization_id = organization_id;
281 self.updated_at = Utc::now();
282 }
283
284 pub fn update_info(
286 &mut self,
287 name: String,
288 address_street: String,
289 address_postal_code: String,
290 address_city: String,
291 bce_number: Option<String>,
292 ) -> Result<(), AcpError> {
293 let name = name.trim().to_string();
294 if name.is_empty() {
295 return Err(AcpError::NameEmpty);
296 }
297 let name_len = name.chars().count();
298 if name_len < 2 {
299 return Err(AcpError::NameTooShort(name_len));
300 }
301 if name_len > 160 {
302 return Err(AcpError::NameTooLong(name_len));
303 }
304 let address_street = address_street.trim().to_string();
305 if address_street.is_empty() {
306 return Err(AcpError::AddressStreetEmpty);
307 }
308 let address_postal_code = address_postal_code.trim().to_string();
309 if address_postal_code.is_empty() {
310 return Err(AcpError::PostalCodeEmpty);
311 }
312 let address_city = address_city.trim().to_string();
313 if address_city.is_empty() {
314 return Err(AcpError::CityEmpty);
315 }
316
317 self.slug = generate_slug(&name);
318 self.name = name;
319 self.address_street = address_street;
320 self.address_postal_code = address_postal_code;
321 self.address_city = address_city;
322 self.bce_number = bce_number;
323 self.updated_at = Utc::now();
324 Ok(())
325 }
326
327 pub fn is_self_managed(&self) -> bool {
329 self.organization_id.is_none()
330 }
331
332 pub fn with_total_tantiemes(mut self, value: i32) -> Result<Self, AcpError> {
338 if value <= 0 {
339 return Err(AcpError::TotalTantiemesInvalid(value));
340 }
341 self.total_tantiemes = value;
342 Ok(self)
343 }
344
345 pub fn set_total_tantiemes(&mut self, value: i32) -> Result<(), AcpError> {
347 if value <= 0 {
348 return Err(AcpError::TotalTantiemesInvalid(value));
349 }
350 self.total_tantiemes = value;
351 self.updated_at = Utc::now();
352 Ok(())
353 }
354
355 pub fn is_conformant(&self, metrics: &AcpMetrics) -> bool {
401 metrics.quota_sum == Decimal::from(self.total_tantiemes)
402 }
403
404 pub fn quota_delta(&self, metrics: &AcpMetrics) -> Decimal {
407 Decimal::from(self.total_tantiemes) - metrics.quota_sum
408 }
409
410 pub fn fixer_fenetre_ag_ordinaire(&mut self, fenetre: FenetreAgOrdinaire) {
419 self.fenetre_ag_ordinaire = Some(fenetre);
420 self.updated_at = Utc::now();
421 }
422
423 pub fn ag_ordinaire_dans_la_fenetre(&self, date: chrono::NaiveDate) -> Option<bool> {
428 self.fenetre_ag_ordinaire.map(|f| f.contient(date))
429 }
430
431 pub fn enregistrer_reception_provisoire(&mut self, date: chrono::NaiveDate) {
436 self.reception_provisoire_parties_communes = Some(date);
437 self.updated_at = Utc::now();
438 }
439
440 pub fn statut_fonds_de_reserve(
448 &self,
449 aujourdhui: chrono::NaiveDate,
450 charges_ordinaires_n_moins_1: Decimal,
451 ) -> super::fonds_de_reserve::StatutFondsReserve {
452 super::fonds_de_reserve::statut(
453 self.reception_provisoire_parties_communes,
454 aujourdhui,
455 self.reserve_fund_waived,
456 charges_ordinaires_n_moins_1,
457 )
458 }
459
460 pub fn enregistrer_premiere_cession(&mut self, date: chrono::NaiveDate) {
463 self.premiere_cession_de_lot = Some(date);
464 self.updated_at = Utc::now();
465 }
466
467 pub fn enregistrer_transcription_statuts(&mut self, date: chrono::NaiveDate) {
469 self.transcription_statuts = Some(date);
470 self.updated_at = Utc::now();
471 }
472
473 pub fn personnalite_juridique(&self) -> super::personnalite_juridique::PersonnaliteJuridique {
479 super::personnalite_juridique::personnalite(
480 self.premiere_cession_de_lot,
481 self.transcription_statuts,
482 )
483 }
484
485 pub fn peut_engager(&self) -> bool {
491 self.personnalite_juridique().opposable_par_lacp()
492 }
493
494 pub fn assert_conformant(&self, metrics: &AcpMetrics) -> Result<(), AcpNotConformantError> {
495 if !self.is_conformant(metrics) {
496 return Err(AcpNotConformantError {
497 acp_id: self.id,
498 units_delta: metrics.declared_units_total - metrics.units_count,
499 quota_delta: self.quota_delta(metrics),
500 quota_basis: self.total_tantiemes,
501 });
502 }
503 Ok(())
504 }
505
506 pub fn set_reserve_fund_balance(&mut self, balance: Decimal) -> Result<(), AcpError> {
518 if balance < Decimal::ZERO {
519 return Err(AcpError::NegativeFundBalance(balance));
520 }
521 self.reserve_fund_balance = balance;
522 self.updated_at = Utc::now();
523 Ok(())
524 }
525
526 pub fn set_working_capital_balance(&mut self, balance: Decimal) -> Result<(), AcpError> {
528 if balance < Decimal::ZERO {
529 return Err(AcpError::NegativeFundBalance(balance));
530 }
531 self.working_capital_balance = balance;
532 self.updated_at = Utc::now();
533 Ok(())
534 }
535
536 pub fn set_reserve_fund_waived(&mut self, waived: bool) {
540 self.reserve_fund_waived = waived;
541 self.updated_at = Utc::now();
542 }
543
544 pub fn required_reserve_fund(&self, ordinary_charges_n1: Decimal) -> Decimal {
547 ordinary_charges_n1 * RESERVE_FUND_RATE
548 }
549
550 pub fn is_reserve_fund_compliant(&self, ordinary_charges_n1: Decimal) -> bool {
554 self.reserve_fund_waived
555 || self.reserve_fund_balance >= self.required_reserve_fund(ordinary_charges_n1)
556 }
557
558 pub fn assert_reserve_fund_compliant(
562 &self,
563 ordinary_charges_n1: Decimal,
564 ) -> Result<(), ReserveFundInsufficientError> {
565 if self.is_reserve_fund_compliant(ordinary_charges_n1) {
566 return Ok(());
567 }
568 Err(ReserveFundInsufficientError {
569 acp_id: self.id,
570 required: self.required_reserve_fund(ordinary_charges_n1),
571 actual: self.reserve_fund_balance,
572 ordinary_charges_n1,
573 })
574 }
575}
576
577#[derive(Debug, Clone, PartialEq, Eq)]
585pub struct AcpNotConformantError {
586 pub acp_id: Uuid,
587 pub units_delta: i32,
588 pub quota_delta: Decimal,
589 pub quota_basis: i32,
590}
591
592impl std::fmt::Display for AcpNotConformantError {
593 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
594 write!(
595 f,
596 "ACP {} not conformant: {} units missing, quota delta {} / {} (acte de base)",
597 self.acp_id, self.units_delta, self.quota_delta, self.quota_basis
598 )
599 }
600}
601
602impl std::error::Error for AcpNotConformantError {}
603
604#[derive(Debug, Clone, PartialEq, Eq)]
609pub struct ReserveFundInsufficientError {
610 pub acp_id: Uuid,
611 pub required: Decimal,
613 pub actual: Decimal,
615 pub ordinary_charges_n1: Decimal,
617}
618
619impl std::fmt::Display for ReserveFundInsufficientError {
620 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
621 write!(
622 f,
623 "ACP {} reserve fund insufficient: {} < required {} (5% of {} ordinary charges N-1)",
624 self.acp_id, self.actual, self.required, self.ordinary_charges_n1
625 )
626 }
627}
628
629impl std::error::Error for ReserveFundInsufficientError {}
630
631fn generate_slug(name: &str) -> String {
635 name.chars()
636 .map(|c| match c {
637 'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'à' | 'á' | 'â' | 'ã' | 'ä' => 'a',
638 'È' | 'É' | 'Ê' | 'Ë' | 'è' | 'é' | 'ê' | 'ë' => 'e',
639 'Ì' | 'Í' | 'Î' | 'Ï' | 'ì' | 'í' | 'î' | 'ï' => 'i',
640 'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' | 'ò' | 'ó' | 'ô' | 'õ' | 'ö' => 'o',
641 'Ù' | 'Ú' | 'Û' | 'Ü' | 'ù' | 'ú' | 'û' | 'ü' => 'u',
642 'Ç' | 'ç' => 'c',
643 'Ñ' | 'ñ' => 'n',
644 _ if c.is_alphanumeric() => c.to_ascii_lowercase(),
645 _ if c.is_whitespace() || c == '-' => '-',
646 _ => '-',
647 })
648 .collect::<String>()
649 .split('-')
650 .filter(|s| !s.is_empty())
651 .collect::<Vec<_>>()
652 .join("-")
653}
654
655#[cfg(test)]
660mod tests_art_3_85_fenetre_statutaire {
661 use super::*;
662 use crate::domain::copropriete::fenetre_ag_ordinaire::FenetreAgOrdinaire;
663
664 fn acp() -> Acp {
665 Acp::new(
666 Some(Uuid::new_v4()),
667 "ACP Résidence du Parc".to_string(),
668 "12 Rue de la Loi".to_string(),
669 "1000".to_string(),
670 "Bruxelles".to_string(),
671 None,
672 )
673 .expect("ACP valide")
674 }
675
676 fn le(annee: i32, mois: u32, jour: u32) -> chrono::NaiveDate {
677 chrono::NaiveDate::from_ymd_opt(annee, mois, jour).expect("date valide")
678 }
679
680 #[test]
682 fn happy_une_ag_dans_la_fenetre_est_conforme() {
683 let mut acp = acp();
684 acp.fixer_fenetre_ag_ordinaire(FenetreAgOrdinaire::new(6, 1).unwrap());
685
686 assert_eq!(acp.ag_ordinaire_dans_la_fenetre(le(2026, 6, 8)), Some(true));
687 }
688
689 #[test]
690 fn negative_une_ag_hors_fenetre_est_signalee() {
691 let mut acp = acp();
692 acp.fixer_fenetre_ag_ordinaire(FenetreAgOrdinaire::new(6, 1).unwrap());
693
694 assert_eq!(
695 acp.ag_ordinaire_dans_la_fenetre(le(2026, 9, 8)),
696 Some(false)
697 );
698 }
699
700 #[test]
706 fn edge_sans_roi_encode_la_question_reste_ouverte() {
707 assert_eq!(acp().ag_ordinaire_dans_la_fenetre(le(2026, 6, 8)), None);
708 }
709
710 #[test]
714 fn happy_une_acp_neuve_na_pas_encore_de_fenetre() {
715 assert!(acp().fenetre_ag_ordinaire.is_none());
716 }
717}
718
719#[cfg(test)]
720mod tests {
721 use super::*;
722
723 #[test]
726 fn happy_new_acp_with_organization_succeeds() {
727 let org_id = Uuid::new_v4();
728 let acp = Acp::new(
729 Some(org_id),
730 "Residence Les Tilleuls".to_string(),
731 "Rue de la Paix 12".to_string(),
732 "1000".to_string(),
733 "Bruxelles".to_string(),
734 None,
735 )
736 .expect("constructor must accept valid inputs");
737
738 assert_eq!(acp.organization_id, Some(org_id));
739 assert_eq!(acp.name, "Residence Les Tilleuls");
740 assert_eq!(acp.slug, "residence-les-tilleuls");
741 assert_eq!(acp.legal_status, AcpLegalStatus::CoproprieteBelge);
742 assert_eq!(acp.address_city, "Bruxelles");
743 assert!(!acp.is_self_managed());
744 }
745
746 #[test]
747 fn happy_new_acp_without_organization_is_self_managed() {
748 let acp = Acp::new(
749 None,
750 "Copro Autogeree".to_string(),
751 "Rue X 1".to_string(),
752 "1000".to_string(),
753 "Bruxelles".to_string(),
754 None,
755 )
756 .unwrap();
757
758 assert!(acp.is_self_managed());
759 assert_eq!(acp.organization_id, None);
760 }
761
762 #[test]
763 fn happy_set_organization_attaches_and_detaches() {
764 let mut acp = Acp::new(
765 None,
766 "Test".to_string(),
767 "Rue X".to_string(),
768 "1000".to_string(),
769 "Bruxelles".to_string(),
770 None,
771 )
772 .unwrap();
773 let original_updated = acp.updated_at;
774
775 let org_id = Uuid::new_v4();
776 acp.set_organization(Some(org_id));
777 assert_eq!(acp.organization_id, Some(org_id));
778 assert!(acp.updated_at >= original_updated);
779
780 acp.set_organization(None);
781 assert_eq!(acp.organization_id, None);
782 assert!(acp.is_self_managed());
783 }
784
785 #[test]
786 fn happy_update_info_regenerates_slug() {
787 let mut acp = Acp::new(
788 None,
789 "Old Name".to_string(),
790 "Rue X".to_string(),
791 "1000".to_string(),
792 "Bruxelles".to_string(),
793 None,
794 )
795 .unwrap();
796 assert_eq!(acp.slug, "old-name");
797
798 acp.update_info(
799 "New Name".to_string(),
800 "Rue X".to_string(),
801 "1000".to_string(),
802 "Bruxelles".to_string(),
803 None,
804 )
805 .unwrap();
806 assert_eq!(acp.name, "New Name");
807 assert_eq!(acp.slug, "new-name");
808 }
809
810 fn sample_acp() -> Acp {
813 Acp::new(
814 None,
815 "Acte Base Test".to_string(),
816 "Rue X 1".to_string(),
817 "1000".to_string(),
818 "Bruxelles".to_string(),
819 None,
820 )
821 .unwrap()
822 }
823
824 #[test]
825 fn happy_total_tantiemes_defaults_to_1000() {
826 assert_eq!(sample_acp().total_tantiemes, DEFAULT_TOTAL_TANTIEMES);
827 assert_eq!(sample_acp().total_tantiemes, 1000);
828 }
829
830 #[test]
831 fn happy_with_total_tantiemes_10000_acte_dix_millemes() {
832 let acp = sample_acp().with_total_tantiemes(10000).unwrap();
833 assert_eq!(acp.total_tantiemes, 10000);
834 }
835
836 #[test]
837 fn edge_with_total_tantiemes_1_accepted() {
838 let acp = sample_acp().with_total_tantiemes(1).unwrap();
839 assert_eq!(acp.total_tantiemes, 1);
840 }
841
842 #[test]
843 fn edge_set_total_tantiemes_updates_timestamp() {
844 let mut acp = sample_acp();
845 let before = acp.updated_at;
846 acp.set_total_tantiemes(10000).unwrap();
847 assert_eq!(acp.total_tantiemes, 10000);
848 assert!(acp.updated_at >= before);
849 }
850
851 #[test]
852 fn security_total_tantiemes_must_be_explicit_to_change() {
853 assert!(sample_acp().total_tantiemes > 0);
856 }
857
858 #[test]
859 fn negative_with_total_tantiemes_zero_rejected() {
860 let err = sample_acp().with_total_tantiemes(0).unwrap_err();
861 assert_eq!(err, AcpError::TotalTantiemesInvalid(0));
862 }
863
864 #[test]
865 fn negative_with_total_tantiemes_negative_rejected() {
866 let err = sample_acp().with_total_tantiemes(-5).unwrap_err();
867 assert_eq!(err, AcpError::TotalTantiemesInvalid(-5));
868 }
869
870 #[test]
871 fn negative_set_total_tantiemes_zero_rejected_and_unchanged() {
872 let mut acp = sample_acp().with_total_tantiemes(10000).unwrap();
873 let err = acp.set_total_tantiemes(0).unwrap_err();
874 assert_eq!(err, AcpError::TotalTantiemesInvalid(0));
875 assert_eq!(acp.total_tantiemes, 10000); }
877
878 fn metrics(units: i32, declared: i32, quota: Decimal, blocs: i32) -> AcpMetrics {
881 AcpMetrics {
882 units_count: units,
883 declared_units_total: declared,
884 quota_sum: quota,
885 buildings_count: blocs,
886 }
887 }
888
889 #[test]
890 fn happy_acp_conformant_base_1000_mono_bloc() {
891 let acp = sample_acp(); let m = metrics(10, 10, Decimal::from(1000), 1);
893 assert!(acp.is_conformant(&m));
894 assert!(acp.assert_conformant(&m).is_ok());
895 }
896
897 #[test]
898 fn happy_acp_conformant_base_10000_multi_blocs() {
899 let acp = sample_acp().with_total_tantiemes(10000).unwrap();
900 let m = metrics(182, 182, Decimal::from(10000), 3);
902 assert!(acp.assert_conformant(&m).is_ok());
903 }
904
905 #[test]
906 fn edge_acp_quota_drift_one_tenth_base_10000() {
907 let acp = sample_acp().with_total_tantiemes(10000).unwrap();
908 let m = metrics(182, 182, Decimal::from(9999) + Decimal::new(9, 1), 3); let err = acp.assert_conformant(&m).unwrap_err();
910 assert_eq!(err.acp_id, acp.id);
911 assert_eq!(err.quota_delta, Decimal::new(1, 1)); assert_eq!(err.quota_basis, 10000);
913 assert_eq!(err.units_delta, 0);
914 }
915
916 #[test]
929 fn edge_acp_units_drift_avec_quotites_justes_est_conforme() {
930 let acp = sample_acp(); let m = metrics(9, 10, Decimal::from(1000), 1);
933 assert!(
934 acp.assert_conformant(&m).is_ok(),
935 "un écart de lots ne doit plus fermer la comptabilité : \
936 c'est le chemin nominal d'un encodage progressif (#770)"
937 );
938 }
939
940 #[test]
946 fn negative_ecart_de_quotites_reste_bloquant() {
947 let acp = sample_acp(); let m = metrics(10, 10, Decimal::from(600), 1);
950 let err = acp.assert_conformant(&m).unwrap_err();
951 assert_eq!(err.quota_delta, Decimal::from(400));
952 assert_eq!(err.quota_basis, 1000);
953 }
954
955 #[test]
956 fn security_acp_metrics_tampering_detected() {
957 let acp = sample_acp().with_total_tantiemes(10000).unwrap();
961 let m = metrics(182, 182, Decimal::from(5000), 3); let err = acp.assert_conformant(&m).unwrap_err();
963 assert_eq!(err.quota_delta, Decimal::from(5000));
964 assert_eq!(err.quota_basis, 10000);
965 }
966
967 #[test]
968 fn negative_acp_empty_metrics_is_not_conformant() {
969 let acp = sample_acp(); let m = AcpMetrics::empty();
971 let err = acp.assert_conformant(&m).unwrap_err();
972 assert_eq!(err.quota_delta, Decimal::from(1000));
973 assert_eq!(err.quota_basis, 1000);
974 assert_eq!(err.units_delta, 0);
975 }
976
977 #[test]
978 fn negative_acp_not_conformant_error_display_is_narrative() {
979 let acp = sample_acp().with_total_tantiemes(10000).unwrap();
980 let err = acp
981 .assert_conformant(&metrics(181, 182, Decimal::from(9975), 3))
982 .unwrap_err();
983 let s = format!("{}", err);
984 assert!(s.contains("not conformant"));
985 assert!(s.contains("10000"));
986 }
987
988 #[test]
991 fn happy_reserve_fund_meets_5pct_threshold() {
992 let charges = Decimal::from(100_000);
994 let mut acp = sample_acp();
995 acp.set_reserve_fund_balance(Decimal::from(5000)).unwrap();
996 assert_eq!(acp.required_reserve_fund(charges), Decimal::from(5000));
997 assert!(acp.is_reserve_fund_compliant(charges));
998 assert!(acp.assert_reserve_fund_compliant(charges).is_ok());
999 acp.set_reserve_fund_balance(Decimal::from(8000)).unwrap();
1001 assert!(acp.assert_reserve_fund_compliant(charges).is_ok());
1002 }
1003
1004 #[test]
1005 fn edge_reserve_fund_exactly_5pct_ok_below_ko_waived_ok() {
1006 let charges = Decimal::from(100_000); let mut acp = sample_acp();
1008 acp.set_reserve_fund_balance(Decimal::from(5000)).unwrap();
1010 assert!(acp.is_reserve_fund_compliant(charges));
1011 acp.set_reserve_fund_balance(Decimal::from(4990)).unwrap();
1013 assert!(!acp.is_reserve_fund_compliant(charges));
1014 acp.set_reserve_fund_waived(true);
1016 assert!(acp.is_reserve_fund_compliant(charges));
1017 assert!(acp.assert_reserve_fund_compliant(charges).is_ok());
1018 }
1019
1020 #[test]
1021 fn security_reserve_fund_threshold_not_bypassable() {
1022 let charges = Decimal::from(200_000); let mut acp = sample_acp();
1026 acp.set_reserve_fund_balance(Decimal::from(9999)).unwrap();
1027 assert!(!acp.reserve_fund_waived);
1028 assert!(!acp.is_reserve_fund_compliant(charges));
1029 let err = acp.assert_reserve_fund_compliant(charges).unwrap_err();
1030 assert_eq!(err.required, Decimal::from(10000));
1031 assert_eq!(err.actual, Decimal::from(9999));
1032 }
1033
1034 #[test]
1035 fn negative_reserve_fund_insufficient_typed_and_negative_balance_rejected() {
1036 let charges = Decimal::from(100_000);
1037 let acp = sample_acp(); let err = acp.assert_reserve_fund_compliant(charges).unwrap_err();
1039 assert_eq!(err.acp_id, acp.id);
1040 assert_eq!(err.required, Decimal::from(5000));
1041 assert_eq!(err.actual, Decimal::ZERO);
1042 assert_eq!(err.ordinary_charges_n1, charges);
1043 assert!(format!("{}", err).contains("reserve fund insufficient"));
1044 let mut acp2 = sample_acp();
1046 let e2 = acp2
1047 .set_reserve_fund_balance(Decimal::from(-1))
1048 .unwrap_err();
1049 assert_eq!(e2, AcpError::NegativeFundBalance(Decimal::from(-1)));
1050 assert_eq!(acp2.reserve_fund_balance, Decimal::ZERO);
1051 }
1052
1053 #[test]
1056 fn edge_minimum_name_length_2_accepted() {
1057 let acp = Acp::new(
1058 None,
1059 "Ab".to_string(),
1060 "Rue X 1".to_string(),
1061 "1000".to_string(),
1062 "Bruxelles".to_string(),
1063 None,
1064 );
1065 assert!(acp.is_ok());
1066 }
1067
1068 #[test]
1069 fn edge_name_is_trimmed_before_validation() {
1070 let acp = Acp::new(
1071 None,
1072 " Trimmed Acp ".to_string(),
1073 "Rue X 1".to_string(),
1074 "1000".to_string(),
1075 "Bruxelles".to_string(),
1076 None,
1077 )
1078 .unwrap();
1079 assert_eq!(acp.name, "Trimmed Acp");
1080 assert_eq!(acp.slug, "trimmed-acp");
1081 }
1082
1083 #[test]
1084 fn edge_address_fields_are_trimmed() {
1085 let acp = Acp::new(
1086 None,
1087 "Some Name".to_string(),
1088 " Rue X 1 ".to_string(),
1089 " 1000 ".to_string(),
1090 " Bruxelles ".to_string(),
1091 None,
1092 )
1093 .unwrap();
1094 assert_eq!(acp.address_street, "Rue X 1");
1095 assert_eq!(acp.address_postal_code, "1000");
1096 assert_eq!(acp.address_city, "Bruxelles");
1097 }
1098
1099 #[test]
1100 fn edge_legal_status_default_is_copropriete_belge() {
1101 let acp = Acp::new(
1102 None,
1103 "Some Name".to_string(),
1104 "Rue X 1".to_string(),
1105 "1000".to_string(),
1106 "Bruxelles".to_string(),
1107 None,
1108 )
1109 .unwrap();
1110 assert_eq!(acp.legal_status.as_db_str(), "copropriete_belge");
1111 }
1112
1113 #[test]
1114 fn edge_unknown_legal_status_db_string_decodes_to_default() {
1115 assert_eq!(
1116 AcpLegalStatus::from_db_str("totally_unknown_value"),
1117 AcpLegalStatus::CoproprieteBelge
1118 );
1119 }
1120
1121 #[test]
1129 fn security_organization_id_is_required_to_be_explicit() {
1130 #[allow(clippy::type_complexity)]
1133 let _: fn(
1134 Option<Uuid>,
1135 String,
1136 String,
1137 String,
1138 String,
1139 Option<String>,
1140 ) -> Result<Acp, AcpError> = Acp::new;
1141 }
1142
1143 #[test]
1146 fn negative_empty_name_is_rejected() {
1147 let err = Acp::new(
1148 None,
1149 "".to_string(),
1150 "Rue X 1".to_string(),
1151 "1000".to_string(),
1152 "Bruxelles".to_string(),
1153 None,
1154 )
1155 .unwrap_err();
1156 assert_eq!(err, AcpError::NameEmpty);
1157 }
1158
1159 #[test]
1160 fn negative_whitespace_only_name_is_rejected_as_empty() {
1161 let err = Acp::new(
1162 None,
1163 " ".to_string(),
1164 "Rue X 1".to_string(),
1165 "1000".to_string(),
1166 "Bruxelles".to_string(),
1167 None,
1168 )
1169 .unwrap_err();
1170 assert_eq!(err, AcpError::NameEmpty);
1171 }
1172
1173 #[test]
1174 fn negative_single_char_name_is_too_short() {
1175 let err = Acp::new(
1176 None,
1177 "A".to_string(),
1178 "Rue X 1".to_string(),
1179 "1000".to_string(),
1180 "Bruxelles".to_string(),
1181 None,
1182 )
1183 .unwrap_err();
1184 assert_eq!(err, AcpError::NameTooShort(1));
1185 }
1186
1187 #[test]
1188 fn negative_name_too_long_is_rejected() {
1189 let long_name = "A".repeat(161);
1190 let err = Acp::new(
1191 None,
1192 long_name,
1193 "Rue X 1".to_string(),
1194 "1000".to_string(),
1195 "Bruxelles".to_string(),
1196 None,
1197 )
1198 .unwrap_err();
1199 assert_eq!(err, AcpError::NameTooLong(161));
1200 }
1201
1202 #[test]
1203 fn negative_empty_street_is_rejected() {
1204 let err = Acp::new(
1205 None,
1206 "Some Name".to_string(),
1207 "".to_string(),
1208 "1000".to_string(),
1209 "Bruxelles".to_string(),
1210 None,
1211 )
1212 .unwrap_err();
1213 assert_eq!(err, AcpError::AddressStreetEmpty);
1214 }
1215
1216 #[test]
1217 fn negative_empty_postal_code_is_rejected() {
1218 let err = Acp::new(
1219 None,
1220 "Some Name".to_string(),
1221 "Rue X 1".to_string(),
1222 "".to_string(),
1223 "Bruxelles".to_string(),
1224 None,
1225 )
1226 .unwrap_err();
1227 assert_eq!(err, AcpError::PostalCodeEmpty);
1228 }
1229
1230 #[test]
1231 fn negative_empty_city_is_rejected() {
1232 let err = Acp::new(
1233 None,
1234 "Some Name".to_string(),
1235 "Rue X 1".to_string(),
1236 "1000".to_string(),
1237 "".to_string(),
1238 None,
1239 )
1240 .unwrap_err();
1241 assert_eq!(err, AcpError::CityEmpty);
1242 }
1243
1244 #[test]
1245 fn negative_update_info_re_validates_invariants() {
1246 let mut acp = Acp::new(
1247 None,
1248 "Valid".to_string(),
1249 "Rue X 1".to_string(),
1250 "1000".to_string(),
1251 "Bruxelles".to_string(),
1252 None,
1253 )
1254 .unwrap();
1255 let err = acp
1256 .update_info(
1257 "".to_string(),
1258 "Rue X 1".to_string(),
1259 "1000".to_string(),
1260 "Bruxelles".to_string(),
1261 None,
1262 )
1263 .unwrap_err();
1264 assert_eq!(err, AcpError::NameEmpty);
1265 assert_eq!(acp.name, "Valid");
1267 }
1268}