1use crate::application::dto::{
2 ApproveInvoiceDto, CreateExpenseDto, CreateInvoiceDraftDto, ExpenseFilters, ExpenseResponseDto,
3 InvoiceResponseDto, PageRequest, PendingInvoicesListDto, RejectInvoiceDto, SortOrder,
4 SubmitForApprovalDto, UpdateInvoiceDraftDto,
5};
6use crate::application::ports::{AcpRepository, BuildingRepository, 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 building_repository: Option<Arc<dyn BuildingRepository>>,
23 acp_repository: Option<Arc<dyn AcpRepository>>,
24}
25
26impl ExpenseUseCases {
27 pub fn new(repository: Arc<dyn ExpenseRepository>) -> Self {
28 Self {
29 repository,
30 accounting_service: None,
31 building_repository: None,
32 acp_repository: None,
33 }
34 }
35
36 pub fn with_accounting_service(
37 repository: Arc<dyn ExpenseRepository>,
38 accounting_service: Arc<ExpenseAccountingService>,
39 ) -> Self {
40 Self {
41 repository,
42 accounting_service: Some(accounting_service),
43 building_repository: None,
44 acp_repository: None,
45 }
46 }
47
48 pub fn with_acp_resolution(mut self, building_repository: Arc<dyn BuildingRepository>) -> Self {
59 self.building_repository = Some(building_repository);
60 self
61 }
62
63 pub fn with_full_wiring(
64 repository: Arc<dyn ExpenseRepository>,
65 accounting_service: Arc<ExpenseAccountingService>,
66 building_repository: Arc<dyn BuildingRepository>,
67 acp_repository: Arc<dyn AcpRepository>,
68 ) -> Self {
69 Self {
70 repository,
71 accounting_service: Some(accounting_service),
72 building_repository: Some(building_repository),
73 acp_repository: Some(acp_repository),
74 }
75 }
76
77 async fn assert_acp_conformant(&self, building_id: Uuid) -> Result<(), String> {
84 let (Some(building_repo), Some(acp_repo)) =
85 (&self.building_repository, &self.acp_repository)
86 else {
87 return Ok(());
88 };
89 let building = building_repo
90 .find_by_id(building_id)
91 .await?
92 .ok_or_else(|| "Building not found".to_string())?;
93 let (acp, metrics) = acp_repo
94 .find_by_id_with_metrics(building.acp_id)
95 .await
96 .map_err(|e| e.to_string())?
97 .ok_or_else(|| "ACP not found".to_string())?;
98 acp.assert_conformant(&metrics)?; Ok(())
100 }
101
102 async fn resolve_acp_id(&self, building_id: Uuid) -> Result<Uuid, String> {
132 let building_repo = self.building_repository.as_ref().ok_or_else(|| {
133 "Câblage incomplet : ExpenseUseCases ne peut pas résoudre l'ACP de \
134 l'immeuble sans dépôt d'immeubles. Employez `with_acp_resolution` \
135 ou `with_full_wiring` (#761)."
136 .to_string()
137 })?;
138 let building = building_repo
139 .find_by_id(building_id)
140 .await?
141 .ok_or_else(|| "Building not found".to_string())?;
142 Ok(building.acp_id)
143 }
144
145 pub async fn create_expense(
146 &self,
147 dto: CreateExpenseDto,
148 ) -> Result<ExpenseResponseDto, String> {
149 let organization_id = Uuid::parse_str(&dto.organization_id)
150 .map_err(|_| "Invalid organization_id format".to_string())?;
151 let building_id = Uuid::parse_str(&dto.building_id)
152 .map_err(|_| "Invalid building ID format".to_string())?;
153
154 self.assert_acp_conformant(building_id).await?;
157
158 let expense_date = DateTime::parse_from_rfc3339(&dto.expense_date)
159 .map_err(|_| "Invalid date format".to_string())?
160 .with_timezone(&chrono::Utc);
161
162 let due_date = match dto.due_date.as_deref() {
163 Some(d) => Some(
164 DateTime::parse_from_rfc3339(d)
165 .map_err(|_| "Invalid due_date format".to_string())?
166 .with_timezone(&chrono::Utc),
167 ),
168 None => None,
169 };
170
171 let acp_id = self.resolve_acp_id(building_id).await?;
173
174 let expense = match (dto.amount_excl_vat, dto.vat_rate) {
182 (Some(amount_excl_vat), Some(vat_rate)) => Expense::new_with_vat(
183 acp_id,
184 organization_id,
185 building_id,
186 dto.category,
187 dto.description,
188 amount_excl_vat,
189 vat_rate,
190 expense_date,
191 due_date,
192 dto.supplier,
193 dto.invoice_number,
194 dto.account_code,
195 )?,
196 _ => {
197 let mut expense = Expense::new(
198 acp_id,
199 organization_id,
200 building_id,
201 dto.category,
202 dto.description,
203 dto.amount,
204 expense_date,
205 dto.supplier,
206 dto.invoice_number,
207 dto.account_code,
208 )?;
209 expense.due_date = due_date;
212 expense
213 }
214 };
215
216 let created = self.repository.create(&expense).await?;
217
218 if let Some(lignes) = &dto.line_items {
222 let lignes: Vec<_> = lignes
223 .iter()
224 .map(
225 |l| crate::application::ports::expense_repository::LigneDeFacture {
226 description: l.description.clone(),
227 quantity: l.quantity,
228 unit_price: l.unit_price,
229 vat_rate: l.vat_rate,
230 },
231 )
232 .collect();
233 self.repository
234 .enregistrer_lignes_de_facture(created.id, &lignes)
235 .await?;
236 }
237
238 Ok(self.to_response_dto(&created))
239 }
240
241 pub async fn get_expense(&self, id: Uuid) -> Result<Option<ExpenseResponseDto>, String> {
242 let expense = self.repository.find_by_id(id).await?;
243 Ok(expense.map(|e| self.to_response_dto(&e)))
244 }
245
246 pub async fn list_expenses_by_building(
247 &self,
248 building_id: Uuid,
249 ) -> Result<Vec<ExpenseResponseDto>, String> {
250 let expenses = self.repository.find_by_building(building_id).await?;
251 Ok(expenses.iter().map(|e| self.to_response_dto(e)).collect())
252 }
253
254 pub async fn list_expenses_paginated(
255 &self,
256 page_request: &PageRequest,
257 organization_id: Option<Uuid>,
258 ) -> Result<(Vec<ExpenseResponseDto>, i64), String> {
259 let filters = ExpenseFilters {
260 organization_id,
261 ..Default::default()
262 };
263
264 let (expenses, total) = self
265 .repository
266 .find_all_paginated(page_request, &filters)
267 .await?;
268
269 let dtos = expenses.iter().map(|e| self.to_response_dto(e)).collect();
270 Ok((dtos, total))
271 }
272
273 pub async fn mark_as_paid(&self, id: Uuid) -> Result<ExpenseResponseDto, String> {
277 let mut expense = self
278 .repository
279 .find_by_id(id)
280 .await?
281 .ok_or_else(|| "Expense not found".to_string())?;
282
283 expense.mark_as_paid()?;
284
285 let updated = self.repository.update(&expense).await?;
286
287 if let Some(ref accounting_service) = self.accounting_service {
289 if let Err(e) = accounting_service
290 .generate_payment_entry(&updated, None, None)
291 .await
292 {
293 log::warn!(
294 "Failed to generate payment journal entry for expense {}: {}",
295 updated.id,
296 e
297 );
298 }
301 }
302
303 Ok(self.to_response_dto(&updated))
304 }
305
306 pub async fn mark_as_overdue(&self, id: Uuid) -> Result<ExpenseResponseDto, String> {
307 let mut expense = self
308 .repository
309 .find_by_id(id)
310 .await?
311 .ok_or_else(|| "Expense not found".to_string())?;
312
313 expense.mark_as_overdue()?;
314
315 let updated = self.repository.update(&expense).await?;
316 Ok(self.to_response_dto(&updated))
317 }
318
319 pub async fn cancel_expense(&self, id: Uuid) -> Result<ExpenseResponseDto, String> {
320 let mut expense = self
321 .repository
322 .find_by_id(id)
323 .await?
324 .ok_or_else(|| "Expense not found".to_string())?;
325
326 expense.cancel()?;
327
328 let updated = self.repository.update(&expense).await?;
329 Ok(self.to_response_dto(&updated))
330 }
331
332 pub async fn reactivate_expense(&self, id: Uuid) -> Result<ExpenseResponseDto, String> {
333 let mut expense = self
334 .repository
335 .find_by_id(id)
336 .await?
337 .ok_or_else(|| "Expense not found".to_string())?;
338
339 expense.reactivate()?;
340
341 let updated = self.repository.update(&expense).await?;
342 Ok(self.to_response_dto(&updated))
343 }
344
345 pub async fn unpay_expense(&self, id: Uuid) -> Result<ExpenseResponseDto, String> {
346 let mut expense = self
347 .repository
348 .find_by_id(id)
349 .await?
350 .ok_or_else(|| "Expense not found".to_string())?;
351
352 expense.unpay()?;
353
354 let updated = self.repository.update(&expense).await?;
355 Ok(self.to_response_dto(&updated))
356 }
357
358 pub async fn create_invoice_draft(
362 &self,
363 dto: CreateInvoiceDraftDto,
364 ) -> Result<InvoiceResponseDto, String> {
365 let organization_id = Uuid::parse_str(&dto.organization_id)
366 .map_err(|_| "Invalid organization_id format".to_string())?;
367 let building_id = Uuid::parse_str(&dto.building_id)
368 .map_err(|_| "Invalid building ID format".to_string())?;
369
370 self.assert_acp_conformant(building_id).await?;
372
373 let invoice_date = DateTime::parse_from_rfc3339(&dto.invoice_date)
374 .map_err(|_| "Invalid invoice_date format".to_string())?
375 .with_timezone(&chrono::Utc);
376
377 let due_date = dto
378 .due_date
379 .map(|d| {
380 DateTime::parse_from_rfc3339(&d)
381 .map_err(|_| "Invalid due_date format".to_string())
382 .map(|dt| dt.with_timezone(&chrono::Utc))
383 })
384 .transpose()?;
385
386 let acp_id = self.resolve_acp_id(building_id).await?;
387
388 let invoice = Expense::new_with_vat(
389 acp_id,
390 organization_id,
391 building_id,
392 dto.category,
393 dto.description,
394 dto.amount_excl_vat,
395 dto.vat_rate,
396 invoice_date,
397 due_date,
398 dto.supplier,
399 dto.invoice_number,
400 None, )?;
402
403 let created = self.repository.create(&invoice).await?;
404 Ok(self.to_invoice_response_dto(&created))
405 }
406
407 pub async fn update_invoice_draft(
409 &self,
410 invoice_id: Uuid,
411 dto: UpdateInvoiceDraftDto,
412 ) -> Result<InvoiceResponseDto, String> {
413 let mut invoice = self
414 .repository
415 .find_by_id(invoice_id)
416 .await?
417 .ok_or_else(|| "Invoice not found".to_string())?;
418
419 if !invoice.can_be_modified() {
421 return Err(format!(
422 "Invoice cannot be modified (status: {:?})",
423 invoice.approval_status
424 ));
425 }
426
427 if let Some(desc) = dto.description {
429 invoice.description = desc;
430 }
431 if let Some(cat) = dto.category {
432 invoice.category = cat;
433 }
434 if let Some(amount_ht) = dto.amount_excl_vat {
435 invoice.amount_excl_vat = Some(amount_ht);
436 }
437 if let Some(vat_rate) = dto.vat_rate {
438 invoice.vat_rate = Some(vat_rate);
439 }
440
441 if dto.amount_excl_vat.is_some() || dto.vat_rate.is_some() {
443 invoice.recalculate_vat()?;
444 }
445
446 if let Some(inv_date) = dto.invoice_date {
447 let parsed_date = DateTime::parse_from_rfc3339(&inv_date)
448 .map_err(|_| "Invalid invoice_date format".to_string())?
449 .with_timezone(&chrono::Utc);
450 invoice.invoice_date = Some(parsed_date);
451 }
452
453 if let Some(due_date_str) = dto.due_date {
454 let parsed_date = DateTime::parse_from_rfc3339(&due_date_str)
455 .map_err(|_| "Invalid due_date format".to_string())?
456 .with_timezone(&chrono::Utc);
457 invoice.due_date = Some(parsed_date);
458 }
459
460 if dto.supplier.is_some() {
461 invoice.supplier = dto.supplier;
462 }
463 if dto.invoice_number.is_some() {
464 invoice.invoice_number = dto.invoice_number;
465 }
466
467 invoice.updated_at = chrono::Utc::now();
468
469 let updated = self.repository.update(&invoice).await?;
470 Ok(self.to_invoice_response_dto(&updated))
471 }
472
473 pub async fn submit_for_approval(
475 &self,
476 invoice_id: Uuid,
477 _dto: SubmitForApprovalDto,
478 ) -> Result<InvoiceResponseDto, String> {
479 let mut invoice = self
480 .repository
481 .find_by_id(invoice_id)
482 .await?
483 .ok_or_else(|| "Invoice not found".to_string())?;
484
485 invoice.submit_for_approval()?;
486
487 let updated = self.repository.update(&invoice).await?;
488 Ok(self.to_invoice_response_dto(&updated))
489 }
490
491 pub async fn approve_invoice(
495 &self,
496 invoice_id: Uuid,
497 dto: ApproveInvoiceDto,
498 ) -> Result<InvoiceResponseDto, String> {
499 let mut invoice = self
500 .repository
501 .find_by_id(invoice_id)
502 .await?
503 .ok_or_else(|| "Invoice not found".to_string())?;
504
505 let approved_by_user_id = Uuid::parse_str(&dto.approved_by_user_id)
506 .map_err(|_| "Invalid approved_by_user_id format".to_string())?;
507
508 invoice.approve(approved_by_user_id)?;
509
510 let updated = self.repository.update(&invoice).await?;
511
512 if let Some(ref accounting_service) = self.accounting_service {
514 if let Err(e) = accounting_service
515 .generate_journal_entry_for_expense(&updated, Some(approved_by_user_id))
516 .await
517 {
518 log::warn!(
519 "Failed to generate journal entry for approved expense {}: {}",
520 updated.id,
521 e
522 );
523 }
526 }
527
528 Ok(self.to_invoice_response_dto(&updated))
529 }
530
531 pub async fn reject_invoice(
533 &self,
534 invoice_id: Uuid,
535 dto: RejectInvoiceDto,
536 ) -> Result<InvoiceResponseDto, String> {
537 let mut invoice = self
538 .repository
539 .find_by_id(invoice_id)
540 .await?
541 .ok_or_else(|| "Invoice not found".to_string())?;
542
543 let rejected_by_user_id = Uuid::parse_str(&dto.rejected_by_user_id)
544 .map_err(|_| "Invalid rejected_by_user_id format".to_string())?;
545
546 invoice.reject(rejected_by_user_id, dto.rejection_reason)?;
547
548 let updated = self.repository.update(&invoice).await?;
549 Ok(self.to_invoice_response_dto(&updated))
550 }
551
552 pub async fn get_pending_invoices(
554 &self,
555 organization_id: Uuid,
556 ) -> Result<PendingInvoicesListDto, String> {
557 let filters = ExpenseFilters {
558 organization_id: Some(organization_id),
559 approval_status: Some(ApprovalStatus::PendingApproval),
560 ..Default::default()
561 };
562
563 let page_request = PageRequest {
565 page: 1,
566 per_page: 1000, sort_by: None,
568 order: SortOrder::default(),
569 };
570
571 let (expenses, _total) = self
572 .repository
573 .find_all_paginated(&page_request, &filters)
574 .await?;
575
576 let invoices: Vec<InvoiceResponseDto> = expenses
577 .iter()
578 .map(|e| self.to_invoice_response_dto(e))
579 .collect();
580
581 Ok(PendingInvoicesListDto {
582 count: invoices.len(),
583 invoices,
584 })
585 }
586
587 pub async fn get_invoice(&self, id: Uuid) -> Result<Option<InvoiceResponseDto>, String> {
589 let expense = self.repository.find_by_id(id).await?;
590 Ok(expense.map(|e| self.to_invoice_response_dto(&e)))
591 }
592
593 fn to_response_dto(&self, expense: &Expense) -> ExpenseResponseDto {
596 ExpenseResponseDto {
597 id: expense.id.to_string(),
598 acp_id: expense.acp_id.to_string(),
599 building_id: expense.building_id.to_string(),
600 category: expense.category.clone(),
601 description: expense.description.clone(),
602 amount: expense.amount,
603 expense_date: expense.expense_date.to_rfc3339(),
604 payment_status: expense.payment_status.clone(),
605 approval_status: expense.approval_status.clone(),
606 supplier: expense.supplier.clone(),
607 invoice_number: expense.invoice_number.clone(),
608 account_code: expense.account_code.clone(),
609 contractor_report_id: expense.contractor_report_id.map(|id| id.to_string()),
610 due_date: expense.due_date.map(|d| d.to_rfc3339()),
611 amount_excl_vat: expense.amount_excl_vat,
612 vat_rate: expense.vat_rate,
613 vat_amount: expense.vat_amount,
614 amount_incl_vat: expense.amount_incl_vat,
615 }
616 }
617
618 fn to_invoice_response_dto(&self, expense: &Expense) -> InvoiceResponseDto {
619 InvoiceResponseDto {
620 id: expense.id.to_string(),
621 organization_id: expense.organization_id.to_string(),
622 building_id: expense.building_id.to_string(),
623 category: expense.category.clone(),
624 description: expense.description.clone(),
625
626 amount: expense.amount,
628 amount_excl_vat: expense.amount_excl_vat,
629 vat_rate: expense.vat_rate,
630 vat_amount: expense.vat_amount,
631 amount_incl_vat: expense.amount_incl_vat,
632
633 expense_date: expense.expense_date.to_rfc3339(),
635 invoice_date: expense.invoice_date.map(|d| d.to_rfc3339()),
636 due_date: expense.due_date.map(|d| d.to_rfc3339()),
637 paid_date: expense.paid_date.map(|d| d.to_rfc3339()),
638
639 approval_status: expense.approval_status.clone(),
641 submitted_at: expense.submitted_at.map(|d| d.to_rfc3339()),
642 approved_by: expense.approved_by.map(|u| u.to_string()),
643 approved_at: expense.approved_at.map(|d| d.to_rfc3339()),
644 rejection_reason: expense.rejection_reason.clone(),
645
646 payment_status: expense.payment_status.clone(),
648 supplier: expense.supplier.clone(),
649 invoice_number: expense.invoice_number.clone(),
650
651 contractor_report_id: expense.contractor_report_id.map(|id| id.to_string()),
652
653 created_at: expense.created_at.to_rfc3339(),
654 updated_at: expense.updated_at.to_rfc3339(),
655 }
656 }
657}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662 use crate::application::dto::{ExpenseFilters, PageRequest};
663 use crate::application::ports::ExpenseRepository;
664 use crate::domain::entities::{ApprovalStatus, Building, ExpenseCategory, PaymentStatus};
665 use async_trait::async_trait;
666 use std::collections::HashMap;
667 use std::sync::Mutex;
668
669 struct MockExpenseRepository {
672 expenses: Mutex<HashMap<Uuid, Expense>>,
673 }
674
675 impl MockExpenseRepository {
676 fn new() -> Self {
677 Self {
678 expenses: Mutex::new(HashMap::new()),
679 }
680 }
681 }
682
683 #[async_trait]
684 impl ExpenseRepository for MockExpenseRepository {
685 async fn enregistrer_lignes_de_facture(
686 &self,
687 _expense_id: Uuid,
688 _lignes: &[crate::application::ports::expense_repository::LigneDeFacture],
689 ) -> Result<(), String> {
690 Ok(())
694 }
695
696 async fn create(&self, expense: &Expense) -> Result<Expense, String> {
697 let mut expenses = self.expenses.lock().unwrap();
698 expenses.insert(expense.id, expense.clone());
699 Ok(expense.clone())
700 }
701
702 async fn find_by_id(&self, id: Uuid) -> Result<Option<Expense>, String> {
703 let expenses = self.expenses.lock().unwrap();
704 Ok(expenses.get(&id).cloned())
705 }
706
707 async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Expense>, String> {
708 let expenses = self.expenses.lock().unwrap();
709 Ok(expenses
710 .values()
711 .filter(|e| e.building_id == building_id)
712 .cloned()
713 .collect())
714 }
715
716 async fn find_all_paginated(
717 &self,
718 _page_request: &PageRequest,
719 filters: &ExpenseFilters,
720 ) -> Result<(Vec<Expense>, i64), String> {
721 let expenses = self.expenses.lock().unwrap();
722 let filtered: Vec<Expense> = expenses
723 .values()
724 .filter(|e| {
725 if let Some(org_id) = filters.organization_id {
726 if e.organization_id != org_id {
727 return false;
728 }
729 }
730 if let Some(ref status) = filters.approval_status {
731 if e.approval_status != *status {
732 return false;
733 }
734 }
735 true
736 })
737 .cloned()
738 .collect();
739 let count = filtered.len() as i64;
740 Ok((filtered, count))
741 }
742
743 async fn update(&self, expense: &Expense) -> Result<Expense, String> {
744 let mut expenses = self.expenses.lock().unwrap();
745 expenses.insert(expense.id, expense.clone());
746 Ok(expense.clone())
747 }
748
749 async fn delete(&self, id: Uuid) -> Result<bool, String> {
750 let mut expenses = self.expenses.lock().unwrap();
751 Ok(expenses.remove(&id).is_some())
752 }
753 }
754
755 fn acp_de_test() -> Uuid {
763 Uuid::parse_str("5f2a1c9d-3e4b-4a6c-8d7e-1b2c3d4e5f6a").expect("UUID valide")
764 }
765
766 struct DepotImmeublesDeTest;
777
778 #[async_trait]
779 impl BuildingRepository for DepotImmeublesDeTest {
780 async fn create(&self, building: &Building) -> Result<Building, String> {
781 Ok(building.clone())
782 }
783
784 async fn find_by_id(&self, id: Uuid) -> Result<Option<Building>, String> {
785 let mut b = Building::new(
786 acp_de_test(),
787 "Résidence de test".to_string(),
788 "12 Rue de la Loi".to_string(),
789 "Bruxelles".to_string(),
790 "1000".to_string(),
791 "Belgium".to_string(),
792 10,
793 1000,
794 None,
795 )
796 .expect("immeuble de test valide");
797 b.id = id;
798 Ok(Some(b))
799 }
800
801 async fn find_all(&self) -> Result<Vec<Building>, String> {
802 Ok(vec![])
803 }
804
805 async fn find_all_paginated(
806 &self,
807 _page_request: &crate::application::dto::PageRequest,
808 _filters: &crate::application::dto::BuildingFilters,
809 ) -> Result<(Vec<Building>, i64), String> {
810 Ok((vec![], 0))
811 }
812
813 async fn update(&self, building: &Building) -> Result<Building, String> {
814 Ok(building.clone())
815 }
816
817 async fn delete(&self, _id: Uuid) -> Result<bool, String> {
818 Ok(true)
819 }
820
821 async fn find_by_slug(&self, _slug: &str) -> Result<Option<Building>, String> {
822 Ok(None)
823 }
824
825 async fn find_by_id_with_metrics(
826 &self,
827 _id: Uuid,
828 ) -> Result<Option<(Building, crate::domain::entities::BuildingMetrics)>, String> {
829 Ok(None)
830 }
831 }
832
833 fn make_use_cases(repo: MockExpenseRepository) -> ExpenseUseCases {
839 ExpenseUseCases::new(Arc::new(repo)).with_acp_resolution(Arc::new(DepotImmeublesDeTest))
840 }
841
842 #[tokio::test]
852 async fn sans_depot_dimmeubles_la_charge_est_refusee_et_non_fabriquee() {
853 let uc = ExpenseUseCases::new(Arc::new(MockExpenseRepository::new()));
854 let dto = valid_create_dto(Uuid::new_v4(), Uuid::new_v4());
855
856 let erreur = uc
857 .create_expense(dto)
858 .await
859 .expect_err("un câblage incomplet doit refuser, pas inventer une ACP");
860
861 assert!(
862 erreur.contains("761") || erreur.to_lowercase().contains("câblage"),
863 "le message doit désigner le câblage comme cause, reçu : {erreur}"
864 );
865 }
866
867 #[tokio::test]
873 async fn la_charge_porte_lacp_de_son_immeuble() {
874 let uc = make_use_cases(MockExpenseRepository::new());
875 let organisation = Uuid::new_v4();
876 let dto = valid_create_dto(organisation, Uuid::new_v4());
877
878 let charge = uc.create_expense(dto).await.expect("création valide");
879
880 assert_eq!(
881 charge.acp_id,
882 acp_de_test().to_string(),
883 "la charge doit porter l'ACP de l'immeuble"
884 );
885 assert_ne!(
886 charge.acp_id,
887 organisation.to_string(),
888 "l'identifiant d'organisation n'est pas un identifiant d'ACP"
889 );
890 }
891
892 fn valid_create_dto(org_id: Uuid, building_id: Uuid) -> CreateExpenseDto {
893 CreateExpenseDto {
894 organization_id: org_id.to_string(),
895 building_id: building_id.to_string(),
896 category: ExpenseCategory::Maintenance,
897 description: "Elevator maintenance Q1".to_string(),
898 amount: rust_decimal_macros::dec!(1500),
899 expense_date: "2026-01-15T10:00:00Z".to_string(),
900 supplier: Some("Schindler SA".to_string()),
901 invoice_number: Some("INV-2026-001".to_string()),
902 account_code: Some("611002".to_string()),
903 amount_excl_vat: None,
904 vat_rate: None,
905 due_date: None,
906 line_items: None,
907 }
908 }
909
910 fn valid_invoice_draft_dto(org_id: Uuid, building_id: Uuid) -> CreateInvoiceDraftDto {
911 CreateInvoiceDraftDto {
912 organization_id: org_id.to_string(),
913 building_id: building_id.to_string(),
914 category: ExpenseCategory::Utilities,
915 description: "Electricity bill January".to_string(),
916 amount_excl_vat: rust_decimal_macros::dec!(1000),
917 vat_rate: rust_decimal_macros::dec!(21),
918 invoice_date: "2026-01-31T10:00:00Z".to_string(),
919 due_date: Some("2026-02-28T10:00:00Z".to_string()),
920 supplier: Some("Engie Electrabel".to_string()),
921 invoice_number: Some("ELEC-2026-001".to_string()),
922 }
923 }
924
925 #[tokio::test]
928 async fn test_create_expense_success() {
929 let repo = MockExpenseRepository::new();
930 let uc = make_use_cases(repo);
931 let org_id = Uuid::new_v4();
932 let building_id = Uuid::new_v4();
933
934 let result = uc
935 .create_expense(valid_create_dto(org_id, building_id))
936 .await;
937
938 assert!(result.is_ok());
939 let dto = result.unwrap();
940 assert_eq!(dto.building_id, building_id.to_string());
941 assert_eq!(dto.description, "Elevator maintenance Q1");
942 assert_eq!(dto.amount, rust_decimal_macros::dec!(1500));
943 assert_eq!(dto.payment_status, PaymentStatus::Pending);
944 assert_eq!(dto.approval_status, ApprovalStatus::Draft);
945 assert_eq!(dto.supplier, Some("Schindler SA".to_string()));
946 assert_eq!(dto.account_code, Some("611002".to_string()));
947 }
948
949 #[tokio::test]
962 async fn test_echeance_et_tva_survivent_a_la_creation() {
963 let repo = MockExpenseRepository::new();
964 let uc = make_use_cases(repo);
965 let org_id = Uuid::new_v4();
966 let building_id = Uuid::new_v4();
967
968 let mut dto = valid_create_dto(org_id, building_id);
969 dto.amount_excl_vat = Some(rust_decimal_macros::dec!(2000));
970 dto.vat_rate = Some(rust_decimal_macros::dec!(21));
971 dto.due_date = Some("2026-10-31T12:00:00Z".to_string());
972
973 let cree = uc.create_expense(dto).await.expect("création acceptée");
974
975 assert_eq!(cree.amount, rust_decimal_macros::dec!(2420));
979 assert_eq!(cree.amount_excl_vat, Some(rust_decimal_macros::dec!(2000)));
980 assert_eq!(cree.vat_rate, Some(rust_decimal_macros::dec!(21)));
981 assert_eq!(cree.vat_amount, Some(rust_decimal_macros::dec!(420)));
982 assert_eq!(cree.amount_incl_vat, Some(rust_decimal_macros::dec!(2420)));
983 assert!(
984 cree.due_date.is_some(),
985 "l'échéance saisie ne doit pas être perdue"
986 );
987 assert!(cree.due_date.unwrap().starts_with("2026-10-31"));
988 }
989
990 #[tokio::test]
996 async fn test_echeance_conservee_sans_detail_tva() {
997 let repo = MockExpenseRepository::new();
998 let uc = make_use_cases(repo);
999
1000 let mut dto = valid_create_dto(Uuid::new_v4(), Uuid::new_v4());
1001 dto.due_date = Some("2026-11-15T12:00:00Z".to_string());
1002
1003 let cree = uc.create_expense(dto).await.expect("création acceptée");
1004
1005 assert_eq!(cree.amount, rust_decimal_macros::dec!(1500));
1006 assert_eq!(cree.amount_excl_vat, None, "aucune TVA n'est inventée");
1007 assert!(cree.due_date.unwrap().starts_with("2026-11-15"));
1008 }
1009
1010 #[tokio::test]
1027 async fn test_la_charge_appartient_a_lacp_pas_au_syndic() {
1028 let repo = MockExpenseRepository::new();
1029 let uc = make_use_cases(repo);
1030 let org_id = Uuid::new_v4();
1031 let building_id = Uuid::new_v4();
1032
1033 let cree = uc
1034 .create_expense(valid_create_dto(org_id, building_id))
1035 .await
1036 .expect("création acceptée");
1037
1038 assert!(
1039 !cree.acp_id.is_empty(),
1040 "la charge doit porter l'ACP à laquelle elle appartient"
1041 );
1042 assert_eq!(cree.building_id, building_id.to_string());
1044 }
1045
1046 #[tokio::test]
1047 async fn test_create_expense_invalid_building_id() {
1048 let repo = MockExpenseRepository::new();
1049 let uc = make_use_cases(repo);
1050
1051 let mut dto = valid_create_dto(Uuid::new_v4(), Uuid::new_v4());
1052 dto.building_id = "not-a-uuid".to_string();
1053
1054 let result = uc.create_expense(dto).await;
1055 assert!(result.is_err());
1056 assert_eq!(result.unwrap_err(), "Invalid building ID format");
1057 }
1058
1059 #[tokio::test]
1060 async fn test_submit_for_approval_success() {
1061 let repo = MockExpenseRepository::new();
1062 let uc = make_use_cases(repo);
1063 let org_id = Uuid::new_v4();
1064 let building_id = Uuid::new_v4();
1065
1066 let created = uc
1068 .create_expense(valid_create_dto(org_id, building_id))
1069 .await
1070 .unwrap();
1071 let expense_id = Uuid::parse_str(&created.id).unwrap();
1072
1073 let result = uc
1075 .submit_for_approval(expense_id, SubmitForApprovalDto {})
1076 .await;
1077
1078 assert!(result.is_ok());
1079 let invoice = result.unwrap();
1080 assert_eq!(invoice.approval_status, ApprovalStatus::PendingApproval);
1081 assert!(invoice.submitted_at.is_some());
1082 }
1083
1084 #[tokio::test]
1085 async fn test_approve_invoice_success() {
1086 let repo = MockExpenseRepository::new();
1087 let uc = make_use_cases(repo);
1088 let org_id = Uuid::new_v4();
1089 let building_id = Uuid::new_v4();
1090 let approver_id = Uuid::new_v4();
1091
1092 let created = uc
1094 .create_expense(valid_create_dto(org_id, building_id))
1095 .await
1096 .unwrap();
1097 let expense_id = Uuid::parse_str(&created.id).unwrap();
1098 uc.submit_for_approval(expense_id, SubmitForApprovalDto {})
1099 .await
1100 .unwrap();
1101
1102 let result = uc
1104 .approve_invoice(
1105 expense_id,
1106 ApproveInvoiceDto {
1107 approved_by_user_id: approver_id.to_string(),
1108 },
1109 )
1110 .await;
1111
1112 assert!(result.is_ok());
1113 let invoice = result.unwrap();
1114 assert_eq!(invoice.approval_status, ApprovalStatus::Approved);
1115 assert_eq!(invoice.approved_by, Some(approver_id.to_string()));
1116 assert!(invoice.approved_at.is_some());
1117 }
1118
1119 #[tokio::test]
1120 async fn test_reject_invoice_success() {
1121 let repo = MockExpenseRepository::new();
1122 let uc = make_use_cases(repo);
1123 let org_id = Uuid::new_v4();
1124 let building_id = Uuid::new_v4();
1125 let rejector_id = Uuid::new_v4();
1126
1127 let created = uc
1129 .create_expense(valid_create_dto(org_id, building_id))
1130 .await
1131 .unwrap();
1132 let expense_id = Uuid::parse_str(&created.id).unwrap();
1133 uc.submit_for_approval(expense_id, SubmitForApprovalDto {})
1134 .await
1135 .unwrap();
1136
1137 let result = uc
1139 .reject_invoice(
1140 expense_id,
1141 RejectInvoiceDto {
1142 rejected_by_user_id: rejector_id.to_string(),
1143 rejection_reason: "Missing supporting documents".to_string(),
1144 },
1145 )
1146 .await;
1147
1148 assert!(result.is_ok());
1149 let invoice = result.unwrap();
1150 assert_eq!(invoice.approval_status, ApprovalStatus::Rejected);
1151 assert_eq!(
1152 invoice.rejection_reason,
1153 Some("Missing supporting documents".to_string())
1154 );
1155 }
1156
1157 #[tokio::test]
1158 async fn test_mark_as_paid_requires_approval() {
1159 let repo = MockExpenseRepository::new();
1160 let uc = make_use_cases(repo);
1161 let org_id = Uuid::new_v4();
1162 let building_id = Uuid::new_v4();
1163
1164 let created = uc
1166 .create_expense(valid_create_dto(org_id, building_id))
1167 .await
1168 .unwrap();
1169 let expense_id = Uuid::parse_str(&created.id).unwrap();
1170
1171 let result = uc.mark_as_paid(expense_id).await;
1173 assert!(result.is_err());
1174 assert!(result
1175 .unwrap_err()
1176 .contains("invoice must be approved first"));
1177 }
1178
1179 #[tokio::test]
1180 async fn test_mark_as_paid_after_approval() {
1181 let repo = MockExpenseRepository::new();
1182 let uc = make_use_cases(repo);
1183 let org_id = Uuid::new_v4();
1184 let building_id = Uuid::new_v4();
1185 let approver_id = Uuid::new_v4();
1186
1187 let created = uc
1189 .create_expense(valid_create_dto(org_id, building_id))
1190 .await
1191 .unwrap();
1192 let expense_id = Uuid::parse_str(&created.id).unwrap();
1193 uc.submit_for_approval(expense_id, SubmitForApprovalDto {})
1194 .await
1195 .unwrap();
1196 uc.approve_invoice(
1197 expense_id,
1198 ApproveInvoiceDto {
1199 approved_by_user_id: approver_id.to_string(),
1200 },
1201 )
1202 .await
1203 .unwrap();
1204
1205 let result = uc.mark_as_paid(expense_id).await;
1207 assert!(result.is_ok());
1208 let dto = result.unwrap();
1209 assert_eq!(dto.payment_status, PaymentStatus::Paid);
1210 }
1211
1212 #[tokio::test]
1213 async fn test_find_by_building() {
1214 let repo = MockExpenseRepository::new();
1215 let uc = make_use_cases(repo);
1216 let org_id = Uuid::new_v4();
1217 let building_a = Uuid::new_v4();
1218 let building_b = Uuid::new_v4();
1219
1220 let mut dto_a = valid_create_dto(org_id, building_a);
1222 dto_a.description = "Building A expense".to_string();
1223 uc.create_expense(dto_a).await.unwrap();
1224
1225 let mut dto_b = valid_create_dto(org_id, building_b);
1226 dto_b.description = "Building B expense".to_string();
1227 uc.create_expense(dto_b).await.unwrap();
1228
1229 let mut dto_a2 = valid_create_dto(org_id, building_a);
1231 dto_a2.description = "Building A expense 2".to_string();
1232 uc.create_expense(dto_a2).await.unwrap();
1233
1234 let result = uc.list_expenses_by_building(building_a).await;
1236 assert!(result.is_ok());
1237 let expenses = result.unwrap();
1238 assert_eq!(expenses.len(), 2);
1239 assert!(expenses
1240 .iter()
1241 .all(|e| e.building_id == building_a.to_string()));
1242 }
1243
1244 #[tokio::test]
1245 async fn test_update_invoice_draft_blocked_after_approval() {
1246 let repo = MockExpenseRepository::new();
1247 let uc = make_use_cases(repo);
1248 let org_id = Uuid::new_v4();
1249 let building_id = Uuid::new_v4();
1250 let approver_id = Uuid::new_v4();
1251
1252 let created = uc
1254 .create_invoice_draft(valid_invoice_draft_dto(org_id, building_id))
1255 .await
1256 .unwrap();
1257 let invoice_id = Uuid::parse_str(&created.id).unwrap();
1258 uc.submit_for_approval(invoice_id, SubmitForApprovalDto {})
1259 .await
1260 .unwrap();
1261 uc.approve_invoice(
1262 invoice_id,
1263 ApproveInvoiceDto {
1264 approved_by_user_id: approver_id.to_string(),
1265 },
1266 )
1267 .await
1268 .unwrap();
1269
1270 let update_dto = UpdateInvoiceDraftDto {
1272 description: Some("Changed description".to_string()),
1273 category: None,
1274 amount_excl_vat: None,
1275 vat_rate: None,
1276 invoice_date: None,
1277 due_date: None,
1278 supplier: None,
1279 invoice_number: None,
1280 };
1281
1282 let result = uc.update_invoice_draft(invoice_id, update_dto).await;
1283 assert!(result.is_err());
1284 assert!(result.unwrap_err().contains("cannot be modified"));
1285 }
1286
1287 #[tokio::test]
1288 async fn test_create_invoice_draft_vat_calculations() {
1289 let repo = MockExpenseRepository::new();
1290 let uc = make_use_cases(repo);
1291 let org_id = Uuid::new_v4();
1292 let building_id = Uuid::new_v4();
1293
1294 let result = uc
1296 .create_invoice_draft(valid_invoice_draft_dto(org_id, building_id))
1297 .await;
1298
1299 assert!(result.is_ok());
1300 let invoice = result.unwrap();
1301
1302 assert_eq!(
1304 invoice.amount_excl_vat,
1305 Some(rust_decimal_macros::dec!(1000))
1306 );
1307 assert_eq!(invoice.vat_rate, Some(rust_decimal_macros::dec!(21)));
1308 assert_eq!(invoice.vat_amount, Some(rust_decimal_macros::dec!(210.00)));
1309 assert_eq!(
1310 invoice.amount_incl_vat,
1311 Some(rust_decimal_macros::dec!(1210.00))
1312 );
1313 assert_eq!(invoice.amount, rust_decimal_macros::dec!(1210.00));
1315 }
1316
1317 #[tokio::test]
1318 async fn test_reject_then_resubmit() {
1319 let repo = MockExpenseRepository::new();
1320 let uc = make_use_cases(repo);
1321 let org_id = Uuid::new_v4();
1322 let building_id = Uuid::new_v4();
1323 let rejector_id = Uuid::new_v4();
1324
1325 let created = uc
1327 .create_expense(valid_create_dto(org_id, building_id))
1328 .await
1329 .unwrap();
1330 let expense_id = Uuid::parse_str(&created.id).unwrap();
1331 uc.submit_for_approval(expense_id, SubmitForApprovalDto {})
1332 .await
1333 .unwrap();
1334 uc.reject_invoice(
1335 expense_id,
1336 RejectInvoiceDto {
1337 rejected_by_user_id: rejector_id.to_string(),
1338 rejection_reason: "Incorrect amount".to_string(),
1339 },
1340 )
1341 .await
1342 .unwrap();
1343
1344 let rejected = uc.get_invoice(expense_id).await.unwrap().unwrap();
1346 assert_eq!(rejected.approval_status, ApprovalStatus::Rejected);
1347 assert_eq!(
1348 rejected.rejection_reason,
1349 Some("Incorrect amount".to_string())
1350 );
1351
1352 let result = uc
1354 .submit_for_approval(expense_id, SubmitForApprovalDto {})
1355 .await;
1356 assert!(result.is_ok());
1357 let resubmitted = result.unwrap();
1358 assert_eq!(resubmitted.approval_status, ApprovalStatus::PendingApproval);
1359 assert_eq!(resubmitted.rejection_reason, None);
1361 }
1362}