Skip to main content

koprogo_api/domain/copropriete/
convocation.rs

1use chrono::{DateTime, Duration, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Convocation type according to Belgian copropriété law
6/// Art. 3.87 §3 Code Civil (ex Art. 577-6 §2): minimum 15 days notice for ALL types
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
8pub enum ConvocationType {
9    /// Ordinary General Assembly (15 days minimum notice)
10    Ordinary,
11    /// Extraordinary General Assembly (15 days minimum notice - same as ordinary per Art. 3.87 §3)
12    Extraordinary,
13    /// Second convocation after quorum not reached (15 days minimum notice - Art. 3.87 §5)
14    SecondConvocation,
15}
16
17impl ConvocationType {
18    /// Get minimum notice period in days according to Belgian law
19    /// Art. 3.87 §3 Code Civil: "Sauf dans les cas d'urgence, la convocation est
20    /// communiquée quinze jours au moins avant la date de l'assemblée."
21    /// This 15-day minimum applies to ALL assembly types (ordinary, extraordinary,
22    /// and second convocation after quorum failure).
23    pub fn minimum_notice_days(&self) -> i64 {
24        // Belgian law notice periods per Art. 3.87 §3 Code Civil:
25        // "Sauf dans les cas d'urgence, la convocation est communiquée quinze jours
26        // au moins avant la date de l'assemblée."
27        // 15 days minimum for ALL assembly types (ordinary, extraordinary, second convocation).
28        match self {
29            ConvocationType::Ordinary
30            | ConvocationType::Extraordinary
31            | ConvocationType::SecondConvocation => 15,
32        }
33    }
34
35    /// Convert to database string
36    pub fn to_db_string(&self) -> &'static str {
37        match self {
38            ConvocationType::Ordinary => "ordinary",
39            ConvocationType::Extraordinary => "extraordinary",
40            ConvocationType::SecondConvocation => "second_convocation",
41        }
42    }
43
44    /// Parse from database string
45    pub fn from_db_string(s: &str) -> Result<Self, String> {
46        match s {
47            "ordinary" => Ok(ConvocationType::Ordinary),
48            "extraordinary" => Ok(ConvocationType::Extraordinary),
49            "second_convocation" => Ok(ConvocationType::SecondConvocation),
50            _ => Err(format!("Invalid meeting type: {}", s)),
51        }
52    }
53}
54
55/// Convocation status
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
57pub enum ConvocationStatus {
58    /// Draft (not yet sent)
59    Draft,
60    /// Scheduled (will be sent at scheduled time)
61    Scheduled,
62    /// Sent (emails dispatched)
63    Sent,
64    /// Cancelled (meeting cancelled)
65    Cancelled,
66}
67
68impl ConvocationStatus {
69    pub fn to_db_string(&self) -> &'static str {
70        match self {
71            ConvocationStatus::Draft => "draft",
72            ConvocationStatus::Scheduled => "scheduled",
73            ConvocationStatus::Sent => "sent",
74            ConvocationStatus::Cancelled => "cancelled",
75        }
76    }
77
78    pub fn from_db_string(s: &str) -> Result<Self, String> {
79        match s {
80            "draft" => Ok(ConvocationStatus::Draft),
81            "scheduled" => Ok(ConvocationStatus::Scheduled),
82            "sent" => Ok(ConvocationStatus::Sent),
83            "cancelled" => Ok(ConvocationStatus::Cancelled),
84            _ => Err(format!("Invalid convocation status: {}", s)),
85        }
86    }
87}
88
89/// Convocation entity - Automatic meeting invitations with legal compliance
90///
91/// Implements Belgian copropriété legal requirements for meeting convocations:
92/// Art. 3.87 §3 Code Civil: 15 days minimum notice for ALL types
93/// (Ordinary, Extraordinary, and Second Convocation after quorum failure)
94/// Art. 3.87 §5 CC: 2e convocation si quorum non atteint — pas de quorum minimum requis
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct Convocation {
97    pub id: Uuid,
98
99    /// L'ACP dont les copropriétaires sont convoqués.
100    ///
101    /// Art. 3.87 § 3 : la convocation est adressée par le syndic, mais
102    /// « les frais administratifs afférents à la convocation à l'assemblée
103    /// générale sont à charge de l'association des copropriétaires ». Elle est
104    /// un acte de l'ACP, pas du cabinet. Cf. ADR-0045.
105    pub acp_id: Uuid,
106
107    /// Le syndic qui a envoyé la convocation, conservé comme trace d'auteur.
108    pub organization_id: Uuid,
109    pub building_id: Uuid,
110    pub meeting_id: Uuid,
111    pub meeting_type: ConvocationType,
112    pub meeting_date: DateTime<Utc>,
113    pub status: ConvocationStatus,
114
115    // Lien vers la 1ère AG si 2e convocation (quorum non atteint — Art. 3.87 §5 CC)
116    pub first_meeting_id: Option<Uuid>,
117
118    // For second convocation: quorum is NOT required (Art. 3.87 §5 CC)
119    // "La deuxième assemblée délibère valablement quel que soit le nombre de présents."
120    pub no_quorum_required: bool,
121
122    // Legal deadline tracking
123    pub minimum_send_date: DateTime<Utc>, // Latest date to send (meeting_date - minimum_notice_days)
124    pub actual_send_date: Option<DateTime<Utc>>, // When actually sent
125    pub scheduled_send_date: Option<DateTime<Utc>>, // When scheduled to be sent
126
127    // PDF generation
128    pub pdf_file_path: Option<String>, // Path to generated PDF
129    pub language: String,              // FR, NL, DE, EN
130
131    // Tracking
132    pub total_recipients: i32,
133    pub opened_count: i32,
134    pub will_attend_count: i32,
135    pub will_not_attend_count: i32,
136
137    // Reminders
138    pub reminder_sent_at: Option<DateTime<Utc>>, // J-3 reminder
139
140    // Audit
141    pub created_at: DateTime<Utc>,
142    pub updated_at: DateTime<Utc>,
143    pub created_by: Uuid,
144}
145
146impl Convocation {
147    /// Create a new convocation
148    ///
149    /// # Arguments
150    /// * `organization_id` - Organization ID
151    /// * `building_id` - Building ID
152    /// * `meeting_id` - Meeting ID
153    /// * `meeting_type` - Type of meeting (Ordinary/Extraordinary/Second)
154    /// * `meeting_date` - Scheduled meeting date
155    /// * `language` - Convocation language (FR/NL/DE/EN)
156    /// * `created_by` - User creating the convocation
157    ///
158    /// # Returns
159    /// Result with Convocation or error if meeting date is too soon
160    pub fn new(
161        acp_id: Uuid,
162        organization_id: Uuid,
163        building_id: Uuid,
164        meeting_id: Uuid,
165        meeting_type: ConvocationType,
166        meeting_date: DateTime<Utc>,
167        language: String,
168        created_by: Uuid,
169    ) -> Result<Self, String> {
170        // Validate language
171        if !["FR", "NL", "DE", "EN"].contains(&language.to_uppercase().as_str()) {
172            return Err(format!(
173                "Invalid language '{}'. Must be FR, NL, DE, or EN",
174                language
175            ));
176        }
177
178        // Calculate minimum send date (meeting_date - minimum_notice_days)
179        let minimum_notice_days = meeting_type.minimum_notice_days();
180        let minimum_send_date = meeting_date - Duration::days(minimum_notice_days);
181
182        // Check if meeting date allows for legal notice period
183        let now = Utc::now();
184        if minimum_send_date < now {
185            // Message en français, et qui dit ce qu'il faut faire.
186            //
187            // « Meeting date too soon » était la version précédente : en
188            // anglais, dans un produit belge à quatre langues, et adressée à un
189            // syndic qui n'a plus aucun recours à ce stade. La recette 4 l'a
190            // relevé comme le premier des trois verrous du cycle d'AG (#780).
191            //
192            // La date limite d'envoi est déjà DÉPASSÉE quand ce refus tombe :
193            // dire « il aurait fallu envoyer avant le … » est la seule
194            // information utile, puisqu'elle nomme le recours — reporter
195            // l'assemblée.
196            return Err(format!(
197                "Art. 3.87 § 3 : une assemblée {} exige un préavis de {} jours. \
198                 La convocation aurait dû partir au plus tard le {}. \
199                 Reportez l'assemblée à une date plus lointaine.",
200                match meeting_type {
201                    ConvocationType::Ordinary => "ordinaire",
202                    ConvocationType::Extraordinary => "extraordinaire",
203                    ConvocationType::SecondConvocation => "sur seconde convocation",
204                },
205                minimum_notice_days,
206                minimum_send_date.format("%d/%m/%Y à %H:%M")
207            ));
208        }
209
210        Ok(Self {
211            id: Uuid::new_v4(),
212            acp_id,
213            organization_id,
214            building_id,
215            meeting_id,
216            meeting_type,
217            meeting_date,
218            status: ConvocationStatus::Draft,
219            first_meeting_id: None,
220            no_quorum_required: false, // Only set to true for second convocations
221            minimum_send_date,
222            actual_send_date: None,
223            scheduled_send_date: None,
224            pdf_file_path: None,
225            language: language.to_uppercase(),
226            total_recipients: 0,
227            opened_count: 0,
228            will_attend_count: 0,
229            will_not_attend_count: 0,
230            reminder_sent_at: None,
231            created_at: now,
232            updated_at: now,
233            created_by,
234        })
235    }
236
237    /// Crée une 2e convocation après échec du quorum (Art. 3.87 §5 CC).
238    ///
239    /// Règles légales:
240    /// - La 2e AG doit avoir lieu ≥15 jours après la 1ère AG (Art. 3.87 §3)
241    /// - La 2e AG délibère valablement quel que soit le nombre de présents
242    ///   (aucun quorum minimum requis)
243    /// - Le contenu de l'ordre du jour est identique à la 1ère AG
244    pub fn new_second_convocation(
245        acp_id: Uuid,
246        organization_id: Uuid,
247        building_id: Uuid,
248        new_meeting_id: Uuid,
249        first_meeting_id: Uuid,
250        first_meeting_date: DateTime<Utc>,
251        new_meeting_date: DateTime<Utc>,
252        language: String,
253        created_by: Uuid,
254    ) -> Result<Self, String> {
255        // Validation: la 2e AG doit être au moins 15 jours après la 1ère
256        //
257        // Ce message était resté en anglais alors que celui de `new()` avait
258        // déjà été traduit (#780) : même défaut, même produit belge à quatre
259        // langues, même syndic sans recours utile face à un texte qu'il ne
260        // comprend pas forcément.
261        let min_second_date = first_meeting_date + Duration::days(15);
262        if new_meeting_date < min_second_date {
263            return Err(format!(
264                "Art. 3.87 § 3 : la seconde assemblée doit se tenir au moins 15 jours \
265                 après la première (tenue le {}). Date proposée : {}. \
266                 Reportez la seconde assemblée au {} ou plus tard.",
267                first_meeting_date.format("%d/%m/%Y"),
268                new_meeting_date.format("%d/%m/%Y"),
269                min_second_date.format("%d/%m/%Y")
270            ));
271        }
272
273        let mut convocation = Self::new(
274            acp_id,
275            organization_id,
276            building_id,
277            new_meeting_id,
278            ConvocationType::SecondConvocation,
279            new_meeting_date,
280            language,
281            created_by,
282        )?;
283
284        convocation.first_meeting_id = Some(first_meeting_id);
285        // Art. 3.87 §5 CC: "La deuxième assemblée délibère valablement quel que soit le nombre de présents."
286        convocation.no_quorum_required = true;
287        Ok(convocation)
288    }
289
290    /// Schedule convocation to be sent at specific date
291    pub fn schedule(&mut self, send_date: DateTime<Utc>) -> Result<(), String> {
292        if self.status != ConvocationStatus::Draft {
293            return Err(format!(
294                "Cannot schedule convocation in status '{:?}'. Must be Draft",
295                self.status
296            ));
297        }
298
299        // Verify send_date is before meeting_date - minimum_notice_days
300        if send_date > self.minimum_send_date {
301            return Err(format!(
302                "Scheduled send date {} is after minimum send date {}. Meeting would not have required notice period",
303                send_date.format("%Y-%m-%d %H:%M"),
304                self.minimum_send_date.format("%Y-%m-%d %H:%M")
305            ));
306        }
307
308        self.scheduled_send_date = Some(send_date);
309        self.status = ConvocationStatus::Scheduled;
310        self.updated_at = Utc::now();
311        Ok(())
312    }
313
314    /// Mark convocation as sent
315    pub fn mark_sent(
316        &mut self,
317        pdf_file_path: String,
318        total_recipients: i32,
319    ) -> Result<(), String> {
320        if self.status != ConvocationStatus::Draft && self.status != ConvocationStatus::Scheduled {
321            return Err(format!(
322                "Cannot send convocation in status '{:?}'",
323                self.status
324            ));
325        }
326
327        if total_recipients <= 0 {
328            return Err("Total recipients must be greater than 0".to_string());
329        }
330
331        self.status = ConvocationStatus::Sent;
332        self.actual_send_date = Some(Utc::now());
333        self.pdf_file_path = Some(pdf_file_path);
334        self.total_recipients = total_recipients;
335        self.updated_at = Utc::now();
336        Ok(())
337    }
338
339    /// Cancel convocation
340    pub fn cancel(&mut self) -> Result<(), String> {
341        if self.status == ConvocationStatus::Cancelled {
342            return Err("Convocation is already cancelled".to_string());
343        }
344
345        self.status = ConvocationStatus::Cancelled;
346        self.updated_at = Utc::now();
347        Ok(())
348    }
349
350    /// Mark reminder as sent (J-3)
351    pub fn mark_reminder_sent(&mut self) -> Result<(), String> {
352        if self.status != ConvocationStatus::Sent {
353            return Err("Cannot send reminder for unsent convocation".to_string());
354        }
355
356        self.reminder_sent_at = Some(Utc::now());
357        self.updated_at = Utc::now();
358        Ok(())
359    }
360
361    /// Update tracking counts from recipients
362    pub fn update_tracking_counts(
363        &mut self,
364        opened_count: i32,
365        will_attend_count: i32,
366        will_not_attend_count: i32,
367    ) {
368        self.opened_count = opened_count;
369        self.will_attend_count = will_attend_count;
370        self.will_not_attend_count = will_not_attend_count;
371        self.updated_at = Utc::now();
372    }
373
374    /// Check if convocation respects legal deadline
375    pub fn respects_legal_deadline(&self) -> bool {
376        match &self.actual_send_date {
377            Some(sent_at) => *sent_at <= self.minimum_send_date,
378            None => {
379                // Not sent yet: still respects deadline if there's time to send
380                Utc::now() <= self.minimum_send_date
381            }
382        }
383    }
384
385    /// Get days until meeting
386    pub fn days_until_meeting(&self) -> i64 {
387        let now = Utc::now();
388        let duration = self.meeting_date.signed_duration_since(now);
389        duration.num_days()
390    }
391
392    /// Check if reminder should be sent (3 days before meeting)
393    pub fn should_send_reminder(&self) -> bool {
394        if self.status != ConvocationStatus::Sent {
395            return false;
396        }
397
398        if self.reminder_sent_at.is_some() {
399            return false; // Already sent
400        }
401
402        let days_until = self.days_until_meeting();
403        days_until <= 3 && days_until >= 0
404    }
405
406    /// Get opening rate (percentage of recipients who opened)
407    pub fn opening_rate(&self) -> f64 {
408        if self.total_recipients == 0 {
409            return 0.0;
410        }
411        (self.opened_count as f64 / self.total_recipients as f64) * 100.0
412    }
413
414    /// Get attendance rate (percentage confirmed attending)
415    pub fn attendance_rate(&self) -> f64 {
416        if self.total_recipients == 0 {
417            return 0.0;
418        }
419        (self.will_attend_count as f64 / self.total_recipients as f64) * 100.0
420    }
421}
422
423impl crate::domain::services::PieceDeGestion for Convocation {
424    fn acp_id(&self) -> Uuid {
425        self.acp_id
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    #[test]
434    fn test_meeting_type_minimum_notice_days() {
435        // Art. 3.87 §3 CC: 15 days for ALL types
436        assert_eq!(ConvocationType::Ordinary.minimum_notice_days(), 15);
437        assert_eq!(ConvocationType::Extraordinary.minimum_notice_days(), 15);
438        assert_eq!(ConvocationType::SecondConvocation.minimum_notice_days(), 15);
439    }
440
441    #[test]
442    fn test_create_convocation_success() {
443        let org_id = Uuid::new_v4();
444        let building_id = Uuid::new_v4();
445        let meeting_id = Uuid::new_v4();
446        let creator_id = Uuid::new_v4();
447        let meeting_date = Utc::now() + Duration::days(20);
448
449        let convocation = Convocation::new(
450            Uuid::new_v4(), // acp_id
451            org_id,
452            building_id,
453            meeting_id,
454            ConvocationType::Ordinary,
455            meeting_date,
456            "FR".to_string(),
457            creator_id,
458        );
459
460        assert!(convocation.is_ok());
461        let conv = convocation.unwrap();
462        assert_eq!(conv.meeting_type, ConvocationType::Ordinary);
463        assert_eq!(conv.language, "FR");
464        assert_eq!(conv.status, ConvocationStatus::Draft);
465        assert_eq!(conv.total_recipients, 0);
466    }
467
468    #[test]
469    fn test_create_convocation_meeting_too_soon() {
470        let meeting_date = Utc::now() + Duration::days(5); // Only 5 days notice for ordinary meeting
471
472        let result = Convocation::new(
473            Uuid::new_v4(), // acp_id
474            Uuid::new_v4(),
475            Uuid::new_v4(),
476            Uuid::new_v4(),
477            ConvocationType::Ordinary, // Requires 15 days
478            meeting_date,
479            "FR".to_string(),
480            Uuid::new_v4(),
481        );
482
483        assert!(result.is_err());
484        let erreur = result.unwrap_err();
485        assert!(
486            erreur.contains("3.87"),
487            "le refus doit citer l'article qui le fonde, reçu : {erreur}"
488        );
489        assert!(
490            erreur.contains("Reportez"),
491            "le refus doit nommer le recours : à ce stade la date limite est \
492             dépassée, et reporter est la seule issue (#780). Reçu : {erreur}"
493        );
494    }
495
496    #[test]
497    fn test_create_convocation_invalid_language() {
498        let meeting_date = Utc::now() + Duration::days(20);
499
500        let result = Convocation::new(
501            Uuid::new_v4(), // acp_id
502            Uuid::new_v4(),
503            Uuid::new_v4(),
504            Uuid::new_v4(),
505            ConvocationType::Ordinary,
506            meeting_date,
507            "ES".to_string(), // Spanish not supported
508            Uuid::new_v4(),
509        );
510
511        assert!(result.is_err());
512        assert!(result.unwrap_err().contains("Invalid language"));
513    }
514
515    #[test]
516    fn test_schedule_convocation() {
517        let meeting_date = Utc::now() + Duration::days(20);
518        let mut convocation = Convocation::new(
519            Uuid::new_v4(), // acp_id
520            Uuid::new_v4(),
521            Uuid::new_v4(),
522            Uuid::new_v4(),
523            ConvocationType::Ordinary,
524            meeting_date,
525            "FR".to_string(),
526            Uuid::new_v4(),
527        )
528        .unwrap();
529
530        let send_date = Utc::now() + Duration::days(3); // Send in 3 days
531        let result = convocation.schedule(send_date);
532
533        assert!(result.is_ok());
534        assert_eq!(convocation.status, ConvocationStatus::Scheduled);
535        assert_eq!(convocation.scheduled_send_date, Some(send_date));
536    }
537
538    #[test]
539    fn test_schedule_convocation_too_late() {
540        let meeting_date = Utc::now() + Duration::days(20);
541        let mut convocation = Convocation::new(
542            Uuid::new_v4(), // acp_id
543            Uuid::new_v4(),
544            Uuid::new_v4(),
545            Uuid::new_v4(),
546            ConvocationType::Ordinary,
547            meeting_date,
548            "FR".to_string(),
549            Uuid::new_v4(),
550        )
551        .unwrap();
552
553        // Try to schedule send date after minimum_send_date
554        let send_date = meeting_date - Duration::days(10); // Only 10 days before (needs 15)
555        let result = convocation.schedule(send_date);
556
557        assert!(result.is_err());
558        assert!(result.unwrap_err().contains("after minimum send date"));
559    }
560
561    #[test]
562    fn test_mark_sent() {
563        let meeting_date = Utc::now() + Duration::days(20);
564        let mut convocation = Convocation::new(
565            Uuid::new_v4(), // acp_id
566            Uuid::new_v4(),
567            Uuid::new_v4(),
568            Uuid::new_v4(),
569            ConvocationType::Ordinary,
570            meeting_date,
571            "FR".to_string(),
572            Uuid::new_v4(),
573        )
574        .unwrap();
575
576        let result = convocation.mark_sent("/uploads/convocations/conv-123.pdf".to_string(), 50);
577
578        assert!(result.is_ok());
579        assert_eq!(convocation.status, ConvocationStatus::Sent);
580        assert!(convocation.actual_send_date.is_some());
581        assert_eq!(convocation.total_recipients, 50);
582        assert_eq!(
583            convocation.pdf_file_path,
584            Some("/uploads/convocations/conv-123.pdf".to_string())
585        );
586    }
587
588    #[test]
589    fn test_should_send_reminder() {
590        // Test case 1: Meeting in 20 days - should NOT send reminder (too early)
591        // Art. 3.87 §3: all types require 15 days notice, so 20 days is valid
592        let far_meeting_date = Utc::now() + Duration::days(20);
593        let mut convocation_far = Convocation::new(
594            Uuid::new_v4(), // acp_id
595            Uuid::new_v4(),
596            Uuid::new_v4(),
597            Uuid::new_v4(),
598            ConvocationType::Extraordinary, // 15 days notice (same as all types per Art. 3.87 §3)
599            far_meeting_date,
600            "FR".to_string(),
601            Uuid::new_v4(),
602        )
603        .unwrap();
604
605        convocation_far
606            .mark_sent("/uploads/conv.pdf".to_string(), 30)
607            .unwrap();
608
609        // Should NOT send reminder yet (meeting is 20 days away, reminder threshold is 3 days)
610        assert!(!convocation_far.should_send_reminder());
611
612        // Test case 2: For a meeting within 3 days, we'd need to create it with proper notice
613        // and then wait. Since we can't time-travel in tests, we just verify the logic
614        // that reminders are sent within 3 days of meeting.
615        // The actual production code would check this daily via a cron job.
616    }
617
618    #[test]
619    fn test_opening_rate() {
620        let meeting_date = Utc::now() + Duration::days(20);
621        let mut convocation = Convocation::new(
622            Uuid::new_v4(), // acp_id
623            Uuid::new_v4(),
624            Uuid::new_v4(),
625            Uuid::new_v4(),
626            ConvocationType::Ordinary,
627            meeting_date,
628            "FR".to_string(),
629            Uuid::new_v4(),
630        )
631        .unwrap();
632
633        convocation
634            .mark_sent("/uploads/conv.pdf".to_string(), 100)
635            .unwrap();
636        convocation.update_tracking_counts(75, 50, 10);
637
638        assert_eq!(convocation.opening_rate(), 75.0);
639        assert_eq!(convocation.attendance_rate(), 50.0);
640    }
641
642    #[test]
643    fn test_respects_legal_deadline() {
644        let meeting_date = Utc::now() + Duration::days(20);
645        let mut convocation = Convocation::new(
646            Uuid::new_v4(), // acp_id
647            Uuid::new_v4(),
648            Uuid::new_v4(),
649            Uuid::new_v4(),
650            ConvocationType::Ordinary,
651            meeting_date,
652            "FR".to_string(),
653            Uuid::new_v4(),
654        )
655        .unwrap();
656
657        // Before sending but still within deadline (meeting J+20, minimum_send J+5)
658        assert!(convocation.respects_legal_deadline());
659
660        // After sending (now is before minimum_send_date so deadline respected)
661        convocation
662            .mark_sent("/uploads/conv.pdf".to_string(), 30)
663            .unwrap();
664        assert!(convocation.respects_legal_deadline());
665    }
666
667    #[test]
668    fn test_second_convocation_success() {
669        // 1ère AG dans 30 jours → 2e AG dans 50 jours (>15 jours après la 1ère)
670        let first_meeting_date = Utc::now() + Duration::days(30);
671        let second_meeting_date = Utc::now() + Duration::days(50);
672        let first_meeting_id = Uuid::new_v4();
673        let new_meeting_id = Uuid::new_v4();
674
675        let result = Convocation::new_second_convocation(
676            Uuid::new_v4(), // acp_id
677            Uuid::new_v4(),
678            Uuid::new_v4(),
679            new_meeting_id,
680            first_meeting_id,
681            first_meeting_date,
682            second_meeting_date,
683            "FR".to_string(),
684            Uuid::new_v4(),
685        );
686
687        assert!(result.is_ok(), "Expected Ok but got: {:?}", result.err());
688        let conv = result.unwrap();
689        assert_eq!(conv.meeting_type, ConvocationType::SecondConvocation);
690        assert_eq!(conv.first_meeting_id, Some(first_meeting_id));
691        assert_eq!(conv.meeting_id, new_meeting_id);
692    }
693
694    #[test]
695    fn test_second_convocation_too_soon_fails() {
696        // 1ère AG dans 30 jours → 2e AG dans 40 jours (seulement 10 jours après → KO)
697        let first_meeting_date = Utc::now() + Duration::days(30);
698        let second_meeting_date = Utc::now() + Duration::days(40); // 10 jours seulement
699
700        let result = Convocation::new_second_convocation(
701            Uuid::new_v4(), // acp_id
702            Uuid::new_v4(),
703            Uuid::new_v4(),
704            Uuid::new_v4(),
705            Uuid::new_v4(),
706            first_meeting_date,
707            second_meeting_date,
708            "FR".to_string(),
709            Uuid::new_v4(),
710        );
711
712        assert!(result.is_err());
713        // Message traduit en français le même jour que celui de `new()`
714        // (#780, DoD : « le message de refus des 15 jours traduit »).
715        // L'assertion vérifiait auparavant `"15 days after"` (texte anglais
716        // désormais retiré) ; elle porte maintenant sur l'article cité et le
717        // recours nommé, au même titre que `test_create_convocation_meeting_too_soon`.
718        let erreur = result.unwrap_err();
719        assert!(
720            erreur.contains("3.87"),
721            "le refus doit citer l'article qui le fonde, reçu : {erreur}"
722        );
723        assert!(
724            erreur.contains("Reportez"),
725            "le refus doit nommer le recours, reçu : {erreur}"
726        );
727    }
728
729    #[test]
730    fn test_second_convocation_exactly_15_days_ok() {
731        // 1ère AG dans 30 jours → 2e AG dans 45 jours (exactement 15 jours → OK)
732        let first_meeting_date = Utc::now() + Duration::days(30);
733        let second_meeting_date = Utc::now() + Duration::days(45);
734
735        let result = Convocation::new_second_convocation(
736            Uuid::new_v4(), // acp_id
737            Uuid::new_v4(),
738            Uuid::new_v4(),
739            Uuid::new_v4(),
740            Uuid::new_v4(),
741            first_meeting_date,
742            second_meeting_date,
743            "FR".to_string(),
744            Uuid::new_v4(),
745        );
746
747        assert!(result.is_ok());
748        let conv = result.unwrap();
749        assert_eq!(conv.meeting_type, ConvocationType::SecondConvocation);
750    }
751}