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    /// ```
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    /// ```
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        };
405
406        let result = service
407            .generate_journal_entry_for_expense(&expense, None)
408            .await;
409
410        assert!(result.is_ok());
411        let entry = result.unwrap();
412
413        // Should have 3 lines: expense debit, VAT debit, supplier credit
414        assert_eq!(entry.lines.len(), 3);
415
416        // Verify balances
417        assert!(entry.is_balanced());
418        assert_eq!(entry.total_debits(), 1210.0);
419        assert_eq!(entry.total_credits(), 1210.0);
420
421        // Verify line details
422        let expense_line = entry
423            .lines
424            .iter()
425            .find(|l| l.account_code == "6100")
426            .unwrap();
427        assert_eq!(expense_line.debit, 1000.0);
428
429        let vat_line = entry
430            .lines
431            .iter()
432            .find(|l| l.account_code == "4110")
433            .unwrap();
434        assert_eq!(vat_line.debit, 210.0);
435
436        let supplier_line = entry
437            .lines
438            .iter()
439            .find(|l| l.account_code == "4400")
440            .unwrap();
441        assert_eq!(supplier_line.credit, 1210.0);
442    }
443
444    #[tokio::test]
445    async fn test_generate_payment_entry() {
446        let repo = Arc::new(MockJournalEntryRepository::new());
447        let service = ExpenseAccountingService::new(repo.clone());
448
449        let org_id = Uuid::new_v4();
450        let expense = Expense {
451            id: Uuid::new_v4(),
452            organization_id: org_id,
453            building_id: Uuid::new_v4(),
454            description: "Facture eau".to_string(),
455            amount: 1210.0,
456            amount_excl_vat: Some(1000.0),
457            vat_rate: Some(0.21),
458            vat_amount: Some(210.0),
459            amount_incl_vat: Some(1210.0),
460            expense_date: Utc::now(),
461            invoice_date: None,
462            due_date: None,
463            paid_date: Some(Utc::now()),
464            category: ExpenseCategory::Utilities,
465            payment_status: PaymentStatus::Paid,
466            approval_status: ApprovalStatus::Approved,
467            supplier: Some("Vivaqua".to_string()),
468            invoice_number: Some("INV-2025-001".to_string()),
469            account_code: Some("6100".to_string()),
470            created_at: Utc::now(),
471            updated_at: Utc::now(),
472            submitted_at: None,
473            approved_at: Some(Utc::now()),
474            approved_by: None,
475            rejection_reason: None,
476        };
477
478        let result = service.generate_payment_entry(&expense, None, None).await;
479
480        assert!(result.is_ok());
481        let entry = result.unwrap();
482
483        // Should have 2 lines: supplier debit, bank credit
484        assert_eq!(entry.lines.len(), 2);
485
486        // Verify balances
487        assert!(entry.is_balanced());
488        assert_eq!(entry.total_debits(), 1210.0);
489        assert_eq!(entry.total_credits(), 1210.0);
490
491        // Verify line details
492        let supplier_line = entry
493            .lines
494            .iter()
495            .find(|l| l.account_code == "4400")
496            .unwrap();
497        assert_eq!(supplier_line.debit, 1210.0);
498
499        let bank_line = entry
500            .lines
501            .iter()
502            .find(|l| l.account_code == "5500")
503            .unwrap();
504        assert_eq!(bank_line.credit, 1210.0);
505    }
506}