koprogo_api/application/services/
expense_accounting_service.rs

1// Application Service: Expense Accounting Service
2//
3// CREDITS & ATTRIBUTION:
4// This implementation is inspired by the Noalyss project (https://gitlab.com/noalyss/noalyss)
5// Noalyss is a free accounting software for Belgian and French accounting
6// License: GPL-2.0-or-later (GNU General Public License version 2 or later)
7// Copyright: (C) 1989, 1991 Free Software Foundation, Inc.
8// Copyright: Dany De Bontridder <dany@alchimerys.eu>
9//
10// Auto-generates double-entry journal entries from expense transactions
11
12use crate::application::ports::JournalEntryRepository;
13use crate::domain::entities::{Expense, JournalEntry, JournalEntryLine};
14use chrono::Utc;
15use std::sync::Arc;
16use uuid::Uuid;
17
18/// Service for automatically generating journal entries from expenses
19///
20/// This service implements Belgian accounting logic based on PCMN (AR 12/07/2012):
21/// - Expense creates debit to expense account (class 6)
22/// - VAT creates debit to VAT recoverable account (4110)
23/// - Total amount creates credit to supplier account (4400)
24///
25/// Inspired by Noalyss' automatic journal entry generation
26pub struct ExpenseAccountingService {
27    journal_entry_repo: Arc<dyn JournalEntryRepository>,
28}
29
30impl ExpenseAccountingService {
31    pub fn new(journal_entry_repo: Arc<dyn JournalEntryRepository>) -> Self {
32        Self { journal_entry_repo }
33    }
34
35    /// Generate journal entry for an expense
36    ///
37    /// # Belgian Accounting Logic (PCMN)
38    ///
39    /// Example: 1,000€ HT + 210€ VAT (21%) = 1,210€ TTC
40    ///
41    /// ```text
42    /// Debit:  6100 (Expense account)     1,000.00€
43    /// Debit:  4110 (VAT Recoverable)       210.00€
44    /// Credit: 4400 (Suppliers)           1,210.00€
45    /// ```
46    ///
47    /// # Arguments
48    /// - `expense`: The expense to generate journal entry for
49    /// - `created_by`: User who created the expense
50    ///
51    /// # Returns
52    /// - `Ok(JournalEntry)` if generation successful
53    /// - `Err(String)` if validation fails or expense has no account_code
54    pub async fn generate_journal_entry_for_expense(
55        &self,
56        expense: &Expense,
57        created_by: Option<Uuid>,
58    ) -> Result<JournalEntry, String> {
59        // Validate expense has account code
60        let account_code = expense
61            .account_code
62            .as_ref()
63            .ok_or("Expense must have an account_code to generate journal entry")?;
64
65        // Calculate amounts
66        let amount_excl_vat = expense.amount_excl_vat.unwrap_or(expense.amount);
67        let vat_amount = expense.amount - amount_excl_vat;
68        let total_amount = expense.amount;
69
70        // Create journal entry lines
71        let mut lines = Vec::new();
72        let entry_id = Uuid::new_v4();
73
74        // Line 1: Debit expense account (class 6)
75        lines.push(
76            JournalEntryLine::new_debit(
77                entry_id,
78                expense.organization_id,
79                account_code.clone(),
80                amount_excl_vat,
81                Some(format!("Dépense: {}", expense.description)),
82            )
83            .map_err(|e| format!("Failed to create expense debit line: {}", e))?,
84        );
85
86        // Line 2: Debit VAT recoverable (4110) if VAT > 0
87        if vat_amount > 0.01 {
88            lines.push(
89                JournalEntryLine::new_debit(
90                    entry_id,
91                    expense.organization_id,
92                    "4110".to_string(), // VAT Recoverable account
93                    vat_amount,
94                    Some(format!(
95                        "TVA récupérable {}%",
96                        expense.vat_rate.unwrap_or(0.0) * 100.0
97                    )),
98                )
99                .map_err(|e| format!("Failed to create VAT debit line: {}", e))?,
100            );
101        }
102
103        // Line 3: Credit supplier account (4400)
104        lines.push(
105            JournalEntryLine::new_credit(
106                entry_id,
107                expense.organization_id,
108                "4400".to_string(), // Suppliers account
109                total_amount,
110                expense
111                    .supplier
112                    .as_ref()
113                    .map(|s| format!("Fournisseur: {}", s)),
114            )
115            .map_err(|e| format!("Failed to create supplier credit line: {}", e))?,
116        );
117
118        // Create journal entry
119        let journal_entry = JournalEntry::new(
120            expense.organization_id,
121            Some(expense.building_id), // building_id
122            expense.expense_date,
123            Some(format!("{} - {:?}", expense.description, expense.category)),
124            expense.invoice_number.clone(), // Use invoice number as document ref
125            Some("ACH".to_string()),        // journal_type: ACH (Purchases/Achats)
126            Some(expense.id),
127            None, // contribution_id
128            lines,
129            created_by,
130        )
131        .map_err(|e| format!("Failed to create journal entry: {}", e))?;
132
133        // Persist to database
134        self.journal_entry_repo
135            .create(&journal_entry)
136            .await
137            .map_err(|e| format!("Failed to persist journal entry: {}", e))
138    }
139
140    /// Generate journal entry for expense payment
141    ///
142    /// When an expense is paid, we record the payment:
143    ///
144    /// ```text
145    /// Debit:  4400 (Suppliers)           1,210.00€
146    /// Credit: 5500 (Bank)                1,210.00€
147    /// ```
148    ///
149    /// # Arguments
150    /// - `expense`: The expense being paid
151    /// - `payment_account`: Account used for payment (default: 5500 Bank)
152    /// - `created_by`: User who recorded the payment
153    pub async fn generate_payment_entry(
154        &self,
155        expense: &Expense,
156        payment_account: Option<String>,
157        created_by: Option<Uuid>,
158    ) -> Result<JournalEntry, String> {
159        let payment_account = payment_account.unwrap_or_else(|| "5500".to_string());
160        let total_amount = expense.amount;
161        let entry_id = Uuid::new_v4();
162
163        let mut lines = Vec::new();
164
165        // Line 1: Debit supplier (reduce liability)
166        lines.push(
167            JournalEntryLine::new_debit(
168                entry_id,
169                expense.organization_id,
170                "4400".to_string(),
171                total_amount,
172                Some(format!("Paiement: {}", expense.description)),
173            )
174            .map_err(|e| format!("Failed to create supplier debit line: {}", e))?,
175        );
176
177        // Line 2: Credit bank/cash (reduce asset)
178        lines.push(
179            JournalEntryLine::new_credit(
180                entry_id,
181                expense.organization_id,
182                payment_account.clone(),
183                total_amount,
184                Some(format!(
185                    "Paiement via {}",
186                    if payment_account == "5500" {
187                        "Banque"
188                    } else if payment_account == "5700" {
189                        "Caisse"
190                    } else {
191                        "Autre"
192                    }
193                )),
194            )
195            .map_err(|e| format!("Failed to create payment credit line: {}", e))?,
196        );
197
198        // Create journal entry
199        let journal_entry = JournalEntry::new(
200            expense.organization_id,
201            Some(expense.building_id), // building_id
202            expense.paid_date.unwrap_or_else(Utc::now),
203            Some(format!("Paiement: {}", expense.description)),
204            expense.invoice_number.clone(),
205            Some("FIN".to_string()), // journal_type: FIN (Financial/Financier)
206            Some(expense.id),
207            None, // contribution_id
208            lines,
209            created_by,
210        )
211        .map_err(|e| format!("Failed to create payment journal entry: {}", e))?;
212
213        // Persist to database
214        self.journal_entry_repo
215            .create(&journal_entry)
216            .await
217            .map_err(|e| format!("Failed to persist payment journal entry: {}", e))
218    }
219
220    /// Check if expense already has journal entries
221    ///
222    /// Prevents duplicate journal entries for the same expense.
223    pub async fn expense_has_journal_entries(&self, expense_id: Uuid) -> Result<bool, String> {
224        let entries = self.journal_entry_repo.find_by_expense(expense_id).await?;
225        Ok(!entries.is_empty())
226    }
227
228    /// Get journal entries for an expense
229    ///
230    /// Returns all entries (expense entry + payment entry if paid).
231    pub async fn get_expense_journal_entries(
232        &self,
233        expense_id: Uuid,
234    ) -> Result<Vec<JournalEntry>, String> {
235        self.journal_entry_repo.find_by_expense(expense_id).await
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::domain::entities::{ApprovalStatus, ExpenseCategory, PaymentStatus};
243
244    // Mock repository for testing
245    struct MockJournalEntryRepository {
246        entries: std::sync::Mutex<Vec<JournalEntry>>,
247    }
248
249    impl MockJournalEntryRepository {
250        fn new() -> Self {
251            Self {
252                entries: std::sync::Mutex::new(Vec::new()),
253            }
254        }
255    }
256
257    #[async_trait::async_trait]
258    impl JournalEntryRepository for MockJournalEntryRepository {
259        async fn create(&self, entry: &JournalEntry) -> Result<JournalEntry, String> {
260            let mut entries = self.entries.lock().unwrap();
261            entries.push(entry.clone());
262            Ok(entry.clone())
263        }
264
265        async fn find_by_expense(&self, expense_id: Uuid) -> Result<Vec<JournalEntry>, String> {
266            let entries = self.entries.lock().unwrap();
267            Ok(entries
268                .iter()
269                .filter(|e| e.expense_id == Some(expense_id))
270                .cloned()
271                .collect())
272        }
273
274        // Other methods not needed for tests
275        async fn find_by_id(
276            &self,
277            _id: Uuid,
278            _organization_id: Uuid,
279        ) -> Result<JournalEntry, String> {
280            unimplemented!()
281        }
282        async fn find_by_organization(
283            &self,
284            _organization_id: Uuid,
285        ) -> Result<Vec<JournalEntry>, String> {
286            unimplemented!()
287        }
288        async fn find_by_date_range(
289            &self,
290            _organization_id: Uuid,
291            _start_date: chrono::DateTime<chrono::Utc>,
292            _end_date: chrono::DateTime<chrono::Utc>,
293        ) -> Result<Vec<JournalEntry>, String> {
294            unimplemented!()
295        }
296        async fn calculate_account_balances(
297            &self,
298            _organization_id: Uuid,
299        ) -> Result<std::collections::HashMap<String, f64>, String> {
300            unimplemented!()
301        }
302        async fn calculate_account_balances_for_period(
303            &self,
304            _organization_id: Uuid,
305            _start_date: chrono::DateTime<chrono::Utc>,
306            _end_date: chrono::DateTime<chrono::Utc>,
307        ) -> Result<std::collections::HashMap<String, f64>, String> {
308            unimplemented!()
309        }
310        async fn calculate_account_balances_for_building(
311            &self,
312            _organization_id: Uuid,
313            _building_id: Uuid,
314        ) -> Result<std::collections::HashMap<String, f64>, String> {
315            unimplemented!()
316        }
317        async fn calculate_account_balances_for_building_and_period(
318            &self,
319            _organization_id: Uuid,
320            _building_id: Uuid,
321            _start_date: chrono::DateTime<chrono::Utc>,
322            _end_date: chrono::DateTime<chrono::Utc>,
323        ) -> Result<std::collections::HashMap<String, f64>, String> {
324            unimplemented!()
325        }
326        async fn create_manual_entry(
327            &self,
328            _entry: &JournalEntry,
329            _lines: &[JournalEntryLine],
330        ) -> Result<(), String> {
331            unimplemented!()
332        }
333        #[allow(clippy::too_many_arguments)]
334        async fn list_entries(
335            &self,
336            _organization_id: Uuid,
337            _building_id: Option<Uuid>,
338            _journal_type: Option<String>,
339            _start_date: Option<chrono::DateTime<chrono::Utc>>,
340            _end_date: Option<chrono::DateTime<chrono::Utc>>,
341            _limit: i64,
342            _offset: i64,
343        ) -> Result<Vec<JournalEntry>, String> {
344            unimplemented!()
345        }
346        async fn find_lines_by_account(
347            &self,
348            _organization_id: Uuid,
349            _account_code: &str,
350        ) -> Result<Vec<JournalEntryLine>, String> {
351            unimplemented!()
352        }
353        async fn find_lines_by_entry(
354            &self,
355            _entry_id: Uuid,
356            _organization_id: Uuid,
357        ) -> Result<Vec<JournalEntryLine>, String> {
358            unimplemented!()
359        }
360        async fn delete_entry(
361            &self,
362            _entry_id: Uuid,
363            _organization_id: Uuid,
364        ) -> Result<(), String> {
365            unimplemented!()
366        }
367        async fn validate_balance(&self, _entry_id: Uuid) -> Result<bool, String> {
368            unimplemented!()
369        }
370    }
371
372    #[tokio::test]
373    async fn test_generate_journal_entry_for_expense_with_vat() {
374        let repo = Arc::new(MockJournalEntryRepository::new());
375        let service = ExpenseAccountingService::new(repo.clone());
376
377        let org_id = Uuid::new_v4();
378        let expense = Expense {
379            id: Uuid::new_v4(),
380            organization_id: org_id,
381            building_id: Uuid::new_v4(),
382            description: "Facture eau".to_string(),
383            amount: 1210.0,                // Total TTC
384            amount_excl_vat: Some(1000.0), // HT
385            vat_rate: Some(0.21),
386            vat_amount: Some(210.0),
387            amount_incl_vat: Some(1210.0),
388            expense_date: Utc::now(),
389            invoice_date: None,
390            due_date: None,
391            paid_date: None,
392            category: ExpenseCategory::Utilities,
393            payment_status: PaymentStatus::Pending,
394            approval_status: ApprovalStatus::Approved,
395            supplier: Some("Vivaqua".to_string()),
396            invoice_number: Some("INV-2025-001".to_string()),
397            account_code: Some("6100".to_string()),
398            created_at: Utc::now(),
399            updated_at: Utc::now(),
400            submitted_at: None,
401            approved_at: Some(Utc::now()),
402            approved_by: None,
403            rejection_reason: None,
404            contractor_report_id: None,
405        };
406
407        let result = service
408            .generate_journal_entry_for_expense(&expense, None)
409            .await;
410
411        assert!(result.is_ok());
412        let entry = result.unwrap();
413
414        // Should have 3 lines: expense debit, VAT debit, supplier credit
415        assert_eq!(entry.lines.len(), 3);
416
417        // Verify balances
418        assert!(entry.is_balanced());
419        assert_eq!(entry.total_debits(), 1210.0);
420        assert_eq!(entry.total_credits(), 1210.0);
421
422        // Verify line details
423        let expense_line = entry
424            .lines
425            .iter()
426            .find(|l| l.account_code == "6100")
427            .unwrap();
428        assert_eq!(expense_line.debit, 1000.0);
429
430        let vat_line = entry
431            .lines
432            .iter()
433            .find(|l| l.account_code == "4110")
434            .unwrap();
435        assert_eq!(vat_line.debit, 210.0);
436
437        let supplier_line = entry
438            .lines
439            .iter()
440            .find(|l| l.account_code == "4400")
441            .unwrap();
442        assert_eq!(supplier_line.credit, 1210.0);
443    }
444
445    #[tokio::test]
446    async fn test_generate_payment_entry() {
447        let repo = Arc::new(MockJournalEntryRepository::new());
448        let service = ExpenseAccountingService::new(repo.clone());
449
450        let org_id = Uuid::new_v4();
451        let expense = Expense {
452            id: Uuid::new_v4(),
453            organization_id: org_id,
454            building_id: Uuid::new_v4(),
455            description: "Facture eau".to_string(),
456            amount: 1210.0,
457            amount_excl_vat: Some(1000.0),
458            vat_rate: Some(0.21),
459            vat_amount: Some(210.0),
460            amount_incl_vat: Some(1210.0),
461            expense_date: Utc::now(),
462            invoice_date: None,
463            due_date: None,
464            paid_date: Some(Utc::now()),
465            category: ExpenseCategory::Utilities,
466            payment_status: PaymentStatus::Paid,
467            approval_status: ApprovalStatus::Approved,
468            supplier: Some("Vivaqua".to_string()),
469            invoice_number: Some("INV-2025-001".to_string()),
470            account_code: Some("6100".to_string()),
471            created_at: Utc::now(),
472            updated_at: Utc::now(),
473            submitted_at: None,
474            approved_at: Some(Utc::now()),
475            approved_by: None,
476            rejection_reason: None,
477            contractor_report_id: None,
478        };
479
480        let result = service.generate_payment_entry(&expense, None, None).await;
481
482        assert!(result.is_ok());
483        let entry = result.unwrap();
484
485        // Should have 2 lines: supplier debit, bank credit
486        assert_eq!(entry.lines.len(), 2);
487
488        // Verify balances
489        assert!(entry.is_balanced());
490        assert_eq!(entry.total_debits(), 1210.0);
491        assert_eq!(entry.total_credits(), 1210.0);
492
493        // Verify line details
494        let supplier_line = entry
495            .lines
496            .iter()
497            .find(|l| l.account_code == "4400")
498            .unwrap();
499        assert_eq!(supplier_line.debit, 1210.0);
500
501        let bank_line = entry
502            .lines
503            .iter()
504            .find(|l| l.account_code == "5500")
505            .unwrap();
506        assert_eq!(bank_line.credit, 1210.0);
507    }
508}