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        }
427    }
428
429    fn to_invoice_response_dto(&self, expense: &Expense) -> InvoiceResponseDto {
430        InvoiceResponseDto {
431            id: expense.id.to_string(),
432            organization_id: expense.organization_id.to_string(),
433            building_id: expense.building_id.to_string(),
434            category: expense.category.clone(),
435            description: expense.description.clone(),
436
437            // Montants
438            amount: expense.amount,
439            amount_excl_vat: expense.amount_excl_vat,
440            vat_rate: expense.vat_rate,
441            vat_amount: expense.vat_amount,
442            amount_incl_vat: expense.amount_incl_vat,
443
444            // Dates
445            expense_date: expense.expense_date.to_rfc3339(),
446            invoice_date: expense.invoice_date.map(|d| d.to_rfc3339()),
447            due_date: expense.due_date.map(|d| d.to_rfc3339()),
448            paid_date: expense.paid_date.map(|d| d.to_rfc3339()),
449
450            // Workflow
451            approval_status: expense.approval_status.clone(),
452            submitted_at: expense.submitted_at.map(|d| d.to_rfc3339()),
453            approved_by: expense.approved_by.map(|u| u.to_string()),
454            approved_at: expense.approved_at.map(|d| d.to_rfc3339()),
455            rejection_reason: expense.rejection_reason.clone(),
456
457            // Payment
458            payment_status: expense.payment_status.clone(),
459            supplier: expense.supplier.clone(),
460            invoice_number: expense.invoice_number.clone(),
461
462            created_at: expense.created_at.to_rfc3339(),
463            updated_at: expense.updated_at.to_rfc3339(),
464        }
465    }
466}