Skip to main content

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