koprogo_api/application/use_cases/
expense_use_cases.rs

1use crate::application::dto::{
2    ApproveInvoiceDto, CreateExpenseDto, CreateInvoiceDraftDto, ExpenseFilters, ExpenseResponseDto,
3    InvoiceResponseDto, PageRequest, PendingInvoicesListDto, RejectInvoiceDto, SortOrder,
4    SubmitForApprovalDto, UpdateInvoiceDraftDto,
5};
6use crate::application::ports::ExpenseRepository;
7use crate::application::services::expense_accounting_service::ExpenseAccountingService;
8use crate::domain::entities::{ApprovalStatus, Expense};
9use chrono::DateTime;
10use std::sync::Arc;
11use uuid::Uuid;
12
13pub struct ExpenseUseCases {
14    repository: Arc<dyn ExpenseRepository>,
15    accounting_service: Option<Arc<ExpenseAccountingService>>,
16}
17
18impl ExpenseUseCases {
19    pub fn new(repository: Arc<dyn ExpenseRepository>) -> Self {
20        Self {
21            repository,
22            accounting_service: None,
23        }
24    }
25
26    pub fn with_accounting_service(
27        repository: Arc<dyn ExpenseRepository>,
28        accounting_service: Arc<ExpenseAccountingService>,
29    ) -> Self {
30        Self {
31            repository,
32            accounting_service: Some(accounting_service),
33        }
34    }
35
36    pub async fn create_expense(
37        &self,
38        dto: CreateExpenseDto,
39    ) -> Result<ExpenseResponseDto, String> {
40        let organization_id = Uuid::parse_str(&dto.organization_id)
41            .map_err(|_| "Invalid organization_id format".to_string())?;
42        let building_id = Uuid::parse_str(&dto.building_id)
43            .map_err(|_| "Invalid building ID format".to_string())?;
44
45        let expense_date = DateTime::parse_from_rfc3339(&dto.expense_date)
46            .map_err(|_| "Invalid date format".to_string())?
47            .with_timezone(&chrono::Utc);
48
49        let expense = Expense::new(
50            organization_id,
51            building_id,
52            dto.category,
53            dto.description,
54            dto.amount,
55            expense_date,
56            dto.supplier,
57            dto.invoice_number,
58            dto.account_code,
59        )?;
60
61        let created = self.repository.create(&expense).await?;
62        Ok(self.to_response_dto(&created))
63    }
64
65    pub async fn get_expense(&self, id: Uuid) -> Result<Option<ExpenseResponseDto>, String> {
66        let expense = self.repository.find_by_id(id).await?;
67        Ok(expense.map(|e| self.to_response_dto(&e)))
68    }
69
70    pub async fn list_expenses_by_building(
71        &self,
72        building_id: Uuid,
73    ) -> Result<Vec<ExpenseResponseDto>, String> {
74        let expenses = self.repository.find_by_building(building_id).await?;
75        Ok(expenses.iter().map(|e| self.to_response_dto(e)).collect())
76    }
77
78    pub async fn list_expenses_paginated(
79        &self,
80        page_request: &PageRequest,
81        organization_id: Option<Uuid>,
82    ) -> Result<(Vec<ExpenseResponseDto>, i64), String> {
83        let filters = ExpenseFilters {
84            organization_id,
85            ..Default::default()
86        };
87
88        let (expenses, total) = self
89            .repository
90            .find_all_paginated(page_request, &filters)
91            .await?;
92
93        let dtos = expenses.iter().map(|e| self.to_response_dto(e)).collect();
94        Ok((dtos, total))
95    }
96
97    /// Marquer une charge comme payée
98    ///
99    /// Crée automatiquement l'écriture comptable de paiement (FIN - Financier)
100    pub async fn mark_as_paid(&self, id: Uuid) -> Result<ExpenseResponseDto, String> {
101        let mut expense = self
102            .repository
103            .find_by_id(id)
104            .await?
105            .ok_or_else(|| "Expense not found".to_string())?;
106
107        expense.mark_as_paid()?;
108
109        let updated = self.repository.update(&expense).await?;
110
111        // Générer automatiquement l'écriture comptable de paiement
112        if let Some(ref accounting_service) = self.accounting_service {
113            if let Err(e) = accounting_service
114                .generate_payment_entry(&updated, None, None)
115                .await
116            {
117                log::warn!(
118                    "Failed to generate payment journal entry for expense {}: {}",
119                    updated.id,
120                    e
121                );
122                // Ne pas échouer le paiement si la création de l'écriture échoue
123                // L'écriture peut être créée manuellement plus tard
124            }
125        }
126
127        Ok(self.to_response_dto(&updated))
128    }
129
130    pub async fn mark_as_overdue(&self, id: Uuid) -> Result<ExpenseResponseDto, String> {
131        let mut expense = self
132            .repository
133            .find_by_id(id)
134            .await?
135            .ok_or_else(|| "Expense not found".to_string())?;
136
137        expense.mark_as_overdue()?;
138
139        let updated = self.repository.update(&expense).await?;
140        Ok(self.to_response_dto(&updated))
141    }
142
143    pub async fn cancel_expense(&self, id: Uuid) -> Result<ExpenseResponseDto, String> {
144        let mut expense = self
145            .repository
146            .find_by_id(id)
147            .await?
148            .ok_or_else(|| "Expense not found".to_string())?;
149
150        expense.cancel()?;
151
152        let updated = self.repository.update(&expense).await?;
153        Ok(self.to_response_dto(&updated))
154    }
155
156    pub async fn reactivate_expense(&self, id: Uuid) -> Result<ExpenseResponseDto, String> {
157        let mut expense = self
158            .repository
159            .find_by_id(id)
160            .await?
161            .ok_or_else(|| "Expense not found".to_string())?;
162
163        expense.reactivate()?;
164
165        let updated = self.repository.update(&expense).await?;
166        Ok(self.to_response_dto(&updated))
167    }
168
169    pub async fn unpay_expense(&self, id: Uuid) -> Result<ExpenseResponseDto, String> {
170        let mut expense = self
171            .repository
172            .find_by_id(id)
173            .await?
174            .ok_or_else(|| "Expense not found".to_string())?;
175
176        expense.unpay()?;
177
178        let updated = self.repository.update(&expense).await?;
179        Ok(self.to_response_dto(&updated))
180    }
181
182    // ========== Invoice Workflow Methods (Issue #73) ==========
183
184    /// Créer une facture brouillon avec gestion TVA
185    pub async fn create_invoice_draft(
186        &self,
187        dto: CreateInvoiceDraftDto,
188    ) -> Result<InvoiceResponseDto, String> {
189        let organization_id = Uuid::parse_str(&dto.organization_id)
190            .map_err(|_| "Invalid organization_id format".to_string())?;
191        let building_id = Uuid::parse_str(&dto.building_id)
192            .map_err(|_| "Invalid building ID format".to_string())?;
193
194        let invoice_date = DateTime::parse_from_rfc3339(&dto.invoice_date)
195            .map_err(|_| "Invalid invoice_date format".to_string())?
196            .with_timezone(&chrono::Utc);
197
198        let due_date = dto
199            .due_date
200            .map(|d| {
201                DateTime::parse_from_rfc3339(&d)
202                    .map_err(|_| "Invalid due_date format".to_string())
203                    .map(|dt| dt.with_timezone(&chrono::Utc))
204            })
205            .transpose()?;
206
207        let invoice = Expense::new_with_vat(
208            organization_id,
209            building_id,
210            dto.category,
211            dto.description,
212            dto.amount_excl_vat,
213            dto.vat_rate,
214            invoice_date,
215            due_date,
216            dto.supplier,
217            dto.invoice_number,
218            None, // account_code (can be added later)
219        )?;
220
221        let created = self.repository.create(&invoice).await?;
222        Ok(self.to_invoice_response_dto(&created))
223    }
224
225    /// Modifier une facture brouillon ou rejetée
226    pub async fn update_invoice_draft(
227        &self,
228        invoice_id: Uuid,
229        dto: UpdateInvoiceDraftDto,
230    ) -> Result<InvoiceResponseDto, String> {
231        let mut invoice = self
232            .repository
233            .find_by_id(invoice_id)
234            .await?
235            .ok_or_else(|| "Invoice not found".to_string())?;
236
237        // Vérifier que la facture peut être modifiée
238        if !invoice.can_be_modified() {
239            return Err(format!(
240                "Invoice cannot be modified (status: {:?})",
241                invoice.approval_status
242            ));
243        }
244
245        // Appliquer les modifications
246        if let Some(desc) = dto.description {
247            invoice.description = desc;
248        }
249        if let Some(cat) = dto.category {
250            invoice.category = cat;
251        }
252        if let Some(amount_ht) = dto.amount_excl_vat {
253            invoice.amount_excl_vat = Some(amount_ht);
254        }
255        if let Some(vat_rate) = dto.vat_rate {
256            invoice.vat_rate = Some(vat_rate);
257        }
258
259        // Recalculer la TVA si nécessaire
260        if dto.amount_excl_vat.is_some() || dto.vat_rate.is_some() {
261            invoice.recalculate_vat()?;
262        }
263
264        if let Some(inv_date) = dto.invoice_date {
265            let parsed_date = DateTime::parse_from_rfc3339(&inv_date)
266                .map_err(|_| "Invalid invoice_date format".to_string())?
267                .with_timezone(&chrono::Utc);
268            invoice.invoice_date = Some(parsed_date);
269        }
270
271        if let Some(due_date_str) = dto.due_date {
272            let parsed_date = DateTime::parse_from_rfc3339(&due_date_str)
273                .map_err(|_| "Invalid due_date format".to_string())?
274                .with_timezone(&chrono::Utc);
275            invoice.due_date = Some(parsed_date);
276        }
277
278        if dto.supplier.is_some() {
279            invoice.supplier = dto.supplier;
280        }
281        if dto.invoice_number.is_some() {
282            invoice.invoice_number = dto.invoice_number;
283        }
284
285        invoice.updated_at = chrono::Utc::now();
286
287        let updated = self.repository.update(&invoice).await?;
288        Ok(self.to_invoice_response_dto(&updated))
289    }
290
291    /// Soumettre une facture pour validation (Draft → PendingApproval)
292    pub async fn submit_for_approval(
293        &self,
294        invoice_id: Uuid,
295        _dto: SubmitForApprovalDto,
296    ) -> Result<InvoiceResponseDto, String> {
297        let mut invoice = self
298            .repository
299            .find_by_id(invoice_id)
300            .await?
301            .ok_or_else(|| "Invoice not found".to_string())?;
302
303        invoice.submit_for_approval()?;
304
305        let updated = self.repository.update(&invoice).await?;
306        Ok(self.to_invoice_response_dto(&updated))
307    }
308
309    /// Approuver une facture (PendingApproval → Approved)
310    ///
311    /// Crée automatiquement l'écriture comptable correspondante (ACH - Achats)
312    pub async fn approve_invoice(
313        &self,
314        invoice_id: Uuid,
315        dto: ApproveInvoiceDto,
316    ) -> Result<InvoiceResponseDto, String> {
317        let mut invoice = self
318            .repository
319            .find_by_id(invoice_id)
320            .await?
321            .ok_or_else(|| "Invoice not found".to_string())?;
322
323        let approved_by_user_id = Uuid::parse_str(&dto.approved_by_user_id)
324            .map_err(|_| "Invalid approved_by_user_id format".to_string())?;
325
326        invoice.approve(approved_by_user_id)?;
327
328        let updated = self.repository.update(&invoice).await?;
329
330        // Générer automatiquement l'écriture comptable pour la facture approuvée
331        if let Some(ref accounting_service) = self.accounting_service {
332            if let Err(e) = accounting_service
333                .generate_journal_entry_for_expense(&updated, Some(approved_by_user_id))
334                .await
335            {
336                log::warn!(
337                    "Failed to generate journal entry for approved expense {}: {}",
338                    updated.id,
339                    e
340                );
341                // Ne pas échouer l'approbation si la création de l'écriture échoue
342                // L'écriture peut être créée manuellement plus tard
343            }
344        }
345
346        Ok(self.to_invoice_response_dto(&updated))
347    }
348
349    /// Rejeter une facture avec raison (PendingApproval → Rejected)
350    pub async fn reject_invoice(
351        &self,
352        invoice_id: Uuid,
353        dto: RejectInvoiceDto,
354    ) -> Result<InvoiceResponseDto, String> {
355        let mut invoice = self
356            .repository
357            .find_by_id(invoice_id)
358            .await?
359            .ok_or_else(|| "Invoice not found".to_string())?;
360
361        let rejected_by_user_id = Uuid::parse_str(&dto.rejected_by_user_id)
362            .map_err(|_| "Invalid rejected_by_user_id format".to_string())?;
363
364        invoice.reject(rejected_by_user_id, dto.rejection_reason)?;
365
366        let updated = self.repository.update(&invoice).await?;
367        Ok(self.to_invoice_response_dto(&updated))
368    }
369
370    /// Récupérer toutes les factures en attente d'approbation (pour syndics)
371    pub async fn get_pending_invoices(
372        &self,
373        organization_id: Uuid,
374    ) -> Result<PendingInvoicesListDto, String> {
375        let filters = ExpenseFilters {
376            organization_id: Some(organization_id),
377            approval_status: Some(ApprovalStatus::PendingApproval),
378            ..Default::default()
379        };
380
381        // Utiliser une pagination large pour récupérer toutes les factures pending
382        let page_request = PageRequest {
383            page: 1,
384            per_page: 1000, // Limite raisonnable
385            sort_by: None,
386            order: SortOrder::default(),
387        };
388
389        let (expenses, _total) = self
390            .repository
391            .find_all_paginated(&page_request, &filters)
392            .await?;
393
394        let invoices: Vec<InvoiceResponseDto> = expenses
395            .iter()
396            .map(|e| self.to_invoice_response_dto(e))
397            .collect();
398
399        Ok(PendingInvoicesListDto {
400            count: invoices.len(),
401            invoices,
402        })
403    }
404
405    /// Récupérer une facture avec tous les détails (enrichi)
406    pub async fn get_invoice(&self, id: Uuid) -> Result<Option<InvoiceResponseDto>, String> {
407        let expense = self.repository.find_by_id(id).await?;
408        Ok(expense.map(|e| self.to_invoice_response_dto(&e)))
409    }
410
411    // ========== Helper Methods ==========
412
413    fn to_response_dto(&self, expense: &Expense) -> ExpenseResponseDto {
414        ExpenseResponseDto {
415            id: expense.id.to_string(),
416            building_id: expense.building_id.to_string(),
417            category: expense.category.clone(),
418            description: expense.description.clone(),
419            amount: expense.amount,
420            expense_date: expense.expense_date.to_rfc3339(),
421            payment_status: expense.payment_status.clone(),
422            approval_status: expense.approval_status.clone(),
423            supplier: expense.supplier.clone(),
424            invoice_number: expense.invoice_number.clone(),
425            account_code: expense.account_code.clone(),
426            contractor_report_id: expense.contractor_report_id.map(|id| id.to_string()),
427        }
428    }
429
430    fn to_invoice_response_dto(&self, expense: &Expense) -> InvoiceResponseDto {
431        InvoiceResponseDto {
432            id: expense.id.to_string(),
433            organization_id: expense.organization_id.to_string(),
434            building_id: expense.building_id.to_string(),
435            category: expense.category.clone(),
436            description: expense.description.clone(),
437
438            // Montants
439            amount: expense.amount,
440            amount_excl_vat: expense.amount_excl_vat,
441            vat_rate: expense.vat_rate,
442            vat_amount: expense.vat_amount,
443            amount_incl_vat: expense.amount_incl_vat,
444
445            // Dates
446            expense_date: expense.expense_date.to_rfc3339(),
447            invoice_date: expense.invoice_date.map(|d| d.to_rfc3339()),
448            due_date: expense.due_date.map(|d| d.to_rfc3339()),
449            paid_date: expense.paid_date.map(|d| d.to_rfc3339()),
450
451            // Workflow
452            approval_status: expense.approval_status.clone(),
453            submitted_at: expense.submitted_at.map(|d| d.to_rfc3339()),
454            approved_by: expense.approved_by.map(|u| u.to_string()),
455            approved_at: expense.approved_at.map(|d| d.to_rfc3339()),
456            rejection_reason: expense.rejection_reason.clone(),
457
458            // Payment
459            payment_status: expense.payment_status.clone(),
460            supplier: expense.supplier.clone(),
461            invoice_number: expense.invoice_number.clone(),
462
463            contractor_report_id: expense.contractor_report_id.map(|id| id.to_string()),
464
465            created_at: expense.created_at.to_rfc3339(),
466            updated_at: expense.updated_at.to_rfc3339(),
467        }
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use crate::application::dto::{ExpenseFilters, PageRequest};
475    use crate::application::ports::ExpenseRepository;
476    use crate::domain::entities::{ApprovalStatus, ExpenseCategory, PaymentStatus};
477    use async_trait::async_trait;
478    use std::collections::HashMap;
479    use std::sync::Mutex;
480
481    // ========== Mock Repository ==========
482
483    struct MockExpenseRepository {
484        expenses: Mutex<HashMap<Uuid, Expense>>,
485    }
486
487    impl MockExpenseRepository {
488        fn new() -> Self {
489            Self {
490                expenses: Mutex::new(HashMap::new()),
491            }
492        }
493    }
494
495    #[async_trait]
496    impl ExpenseRepository for MockExpenseRepository {
497        async fn create(&self, expense: &Expense) -> Result<Expense, String> {
498            let mut expenses = self.expenses.lock().unwrap();
499            expenses.insert(expense.id, expense.clone());
500            Ok(expense.clone())
501        }
502
503        async fn find_by_id(&self, id: Uuid) -> Result<Option<Expense>, String> {
504            let expenses = self.expenses.lock().unwrap();
505            Ok(expenses.get(&id).cloned())
506        }
507
508        async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Expense>, String> {
509            let expenses = self.expenses.lock().unwrap();
510            Ok(expenses
511                .values()
512                .filter(|e| e.building_id == building_id)
513                .cloned()
514                .collect())
515        }
516
517        async fn find_all_paginated(
518            &self,
519            _page_request: &PageRequest,
520            filters: &ExpenseFilters,
521        ) -> Result<(Vec<Expense>, i64), String> {
522            let expenses = self.expenses.lock().unwrap();
523            let filtered: Vec<Expense> = expenses
524                .values()
525                .filter(|e| {
526                    if let Some(org_id) = filters.organization_id {
527                        if e.organization_id != org_id {
528                            return false;
529                        }
530                    }
531                    if let Some(ref status) = filters.approval_status {
532                        if e.approval_status != *status {
533                            return false;
534                        }
535                    }
536                    true
537                })
538                .cloned()
539                .collect();
540            let count = filtered.len() as i64;
541            Ok((filtered, count))
542        }
543
544        async fn update(&self, expense: &Expense) -> Result<Expense, String> {
545            let mut expenses = self.expenses.lock().unwrap();
546            expenses.insert(expense.id, expense.clone());
547            Ok(expense.clone())
548        }
549
550        async fn delete(&self, id: Uuid) -> Result<bool, String> {
551            let mut expenses = self.expenses.lock().unwrap();
552            Ok(expenses.remove(&id).is_some())
553        }
554    }
555
556    // ========== Helpers ==========
557
558    fn make_use_cases(repo: MockExpenseRepository) -> ExpenseUseCases {
559        ExpenseUseCases::new(Arc::new(repo))
560    }
561
562    fn valid_create_dto(org_id: Uuid, building_id: Uuid) -> CreateExpenseDto {
563        CreateExpenseDto {
564            organization_id: org_id.to_string(),
565            building_id: building_id.to_string(),
566            category: ExpenseCategory::Maintenance,
567            description: "Elevator maintenance Q1".to_string(),
568            amount: 1500.0,
569            expense_date: "2026-01-15T10:00:00Z".to_string(),
570            supplier: Some("Schindler SA".to_string()),
571            invoice_number: Some("INV-2026-001".to_string()),
572            account_code: Some("611002".to_string()),
573        }
574    }
575
576    fn valid_invoice_draft_dto(org_id: Uuid, building_id: Uuid) -> CreateInvoiceDraftDto {
577        CreateInvoiceDraftDto {
578            organization_id: org_id.to_string(),
579            building_id: building_id.to_string(),
580            category: ExpenseCategory::Utilities,
581            description: "Electricity bill January".to_string(),
582            amount_excl_vat: 1000.0,
583            vat_rate: 21.0,
584            invoice_date: "2026-01-31T10:00:00Z".to_string(),
585            due_date: Some("2026-02-28T10:00:00Z".to_string()),
586            supplier: Some("Engie Electrabel".to_string()),
587            invoice_number: Some("ELEC-2026-001".to_string()),
588        }
589    }
590
591    // ========== Tests ==========
592
593    #[tokio::test]
594    async fn test_create_expense_success() {
595        let repo = MockExpenseRepository::new();
596        let uc = make_use_cases(repo);
597        let org_id = Uuid::new_v4();
598        let building_id = Uuid::new_v4();
599
600        let result = uc
601            .create_expense(valid_create_dto(org_id, building_id))
602            .await;
603
604        assert!(result.is_ok());
605        let dto = result.unwrap();
606        assert_eq!(dto.building_id, building_id.to_string());
607        assert_eq!(dto.description, "Elevator maintenance Q1");
608        assert_eq!(dto.amount, 1500.0);
609        assert_eq!(dto.payment_status, PaymentStatus::Pending);
610        assert_eq!(dto.approval_status, ApprovalStatus::Draft);
611        assert_eq!(dto.supplier, Some("Schindler SA".to_string()));
612        assert_eq!(dto.account_code, Some("611002".to_string()));
613    }
614
615    #[tokio::test]
616    async fn test_create_expense_invalid_building_id() {
617        let repo = MockExpenseRepository::new();
618        let uc = make_use_cases(repo);
619
620        let mut dto = valid_create_dto(Uuid::new_v4(), Uuid::new_v4());
621        dto.building_id = "not-a-uuid".to_string();
622
623        let result = uc.create_expense(dto).await;
624        assert!(result.is_err());
625        assert_eq!(result.unwrap_err(), "Invalid building ID format");
626    }
627
628    #[tokio::test]
629    async fn test_submit_for_approval_success() {
630        let repo = MockExpenseRepository::new();
631        let uc = make_use_cases(repo);
632        let org_id = Uuid::new_v4();
633        let building_id = Uuid::new_v4();
634
635        // Create an expense (starts as Draft)
636        let created = uc
637            .create_expense(valid_create_dto(org_id, building_id))
638            .await
639            .unwrap();
640        let expense_id = Uuid::parse_str(&created.id).unwrap();
641
642        // Submit for approval
643        let result = uc
644            .submit_for_approval(expense_id, SubmitForApprovalDto {})
645            .await;
646
647        assert!(result.is_ok());
648        let invoice = result.unwrap();
649        assert_eq!(invoice.approval_status, ApprovalStatus::PendingApproval);
650        assert!(invoice.submitted_at.is_some());
651    }
652
653    #[tokio::test]
654    async fn test_approve_invoice_success() {
655        let repo = MockExpenseRepository::new();
656        let uc = make_use_cases(repo);
657        let org_id = Uuid::new_v4();
658        let building_id = Uuid::new_v4();
659        let approver_id = Uuid::new_v4();
660
661        // Create and submit
662        let created = uc
663            .create_expense(valid_create_dto(org_id, building_id))
664            .await
665            .unwrap();
666        let expense_id = Uuid::parse_str(&created.id).unwrap();
667        uc.submit_for_approval(expense_id, SubmitForApprovalDto {})
668            .await
669            .unwrap();
670
671        // Approve
672        let result = uc
673            .approve_invoice(
674                expense_id,
675                ApproveInvoiceDto {
676                    approved_by_user_id: approver_id.to_string(),
677                },
678            )
679            .await;
680
681        assert!(result.is_ok());
682        let invoice = result.unwrap();
683        assert_eq!(invoice.approval_status, ApprovalStatus::Approved);
684        assert_eq!(invoice.approved_by, Some(approver_id.to_string()));
685        assert!(invoice.approved_at.is_some());
686    }
687
688    #[tokio::test]
689    async fn test_reject_invoice_success() {
690        let repo = MockExpenseRepository::new();
691        let uc = make_use_cases(repo);
692        let org_id = Uuid::new_v4();
693        let building_id = Uuid::new_v4();
694        let rejector_id = Uuid::new_v4();
695
696        // Create and submit
697        let created = uc
698            .create_expense(valid_create_dto(org_id, building_id))
699            .await
700            .unwrap();
701        let expense_id = Uuid::parse_str(&created.id).unwrap();
702        uc.submit_for_approval(expense_id, SubmitForApprovalDto {})
703            .await
704            .unwrap();
705
706        // Reject
707        let result = uc
708            .reject_invoice(
709                expense_id,
710                RejectInvoiceDto {
711                    rejected_by_user_id: rejector_id.to_string(),
712                    rejection_reason: "Missing supporting documents".to_string(),
713                },
714            )
715            .await;
716
717        assert!(result.is_ok());
718        let invoice = result.unwrap();
719        assert_eq!(invoice.approval_status, ApprovalStatus::Rejected);
720        assert_eq!(
721            invoice.rejection_reason,
722            Some("Missing supporting documents".to_string())
723        );
724    }
725
726    #[tokio::test]
727    async fn test_mark_as_paid_requires_approval() {
728        let repo = MockExpenseRepository::new();
729        let uc = make_use_cases(repo);
730        let org_id = Uuid::new_v4();
731        let building_id = Uuid::new_v4();
732
733        // Create an expense (Draft status, not approved)
734        let created = uc
735            .create_expense(valid_create_dto(org_id, building_id))
736            .await
737            .unwrap();
738        let expense_id = Uuid::parse_str(&created.id).unwrap();
739
740        // Attempt to mark as paid without approval should fail
741        let result = uc.mark_as_paid(expense_id).await;
742        assert!(result.is_err());
743        assert!(result
744            .unwrap_err()
745            .contains("invoice must be approved first"));
746    }
747
748    #[tokio::test]
749    async fn test_mark_as_paid_after_approval() {
750        let repo = MockExpenseRepository::new();
751        let uc = make_use_cases(repo);
752        let org_id = Uuid::new_v4();
753        let building_id = Uuid::new_v4();
754        let approver_id = Uuid::new_v4();
755
756        // Create, submit, and approve
757        let created = uc
758            .create_expense(valid_create_dto(org_id, building_id))
759            .await
760            .unwrap();
761        let expense_id = Uuid::parse_str(&created.id).unwrap();
762        uc.submit_for_approval(expense_id, SubmitForApprovalDto {})
763            .await
764            .unwrap();
765        uc.approve_invoice(
766            expense_id,
767            ApproveInvoiceDto {
768                approved_by_user_id: approver_id.to_string(),
769            },
770        )
771        .await
772        .unwrap();
773
774        // Now mark as paid
775        let result = uc.mark_as_paid(expense_id).await;
776        assert!(result.is_ok());
777        let dto = result.unwrap();
778        assert_eq!(dto.payment_status, PaymentStatus::Paid);
779    }
780
781    #[tokio::test]
782    async fn test_find_by_building() {
783        let repo = MockExpenseRepository::new();
784        let uc = make_use_cases(repo);
785        let org_id = Uuid::new_v4();
786        let building_a = Uuid::new_v4();
787        let building_b = Uuid::new_v4();
788
789        // Create expenses for two different buildings
790        let mut dto_a = valid_create_dto(org_id, building_a);
791        dto_a.description = "Building A expense".to_string();
792        uc.create_expense(dto_a).await.unwrap();
793
794        let mut dto_b = valid_create_dto(org_id, building_b);
795        dto_b.description = "Building B expense".to_string();
796        uc.create_expense(dto_b).await.unwrap();
797
798        // Another expense for building A
799        let mut dto_a2 = valid_create_dto(org_id, building_a);
800        dto_a2.description = "Building A expense 2".to_string();
801        uc.create_expense(dto_a2).await.unwrap();
802
803        // Query for building A
804        let result = uc.list_expenses_by_building(building_a).await;
805        assert!(result.is_ok());
806        let expenses = result.unwrap();
807        assert_eq!(expenses.len(), 2);
808        assert!(expenses
809            .iter()
810            .all(|e| e.building_id == building_a.to_string()));
811    }
812
813    #[tokio::test]
814    async fn test_update_invoice_draft_blocked_after_approval() {
815        let repo = MockExpenseRepository::new();
816        let uc = make_use_cases(repo);
817        let org_id = Uuid::new_v4();
818        let building_id = Uuid::new_v4();
819        let approver_id = Uuid::new_v4();
820
821        // Create invoice draft, submit, and approve
822        let created = uc
823            .create_invoice_draft(valid_invoice_draft_dto(org_id, building_id))
824            .await
825            .unwrap();
826        let invoice_id = Uuid::parse_str(&created.id).unwrap();
827        uc.submit_for_approval(invoice_id, SubmitForApprovalDto {})
828            .await
829            .unwrap();
830        uc.approve_invoice(
831            invoice_id,
832            ApproveInvoiceDto {
833                approved_by_user_id: approver_id.to_string(),
834            },
835        )
836        .await
837        .unwrap();
838
839        // Attempt to modify the approved invoice
840        let update_dto = UpdateInvoiceDraftDto {
841            description: Some("Changed description".to_string()),
842            category: None,
843            amount_excl_vat: None,
844            vat_rate: None,
845            invoice_date: None,
846            due_date: None,
847            supplier: None,
848            invoice_number: None,
849        };
850
851        let result = uc.update_invoice_draft(invoice_id, update_dto).await;
852        assert!(result.is_err());
853        assert!(result.unwrap_err().contains("cannot be modified"));
854    }
855
856    #[tokio::test]
857    async fn test_create_invoice_draft_vat_calculations() {
858        let repo = MockExpenseRepository::new();
859        let uc = make_use_cases(repo);
860        let org_id = Uuid::new_v4();
861        let building_id = Uuid::new_v4();
862
863        // Create invoice with 21% VAT on 1000 EUR HT
864        let result = uc
865            .create_invoice_draft(valid_invoice_draft_dto(org_id, building_id))
866            .await;
867
868        assert!(result.is_ok());
869        let invoice = result.unwrap();
870
871        // 1000 * 21% = 210 VAT, total = 1210
872        assert_eq!(invoice.amount_excl_vat, Some(1000.0));
873        assert_eq!(invoice.vat_rate, Some(21.0));
874        assert_eq!(invoice.vat_amount, Some(210.0));
875        assert_eq!(invoice.amount_incl_vat, Some(1210.0));
876        // backward compat: amount field = TTC
877        assert_eq!(invoice.amount, 1210.0);
878    }
879
880    #[tokio::test]
881    async fn test_reject_then_resubmit() {
882        let repo = MockExpenseRepository::new();
883        let uc = make_use_cases(repo);
884        let org_id = Uuid::new_v4();
885        let building_id = Uuid::new_v4();
886        let rejector_id = Uuid::new_v4();
887
888        // Create, submit, reject
889        let created = uc
890            .create_expense(valid_create_dto(org_id, building_id))
891            .await
892            .unwrap();
893        let expense_id = Uuid::parse_str(&created.id).unwrap();
894        uc.submit_for_approval(expense_id, SubmitForApprovalDto {})
895            .await
896            .unwrap();
897        uc.reject_invoice(
898            expense_id,
899            RejectInvoiceDto {
900                rejected_by_user_id: rejector_id.to_string(),
901                rejection_reason: "Incorrect amount".to_string(),
902            },
903        )
904        .await
905        .unwrap();
906
907        // Verify rejected state
908        let rejected = uc.get_invoice(expense_id).await.unwrap().unwrap();
909        assert_eq!(rejected.approval_status, ApprovalStatus::Rejected);
910        assert_eq!(
911            rejected.rejection_reason,
912            Some("Incorrect amount".to_string())
913        );
914
915        // Re-submit after rejection (allowed)
916        let result = uc
917            .submit_for_approval(expense_id, SubmitForApprovalDto {})
918            .await;
919        assert!(result.is_ok());
920        let resubmitted = result.unwrap();
921        assert_eq!(resubmitted.approval_status, ApprovalStatus::PendingApproval);
922        // rejection_reason should be cleared upon resubmission
923        assert_eq!(resubmitted.rejection_reason, None);
924    }
925}