koprogo_api/application/use_cases/
budget_use_cases.rs

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