Skip to main content

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::{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    /// Track H Story H2/H7 — validate-before-compute (FR-CL1). Optional pour
17    /// préserver les constructeurs des tests unitaires (mocks expense seuls).
18    /// Quand présents (wiring `main.rs`), le pre-check `Acp::assert_conformant`
19    /// (niveau copropriété, ADR-0010) s'exécute avant `create_expense` /
20    /// `create_invoice_draft` : on résout `building.acp_id` puis on agrège les
21    /// métriques de tous les blocs de l'ACP.
22    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    /// Track H Story H7 — wiring complet (mutations gated par
49    /// validate-before-compute ACP-level). Utilisé par `main.rs`. Les tests
50    /// unitaires continuent d'utiliser `new()` / `with_accounting_service()`
51    /// sans repos (pre-check no-op).
52    /// Ne câble que la résolution de l'ACP, sans le service comptable ni le
53    /// dépôt d'ACP. Les harnais d'intégration montent l'application sans
54    /// comptabilité, mais écrivent en base réelle : sans ce câblage,
55    /// `resolve_acp_id` retombait sur l'organisation et l'insertion violait
56    /// `fk_expenses_acp`. Vingt-cinq tests e2e rouges le 2026-09-03, tous de
57    /// cette seule cause. Même nom que `MeetingUseCases::with_acp_resolution`.
58    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    /// Track H Story H7 — pre-check conformité **copropriété (ACP)**. No-op si
78    /// les repos ne sont pas injectés (tests unitaires). Sinon : résout
79    /// `building.acp_id`, agrège les métriques de tous les blocs de l'ACP, et
80    /// appelle `Acp::assert_conformant`. Le `?` convertit `AcpNotConformantError`
81    /// en `String` (préfixe `ACP_NOT_CONFORMANT:` parsé en 422 par le handler,
82    /// cf. `conformity_response.rs`). ADR-0010, mémoire `validate-before-compute`.
83    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)?; // From<AcpNotConformantError> for String
99        Ok(())
100    }
101
102    /// Résout l'ACP propriétaire d'un immeuble.
103    ///
104    /// La comptabilité appartient à l'ACP, pas au syndic : c'est cette clé qui
105    /// doit être posée sur la charge.
106    ///
107    /// ── Ce que faisait ce code ──────────────────────────────────────────
108    ///
109    /// En l'absence de dépôt d'immeubles, il retombait sur l'identifiant
110    /// d'**organisation** — un identifiant présenté pour ce qu'il n'est pas.
111    /// La contrainte `fk_expenses_acp` le rejetait en base, et vingt-cinq
112    /// tests e2e sont tombés sur cette seule cause le 2026-09-03.
113    ///
114    /// Le commentaire justifiait le repli en disant qu'il « préservait les
115    /// tests à mocks sans introduire de dérive en production ». C'était faux
116    /// dans les deux termes : la dérive existait, et ce que le repli
117    /// préservait, ce n'étaient pas les tests mais leur silence.
118    ///
119    /// C'est le motif « `None` veut dire test » : la valeur fabriquée rend le
120    /// test unitaire vert **et** rend le défaut invisible exactement là où on
121    /// le cherche. Un test qui ne peut pas échouer ne prouve rien.
122    ///
123    /// ── Ce qu'il fait maintenant ───────────────────────────────────────
124    ///
125    /// Il refuse. Un appelant qui ne fournit pas de quoi résoudre l'ACP a une
126    /// erreur de câblage, pas un cas limite à absorber : `main.rs` fournit
127    /// toujours ce dépôt, et les quatre harnais d'intégration passent par
128    /// `with_acp_resolution`.
129    ///
130    /// Suivi en #761.
131    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        // Track H Story H2 — validate-before-compute gate (Art. 3.85 CC).
155        // No-op si building_repository absent (tests unitaires).
156        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        // L'ACP propriétaire — clé de rattachement patrimonial de la charge.
172        let acp_id = self.resolve_acp_id(building_id).await?;
173
174        // Deux constructeurs pour une seule route : `new_with_vat` dès que le
175        // détail TVA est fourni, `new` sinon (montant TTC seul).
176        //
177        // Le montant TTC est alors CALCULÉ à partir du HT et du taux, et non
178        // repris de `amount` : c'est le seul moyen d'avoir des lignes
179        // comptables qui s'équilibrent au centime. Un client qui envoie les
180        // trois avec un `amount` incohérent verra donc le calcul l'emporter.
181        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::new` ne prend pas d'échéance : elle serait perdue
210                // pour toute dépense saisie sans détail TVA.
211                expense.due_date = due_date;
212                expense
213            }
214        };
215
216        let created = self.repository.create(&expense).await?;
217
218        // Le détail de la facture, quand elle est saisie ligne par ligne.
219        // Sans cet appel, `line_items` était accepté par le DTO puis perdu :
220        // la facture gardait ses totaux et le comptable perdait sa saisie.
221        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    /// Marquer une charge comme payée
274    ///
275    /// Crée automatiquement l'écriture comptable de paiement (FIN - Financier)
276    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        // Générer automatiquement l'écriture comptable de paiement
288        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                // Ne pas échouer le paiement si la création de l'écriture échoue
299                // L'écriture peut être créée manuellement plus tard
300            }
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    // ========== Invoice Workflow Methods (Issue #73) ==========
359
360    /// Créer une facture brouillon avec gestion TVA
361    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        // Track H Story H2 — validate-before-compute gate (Art. 3.85 CC).
371        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, // account_code (can be added later)
401        )?;
402
403        let created = self.repository.create(&invoice).await?;
404        Ok(self.to_invoice_response_dto(&created))
405    }
406
407    /// Modifier une facture brouillon ou rejetée
408    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        // Vérifier que la facture peut être modifiée
420        if !invoice.can_be_modified() {
421            return Err(format!(
422                "Invoice cannot be modified (status: {:?})",
423                invoice.approval_status
424            ));
425        }
426
427        // Appliquer les modifications
428        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        // Recalculer la TVA si nécessaire
442        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    /// Soumettre une facture pour validation (Draft → PendingApproval)
474    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    /// Approuver une facture (PendingApproval → Approved)
492    ///
493    /// Crée automatiquement l'écriture comptable correspondante (ACH - Achats)
494    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        // Générer automatiquement l'écriture comptable pour la facture approuvée
513        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                // Ne pas échouer l'approbation si la création de l'écriture échoue
524                // L'écriture peut être créée manuellement plus tard
525            }
526        }
527
528        Ok(self.to_invoice_response_dto(&updated))
529    }
530
531    /// Rejeter une facture avec raison (PendingApproval → Rejected)
532    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    /// Récupérer toutes les factures en attente d'approbation (pour syndics)
553    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        // Utiliser une pagination large pour récupérer toutes les factures pending
564        let page_request = PageRequest {
565            page: 1,
566            per_page: 1000, // Limite raisonnable
567            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    /// Récupérer une facture avec tous les détails (enrichi)
588    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    // ========== Helper Methods ==========
594
595    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            // Montants
627            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            // Dates
634            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            // Workflow
640            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
647            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    // ========== Mock Repository ==========
670
671    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            // Mock : rien à enregistrer. Le port n'offre pas d'implémentation
691            // par défaut, précisément pour que ce choix soit écrit ici plutôt
692            // que subi partout.
693            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    // ========== Helpers ==========
756
757    /// L'ACP que le dépôt d'immeubles de test attribue à tout immeuble.
758    ///
759    /// Une constante, et non un identifiant tiré au hasard : les assertions
760    /// peuvent ainsi vérifier que la charge porte bien CETTE clé, ce qui est
761    /// tout l'objet de #761.
762    fn acp_de_test() -> Uuid {
763        Uuid::parse_str("5f2a1c9d-3e4b-4a6c-8d7e-1b2c3d4e5f6a").expect("UUID valide")
764    }
765
766    /// Un dépôt d'immeubles qui répond à toute demande.
767    ///
768    /// Il existe parce que `resolve_acp_id` ne fabrique plus d'ACP quand le
769    /// dépôt manque : il refuse (#761). Les tests unitaires doivent donc
770    /// fournir de quoi résoudre l'ACP, comme le fait `main.rs`.
771    ///
772    /// C'est le point de cette correction. L'ancien repli rendait ces tests
773    /// verts en posant un identifiant d'organisation à la place d'un
774    /// identifiant d'ACP — la contrainte `fk_expenses_acp` s'en chargeait en
775    /// base, mais bien plus tard, et ailleurs.
776    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    /// Câblé comme la production : `main.rs` fournit toujours ce dépôt.
834    ///
835    /// Le dépôt d'ACP reste absent, donc `assert_acp_conformant` demeure sans
836    /// effet — on ne câble QUE la résolution de l'ACP, pas le contrôle de
837    /// conformité, dont ces tests ne parlent pas.
838    fn make_use_cases(repo: MockExpenseRepository) -> ExpenseUseCases {
839        ExpenseUseCases::new(Arc::new(repo)).with_acp_resolution(Arc::new(DepotImmeublesDeTest))
840    }
841
842    /// Sans dépôt d'immeubles, la création REFUSE au lieu de fabriquer une clé.
843    ///
844    /// C'est la garde de #761. L'ancien code retournait ici l'identifiant
845    /// d'organisation en le présentant comme un identifiant d'ACP : le test
846    /// unitaire passait, et la contrainte `fk_expenses_acp` rejetait
847    /// l'insertion bien plus tard, ailleurs, dans vingt-cinq tests e2e.
848    ///
849    /// On construit donc volontairement le cas de câblage incomplet — le seul
850    /// endroit du fichier qui n'emploie pas `make_use_cases`.
851    #[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    /// La charge porte l'ACP de son IMMEUBLE, pas l'organisation de l'appelant.
868    ///
869    /// Sans ce cas, le test précédent passerait aussi avec une implémentation
870    /// qui refuserait toujours. Il faut les deux : l'un dit qu'on ne fabrique
871    /// plus, l'autre qu'on résout correctement.
872    #[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    // ========== Tests ==========
926
927    #[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    /// Non-régression F12 / F20 — l'échéance et le détail TVA doivent survivre
950    /// à l'aller-retour.
951    ///
952    /// `InvoiceForm.svelte` envoyait déjà `amount_excl_vat`, `vat_rate` et
953    /// `due_date` à `POST /expenses`. Ces champs n'existaient que sur
954    /// `CreateInvoiceDraftDto`, servi par une AUTRE route que l'interface
955    /// n'appelle pas : serde les jetait en silence.
956    ///
957    /// Trois couches manquaient les mêmes champs — le DTO d'entrée, le
958    /// constructeur, et le DTO de réponse. Corriger une seule n'aurait rien
959    /// changé de visible, ce qui est précisément pourquoi ce test les traverse
960    /// toutes les trois.
961    #[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        // Le TTC est CALCULÉ à partir du HT et du taux, pas repris de
976        // `amount` (1500 dans la fixture) : sans quoi les lignes comptables
977        // ne s'équilibreraient pas au centime.
978        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    /// @edge — une dépense SANS détail TVA garde son échéance.
991    ///
992    /// `Expense::new` ne prend pas d'échéance en paramètre : sans traitement
993    /// explicite, elle serait perdue pour toute dépense saisie en TTC seul,
994    /// c'est-à-dire le cas le plus courant.
995    #[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    /// La charge appartient à l'ACP, pas au syndic qui l'encode.
1011    ///
1012    /// Deux faits distincts, tous deux réels, tous deux conservés :
1013    ///   `acp_id`          — À QUI la charge appartient. Clé de portée.
1014    ///   `organization_id` — QUI l'a encodée. Traçabilité.
1015    ///
1016    /// Le second ne doit jamais servir de critère de portée. Mesuré le
1017    /// 2026-09-02 sur une passation de syndic : filtrer sur l'organisation
1018    /// laissait le cabinet ENTRANT sans grand livre, sans budget et sans
1019    /// copropriétaires, pendant que le cabinet SORTANT continuait de les
1020    /// voir — noms, adresses et arriérés compris, sans base légale.
1021    ///
1022    /// L'ACP se déduit de l'immeuble, qui la porte. Le cas d'usage la résout
1023    /// donc à la création plutôt que de la faire porter par l'appelant : un
1024    /// client ne doit pas pouvoir déclarer à quelle copropriété appartient la
1025    /// charge qu'il saisit.
1026    #[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        // La traçabilité du saisisseur reste, distincte de la propriété.
1043        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        // Create an expense (starts as Draft)
1067        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        // Submit for approval
1074        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        // Create and submit
1093        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        // Approve
1103        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        // Create and submit
1128        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        // Reject
1138        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        // Create an expense (Draft status, not approved)
1165        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        // Attempt to mark as paid without approval should fail
1172        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        // Create, submit, and approve
1188        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        // Now mark as paid
1206        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        // Create expenses for two different buildings
1221        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        // Another expense for building A
1230        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        // Query for building A
1235        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        // Create invoice draft, submit, and approve
1253        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        // Attempt to modify the approved invoice
1271        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        // Create invoice with 21% VAT on 1000 EUR HT
1295        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        // 1000 * 21% = 210 VAT, total = 1210
1303        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        // backward compat: amount field = TTC
1314        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        // Create, submit, reject
1326        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        // Verify rejected state
1345        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        // Re-submit after rejection (allowed)
1353        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        // rejection_reason should be cleared upon resubmission
1360        assert_eq!(resubmitted.rejection_reason, None);
1361    }
1362}