Skip to main content

koprogo_api/domain/copropriete/
meeting.rs

1use chrono::{DateTime, Duration, Utc};
2use rust_decimal::Decimal;
3use rust_decimal_macros::dec;
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7/// Type d'assemblée générale
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
9pub enum MeetingType {
10    Ordinary,      // Assemblée Générale Ordinaire (AGO)
11    Extraordinary, // Assemblée Générale Extraordinaire (AGE)
12}
13
14/// Statut de l'assemblée
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
16pub enum MeetingStatus {
17    Scheduled,
18    Completed,
19    Cancelled,
20}
21
22/// Modalité de tenue de l'assemblée (Art. 3.87 §1er CC : "physiquement ou à
23/// distance au moyen d'une communication électronique"). Story 4.1.
24#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
25#[serde(rename_all = "snake_case")]
26pub enum MeetingMode {
27    InPerson,
28    Remote,
29    Hybrid,
30}
31
32impl MeetingMode {
33    pub fn from_db_string(s: &str) -> Result<Self, String> {
34        match s {
35            "in_person" => Ok(Self::InPerson),
36            "remote" => Ok(Self::Remote),
37            "hybrid" => Ok(Self::Hybrid),
38            other => Err(format!("Unknown meeting mode: {other}")),
39        }
40    }
41
42    pub fn to_db_str(&self) -> &'static str {
43        match self {
44            Self::InPerson => "in_person",
45            Self::Remote => "remote",
46            Self::Hybrid => "hybrid",
47        }
48    }
49
50    /// Art. 3.87 §1er CC ne rend la participation à distance possible que si
51    /// le lien de connexion est communiqué : sans lui, la modalité annoncée
52    /// est creuse — personne ne peut effectivement rejoindre l'AG.
53    fn requires_videoconf_url(&self) -> bool {
54        matches!(self, Self::Remote | Self::Hybrid)
55    }
56
57    /// Story 4.2 — Art. 3.87 §1er CC : un vote émis à distance doit pouvoir
58    /// être rattaché de façon fiable à son auteur, ce qu'une AG physique
59    /// garantit déjà par la présence elle-même. Même ensemble de modes que
60    /// `requires_videoconf_url` : c'est la même bascule distancielle qui
61    /// déclenche les deux exigences.
62    pub fn requires_strong_vote_auth(&self) -> bool {
63        self.requires_videoconf_url()
64    }
65}
66
67/// Story 4.1 — `Meeting::set_mode()` a refusé un mode distanciel/hybride
68/// sans URL de visioconférence. Mappé vers 422 par `AppError` (payload
69/// `MEETING_MODE_REQUIRES_VIDEOCONF`, cf. `application/error.rs`).
70#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
71pub enum MeetingModeError {
72    #[error(
73        "La configuration de visioconférence (URL) est obligatoire pour une AG en mode {mode:?} \
74         (Art. 3.87 §1er CC)"
75    )]
76    VideoconfUrlRequired { mode: MeetingMode },
77}
78
79/// Représente une assemblée générale de copropriétaires
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
81pub struct Meeting {
82    pub id: Uuid,
83
84    /// L'ACP dont c'est l'assemblée.
85    ///
86    /// Art. 3.87 § 1er : « Chaque propriétaire d'un lot fait partie de
87    /// l'assemblée générale ». L'assemblée est l'organe de l'association, pas
88    /// une réunion que le syndic organiserait pour son compte. Il la tient
89    /// (Art. 3.87 § 2), il ne la possède pas. Cf. ADR-0045.
90    pub acp_id: Uuid,
91
92    /// Le syndic qui a tenu l'assemblée, conservé comme trace d'auteur.
93    pub organization_id: Uuid,
94    pub building_id: Uuid,
95    pub meeting_type: MeetingType,
96    pub title: String,
97    pub description: Option<String>,
98    pub scheduled_date: DateTime<Utc>,
99    pub location: String,
100    pub status: MeetingStatus,
101    pub agenda: Vec<String>,
102    pub attendees_count: Option<i32>,
103    // Quorum — Art. 3.87 §5 CC : AG valide si >50% des quotes-parts présentes/représentées
104    pub quorum_validated: bool,
105    pub quorum_percentage: Option<f64>, // % des quotes-parts présentes/représentées (0.0-100.0)
106    pub total_quotas: Option<Decimal>,  // Total millièmes du bâtiment (Decimal exact — ADR-0008)
107    pub present_quotas: Option<Decimal>, // Millièmes présents + représentés (Decimal exact — ADR-0008)
108    // Second Convocation — Issue #311 (Art. 3.87 §5 CC: No quorum required for 2nd convocation)
109    pub is_second_convocation: bool, // true = 2e convocation (no quorum check needed)
110    // PV Distribution — Issue #313: Track when AG minutes are sent to owners
111    pub minutes_document_id: Option<Uuid>, // FK to Document
112    pub minutes_sent_at: Option<DateTime<Utc>>, // When PV was distributed
113    // Modalité + configuration distancielle — Art. 3.87 §1er CC (Story 4.1)
114    pub mode: MeetingMode,
115    /// URL de connexion à la session distancielle. Obligatoire si `mode` ∈
116    /// {Remote, Hybrid} — cf. `set_mode()`.
117    pub videoconf_url: Option<String>,
118    pub created_at: DateTime<Utc>,
119    pub updated_at: DateTime<Utc>,
120}
121
122impl Meeting {
123    pub fn new(
124        acp_id: Uuid,
125        organization_id: Uuid,
126        building_id: Uuid,
127        meeting_type: MeetingType,
128        title: String,
129        description: Option<String>,
130        scheduled_date: DateTime<Utc>,
131        location: String,
132    ) -> Result<Self, String> {
133        if title.is_empty() {
134            return Err("Title cannot be empty".to_string());
135        }
136        if location.is_empty() {
137            return Err("Location cannot be empty".to_string());
138        }
139
140        let now = Utc::now();
141        Ok(Self {
142            id: Uuid::new_v4(),
143            acp_id,
144            organization_id,
145            building_id,
146            meeting_type,
147            title,
148            description,
149            scheduled_date,
150            location,
151            status: MeetingStatus::Scheduled,
152            agenda: Vec::new(),
153            attendees_count: None,
154            quorum_validated: false,
155            quorum_percentage: None,
156            total_quotas: None,
157            present_quotas: None,
158            is_second_convocation: false, // Default: first convocation
159            minutes_document_id: None,
160            minutes_sent_at: None,
161            mode: MeetingMode::InPerson,
162            videoconf_url: None,
163            created_at: now,
164            updated_at: now,
165        })
166    }
167
168    /// Configure la modalité de tenue de l'AG (Story 4.1, Art. 3.87 §1er CC).
169    ///
170    /// Les modes `Remote` et `Hybrid` supposent qu'un copropriétaire puisse
171    /// effectivement se connecter : sans URL de visioconférence, l'annonce du
172    /// mode est creuse. Refusé avant persistance (422), pas découvert après
173    /// convocation.
174    pub fn set_mode(
175        &mut self,
176        mode: MeetingMode,
177        videoconf_url: Option<String>,
178    ) -> Result<(), MeetingModeError> {
179        let url_present = videoconf_url
180            .as_deref()
181            .map(|u| !u.trim().is_empty())
182            .unwrap_or(false);
183        if mode.requires_videoconf_url() && !url_present {
184            return Err(MeetingModeError::VideoconfUrlRequired { mode });
185        }
186        self.mode = mode;
187        self.videoconf_url = videoconf_url;
188        self.updated_at = Utc::now();
189        Ok(())
190    }
191
192    pub fn add_agenda_item(&mut self, item: String) -> Result<(), String> {
193        // `is_empty()` seul laissait passer un intitule fait d'espaces : le
194        // point s'inscrivait sans un mot de refus, et le defaut n'apparaissait
195        // qu'au vote, ou `cast_vote` le rejette — trop tard pour convoquer
196        // autrement. La convocation doit enoncer l'objet des decisions
197        // (Art. 3.87 § 2 CC) ; un intitule blanc n'enonce rien.
198        if item.trim().is_empty() {
199            return Err(
200                "Un point d'ordre du jour doit énoncer ce qui sera mis aux voix : \
201                 un intitulé vide n'informe aucun copropriétaire (Art. 3.87 § 2 CC)."
202                    .to_string(),
203            );
204        }
205        self.agenda.push(item);
206        self.updated_at = Utc::now();
207        Ok(())
208    }
209
210    /// Transition d'état pure : `Scheduled → Completed`.
211    ///
212    /// **Track H Story H3** : ancien `complete(attendees_count)` renommé en
213    /// `complete_internal()`. La validation des invariants Art. 3.87 §3-5 CC
214    /// (convocations envoyées, votes clôturés, présences enregistrées,
215    /// quorum, minutes draft) est gérée par
216    /// `assert_can_complete(&checklist)` (cf. story H3).
217    ///
218    /// Le use-case `complete_meeting()` enchaîne :
219    /// 1. `completion_checker.build_checklist(meeting_id)` (port DB)
220    /// 2. `meeting.assert_can_complete(&checklist)?` (gate métier — 422 narratif)
221    /// 3. `meeting.complete_internal()?` (state machine — cette méthode)
222    /// 4. `repository.update(&meeting)`
223    ///
224    /// `attendees_count` (legacy param) reste accepté par le handler pour
225    /// compat backward ; depuis Track H Story H3 la source de vérité est
226    /// `checklist.attended_quotas` (présents + représentés agrégés DB-side).
227    pub fn complete_internal(&mut self) -> Result<(), String> {
228        match self.status {
229            MeetingStatus::Scheduled => {
230                self.status = MeetingStatus::Completed;
231                self.updated_at = Utc::now();
232                Ok(())
233            }
234            MeetingStatus::Completed => Err("Meeting is already completed".to_string()),
235            MeetingStatus::Cancelled => Err("Cannot complete a cancelled meeting".to_string()),
236        }
237    }
238
239    /// **DEPRECATED** — préservé pour la compatibilité backward des tests
240    /// internes au domain qui historiquement passaient `attendees_count` à
241    /// `complete()`. Délègue à `complete_internal()` puis stocke
242    /// `attendees_count` sur l'entité.
243    ///
244    /// Nouveaux call-sites : utiliser `complete_internal()` après
245    /// `assert_can_complete(&checklist)` — la valeur d'attendance est
246    /// dérivée de la checklist (Art. 3.87 §5 CC, present_quotas DB-side).
247    #[deprecated(
248        note = "Use complete_internal() after assert_can_complete(&checklist) — Track H Story H3"
249    )]
250    pub fn complete(&mut self, attendees_count: i32) -> Result<(), String> {
251        self.complete_internal()?;
252        self.attendees_count = Some(attendees_count);
253        Ok(())
254    }
255
256    /// Track H Story H3 — Vérifie que toutes les conditions Art. 3.87 §3-5 CC
257    /// sont réunies pour clôturer la réunion. Retourne `Err` avec la liste
258    /// exhaustive des invariants manquants pour permettre au FE de guider
259    /// le syndic (cf. composant `<MissingInvariantsList>`).
260    ///
261    /// **Logique métier** :
262    /// - `convocations_sent` (Art. 3.87 §3 CC — convocations envoyées en amont)
263    /// - `open_resolutions == 0` (Art. 3.87 §4 CC — tous votes clôturés)
264    /// - `attendance_recorded` (Art. 3.87 §5 CC — présences enregistrées)
265    /// - **Quorum double** (Art. 3.87 §5, Story H9) : (A) têtes > 50 % strict
266    ///   ET quotités ≥ 50 % inclusif, OU (B) alternative quotités > 3/4 strict.
267    ///   Volet quotités KO → `QuorumNotReached` ; volet têtes KO →
268    ///   `HeadCountQuorumNotReached`. `total_* == 0` → volet KO (pas de div/0).
269    /// - `minutes_draft_exists` (PV draft sauvegardé avant clôture)
270    ///
271    /// **Pureté** : aucune I/O, aucune dépendance infra. La checklist est
272    /// construite par le port `MeetingCompletionCheckerPort` (DB-side).
273    pub fn assert_can_complete(
274        &self,
275        checklist: &MeetingCompletionChecklist,
276    ) -> Result<(), MeetingNotCompletableError> {
277        let mut missing: Vec<MissingInvariant> = Vec::new();
278
279        if !checklist.convocations_sent {
280            missing.push(MissingInvariant::ConvocationsNotSent);
281        }
282        if checklist.open_resolutions > 0 {
283            missing.push(MissingInvariant::VotesNotClosed {
284                open_resolutions: checklist.open_resolutions,
285            });
286        }
287        if !checklist.attendance_recorded {
288            missing.push(MissingInvariant::AttendanceNotRecorded);
289        }
290
291        // Story H9 — Quorum DOUBLE (Art. 3.87 §5 CC) :
292        //   (A) primaire   : têtes > 50% (strict) ET quotités ≥ 50% (inclusif)
293        //   (B) alternative: quotités > 3/4 (strict), quelles que soient les têtes
294        //   sinon → 2e convocation (gérée ailleurs : `is_second_convocation`).
295        // Comparaisons par multiplication croisée : exact (Decimal), pas de
296        // division ⇒ pas de div/0 ni d'arrondi. `total_* <= 0` → volet KO.
297        let quotas_alternative_ok =
298            Self::quotas_three_quarters_reached(checklist.attended_quotas, checklist.total_quotas);
299        if !quotas_alternative_ok {
300            // Volet quotités ≥ 50% (inclusif — « au moins la moitié »).
301            let quotas_half_ok =
302                Self::quotas_half_reached(checklist.attended_quotas, checklist.total_quotas);
303            if !quotas_half_ok {
304                missing.push(MissingInvariant::QuorumNotReached {
305                    attended_quotas: checklist.attended_quotas,
306                    total_quotas: checklist.total_quotas,
307                });
308            }
309            // Volet têtes > 50% (strict — « plus de la moitié des copropriétaires »).
310            let heads_ok = Self::heads_majority_reached(
311                checklist.present_owners_count,
312                checklist.total_owners_count,
313            );
314            if !heads_ok {
315                missing.push(MissingInvariant::HeadCountQuorumNotReached {
316                    present_owners_count: checklist.present_owners_count,
317                    total_owners_count: checklist.total_owners_count,
318                });
319            }
320        }
321
322        if !checklist.minutes_draft_exists {
323            missing.push(MissingInvariant::MinutesDraftMissing);
324        }
325
326        if missing.is_empty() {
327            Ok(())
328        } else {
329            Err(MeetingNotCompletableError {
330                meeting_id: self.id,
331                missing,
332            })
333        }
334    }
335
336    pub fn cancel(&mut self) -> Result<(), String> {
337        match self.status {
338            MeetingStatus::Scheduled => {
339                self.status = MeetingStatus::Cancelled;
340                self.updated_at = Utc::now();
341                Ok(())
342            }
343            MeetingStatus::Completed => Err("Cannot cancel a completed meeting".to_string()),
344            MeetingStatus::Cancelled => Err("Meeting is already cancelled".to_string()),
345        }
346    }
347
348    pub fn reschedule(&mut self, new_date: DateTime<Utc>) -> Result<(), String> {
349        match self.status {
350            MeetingStatus::Scheduled | MeetingStatus::Cancelled => {
351                self.scheduled_date = new_date;
352                self.status = MeetingStatus::Scheduled;
353                self.updated_at = Utc::now();
354                Ok(())
355            }
356            MeetingStatus::Completed => Err("Cannot reschedule a completed meeting".to_string()),
357        }
358    }
359
360    pub fn is_upcoming(&self) -> bool {
361        self.status == MeetingStatus::Scheduled && self.scheduled_date > Utc::now()
362    }
363
364    // ------------------------------------------------------------------
365    // Art. 3.87 §5 CC — prédicats du quorum double (#661)
366    //
367    // Source UNIQUE de la règle : `assert_can_complete` (chemin présentiel,
368    // Story H9) et `AgSession::is_combined_quorum_reached` (chemin hybride
369    // présentiel + distanciel) appellent tous deux ces fonctions. Avant #661,
370    // chaque chemin portait son propre littéral `50`, avec des sémantiques qui
371    // avaient déjà divergé (strict ici, inclusif là) sans que rien ne le
372    // signale.
373    //
374    // Toutes les comparaisons se font par **multiplication croisée** en
375    // `Decimal` : exact, sans division, donc sans arrondi ni division par zéro.
376    // ------------------------------------------------------------------
377
378    /// Alternative de l'Art. 3.87 §5 : quotités présentes **> 3/4** du total,
379    /// quel que soit le nombre de têtes.
380    pub fn quotas_three_quarters_reached(attended_quotas: Decimal, total_quotas: Decimal) -> bool {
381        total_quotas > Decimal::ZERO && attended_quotas * dec!(4) > total_quotas * dec!(3)
382    }
383
384    /// Volet **quotités** du quorum double : au moins la moitié des quotes-parts
385    /// (**inclusif** — « au moins la moitié »).
386    pub fn quotas_half_reached(attended_quotas: Decimal, total_quotas: Decimal) -> bool {
387        total_quotas > Decimal::ZERO && attended_quotas * dec!(2) >= total_quotas
388    }
389
390    /// Volet **têtes** du quorum double : plus de la moitié des copropriétaires
391    /// présents ou représentés (**strict** — « plus de la moitié »).
392    pub fn heads_majority_reached(present_owners_count: i32, total_owners_count: i32) -> bool {
393        total_owners_count > 0 && present_owners_count * 2 > total_owners_count
394    }
395
396    /// Quorum double complet (Art. 3.87 §5 CC) :
397    ///   (A) têtes > 50% **ET** quotités ≥ 50%, **ou**
398    ///   (B) quotités > 3/4 seules.
399    pub fn double_quorum_reached(
400        attended_quotas: Decimal,
401        total_quotas: Decimal,
402        present_owners_count: i32,
403        total_owners_count: i32,
404    ) -> bool {
405        Self::quotas_three_quarters_reached(attended_quotas, total_quotas)
406            || (Self::quotas_half_reached(attended_quotas, total_quotas)
407                && Self::heads_majority_reached(present_owners_count, total_owners_count))
408    }
409
410    /// Valide le quorum de l'AG (Art. 3.87 §5 CC).
411    /// Quorum atteint si les quotes-parts présentes/représentées dépassent 50% du total.
412    /// Retourne Ok(true) si quorum atteint, Ok(false) si insuffisant (2e convocation requise).
413    ///
414    /// Decimal exact — ADR-0008. `quorum_percentage` reste en f64 pour compat
415    /// affichage (mémoire `no-f64-in-money` : f64 OK pour pourcentage display, pas pour
416    /// montants/quotas).
417    pub fn validate_quorum(
418        &mut self,
419        present_quotas: Decimal,
420        total_quotas: Decimal,
421    ) -> Result<bool, String> {
422        use rust_decimal::prelude::ToPrimitive;
423        if total_quotas <= Decimal::ZERO {
424            return Err("Total quotas must be positive".to_string());
425        }
426        if present_quotas < Decimal::ZERO {
427            return Err("Present quotas cannot be negative".to_string());
428        }
429        if present_quotas > total_quotas {
430            return Err("Present quotas cannot exceed total quotas".to_string());
431        }
432
433        let percentage = (present_quotas / total_quotas) * dec!(100);
434        // Volet **quotités seul** — `quorum_validated` sert de garde au vote
435        // (cf. `check_quorum_for_voting`). Le quorum double complet — têtes ET
436        // quotités, Art. 3.87 §5 — est jugé par `double_quorum_reached`, que
437        // `assert_can_complete` applique à la clôture (#661).
438        //
439        // ⚠ DIVERGENCE CONSTATÉE, VOLONTAIREMENT NON CORRIGÉE ICI (#661) :
440        // ce seuil est **strict** (> 50%) alors que `quotas_half_reached`,
441        // utilisé par la clôture H9, est **inclusif** (≥ 50%). L'Art. 3.87 §5
442        // dit « pour autant qu'ils possèdent au moins la moitié des
443        // quotes-parts » — l'inclusif est donc le bon. Ce chemin-ci est plus
444        // restrictif que la loi : il refuse un quorum à 50% pile que la loi
445        // accepte. Défaut réel, mais changer le comportement du garde-fou de
446        // vote dépasse le périmètre de #661 (qui porte sur le type, pas sur le
447        // seuil) et casserait `test_quorum_not_reached_at_50_percent_exact`,
448        // écrit pour la sémantique stricte. À trancher dans une story dédiée.
449        let quorum_reached = percentage > dec!(50);
450
451        self.present_quotas = Some(present_quotas);
452        self.total_quotas = Some(total_quotas);
453        self.quorum_percentage = percentage.to_f64();
454        self.quorum_validated = quorum_reached;
455        self.updated_at = Utc::now();
456
457        Ok(quorum_reached)
458    }
459
460    /// Vérifie si le quorum est atteint avant d'autoriser un vote.
461    /// Retourne Err si le quorum n'a pas encore été validé ou n'est pas atteint.
462    ///
463    /// EXCEPTION (Art. 3.87 §5 CC): No quorum check required for second convocation (is_second_convocation = true).
464    /// Belgian law: 2e convocation = voting allowed without quorum requirement.
465    pub fn check_quorum_for_voting(&self) -> Result<(), String> {
466        // Art. 3.87 §5 CC: No quorum check needed for 2nd convocation
467        if self.is_second_convocation {
468            return Ok(());
469        }
470
471        if self.quorum_percentage.is_none() {
472            return Err("Quorum has not been validated yet (Art. 3.87 §5 CC)".to_string());
473        }
474        if !self.quorum_validated {
475            let pct = self.quorum_percentage.unwrap_or(0.0);
476            return Err(format!(
477                "Quorum not reached: {:.1}% present (>50% required, Art. 3.87 §5 CC). \
478                 A second convocation is required.",
479                pct
480            ));
481        }
482        Ok(())
483    }
484
485    /// Sets minutes as sent (Issue #313: PV distribution tracking).
486    /// Can only be called once meeting is Completed.
487    pub fn set_minutes_sent(&mut self, document_id: Uuid) -> Result<(), String> {
488        if self.status != MeetingStatus::Completed {
489            return Err("Minutes can only be sent after meeting is completed".to_string());
490        }
491        self.minutes_document_id = Some(document_id);
492        self.minutes_sent_at = Some(Utc::now());
493        self.updated_at = Utc::now();
494        Ok(())
495    }
496
497    /// Checks if minutes are overdue (Issue #313: 30 days after meeting completion).
498    /// Returns true if meeting is Completed, minutes not yet sent, and >30 days have passed.
499    pub fn is_minutes_overdue(&self) -> bool {
500        if self.status != MeetingStatus::Completed {
501            return false;
502        }
503        if self.minutes_sent_at.is_some() {
504            return false;
505        }
506        // Minutes are overdue if more than 30 days have passed since completion/update
507        let deadline = self.updated_at + Duration::days(30);
508        Utc::now() > deadline
509    }
510}
511
512/// Track H Story H3 — Invariant légal manquant pour clôturer une AG.
513///
514/// Énuméré au lieu d'un simple message string : le frontend rend une liste
515/// `<MissingInvariantsList>` typée par variant (label i18n distinct par
516/// invariant) et conserve les métadonnées (open_resolutions, quotas).
517///
518/// **Pourquoi enum + struct fields** : permet au FE de localiser sans
519/// parser de message, et de proposer la bonne action de correction (ex:
520/// router vers panel résolutions si `VotesNotClosed`).
521#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
522#[serde(tag = "type")]
523pub enum MissingInvariant {
524    /// Art. 3.87 §3 CC — Convocations pas envoyées (au moins une non `sent`).
525    ConvocationsNotSent,
526    /// Art. 3.87 §4 CC — Au moins une résolution est encore `Pending`.
527    VotesNotClosed { open_resolutions: i32 },
528    /// Art. 3.87 §5 CC — `present_quotas` non renseigné côté meeting.
529    AttendanceNotRecorded,
530    /// Art. 3.87 §5 CC — Volet **quotités** du quorum double non atteint :
531    /// quotités présentes < 50 % (inclusif) ET < 3/4 (alternative). `total_quotas
532    /// == 0` est mappé ici (pas de div by zero).
533    QuorumNotReached {
534        attended_quotas: Decimal,
535        total_quotas: Decimal,
536    },
537    /// Story H9 — Art. 3.87 §5 CC — Volet **têtes** du quorum double non
538    /// atteint : ≤ 50 % des copropriétaires présents/représentés (« plus de la
539    /// moitié » requis, strict), et l'alternative > 3/4 des quotités n'est pas
540    /// remplie. `total_owners_count == 0` est mappé ici.
541    HeadCountQuorumNotReached {
542        present_owners_count: i32,
543        total_owners_count: i32,
544    },
545    /// PV draft (minutes) pas encore enregistré (cf. `minutes_document_id`).
546    MinutesDraftMissing,
547}
548
549/// Track H Story H3 — Snapshot des conditions Art. 3.87 §3-5 CC pour
550/// décider de la clôturabilité d'une AG.
551///
552/// **Pureté** : struct de données, agrégée par
553/// `MeetingCompletionCheckerPort::build_checklist()` (1 query SQL agrégée).
554/// `Meeting::assert_can_complete(&checklist)` consomme cette struct sans
555/// faire d'I/O — propriété hexagonale.
556///
557/// **Decimal exact** (mémoire `no-f64-in-money`) : `attended_quotas` et
558/// `total_quotas` sont en `Decimal` (millièmes / dix-millièmes selon acte
559/// de base). Convention : `total_quotas = SUM(units.quota)` du building.
560#[derive(Debug, Clone, PartialEq)]
561pub struct MeetingCompletionChecklist {
562    pub convocations_sent: bool,
563    /// 0 = toutes résolutions clôturées (status != 'Pending').
564    pub open_resolutions: i32,
565    pub attendance_recorded: bool,
566    /// Somme des quotas présents+représentés (cf. `meetings.present_quotas`).
567    pub attended_quotas: Decimal,
568    /// Somme des quotas du building (SUM units.quota).
569    pub total_quotas: Decimal,
570    /// Story H9 — Nombre de copropriétaires présents OU représentés (têtes).
571    /// Source : `meetings.present_owners_count` (saisi par le syndic, comme
572    /// `present_quotas`). Volet « têtes » du quorum double (Art. 3.87 §5).
573    pub present_owners_count: i32,
574    /// Story H9 — Nombre total de copropriétaires du building (têtes).
575    /// Source : `COUNT(DISTINCT owner)` du building (calculé DB-side).
576    pub total_owners_count: i32,
577    /// `meetings.minutes_document_id IS NOT NULL`.
578    pub minutes_draft_exists: bool,
579}
580
581/// Track H Story H3 — Erreur typée signalant que l'AG ne peut pas être
582/// clôturée. Liste exhaustive des invariants manquants pour permettre au
583/// FE de rendre `<MissingInvariantsList>` actionnable.
584///
585/// Mappée vers HTTP 422 `MEETING_NOT_COMPLETABLE` via `From<>` dans
586/// `application/error.rs`. Le bridge `From<>` vers `String` permet aux
587/// use-cases legacy `Result<_, String>` (cf. `complete_meeting()` qui n'a
588/// pas encore migré vers `AppError`) de propager l'erreur via `?` ; le
589/// handler décode le préfixe `MEETING_NOT_COMPLETABLE:` pour répondre 422
590/// + payload structuré.
591#[derive(Debug, Clone, PartialEq, Eq)]
592pub struct MeetingNotCompletableError {
593    pub meeting_id: Uuid,
594    pub missing: Vec<MissingInvariant>,
595}
596
597impl std::fmt::Display for MeetingNotCompletableError {
598    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
599        write!(
600            f,
601            "Meeting {} not completable: {} missing invariant(s)",
602            self.meeting_id,
603            self.missing.len()
604        )
605    }
606}
607
608impl std::error::Error for MeetingNotCompletableError {}
609
610impl crate::domain::services::PieceDeGestion for Meeting {
611    fn acp_id(&self) -> Uuid {
612        self.acp_id
613    }
614}
615
616#[cfg(test)]
617#[allow(deprecated)] // tests legacy `complete()` — Track H Story H3
618mod tests {
619    use super::*;
620    use chrono::Duration;
621
622    #[test]
623    fn test_create_meeting_success() {
624        let org_id = Uuid::new_v4();
625        let building_id = Uuid::new_v4();
626        let future_date = Utc::now() + Duration::days(30);
627
628        let meeting = Meeting::new(
629            Uuid::new_v4(), // acp_id
630            org_id,
631            building_id,
632            MeetingType::Ordinary,
633            "AGO 2024".to_string(),
634            Some("Assemblée générale ordinaire annuelle".to_string()),
635            future_date,
636            "Salle des fêtes".to_string(),
637        );
638
639        assert!(meeting.is_ok());
640        let meeting = meeting.unwrap();
641        assert_eq!(meeting.organization_id, org_id);
642        assert_eq!(meeting.status, MeetingStatus::Scheduled);
643        assert!(meeting.is_upcoming());
644    }
645
646    #[test]
647    fn test_add_agenda_item() {
648        let org_id = Uuid::new_v4();
649        let building_id = Uuid::new_v4();
650        let future_date = Utc::now() + Duration::days(30);
651
652        let mut meeting = Meeting::new(
653            Uuid::new_v4(), // acp_id
654            org_id,
655            building_id,
656            MeetingType::Ordinary,
657            "AGO 2024".to_string(),
658            None,
659            future_date,
660            "Salle des fêtes".to_string(),
661        )
662        .unwrap();
663
664        let result = meeting.add_agenda_item("Approbation des comptes".to_string());
665        assert!(result.is_ok());
666        assert_eq!(meeting.agenda.len(), 1);
667    }
668
669    /// Un point d'ordre du jour fait d'espaces n'enonce rien.
670    ///
671    /// Le refus etait pose sur `is_empty()`, qui laisse passer « \t » comme
672    /// «\u{a0}\u{a0}». Le point s'inscrivait donc sans un mot, et le defaut ne se
673    /// revelait qu'au vote — quand `cast_vote` le rejette, trop tard pour
674    /// convoquer autrement (Art. 3.87 § 2 CC).
675    #[test]
676    fn negative_un_point_dordre_du_jour_blanc_est_refuse_a_linscription() {
677        let mut meeting = Meeting::new(
678            Uuid::new_v4(),
679            Uuid::new_v4(),
680            Uuid::new_v4(),
681            MeetingType::Ordinary,
682            "AGO 2026".to_string(),
683            None,
684            Utc::now() + Duration::days(30),
685            "Salle des fêtes".to_string(),
686        )
687        .unwrap();
688
689        for blanc in ["", "   ", "\t", "\n", " \t \n "] {
690            let erreur = meeting
691                .add_agenda_item(blanc.to_string())
692                .expect_err("un intitulé blanc ne doit pas s'inscrire : {blanc:?}");
693            assert!(
694                erreur.contains("3.87"),
695                "le refus doit citer l'article qui le fonde : {erreur}"
696            );
697        }
698        assert!(
699            meeting.agenda.is_empty(),
700            "aucun point blanc ne doit avoir ete inscrit : {:?}",
701            meeting.agenda
702        );
703    }
704
705    #[test]
706    fn test_complete_meeting() {
707        let org_id = Uuid::new_v4();
708        let building_id = Uuid::new_v4();
709        let future_date = Utc::now() + Duration::days(30);
710
711        let mut meeting = Meeting::new(
712            Uuid::new_v4(), // acp_id
713            org_id,
714            building_id,
715            MeetingType::Ordinary,
716            "AGO 2024".to_string(),
717            None,
718            future_date,
719            "Salle des fêtes".to_string(),
720        )
721        .unwrap();
722
723        let result = meeting.complete(45);
724        assert!(result.is_ok());
725        assert_eq!(meeting.status, MeetingStatus::Completed);
726        assert_eq!(meeting.attendees_count, Some(45));
727        assert!(!meeting.is_upcoming());
728    }
729
730    #[test]
731    fn test_complete_already_completed_fails() {
732        let org_id = Uuid::new_v4();
733        let building_id = Uuid::new_v4();
734        let future_date = Utc::now() + Duration::days(30);
735
736        let mut meeting = Meeting::new(
737            Uuid::new_v4(), // acp_id
738            org_id,
739            building_id,
740            MeetingType::Ordinary,
741            "AGO 2024".to_string(),
742            None,
743            future_date,
744            "Salle des fêtes".to_string(),
745        )
746        .unwrap();
747
748        meeting.complete(45).unwrap();
749        let result = meeting.complete(50);
750        assert!(result.is_err());
751        assert_eq!(meeting.attendees_count, Some(45)); // Should not change
752    }
753
754    #[test]
755    fn test_cancel_meeting() {
756        let org_id = Uuid::new_v4();
757        let building_id = Uuid::new_v4();
758        let future_date = Utc::now() + Duration::days(30);
759
760        let mut meeting = Meeting::new(
761            Uuid::new_v4(), // acp_id
762            org_id,
763            building_id,
764            MeetingType::Ordinary,
765            "AGO 2024".to_string(),
766            None,
767            future_date,
768            "Salle des fêtes".to_string(),
769        )
770        .unwrap();
771
772        let result = meeting.cancel();
773        assert!(result.is_ok());
774        assert_eq!(meeting.status, MeetingStatus::Cancelled);
775    }
776
777    #[test]
778    fn test_quorum_reached_above_50_percent() {
779        let org_id = Uuid::new_v4();
780        let building_id = Uuid::new_v4();
781        let future_date = Utc::now() + Duration::days(30);
782
783        let mut meeting = Meeting::new(
784            Uuid::new_v4(), // acp_id
785            org_id,
786            building_id,
787            MeetingType::Ordinary,
788            "AGO 2024".to_string(),
789            None,
790            future_date,
791            "Salle des fêtes".to_string(),
792        )
793        .unwrap();
794
795        // 600 millièmes présents sur 1000 = 60% → quorum atteint
796        let result = meeting.validate_quorum(dec!(600), dec!(1000));
797        assert!(result.is_ok());
798        assert!(result.unwrap());
799        assert!(meeting.quorum_validated);
800        assert!((meeting.quorum_percentage.unwrap() - 60.0).abs() < 0.01);
801    }
802
803    #[test]
804    fn test_quorum_not_reached_at_50_percent_exact() {
805        let org_id = Uuid::new_v4();
806        let building_id = Uuid::new_v4();
807        let future_date = Utc::now() + Duration::days(30);
808
809        let mut meeting = Meeting::new(
810            Uuid::new_v4(), // acp_id
811            org_id,
812            building_id,
813            MeetingType::Ordinary,
814            "AGO 2024".to_string(),
815            None,
816            future_date,
817            "Salle des fêtes".to_string(),
818        )
819        .unwrap();
820
821        // 500 millièmes sur 1000 = exactement 50% → quorum NON atteint (Art. 3.87 §5 : >50% requis)
822        let result = meeting.validate_quorum(dec!(500), dec!(1000));
823        assert!(result.is_ok());
824        assert!(!result.unwrap());
825        assert!(!meeting.quorum_validated);
826    }
827
828    #[test]
829    fn test_quorum_not_reached_below_50_percent() {
830        let org_id = Uuid::new_v4();
831        let building_id = Uuid::new_v4();
832        let future_date = Utc::now() + Duration::days(30);
833
834        let mut meeting = Meeting::new(
835            Uuid::new_v4(), // acp_id
836            org_id,
837            building_id,
838            MeetingType::Ordinary,
839            "AGO 2024".to_string(),
840            None,
841            future_date,
842            "Salle des fêtes".to_string(),
843        )
844        .unwrap();
845
846        // 400 millièmes sur 1000 = 40% → quorum non atteint
847        let result = meeting.validate_quorum(dec!(400), dec!(1000));
848        assert!(result.is_ok());
849        assert!(!result.unwrap());
850        assert!(!meeting.quorum_validated);
851    }
852
853    #[test]
854    fn test_check_quorum_blocks_vote_when_not_validated() {
855        let org_id = Uuid::new_v4();
856        let building_id = Uuid::new_v4();
857        let future_date = Utc::now() + Duration::days(30);
858
859        let meeting = Meeting::new(
860            Uuid::new_v4(), // acp_id
861            org_id,
862            building_id,
863            MeetingType::Ordinary,
864            "AGO 2024".to_string(),
865            None,
866            future_date,
867            "Salle des fêtes".to_string(),
868        )
869        .unwrap();
870
871        let result = meeting.check_quorum_for_voting();
872        assert!(result.is_err());
873        assert!(result.unwrap_err().contains("not been validated yet"));
874    }
875
876    #[test]
877    fn test_check_quorum_skipped_for_second_convocation() {
878        // Art. 3.87 §5 CC: No quorum check for 2nd convocation
879        let org_id = Uuid::new_v4();
880        let building_id = Uuid::new_v4();
881        let future_date = Utc::now() + Duration::days(30);
882
883        let mut meeting = Meeting::new(
884            Uuid::new_v4(), // acp_id
885            org_id,
886            building_id,
887            MeetingType::Extraordinary,
888            "2e Convocation AGE".to_string(),
889            Some("Deuxième convocation - sans quorum".to_string()),
890            future_date,
891            "Salle des fêtes".to_string(),
892        )
893        .unwrap();
894
895        // Mark as second convocation
896        meeting.is_second_convocation = true;
897
898        // Should allow voting even without quorum validation
899        let result = meeting.check_quorum_for_voting();
900        assert!(result.is_ok(), "2nd convocation should skip quorum check");
901    }
902
903    #[test]
904    fn test_check_quorum_blocks_vote_when_quorum_not_reached() {
905        let org_id = Uuid::new_v4();
906        let building_id = Uuid::new_v4();
907        let future_date = Utc::now() + Duration::days(30);
908
909        let mut meeting = Meeting::new(
910            Uuid::new_v4(), // acp_id
911            org_id,
912            building_id,
913            MeetingType::Ordinary,
914            "AGO 2024".to_string(),
915            None,
916            future_date,
917            "Salle des fêtes".to_string(),
918        )
919        .unwrap();
920
921        meeting.validate_quorum(dec!(400), dec!(1000)).unwrap();
922        let result = meeting.check_quorum_for_voting();
923        assert!(result.is_err());
924        assert!(result.unwrap_err().contains("second convocation"));
925    }
926
927    #[test]
928    fn test_quorum_invalid_total_quotas() {
929        let org_id = Uuid::new_v4();
930        let building_id = Uuid::new_v4();
931        let future_date = Utc::now() + Duration::days(30);
932
933        let mut meeting = Meeting::new(
934            Uuid::new_v4(), // acp_id
935            org_id,
936            building_id,
937            MeetingType::Ordinary,
938            "AGO 2024".to_string(),
939            None,
940            future_date,
941            "Salle des fêtes".to_string(),
942        )
943        .unwrap();
944
945        let result = meeting.validate_quorum(dec!(100), dec!(0));
946        assert!(result.is_err());
947    }
948
949    // ------------------------------------------------------------------
950    // Story 4.1 — `Meeting::set_mode()` — taxonomie 4-cat.
951    // ------------------------------------------------------------------
952
953    /// @happy — mode in_person ne réclame aucune URL.
954    #[test]
955    fn happy_set_mode_in_person_without_url() {
956        let mut meeting = make_meeting_for_mode_tests();
957        assert!(meeting.set_mode(MeetingMode::InPerson, None).is_ok());
958        assert_eq!(meeting.mode, MeetingMode::InPerson);
959        assert!(meeting.videoconf_url.is_none());
960    }
961
962    /// @happy — mode hybrid avec une URL de visioconférence est accepté.
963    #[test]
964    fn happy_set_mode_hybrid_with_url() {
965        let mut meeting = make_meeting_for_mode_tests();
966        let result = meeting.set_mode(
967            MeetingMode::Hybrid,
968            Some("https://meet.jit.si/koprogo-ago-2026".to_string()),
969        );
970        assert!(result.is_ok());
971        assert_eq!(meeting.mode, MeetingMode::Hybrid);
972        assert_eq!(
973            meeting.videoconf_url.as_deref(),
974            Some("https://meet.jit.si/koprogo-ago-2026")
975        );
976    }
977
978    /// @edge — une URL faite uniquement d'espaces n'est pas une configuration.
979    #[test]
980    fn edge_set_mode_remote_with_blank_url_is_refused() {
981        let mut meeting = make_meeting_for_mode_tests();
982        let err = meeting
983            .set_mode(MeetingMode::Remote, Some("   ".to_string()))
984            .expect_err("une URL blanche ne configure rien");
985        assert_eq!(
986            err,
987            MeetingModeError::VideoconfUrlRequired {
988                mode: MeetingMode::Remote
989            }
990        );
991    }
992
993    /// @edge — repasser en in_person efface l'URL sans erreur, quel que soit
994    /// l'état précédent.
995    #[test]
996    fn edge_set_mode_back_to_in_person_clears_url() {
997        let mut meeting = make_meeting_for_mode_tests();
998        meeting
999            .set_mode(
1000                MeetingMode::Remote,
1001                Some("https://meet.jit.si/x".to_string()),
1002            )
1003            .unwrap();
1004        assert!(meeting.set_mode(MeetingMode::InPerson, None).is_ok());
1005        assert!(meeting.videoconf_url.is_none());
1006    }
1007
1008    /// @security — un appelant ne peut pas contourner l'obligation d'URL en
1009    /// passant `Some("")` plutôt que `None` : les deux sont traités pareil.
1010    #[test]
1011    fn security_set_mode_empty_string_url_is_treated_as_missing() {
1012        let mut meeting = make_meeting_for_mode_tests();
1013        let err = meeting
1014            .set_mode(MeetingMode::Hybrid, Some(String::new()))
1015            .expect_err("une chaîne vide ne doit pas contourner l'obligation d'URL");
1016        assert_eq!(
1017            err,
1018            MeetingModeError::VideoconfUrlRequired {
1019                mode: MeetingMode::Hybrid
1020            }
1021        );
1022    }
1023
1024    /// @negative — mode hybrid sans configuration de distance (videoconf_url
1025    /// manquant) est refusé, et l'entité n'est pas mutée par la tentative.
1026    #[test]
1027    fn negative_set_mode_hybrid_without_videoconf_url_is_rejected() {
1028        let mut meeting = make_meeting_for_mode_tests();
1029        let mode_before = meeting.mode;
1030        let err = meeting
1031            .set_mode(MeetingMode::Hybrid, None)
1032            .expect_err("hybrid sans URL doit échouer (422 côté AppError)");
1033        assert_eq!(
1034            err,
1035            MeetingModeError::VideoconfUrlRequired {
1036                mode: MeetingMode::Hybrid
1037            }
1038        );
1039        assert_eq!(
1040            meeting.mode, mode_before,
1041            "une tentative refusée ne doit pas muter l'entité"
1042        );
1043        assert!(meeting.videoconf_url.is_none());
1044    }
1045
1046    fn make_meeting_for_mode_tests() -> Meeting {
1047        let org_id = Uuid::new_v4();
1048        let building_id = Uuid::new_v4();
1049        let future_date = Utc::now() + Duration::days(30);
1050        Meeting::new(
1051            Uuid::new_v4(),
1052            org_id,
1053            building_id,
1054            MeetingType::Ordinary,
1055            "AGO 2026 hybride".to_string(),
1056            None,
1057            future_date,
1058            "Salle des fêtes".to_string(),
1059        )
1060        .unwrap()
1061    }
1062
1063    #[test]
1064    fn test_reschedule_meeting() {
1065        let org_id = Uuid::new_v4();
1066        let building_id = Uuid::new_v4();
1067        let future_date = Utc::now() + Duration::days(30);
1068
1069        let mut meeting = Meeting::new(
1070            Uuid::new_v4(), // acp_id
1071            org_id,
1072            building_id,
1073            MeetingType::Ordinary,
1074            "AGO 2024".to_string(),
1075            None,
1076            future_date,
1077            "Salle des fêtes".to_string(),
1078        )
1079        .unwrap();
1080
1081        let new_date = Utc::now() + Duration::days(60);
1082        let result = meeting.reschedule(new_date);
1083        assert!(result.is_ok());
1084        assert_eq!(meeting.scheduled_date, new_date);
1085    }
1086
1087    #[test]
1088    fn test_set_minutes_sent_success() {
1089        // Arrange
1090        let org_id = Uuid::new_v4();
1091        let building_id = Uuid::new_v4();
1092        let future_date = Utc::now() + Duration::days(30);
1093        let doc_id = Uuid::new_v4();
1094
1095        let mut meeting = Meeting::new(
1096            Uuid::new_v4(), // acp_id
1097            org_id,
1098            building_id,
1099            MeetingType::Ordinary,
1100            "AGO 2024".to_string(),
1101            None,
1102            future_date,
1103            "Salle des fêtes".to_string(),
1104        )
1105        .unwrap();
1106
1107        // Act: Complete the meeting first
1108        meeting.complete(45).unwrap();
1109        let result = meeting.set_minutes_sent(doc_id);
1110
1111        // Assert
1112        assert!(result.is_ok());
1113        assert_eq!(meeting.minutes_document_id, Some(doc_id));
1114        assert!(meeting.minutes_sent_at.is_some());
1115    }
1116
1117    #[test]
1118    fn test_set_minutes_sent_before_completion_fails() {
1119        // Arrange
1120        let org_id = Uuid::new_v4();
1121        let building_id = Uuid::new_v4();
1122        let future_date = Utc::now() + Duration::days(30);
1123        let doc_id = Uuid::new_v4();
1124
1125        let mut meeting = Meeting::new(
1126            Uuid::new_v4(), // acp_id
1127            org_id,
1128            building_id,
1129            MeetingType::Ordinary,
1130            "AGO 2024".to_string(),
1131            None,
1132            future_date,
1133            "Salle des fêtes".to_string(),
1134        )
1135        .unwrap();
1136
1137        // Act: Try to send minutes while meeting is still Scheduled
1138        let result = meeting.set_minutes_sent(doc_id);
1139
1140        // Assert
1141        assert!(result.is_err());
1142        assert_eq!(
1143            result.unwrap_err(),
1144            "Minutes can only be sent after meeting is completed"
1145        );
1146    }
1147
1148    #[test]
1149    fn test_is_minutes_overdue_not_completed() {
1150        // Arrange
1151        let org_id = Uuid::new_v4();
1152        let building_id = Uuid::new_v4();
1153        let future_date = Utc::now() + Duration::days(30);
1154
1155        let meeting = Meeting::new(
1156            Uuid::new_v4(), // acp_id
1157            org_id,
1158            building_id,
1159            MeetingType::Ordinary,
1160            "AGO 2024".to_string(),
1161            None,
1162            future_date,
1163            "Salle des fêtes".to_string(),
1164        )
1165        .unwrap();
1166
1167        // Act & Assert
1168        assert!(!meeting.is_minutes_overdue()); // Not completed yet
1169    }
1170
1171    #[test]
1172    fn test_is_minutes_overdue_sent() {
1173        // Arrange
1174        let org_id = Uuid::new_v4();
1175        let building_id = Uuid::new_v4();
1176        let future_date = Utc::now() + Duration::days(30);
1177        let doc_id = Uuid::new_v4();
1178
1179        let mut meeting = Meeting::new(
1180            Uuid::new_v4(), // acp_id
1181            org_id,
1182            building_id,
1183            MeetingType::Ordinary,
1184            "AGO 2024".to_string(),
1185            None,
1186            future_date,
1187            "Salle des fêtes".to_string(),
1188        )
1189        .unwrap();
1190
1191        // Act
1192        meeting.complete(45).unwrap();
1193        meeting.set_minutes_sent(doc_id).unwrap();
1194
1195        // Assert
1196        assert!(!meeting.is_minutes_overdue()); // Minutes sent
1197    }
1198
1199    #[test]
1200    fn test_is_minutes_overdue_past_30_days() {
1201        // Arrange
1202        let org_id = Uuid::new_v4();
1203        let building_id = Uuid::new_v4();
1204        let future_date = Utc::now() + Duration::days(30);
1205
1206        let mut meeting = Meeting::new(
1207            Uuid::new_v4(), // acp_id
1208            org_id,
1209            building_id,
1210            MeetingType::Ordinary,
1211            "AGO 2024".to_string(),
1212            None,
1213            future_date,
1214            "Salle des fêtes".to_string(),
1215        )
1216        .unwrap();
1217
1218        // Act: Complete the meeting and manually set updated_at to >30 days ago
1219        meeting.complete(45).unwrap();
1220        meeting.updated_at = Utc::now() - Duration::days(31);
1221
1222        // Assert
1223        assert!(meeting.is_minutes_overdue()); // >30 days without sending minutes
1224    }
1225
1226    #[test]
1227    fn test_is_minutes_overdue_within_30_days() {
1228        // Arrange
1229        let org_id = Uuid::new_v4();
1230        let building_id = Uuid::new_v4();
1231        let future_date = Utc::now() + Duration::days(30);
1232
1233        let mut meeting = Meeting::new(
1234            Uuid::new_v4(), // acp_id
1235            org_id,
1236            building_id,
1237            MeetingType::Ordinary,
1238            "AGO 2024".to_string(),
1239            None,
1240            future_date,
1241            "Salle des fêtes".to_string(),
1242        )
1243        .unwrap();
1244
1245        // Act: Complete the meeting
1246        meeting.complete(45).unwrap();
1247
1248        // Assert
1249        assert!(!meeting.is_minutes_overdue()); // Within 30 days
1250    }
1251}
1252
1253// ============================================================================
1254// Track H Story H3 — Tests `assert_can_complete` taxonomie 4-cat (CRITICAL #3).
1255// ============================================================================
1256
1257#[cfg(test)]
1258#[allow(deprecated)] // ancien `complete()` reste testé pour la compat backward
1259mod assert_can_complete_tests {
1260    use super::*;
1261    use chrono::Duration;
1262
1263    /// Construit un meeting standard (Scheduled, AGO).
1264    fn make_meeting() -> Meeting {
1265        let org_id = Uuid::new_v4();
1266        let building_id = Uuid::new_v4();
1267        Meeting::new(
1268            Uuid::new_v4(), // acp_id
1269            org_id,
1270            building_id,
1271            MeetingType::Ordinary,
1272            "AGO Track H Story H3".to_string(),
1273            None,
1274            Utc::now() + Duration::days(30),
1275            "Salle des fêtes".to_string(),
1276        )
1277        .unwrap()
1278    }
1279
1280    /// Checklist 100% conforme : tous invariants présents, quorum atteint.
1281    fn checklist_all_ok() -> MeetingCompletionChecklist {
1282        MeetingCompletionChecklist {
1283            convocations_sent: true,
1284            open_resolutions: 0,
1285            attendance_recorded: true,
1286            attended_quotas: dec!(600),
1287            total_quotas: dec!(1000),
1288            // Story H9 — têtes 6/10 = 60% > 50% (volet têtes du quorum double OK).
1289            present_owners_count: 6,
1290            total_owners_count: 10,
1291            minutes_draft_exists: true,
1292        }
1293    }
1294
1295    // ------------------------------------------------------------------------
1296    // @happy — chemin nominal
1297    // ------------------------------------------------------------------------
1298
1299    #[test]
1300    fn happy_all_invariants_ok_returns_ok() {
1301        // AC-H3.h1
1302        let m = make_meeting();
1303        let c = checklist_all_ok();
1304        assert!(m.assert_can_complete(&c).is_ok());
1305    }
1306
1307    #[test]
1308    fn happy_then_complete_internal_transitions_to_completed() {
1309        // AC-H3.h2 — chaînage assert + complete_internal.
1310        let mut m = make_meeting();
1311        let c = checklist_all_ok();
1312        m.assert_can_complete(&c).unwrap();
1313        m.complete_internal().unwrap();
1314        assert_eq!(m.status, MeetingStatus::Completed);
1315    }
1316
1317    // ------------------------------------------------------------------------
1318    // @edge — bornes quorum, résolutions, quotas
1319    // ------------------------------------------------------------------------
1320
1321    #[test]
1322    fn edge_quorum_quotas_exact_50_percent_accepted_with_heads_ok() {
1323        // Story H9 (corrige H3) — Art. 3.87 §5 : quotités « au moins la moitié »
1324        // = ≥ 50% INCLUSIF. 500/1000 = exactement 50% → volet quotités OK ;
1325        // têtes 6/10 (checklist_all_ok) OK → quorum double atteint.
1326        let m = make_meeting();
1327        let c = MeetingCompletionChecklist {
1328            attended_quotas: dec!(500),
1329            total_quotas: dec!(1000),
1330            ..checklist_all_ok()
1331        };
1332        assert!(m.assert_can_complete(&c).is_ok());
1333    }
1334
1335    #[test]
1336    fn edge_quorum_just_above_50_percent_is_accepted() {
1337        // AC-H3.e2 — 500.0001/1000 OK.
1338        let m = make_meeting();
1339        let c = MeetingCompletionChecklist {
1340            attended_quotas: dec!(500.0001),
1341            total_quotas: dec!(1000),
1342            ..checklist_all_ok()
1343        };
1344        assert!(m.assert_can_complete(&c).is_ok());
1345    }
1346
1347    #[test]
1348    fn edge_quorum_basis_10000_just_above_50_percent_is_accepted() {
1349        // Cas acte de base 10000 (cf. Story H1 fix building).
1350        let m = make_meeting();
1351        let c = MeetingCompletionChecklist {
1352            attended_quotas: dec!(5000.5),
1353            total_quotas: dec!(10000),
1354            ..checklist_all_ok()
1355        };
1356        assert!(m.assert_can_complete(&c).is_ok());
1357    }
1358
1359    #[test]
1360    fn edge_open_resolution_count_one_is_blocking() {
1361        // AC-H3.e4 — 1 résolution Pending bloque (cancelled/Adopted/Rejected
1362        // ne comptent pas dans `open_resolutions` — c'est la query qui filtre
1363        // `status='Pending'`).
1364        let m = make_meeting();
1365        let c = MeetingCompletionChecklist {
1366            open_resolutions: 1,
1367            ..checklist_all_ok()
1368        };
1369        let err = m.assert_can_complete(&c).unwrap_err();
1370        assert!(err.missing.iter().any(|m| matches!(
1371            m,
1372            MissingInvariant::VotesNotClosed {
1373                open_resolutions: 1
1374            }
1375        )));
1376    }
1377
1378    #[test]
1379    fn edge_total_quotas_zero_returns_quorum_not_reached_not_panic() {
1380        // AC-H3.n3 — building soft-deleted ou cas exotic : pas de div by zero.
1381        let m = make_meeting();
1382        let c = MeetingCompletionChecklist {
1383            attended_quotas: dec!(0),
1384            total_quotas: dec!(0),
1385            ..checklist_all_ok()
1386        };
1387        let err = m.assert_can_complete(&c).unwrap_err();
1388        assert!(err.missing.iter().any(|m| matches!(
1389            m,
1390            MissingInvariant::QuorumNotReached { attended_quotas, total_quotas }
1391                if *attended_quotas == dec!(0) && *total_quotas == dec!(0)
1392        )));
1393    }
1394
1395    // ------------------------------------------------------------------------
1396    // @security — pas de tampering, payload sain
1397    // ------------------------------------------------------------------------
1398
1399    #[test]
1400    fn security_assert_is_pure_no_state_mutation() {
1401        // assert_can_complete ne mute pas l'entité. Vérifie qu'après un appel
1402        // (Ok ou Err) le meeting reste Scheduled (state machine non touchée).
1403        let m = make_meeting();
1404        let scheduled_status_before = m.status.clone();
1405        let _ = m.assert_can_complete(&checklist_all_ok());
1406        assert_eq!(m.status, scheduled_status_before);
1407
1408        // Même chose sur un Err.
1409        let c = MeetingCompletionChecklist {
1410            convocations_sent: false,
1411            ..checklist_all_ok()
1412        };
1413        let _ = m.assert_can_complete(&c);
1414        assert_eq!(m.status, scheduled_status_before);
1415    }
1416
1417    #[test]
1418    fn security_attendees_count_param_is_ignored_source_of_truth_is_checklist() {
1419        // AC-H3.s1 — attendees_count (legacy) n'influence pas assert.
1420        // Démonstration : `complete_internal()` ne touche pas attendees_count,
1421        // et `assert_can_complete()` calcule depuis checklist.attended_quotas.
1422        // Un attaquant qui forge attendees_count via le handler ne peut pas
1423        // bypasser le quorum.
1424        let mut m = make_meeting();
1425        m.attendees_count = Some(99_999); // forged
1426        let c = MeetingCompletionChecklist {
1427            attended_quotas: dec!(100), // réel : insuffisant
1428            total_quotas: dec!(1000),
1429            ..checklist_all_ok()
1430        };
1431        let err = m.assert_can_complete(&c).unwrap_err();
1432        assert!(err
1433            .missing
1434            .iter()
1435            .any(|m| matches!(m, MissingInvariant::QuorumNotReached { .. })));
1436    }
1437
1438    // ------------------------------------------------------------------------
1439    // @negative — défaillance correcte (toutes conditions absentes)
1440    // ------------------------------------------------------------------------
1441
1442    #[test]
1443    fn negative_all_invariants_missing_returns_all_six() {
1444        // Story H9 — toutes conditions falsy → 6 MissingInvariant exhaustifs
1445        // (le quorum double ajoute HeadCountQuorumNotReached au volet têtes).
1446        let m = make_meeting();
1447        let c = MeetingCompletionChecklist {
1448            convocations_sent: false,
1449            open_resolutions: 3,
1450            attendance_recorded: false,
1451            attended_quotas: dec!(0),
1452            total_quotas: dec!(1000),
1453            present_owners_count: 0,
1454            total_owners_count: 10,
1455            minutes_draft_exists: false,
1456        };
1457        let err = m.assert_can_complete(&c).unwrap_err();
1458        // 6 : ConvocationsNotSent + VotesNotClosed + AttendanceNotRecorded
1459        // + QuorumNotReached (quotités) + HeadCountQuorumNotReached (têtes)
1460        // + MinutesDraftMissing
1461        assert_eq!(err.missing.len(), 6);
1462        assert!(matches!(
1463            err.missing[0],
1464            MissingInvariant::ConvocationsNotSent
1465        ));
1466        assert!(matches!(
1467            err.missing[1],
1468            MissingInvariant::VotesNotClosed {
1469                open_resolutions: 3
1470            }
1471        ));
1472        assert!(matches!(
1473            err.missing[2],
1474            MissingInvariant::AttendanceNotRecorded
1475        ));
1476        assert!(matches!(
1477            err.missing[3],
1478            MissingInvariant::QuorumNotReached { .. }
1479        ));
1480        assert!(matches!(
1481            err.missing[4],
1482            MissingInvariant::HeadCountQuorumNotReached { .. }
1483        ));
1484        assert!(matches!(
1485            err.missing[5],
1486            MissingInvariant::MinutesDraftMissing
1487        ));
1488    }
1489
1490    #[test]
1491    fn negative_only_convocations_missing_returns_one_invariant() {
1492        let m = make_meeting();
1493        let c = MeetingCompletionChecklist {
1494            convocations_sent: false,
1495            ..checklist_all_ok()
1496        };
1497        let err = m.assert_can_complete(&c).unwrap_err();
1498        assert_eq!(err.missing.len(), 1);
1499        assert_eq!(err.missing[0], MissingInvariant::ConvocationsNotSent);
1500        assert_eq!(err.meeting_id, m.id);
1501    }
1502
1503    #[test]
1504    fn negative_only_minutes_missing_returns_one_invariant() {
1505        let m = make_meeting();
1506        let c = MeetingCompletionChecklist {
1507            minutes_draft_exists: false,
1508            ..checklist_all_ok()
1509        };
1510        let err = m.assert_can_complete(&c).unwrap_err();
1511        assert_eq!(err.missing.len(), 1);
1512        assert_eq!(err.missing[0], MissingInvariant::MinutesDraftMissing);
1513    }
1514
1515    #[test]
1516    fn negative_display_does_not_leak_business_internals() {
1517        // Display = "Meeting X not completable: N missing invariant(s)". Pas
1518        // de fuite de quotas / IDs sensibles dans le Display public (le
1519        // détail vit dans le payload structuré JSON 422, pas en clair).
1520        //
1521        // Identifiant FIXE, et choisi pour ne contenir ni « 400 » ni « 1000 ».
1522        //
1523        // Il valait `Uuid::new_v4()`, alors que le Display inclut cet
1524        // identifiant et que les deux assertions ci-dessous portent sur des
1525        // sous-chaînes. Un UUID tiré au hasard contient « 400 » environ une
1526        // fois sur 130 : le test échouait donc par intermittence, sans aucun
1527        // rapport avec le code testé. Constaté en CI le 2026-09-06, run
1528        // 34023615618, sur un commit qui ne touchait pas ce module.
1529        //
1530        // Un test dont l'échec ne dit rien du code est pire qu'absent : il
1531        // apprend à ignorer le rouge.
1532        const IDENTIFIANT_DE_TEST: &str = "7bcd5e2f-8a9b-4c7d-9e5f-2a3b6c8d9e5f";
1533
1534        // Le commentaire ci-dessus dit de choisir un identifiant qui ne
1535        // contienne aucune des deux sous-chaînes. Ce contrôle le VÉRIFIE, au
1536        // lieu de compter sur la lecture.
1537        //
1538        // Sans lui, quiconque remettrait `Uuid::new_v4()` verrait l'ancien
1539        // symptôme revenir — un échec sur cent trente disant
1540        // `assertion failed: !s.contains("400")`, qui envoie chercher une
1541        // fuite de quotas là où il n'y en a pas. Le message ci-dessous dit la
1542        // vraie cause du premier coup.
1543        assert!(
1544            !IDENTIFIANT_DE_TEST.contains("400") && !IDENTIFIANT_DE_TEST.contains("1000"),
1545            "l'identifiant de ce test contient une des sous-chaînes qu'on \
1546             vérifie justement être absentes : l'échec porterait sur \
1547             l'identifiant, pas sur le Display. Choisissez-en un autre, et \
1548             surtout pas un identifiant tiré au hasard (#777)."
1549        );
1550
1551        let err = MeetingNotCompletableError {
1552            meeting_id: Uuid::parse_str(IDENTIFIANT_DE_TEST).expect("UUID de test valide"),
1553            missing: vec![MissingInvariant::QuorumNotReached {
1554                attended_quotas: dec!(400),
1555                total_quotas: dec!(1000),
1556            }],
1557        };
1558        let s = format!("{}", err);
1559        assert!(s.contains("not completable"));
1560        // Display ne contient PAS la valeur des quotas (réservé au JSON).
1561        assert!(!s.contains("400"));
1562        assert!(!s.contains("1000"));
1563    }
1564
1565    #[test]
1566    fn negative_complete_internal_on_completed_meeting_fails() {
1567        // AC-H3.n2 — déjà Completed → complete_internal Err, sans toucher au
1568        // status.
1569        let mut m = make_meeting();
1570        m.assert_can_complete(&checklist_all_ok()).unwrap();
1571        m.complete_internal().unwrap();
1572        // Re-tente : déjà Completed.
1573        let res = m.complete_internal();
1574        assert!(res.is_err());
1575        assert_eq!(m.status, MeetingStatus::Completed);
1576    }
1577
1578    // ------------------------------------------------------------------------
1579    // Story H9 (CL3) — Quorum DOUBLE têtes + quotités (Art. 3.87 §5) — 4-cat.
1580    // ------------------------------------------------------------------------
1581
1582    /// @happy — primaire : têtes > 50% ET quotités ≥ 50% → clôture OK.
1583    #[test]
1584    fn happy_double_quorum_primary_heads_and_quotas() {
1585        let m = make_meeting();
1586        let c = MeetingCompletionChecklist {
1587            attended_quotas: dec!(500), // 50% exact → inclusif OK
1588            total_quotas: dec!(1000),
1589            present_owners_count: 6, // 60% têtes OK
1590            total_owners_count: 10,
1591            ..checklist_all_ok()
1592        };
1593        assert!(m.assert_can_complete(&c).is_ok());
1594    }
1595
1596    /// @happy — alternative : quotités > 3/4 suffit même si têtes ≤ 50%.
1597    #[test]
1598    fn happy_double_quorum_alternative_three_quarters() {
1599        let m = make_meeting();
1600        let c = MeetingCompletionChecklist {
1601            attended_quotas: dec!(760), // 76% > 3/4
1602            total_quotas: dec!(1000),
1603            present_owners_count: 2, // 20% têtes (insuffisant en primaire)
1604            total_owners_count: 10,
1605            ..checklist_all_ok()
1606        };
1607        assert!(m.assert_can_complete(&c).is_ok());
1608    }
1609
1610    /// @edge — têtes exactement 50% (strict requis) → volet têtes KO,
1611    /// volet quotités OK (pas de QuorumNotReached).
1612    #[test]
1613    fn edge_heads_exactly_50_percent_rejected() {
1614        let m = make_meeting();
1615        let c = MeetingCompletionChecklist {
1616            attended_quotas: dec!(600),
1617            total_quotas: dec!(1000),
1618            present_owners_count: 5, // 5/10 = 50% exact → KO (strict)
1619            total_owners_count: 10,
1620            ..checklist_all_ok()
1621        };
1622        let err = m.assert_can_complete(&c).unwrap_err();
1623        assert!(err
1624            .missing
1625            .iter()
1626            .any(|x| matches!(x, MissingInvariant::HeadCountQuorumNotReached { .. })));
1627        assert!(!err
1628            .missing
1629            .iter()
1630            .any(|x| matches!(x, MissingInvariant::QuorumNotReached { .. })));
1631    }
1632
1633    /// @edge — quotités exactement 75% (pas > 3/4) + têtes faibles → KO ;
1634    /// juste au-dessus de 75% → OK (alternative).
1635    #[test]
1636    fn edge_three_quarters_boundary() {
1637        let m = make_meeting();
1638        let exactly = MeetingCompletionChecklist {
1639            attended_quotas: dec!(750), // 75% pile, pas strict > 3/4
1640            total_quotas: dec!(1000),
1641            present_owners_count: 3, // 30% têtes
1642            total_owners_count: 10,
1643            ..checklist_all_ok()
1644        };
1645        assert!(m.assert_can_complete(&exactly).is_err());
1646
1647        let above = MeetingCompletionChecklist {
1648            attended_quotas: dec!(750.001), // > 3/4 → alternative OK
1649            total_quotas: dec!(1000),
1650            present_owners_count: 3,
1651            total_owners_count: 10,
1652            ..checklist_all_ok()
1653        };
1654        assert!(m.assert_can_complete(&above).is_ok());
1655    }
1656
1657    /// @security — têtes forgées via `attendees_count` (legacy) sans effet :
1658    /// la source est la checklist (DB COUNT DISTINCT owners).
1659    #[test]
1660    fn security_head_count_source_is_checklist_not_forged_field() {
1661        let mut m = make_meeting();
1662        m.attendees_count = Some(99_999); // forgé
1663        let c = MeetingCompletionChecklist {
1664            attended_quotas: dec!(600), // quotités OK
1665            total_quotas: dec!(1000),
1666            present_owners_count: 1, // réel : 1/10 têtes → KO
1667            total_owners_count: 10,
1668            ..checklist_all_ok()
1669        };
1670        let err = m.assert_can_complete(&c).unwrap_err();
1671        assert!(err
1672            .missing
1673            .iter()
1674            .any(|x| matches!(x, MissingInvariant::HeadCountQuorumNotReached { .. })));
1675    }
1676
1677    /// @negative — têtes KO seules (quotités OK) → uniquement HeadCount.
1678    #[test]
1679    fn negative_only_head_count_quorum_missing() {
1680        let m = make_meeting();
1681        let c = MeetingCompletionChecklist {
1682            attended_quotas: dec!(600),
1683            total_quotas: dec!(1000),
1684            present_owners_count: 4, // 40% têtes KO
1685            total_owners_count: 10,
1686            ..checklist_all_ok()
1687        };
1688        let err = m.assert_can_complete(&c).unwrap_err();
1689        assert_eq!(err.missing.len(), 1);
1690        assert!(matches!(
1691            err.missing[0],
1692            MissingInvariant::HeadCountQuorumNotReached {
1693                present_owners_count: 4,
1694                total_owners_count: 10
1695            }
1696        ));
1697    }
1698
1699    /// @negative — total_owners_count == 0 → HeadCountQuorumNotReached (pas de div).
1700    #[test]
1701    fn negative_zero_total_owners_is_head_quorum_not_reached() {
1702        let m = make_meeting();
1703        let c = MeetingCompletionChecklist {
1704            attended_quotas: dec!(600),
1705            total_quotas: dec!(1000),
1706            present_owners_count: 0,
1707            total_owners_count: 0,
1708            ..checklist_all_ok()
1709        };
1710        let err = m.assert_can_complete(&c).unwrap_err();
1711        assert!(err
1712            .missing
1713            .iter()
1714            .any(|x| matches!(x, MissingInvariant::HeadCountQuorumNotReached { .. })));
1715    }
1716}