Skip to main content

koprogo_api/application/use_cases/
budget_use_cases.rs

1use crate::application::dto::{
2    BudgetResponse, CreateBudgetRequest, PageRequest, UpdateBudgetRequest,
3};
4use crate::application::error::AppError;
5use crate::application::ports::{
6    BudgetRepository, BudgetStatsResponse, BudgetVarianceResponse, BuildingRepository,
7    ExpenseRepository,
8};
9use crate::domain::entities::{Budget, BudgetStatus};
10use std::sync::Arc;
11use uuid::Uuid;
12
13pub struct BudgetUseCases {
14    repository: Arc<dyn BudgetRepository>,
15    building_repository: Arc<dyn BuildingRepository>,
16    #[allow(dead_code)]
17    expense_repository: Arc<dyn ExpenseRepository>,
18}
19
20impl BudgetUseCases {
21    pub fn new(
22        repository: Arc<dyn BudgetRepository>,
23        building_repository: Arc<dyn BuildingRepository>,
24        expense_repository: Arc<dyn ExpenseRepository>,
25    ) -> Self {
26        Self {
27            repository,
28            building_repository,
29            expense_repository,
30        }
31    }
32
33    /// Create a new budget
34    pub async fn create_budget(
35        &self,
36        request: CreateBudgetRequest,
37    ) -> Result<BudgetResponse, AppError> {
38        // L'immeuble donne l'ACP : c'est elle qui vote et supporte le budget
39        // (Art. 3.89 § 5, 16°), le syndic ne fait que le préparer. Cf. ADR-0045.
40        let building = self
41            .building_repository
42            .find_by_id(request.building_id)
43            .await
44            .map_err(AppError::from)?
45            .ok_or_else(|| AppError::NotFound("Building not found".to_string()))?;
46
47        // Check if budget already exists for this building/fiscal_year
48        if let Some(_existing) = self
49            .repository
50            .find_by_building_and_fiscal_year(request.building_id, request.fiscal_year)
51            .await?
52        {
53            return Err(AppError::Conflict(format!(
54                "Budget already exists for building {} and fiscal year {}",
55                request.building_id, request.fiscal_year
56            )));
57        }
58
59        // Create budget
60        let mut budget = Budget::new(
61            building.acp_id,
62            request.organization_id,
63            request.building_id,
64            request.fiscal_year,
65            request.ordinary_budget,
66            request.extraordinary_budget,
67        )?;
68
69        // Set notes if provided
70        if let Some(notes) = request.notes {
71            budget.update_notes(notes);
72        }
73
74        let created = self.repository.create(&budget).await?;
75        Ok(BudgetResponse::from(created))
76    }
77
78    /// Get budget by ID
79    pub async fn get_budget(&self, id: Uuid) -> Result<Option<BudgetResponse>, AppError> {
80        let budget = self.repository.find_by_id(id).await?;
81        Ok(budget.map(BudgetResponse::from))
82    }
83
84    /// Get budget for a building and fiscal year
85    pub async fn get_by_building_and_fiscal_year(
86        &self,
87        building_id: Uuid,
88        fiscal_year: i32,
89    ) -> Result<Option<BudgetResponse>, AppError> {
90        let budget = self
91            .repository
92            .find_by_building_and_fiscal_year(building_id, fiscal_year)
93            .await?;
94        Ok(budget.map(BudgetResponse::from))
95    }
96
97    /// Get active budget for a building
98    pub async fn get_active_budget(
99        &self,
100        building_id: Uuid,
101    ) -> Result<Option<BudgetResponse>, AppError> {
102        let budget = self.repository.find_active_by_building(building_id).await?;
103        Ok(budget.map(BudgetResponse::from))
104    }
105
106    /// List budgets for a building
107    pub async fn list_by_building(
108        &self,
109        building_id: Uuid,
110    ) -> Result<Vec<BudgetResponse>, AppError> {
111        let budgets = self.repository.find_by_building(building_id).await?;
112        Ok(budgets.into_iter().map(BudgetResponse::from).collect())
113    }
114
115    /// List budgets by fiscal year
116    pub async fn list_by_fiscal_year(
117        &self,
118        organization_id: Uuid,
119        fiscal_year: i32,
120    ) -> Result<Vec<BudgetResponse>, AppError> {
121        let budgets = self
122            .repository
123            .find_by_fiscal_year(organization_id, fiscal_year)
124            .await?;
125        Ok(budgets.into_iter().map(BudgetResponse::from).collect())
126    }
127
128    /// List budgets by status
129    pub async fn list_by_status(
130        &self,
131        organization_id: Uuid,
132        status: BudgetStatus,
133    ) -> Result<Vec<BudgetResponse>, AppError> {
134        let budgets = self
135            .repository
136            .find_by_status(organization_id, status)
137            .await?;
138        Ok(budgets.into_iter().map(BudgetResponse::from).collect())
139    }
140
141    /// List budgets paginated
142    pub async fn list_paginated(
143        &self,
144        page_request: &PageRequest,
145        organization_id: Option<Uuid>,
146        building_id: Option<Uuid>,
147        status: Option<BudgetStatus>,
148    ) -> Result<(Vec<BudgetResponse>, i64), AppError> {
149        let (budgets, total) = self
150            .repository
151            .find_all_paginated(page_request, organization_id, building_id, status)
152            .await?;
153
154        let dtos = budgets.into_iter().map(BudgetResponse::from).collect();
155        Ok((dtos, total))
156    }
157
158    /// Update budget amounts (Draft only)
159    pub async fn update_budget(
160        &self,
161        id: Uuid,
162        request: UpdateBudgetRequest,
163    ) -> Result<BudgetResponse, AppError> {
164        let mut budget = self
165            .repository
166            .find_by_id(id)
167            .await?
168            .ok_or_else(|| "Budget not found".to_string())?;
169
170        // Apply updates (use existing values as defaults for partial updates)
171        if request.ordinary_budget.is_some() || request.extraordinary_budget.is_some() {
172            let ordinary = request.ordinary_budget.unwrap_or(budget.ordinary_budget);
173            let extraordinary = request
174                .extraordinary_budget
175                .unwrap_or(budget.extraordinary_budget);
176            budget.update_amounts(ordinary, extraordinary)?;
177        }
178
179        if let Some(notes) = request.notes {
180            budget.update_notes(notes);
181        }
182
183        let updated = self.repository.update(&budget).await?;
184        Ok(BudgetResponse::from(updated))
185    }
186
187    /// Submit budget for approval
188    pub async fn submit_for_approval(&self, id: Uuid) -> Result<BudgetResponse, AppError> {
189        let mut budget = self
190            .repository
191            .find_by_id(id)
192            .await?
193            .ok_or_else(|| "Budget not found".to_string())?;
194
195        budget.submit_for_approval()?;
196
197        let updated = self.repository.update(&budget).await?;
198        Ok(BudgetResponse::from(updated))
199    }
200
201    /// Approve budget (requires meeting_id for legal traceability)
202    pub async fn approve_budget(
203        &self,
204        id: Uuid,
205        meeting_id: Uuid,
206    ) -> Result<BudgetResponse, AppError> {
207        let mut budget = self
208            .repository
209            .find_by_id(id)
210            .await?
211            .ok_or_else(|| "Budget not found".to_string())?;
212
213        budget.approve(meeting_id)?;
214
215        let updated = self.repository.update(&budget).await?;
216        Ok(BudgetResponse::from(updated))
217    }
218
219    /// Reject budget (with optional reason)
220    pub async fn reject_budget(
221        &self,
222        id: Uuid,
223        reason: Option<String>,
224    ) -> Result<BudgetResponse, AppError> {
225        let mut budget = self
226            .repository
227            .find_by_id(id)
228            .await?
229            .ok_or_else(|| "Budget not found".to_string())?;
230
231        // Add rejection reason to notes
232        if let Some(reason) = reason {
233            let current_notes = budget.notes.clone().unwrap_or_default();
234            let new_notes = if current_notes.is_empty() {
235                format!("REJECTED: {}", reason)
236            } else {
237                format!("{}\n\nREJECTED: {}", current_notes, reason)
238            };
239            budget.update_notes(new_notes);
240        }
241
242        budget.reject()?;
243
244        let updated = self.repository.update(&budget).await?;
245        Ok(BudgetResponse::from(updated))
246    }
247
248    /// Archive budget
249    pub async fn archive_budget(&self, id: Uuid) -> Result<BudgetResponse, AppError> {
250        let mut budget = self
251            .repository
252            .find_by_id(id)
253            .await?
254            .ok_or_else(|| "Budget not found".to_string())?;
255
256        budget.archive()?;
257
258        let updated = self.repository.update(&budget).await?;
259        Ok(BudgetResponse::from(updated))
260    }
261
262    /// Delete budget
263    pub async fn delete_budget(&self, id: Uuid) -> Result<bool, AppError> {
264        self.repository.delete(id).await
265    }
266
267    /// Get budget statistics
268    pub async fn get_stats(&self, organization_id: Uuid) -> Result<BudgetStatsResponse, AppError> {
269        self.repository.get_stats(organization_id).await
270    }
271
272    /// Get budget variance analysis (budget vs actual expenses)
273    pub async fn get_variance(
274        &self,
275        budget_id: Uuid,
276    ) -> Result<Option<BudgetVarianceResponse>, AppError> {
277        self.repository.get_variance(budget_id).await
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use crate::application::dto::PageRequest;
285    use crate::application::ports::{
286        BudgetRepository, BudgetStatsResponse, BudgetVarianceResponse, BuildingRepository,
287        ExpenseRepository,
288    };
289    use crate::domain::entities::{Building, Expense};
290    use mockall::mock;
291    use mockall::predicate::*;
292    use rust_decimal_macros::dec;
293
294    // Mock BudgetRepository
295    mock! {
296        pub BudgetRepo {}
297
298        #[async_trait::async_trait]
299        impl BudgetRepository for BudgetRepo {
300            async fn create(&self, budget: &Budget) -> Result<Budget, AppError>;
301            async fn find_by_id(&self, id: Uuid) -> Result<Option<Budget>, AppError>;
302            async fn find_by_building_and_fiscal_year(
303                &self,
304                building_id: Uuid,
305                fiscal_year: i32,
306            ) -> Result<Option<Budget>, AppError>;
307            async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Budget>, AppError>;
308            async fn find_active_by_building(&self, building_id: Uuid) -> Result<Option<Budget>, AppError>;
309            async fn find_by_fiscal_year(
310                &self,
311                organization_id: Uuid,
312                fiscal_year: i32,
313            ) -> Result<Vec<Budget>, AppError>;
314            async fn find_by_status(
315                &self,
316                organization_id: Uuid,
317                status: BudgetStatus,
318            ) -> Result<Vec<Budget>, AppError>;
319            async fn find_all_paginated(
320                &self,
321                page_request: &PageRequest,
322                organization_id: Option<Uuid>,
323                building_id: Option<Uuid>,
324                status: Option<BudgetStatus>,
325            ) -> Result<(Vec<Budget>, i64), AppError>;
326            async fn update(&self, budget: &Budget) -> Result<Budget, AppError>;
327            async fn delete(&self, id: Uuid) -> Result<bool, AppError>;
328            async fn get_stats(&self, organization_id: Uuid) -> Result<BudgetStatsResponse, AppError>;
329            async fn get_variance(&self, budget_id: Uuid) -> Result<Option<BudgetVarianceResponse>, AppError>;
330        }
331    }
332
333    // Mock BuildingRepository (port still uses String; do not migrate here)
334    mock! {
335        pub BuildingRepo {}
336
337        #[async_trait::async_trait]
338        impl BuildingRepository for BuildingRepo {
339            async fn create(&self, building: &Building) -> Result<Building, String>;
340            async fn find_by_id(&self, id: Uuid) -> Result<Option<Building>, String>;
341            async fn find_by_slug(&self, slug: &str) -> Result<Option<Building>, String>;
342            async fn find_all(&self) -> Result<Vec<Building>, String>;
343            async fn find_all_paginated(
344                &self,
345                page_request: &crate::application::dto::PageRequest,
346                filters: &crate::application::dto::BuildingFilters,
347            ) -> Result<(Vec<Building>, i64), String>;
348            async fn update(&self, building: &Building) -> Result<Building, String>;
349            async fn delete(&self, id: Uuid) -> Result<bool, String>;
350            async fn find_by_id_with_metrics(
351                &self,
352                id: Uuid,
353            ) -> Result<Option<(Building, crate::domain::entities::BuildingMetrics)>, String>;
354        }
355    }
356
357    // Mock ExpenseRepository (port still uses String; do not migrate here)
358    mock! {
359        pub ExpenseRepo {}
360
361        #[async_trait::async_trait]
362        impl ExpenseRepository for ExpenseRepo {
363        async fn enregistrer_lignes_de_facture(
364            &self,
365            _expense_id: Uuid,
366            _lignes: &[crate::application::ports::expense_repository::LigneDeFacture],
367        ) -> Result<(), String> {
368            // Mock : rien à enregistrer. Le port n'offre pas d'implémentation
369            // par défaut, précisément pour que ce choix soit écrit ici plutôt
370            // que subi partout.
371            Ok(())
372        }
373
374            async fn create(&self, expense: &Expense) -> Result<Expense, String>;
375            async fn find_by_id(&self, id: Uuid) -> Result<Option<Expense>, String>;
376            async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Expense>, String>;
377            async fn find_all_paginated(
378                &self,
379                page_request: &crate::application::dto::PageRequest,
380                filters: &crate::application::dto::ExpenseFilters,
381            ) -> Result<(Vec<Expense>, i64), String>;
382            async fn update(&self, expense: &Expense) -> Result<Expense, String>;
383            async fn delete(&self, id: Uuid) -> Result<bool, String>;
384        }
385    }
386
387    /// Helper: create a valid Building for mock returns
388    /// Un immeuble rattaché à une ACP.
389    ///
390    /// Le premier argument de `Building::new` est l'**ACP**, pas
391    /// l'organisation : un immeuble appartient à une copropriété, pas à un
392    /// cabinet de syndic (Art. 3.85 § 1er, ADR-0010).
393    fn make_building(acp_id: Uuid) -> Building {
394        Building::new(
395            acp_id,
396            "Résidence du Parc".to_string(),
397            "12 Rue de la Loi".to_string(),
398            "Brussels".to_string(),
399            "1000".to_string(),
400            "Belgium".to_string(),
401            20,
402            1000,
403            Some(2015),
404        )
405        .unwrap()
406    }
407
408    /// Helper: create a Draft budget ready for use in tests
409    ///
410    /// L'ACP vient en premier : c'est elle qui vote le budget et le supporte
411    /// (Art. 3.89 § 5, 16°), le syndic ne fait que le préparer.
412    fn make_draft_budget(acp_id: Uuid, org_id: Uuid, building_id: Uuid) -> Budget {
413        Budget::new(acp_id, org_id, building_id, 2025, dec!(60000), dec!(15000)).unwrap()
414    }
415
416    /// Helper: build the BudgetUseCases from three mock repos
417    fn make_use_cases(
418        budget_repo: MockBudgetRepo,
419        building_repo: MockBuildingRepo,
420        expense_repo: MockExpenseRepo,
421    ) -> BudgetUseCases {
422        BudgetUseCases::new(
423            Arc::new(budget_repo),
424            Arc::new(building_repo),
425            Arc::new(expense_repo),
426        )
427    }
428
429    /// Art. 3.89 § 5, 16° et ADR-0045 : le budget appartient à l'ACP.
430    ///
431    /// L'ACP se **déduit de l'immeuble**, elle n'est jamais lue dans la
432    /// requête. C'est ce qui empêche un cabinet d'écrire dans le dossier d'un
433    /// autre en forgeant un identifiant : le lien immeuble → ACP est fixé par
434    /// l'acte de base, la requête ne peut pas le contredire.
435    #[tokio::test]
436    async fn test_le_budget_appartient_a_lacp_de_limmeuble_pas_au_syndic() {
437        let acp_de_limmeuble = Uuid::new_v4();
438        let cabinet_qui_encode = Uuid::new_v4();
439        let building_id = Uuid::new_v4();
440
441        let mut budget_repo = MockBudgetRepo::new();
442        let mut building_repo = MockBuildingRepo::new();
443        let expense_repo = MockExpenseRepo::new();
444
445        let building = make_building(acp_de_limmeuble);
446        building_repo
447            .expect_find_by_id()
448            .with(eq(building_id))
449            .times(1)
450            .returning(move |_| Ok(Some(building.clone())));
451        budget_repo
452            .expect_find_by_building_and_fiscal_year()
453            .times(1)
454            .returning(|_, _| Ok(None));
455        budget_repo
456            .expect_create()
457            .times(1)
458            .returning(|b| Ok(b.clone()));
459
460        let uc = make_use_cases(budget_repo, building_repo, expense_repo);
461
462        let budget = uc
463            .create_budget(CreateBudgetRequest {
464                organization_id: cabinet_qui_encode,
465                building_id,
466                fiscal_year: 2026,
467                ordinary_budget: dec!(48000),
468                extraordinary_budget: dec!(12000),
469                notes: None,
470            })
471            .await
472            .expect("création valide");
473
474        assert_eq!(
475            budget.acp_id, acp_de_limmeuble,
476            "le budget doit être rattaché à l'ACP de l'immeuble"
477        );
478        assert_eq!(
479            budget.organization_id, cabinet_qui_encode,
480            "le syndic reste tracé comme auteur, sans que ça lui donne un droit"
481        );
482        assert_ne!(
483            budget.acp_id, budget.organization_id,
484            "l'ACP et le syndic sont deux entités distinctes, pas deux noms de la même"
485        );
486    }
487
488    // ---------------------------------------------------------------
489    // 1. Create budget (happy path)
490    // ---------------------------------------------------------------
491    #[tokio::test]
492    async fn test_create_budget_success() {
493        let acp_id = Uuid::new_v4();
494        let org_id = Uuid::new_v4();
495        let building_id = Uuid::new_v4();
496
497        let mut budget_repo = MockBudgetRepo::new();
498        let mut building_repo = MockBuildingRepo::new();
499        let expense_repo = MockExpenseRepo::new();
500
501        // Building exists
502        let building = make_building(acp_id);
503        building_repo
504            .expect_find_by_id()
505            .with(eq(building_id))
506            .times(1)
507            .returning(move |_| Ok(Some(building.clone())));
508
509        // No duplicate for this building+fiscal_year
510        budget_repo
511            .expect_find_by_building_and_fiscal_year()
512            .with(eq(building_id), eq(2025))
513            .times(1)
514            .returning(|_, _| Ok(None));
515
516        // Repo creates successfully
517        budget_repo
518            .expect_create()
519            .times(1)
520            .returning(|b| Ok(b.clone()));
521
522        let uc = make_use_cases(budget_repo, building_repo, expense_repo);
523
524        let request = CreateBudgetRequest {
525            organization_id: org_id,
526            building_id,
527            fiscal_year: 2025,
528            ordinary_budget: dec!(60000),
529            extraordinary_budget: dec!(15000),
530            notes: Some("Budget prévisionnel toiture".to_string()),
531        };
532
533        let result = uc.create_budget(request).await;
534        assert!(result.is_ok());
535        let resp = result.unwrap();
536        assert_eq!(resp.fiscal_year, 2025);
537        assert_eq!(resp.ordinary_budget, dec!(60000));
538        assert_eq!(resp.extraordinary_budget, dec!(15000));
539        assert_eq!(resp.total_budget, dec!(75000));
540        assert_eq!(resp.status, BudgetStatus::Draft);
541        assert!(resp.is_editable);
542        assert!(!resp.is_active);
543        assert_eq!(resp.notes, Some("Budget prévisionnel toiture".to_string()));
544    }
545
546    // ---------------------------------------------------------------
547    // 2. Create budget fails when building+fiscal_year already exists
548    // ---------------------------------------------------------------
549    #[tokio::test]
550    async fn test_create_budget_duplicate_fiscal_year() {
551        let acp_id = Uuid::new_v4();
552        let org_id = Uuid::new_v4();
553        let building_id = Uuid::new_v4();
554
555        let mut budget_repo = MockBudgetRepo::new();
556        let mut building_repo = MockBuildingRepo::new();
557        let expense_repo = MockExpenseRepo::new();
558
559        let building = make_building(acp_id);
560        building_repo
561            .expect_find_by_id()
562            .with(eq(building_id))
563            .times(1)
564            .returning(move |_| Ok(Some(building.clone())));
565
566        // Duplicate exists
567        let existing = make_draft_budget(acp_id, org_id, building_id);
568        budget_repo
569            .expect_find_by_building_and_fiscal_year()
570            .with(eq(building_id), eq(2025))
571            .times(1)
572            .returning(move |_, _| Ok(Some(existing.clone())));
573
574        let uc = make_use_cases(budget_repo, building_repo, expense_repo);
575
576        let request = CreateBudgetRequest {
577            organization_id: org_id,
578            building_id,
579            fiscal_year: 2025,
580            ordinary_budget: dec!(60000),
581            extraordinary_budget: dec!(15000),
582            notes: None,
583        };
584
585        let result = uc.create_budget(request).await;
586        assert!(result.is_err());
587        assert!(result
588            .unwrap_err()
589            .to_string()
590            .contains("Budget already exists"));
591    }
592
593    // ---------------------------------------------------------------
594    // 3. Submit for approval (Draft -> Submitted)
595    // ---------------------------------------------------------------
596    #[tokio::test]
597    async fn test_submit_for_approval_success() {
598        let acp_id = Uuid::new_v4();
599        let org_id = Uuid::new_v4();
600        let building_id = Uuid::new_v4();
601        let budget_id = Uuid::new_v4();
602
603        let mut budget_repo = MockBudgetRepo::new();
604        let building_repo = MockBuildingRepo::new();
605        let expense_repo = MockExpenseRepo::new();
606
607        let mut draft = make_draft_budget(acp_id, org_id, building_id);
608        draft.id = budget_id;
609
610        budget_repo
611            .expect_find_by_id()
612            .with(eq(budget_id))
613            .times(1)
614            .returning(move |_| Ok(Some(draft.clone())));
615
616        budget_repo
617            .expect_update()
618            .times(1)
619            .returning(|b| Ok(b.clone()));
620
621        let uc = make_use_cases(budget_repo, building_repo, expense_repo);
622
623        let result = uc.submit_for_approval(budget_id).await;
624        assert!(result.is_ok());
625        let resp = result.unwrap();
626        assert_eq!(resp.status, BudgetStatus::Submitted);
627        assert!(resp.submitted_date.is_some());
628    }
629
630    // ---------------------------------------------------------------
631    // 4. Approve budget (Submitted -> Approved, requires meeting_id)
632    // ---------------------------------------------------------------
633    #[tokio::test]
634    async fn test_approve_budget_success() {
635        let acp_id = Uuid::new_v4();
636        let org_id = Uuid::new_v4();
637        let building_id = Uuid::new_v4();
638        let budget_id = Uuid::new_v4();
639        let meeting_id = Uuid::new_v4();
640
641        let mut budget_repo = MockBudgetRepo::new();
642        let building_repo = MockBuildingRepo::new();
643        let expense_repo = MockExpenseRepo::new();
644
645        // Budget must be in Submitted state
646        let mut submitted = make_draft_budget(acp_id, org_id, building_id);
647        submitted.id = budget_id;
648        submitted.submit_for_approval().unwrap();
649
650        budget_repo
651            .expect_find_by_id()
652            .with(eq(budget_id))
653            .times(1)
654            .returning(move |_| Ok(Some(submitted.clone())));
655
656        budget_repo
657            .expect_update()
658            .times(1)
659            .returning(|b| Ok(b.clone()));
660
661        let uc = make_use_cases(budget_repo, building_repo, expense_repo);
662
663        let result = uc.approve_budget(budget_id, meeting_id).await;
664        assert!(result.is_ok());
665        let resp = result.unwrap();
666        assert_eq!(resp.status, BudgetStatus::Approved);
667        assert!(resp.approved_date.is_some());
668        assert_eq!(resp.approved_by_meeting_id, Some(meeting_id));
669        assert!(resp.is_active);
670    }
671
672    // ---------------------------------------------------------------
673    // 5. Reject budget (Submitted -> Rejected, with reason)
674    // ---------------------------------------------------------------
675    #[tokio::test]
676    async fn test_reject_budget_with_reason() {
677        let acp_id = Uuid::new_v4();
678        let org_id = Uuid::new_v4();
679        let building_id = Uuid::new_v4();
680        let budget_id = Uuid::new_v4();
681
682        let mut budget_repo = MockBudgetRepo::new();
683        let building_repo = MockBuildingRepo::new();
684        let expense_repo = MockExpenseRepo::new();
685
686        let mut submitted = make_draft_budget(acp_id, org_id, building_id);
687        submitted.id = budget_id;
688        submitted.submit_for_approval().unwrap();
689
690        budget_repo
691            .expect_find_by_id()
692            .with(eq(budget_id))
693            .times(1)
694            .returning(move |_| Ok(Some(submitted.clone())));
695
696        budget_repo
697            .expect_update()
698            .times(1)
699            .returning(|b| Ok(b.clone()));
700
701        let uc = make_use_cases(budget_repo, building_repo, expense_repo);
702
703        let reason = Some("Montant extraordinaire trop élevé".to_string());
704        let result = uc.reject_budget(budget_id, reason).await;
705        assert!(result.is_ok());
706        let resp = result.unwrap();
707        assert_eq!(resp.status, BudgetStatus::Rejected);
708        assert!(resp.notes.is_some());
709        assert!(resp
710            .notes
711            .unwrap()
712            .contains("REJECTED: Montant extraordinaire trop élevé"));
713    }
714
715    // ---------------------------------------------------------------
716    // 6. Archive budget (Approved -> Archived)
717    // ---------------------------------------------------------------
718    #[tokio::test]
719    async fn test_archive_budget_success() {
720        let acp_id = Uuid::new_v4();
721        let org_id = Uuid::new_v4();
722        let building_id = Uuid::new_v4();
723        let budget_id = Uuid::new_v4();
724        let meeting_id = Uuid::new_v4();
725
726        let mut budget_repo = MockBudgetRepo::new();
727        let building_repo = MockBuildingRepo::new();
728        let expense_repo = MockExpenseRepo::new();
729
730        // Budget must be Approved to archive
731        let mut approved = make_draft_budget(acp_id, org_id, building_id);
732        approved.id = budget_id;
733        approved.submit_for_approval().unwrap();
734        approved.approve(meeting_id).unwrap();
735
736        budget_repo
737            .expect_find_by_id()
738            .with(eq(budget_id))
739            .times(1)
740            .returning(move |_| Ok(Some(approved.clone())));
741
742        budget_repo
743            .expect_update()
744            .times(1)
745            .returning(|b| Ok(b.clone()));
746
747        let uc = make_use_cases(budget_repo, building_repo, expense_repo);
748
749        let result = uc.archive_budget(budget_id).await;
750        assert!(result.is_ok());
751        let resp = result.unwrap();
752        assert_eq!(resp.status, BudgetStatus::Archived);
753        assert!(!resp.is_active);
754        assert!(!resp.is_editable);
755    }
756
757    // ---------------------------------------------------------------
758    // 7. Get variance analysis
759    // ---------------------------------------------------------------
760    #[tokio::test]
761    async fn test_get_variance_returns_analysis() {
762        let budget_id = Uuid::new_v4();
763        let building_id = Uuid::new_v4();
764
765        let mut budget_repo = MockBudgetRepo::new();
766        let building_repo = MockBuildingRepo::new();
767        let expense_repo = MockExpenseRepo::new();
768
769        let variance = BudgetVarianceResponse {
770            budget_id,
771            fiscal_year: 2025,
772            building_id,
773            budgeted_ordinary: dec!(60000),
774            budgeted_extraordinary: dec!(15000),
775            budgeted_total: dec!(75000),
776            actual_ordinary: dec!(45000),
777            actual_extraordinary: dec!(20000),
778            actual_total: dec!(65000),
779            variance_ordinary: dec!(15000),
780            variance_extraordinary: dec!(-5000),
781            variance_total: dec!(10000),
782            variance_ordinary_pct: dec!(25),
783            variance_extraordinary_pct: dec!(-33.33),
784            variance_total_pct: dec!(13.33),
785            has_overruns: true,
786            overrun_categories: vec!["Extraordinary".to_string()],
787            months_elapsed: 8,
788            projected_year_end_total: dec!(97500),
789        };
790
791        let expected_variance = variance.clone();
792
793        budget_repo
794            .expect_get_variance()
795            .with(eq(budget_id))
796            .times(1)
797            .returning(move |_| Ok(Some(variance.clone())));
798
799        let uc = make_use_cases(budget_repo, building_repo, expense_repo);
800
801        let result = uc.get_variance(budget_id).await;
802        assert!(result.is_ok());
803        let opt = result.unwrap();
804        assert!(opt.is_some());
805        let v = opt.unwrap();
806        assert_eq!(v.budget_id, expected_variance.budget_id);
807        assert_eq!(v.budgeted_total, dec!(75000));
808        assert_eq!(v.actual_total, dec!(65000));
809        assert_eq!(v.variance_total, dec!(10000));
810        assert!(v.has_overruns);
811        assert_eq!(v.overrun_categories, vec!["Extraordinary".to_string()]);
812    }
813
814    // ---------------------------------------------------------------
815    // 8. Get active budget for a building
816    // ---------------------------------------------------------------
817    #[tokio::test]
818    async fn test_get_active_budget_for_building() {
819        let acp_id = Uuid::new_v4();
820        let org_id = Uuid::new_v4();
821        let building_id = Uuid::new_v4();
822        let meeting_id = Uuid::new_v4();
823
824        let mut budget_repo = MockBudgetRepo::new();
825        let building_repo = MockBuildingRepo::new();
826        let expense_repo = MockExpenseRepo::new();
827
828        // Active budget = Approved
829        let mut approved = make_draft_budget(acp_id, org_id, building_id);
830        approved.submit_for_approval().unwrap();
831        approved.approve(meeting_id).unwrap();
832
833        let expected_id = approved.id;
834
835        budget_repo
836            .expect_find_active_by_building()
837            .with(eq(building_id))
838            .times(1)
839            .returning(move |_| Ok(Some(approved.clone())));
840
841        let uc = make_use_cases(budget_repo, building_repo, expense_repo);
842
843        let result = uc.get_active_budget(building_id).await;
844        assert!(result.is_ok());
845        let opt = result.unwrap();
846        assert!(opt.is_some());
847        let resp = opt.unwrap();
848        assert_eq!(resp.id, expected_id);
849        assert_eq!(resp.status, BudgetStatus::Approved);
850        assert!(resp.is_active);
851        assert_eq!(resp.building_id, building_id);
852    }
853}