koprogo_api/application/use_cases/
journal_entry_use_cases.rs

1// Use Cases: Journal Entry (Manual Accounting Operations)
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// Noalyss features that inspired this implementation:
11// - Journal types (ACH=Purchases, VEN=Sales, FIN=Financial, ODS=Miscellaneous)
12// - Double-entry bookkeeping with debit/credit columns
13// - Quick codes for account selection
14// - Automatic balance validation
15//
16// Use cases for manual journal entry creation and retrieval
17
18use crate::application::ports::journal_entry_repository::JournalEntryRepository;
19use crate::domain::entities::journal_entry::{JournalEntry, JournalEntryLine};
20use chrono::{DateTime, Utc};
21use std::sync::Arc;
22use uuid::Uuid;
23
24pub struct JournalEntryUseCases {
25    journal_entry_repo: Arc<dyn JournalEntryRepository>,
26}
27
28impl JournalEntryUseCases {
29    pub fn new(journal_entry_repo: Arc<dyn JournalEntryRepository>) -> Self {
30        Self { journal_entry_repo }
31    }
32
33    /// Create a manual journal entry with multiple lines
34    ///
35    /// This follows the Noalyss approach where each journal entry can have multiple lines
36    /// with debit and credit columns. The total debits must equal total credits.
37    ///
38    /// # Arguments
39    /// * `organization_id` - Organization ID
40    /// * `building_id` - Optional building ID for building-specific entries
41    /// * `journal_type` - Type of journal (ACH, VEN, FIN, ODS)
42    /// * `entry_date` - Date of the accounting operation
43    /// * `description` - Description of the operation
44    /// * `reference` - Optional reference number (invoice, receipt, etc.)
45    /// * `lines` - Vector of journal entry lines with account_code, debit, credit, description
46    #[allow(clippy::too_many_arguments)]
47    pub async fn create_manual_entry(
48        &self,
49        organization_id: Uuid,
50        building_id: Option<Uuid>,
51        journal_type: Option<String>,
52        entry_date: DateTime<Utc>,
53        description: Option<String>,
54        document_ref: Option<String>,
55        lines: Vec<(String, f64, f64, String)>, // (account_code, debit, credit, line_description)
56    ) -> Result<JournalEntry, String> {
57        // Validate journal type if provided (inspired by Noalyss journal types)
58        if let Some(ref jtype) = journal_type {
59            if !["ACH", "VEN", "FIN", "ODS"].contains(&jtype.as_str()) {
60                return Err(format!(
61                    "Invalid journal type: {}. Must be one of: ACH (Purchases), VEN (Sales), FIN (Financial), ODS (Miscellaneous)",
62                    jtype
63                ));
64            }
65        }
66
67        // Validate that we have at least 2 lines (double-entry principle)
68        if lines.len() < 2 {
69            return Err("Journal entry must have at least 2 lines (debit and credit)".to_string());
70        }
71
72        // Calculate totals and validate balance (Noalyss principle)
73        let total_debit: f64 = lines.iter().map(|(_, debit, _, _)| debit).sum();
74        let total_credit: f64 = lines.iter().map(|(_, _, credit, _)| credit).sum();
75
76        if (total_debit - total_credit).abs() > 0.01 {
77            return Err(format!(
78                "Journal entry is unbalanced: debits={:.2} credits={:.2}. Debits must equal credits.",
79                total_debit, total_credit
80            ));
81        }
82
83        // Create journal entry ID
84        let entry_id = Uuid::new_v4();
85
86        // Create journal entry lines
87        let mut journal_lines = Vec::new();
88        for (account_code, debit, credit, line_desc) in lines {
89            let line = JournalEntryLine {
90                id: Uuid::new_v4(),
91                journal_entry_id: entry_id,
92                organization_id,
93                account_code: account_code.clone(),
94                debit,
95                credit,
96                description: Some(line_desc),
97                created_at: Utc::now(),
98            };
99            journal_lines.push(line);
100        }
101
102        // Create journal entry
103        let journal_entry = JournalEntry {
104            id: entry_id,
105            organization_id,
106            building_id,
107            entry_date,
108            description,
109            document_ref,
110            journal_type,
111            expense_id: None,
112            contribution_id: None,
113            lines: journal_lines.clone(),
114            created_at: Utc::now(),
115            updated_at: Utc::now(),
116            created_by: None,
117        };
118
119        // Save to repository
120        self.journal_entry_repo
121            .create_manual_entry(&journal_entry, &journal_lines)
122            .await?;
123
124        Ok(journal_entry)
125    }
126
127    /// List journal entries for an organization
128    ///
129    /// # Arguments
130    /// * `organization_id` - Organization ID
131    /// * `building_id` - Optional building ID filter
132    /// * `journal_type` - Optional journal type filter
133    /// * `start_date` - Optional start date filter
134    /// * `end_date` - Optional end date filter
135    /// * `limit` - Maximum number of entries to return
136    /// * `offset` - Number of entries to skip
137    #[allow(clippy::too_many_arguments)]
138    pub async fn list_entries(
139        &self,
140        organization_id: Uuid,
141        building_id: Option<Uuid>,
142        journal_type: Option<String>,
143        start_date: Option<DateTime<Utc>>,
144        end_date: Option<DateTime<Utc>>,
145        limit: i64,
146        offset: i64,
147    ) -> Result<Vec<JournalEntry>, String> {
148        self.journal_entry_repo
149            .list_entries(
150                organization_id,
151                building_id,
152                journal_type,
153                start_date,
154                end_date,
155                limit,
156                offset,
157            )
158            .await
159    }
160
161    /// Get a single journal entry with its lines
162    ///
163    /// # Arguments
164    /// * `entry_id` - Journal entry ID
165    /// * `organization_id` - Organization ID for authorization
166    pub async fn get_entry_with_lines(
167        &self,
168        entry_id: Uuid,
169        organization_id: Uuid,
170    ) -> Result<(JournalEntry, Vec<JournalEntryLine>), String> {
171        let entry = self
172            .journal_entry_repo
173            .find_by_id(entry_id, organization_id)
174            .await?;
175
176        let lines = self
177            .journal_entry_repo
178            .find_lines_by_entry(entry_id, organization_id)
179            .await?;
180
181        Ok((entry, lines))
182    }
183
184    /// Delete a manual journal entry
185    ///
186    /// Only manual entries (not auto-generated from expenses/contributions) can be deleted.
187    ///
188    /// # Arguments
189    /// * `entry_id` - Journal entry ID
190    /// * `organization_id` - Organization ID for authorization
191    pub async fn delete_manual_entry(
192        &self,
193        entry_id: Uuid,
194        organization_id: Uuid,
195    ) -> Result<(), String> {
196        // Check if entry exists and is manual
197        let entry = self
198            .journal_entry_repo
199            .find_by_id(entry_id, organization_id)
200            .await?;
201
202        if entry.expense_id.is_some() || entry.contribution_id.is_some() {
203            return Err(
204                "Cannot delete auto-generated journal entries. Only manual entries can be deleted."
205                    .to_string(),
206            );
207        }
208
209        self.journal_entry_repo
210            .delete_entry(entry_id, organization_id)
211            .await
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::application::ports::journal_entry_repository::JournalEntryRepository;
219    use crate::domain::entities::journal_entry::{JournalEntry, JournalEntryLine};
220    use async_trait::async_trait;
221    use std::collections::HashMap;
222    use std::sync::Mutex;
223
224    // ========== Mock Repository ==========
225
226    struct MockJournalEntryRepository {
227        entries: Mutex<HashMap<Uuid, JournalEntry>>,
228        lines: Mutex<HashMap<Uuid, Vec<JournalEntryLine>>>,
229    }
230
231    impl MockJournalEntryRepository {
232        fn new() -> Self {
233            Self {
234                entries: Mutex::new(HashMap::new()),
235                lines: Mutex::new(HashMap::new()),
236            }
237        }
238    }
239
240    #[async_trait]
241    impl JournalEntryRepository for MockJournalEntryRepository {
242        async fn create(&self, entry: &JournalEntry) -> Result<JournalEntry, String> {
243            let mut entries = self.entries.lock().unwrap();
244            entries.insert(entry.id, entry.clone());
245            let mut lines = self.lines.lock().unwrap();
246            lines.insert(entry.id, entry.lines.clone());
247            Ok(entry.clone())
248        }
249
250        async fn find_by_organization(
251            &self,
252            organization_id: Uuid,
253        ) -> Result<Vec<JournalEntry>, String> {
254            let entries = self.entries.lock().unwrap();
255            Ok(entries
256                .values()
257                .filter(|e| e.organization_id == organization_id)
258                .cloned()
259                .collect())
260        }
261
262        async fn find_by_expense(&self, expense_id: Uuid) -> Result<Vec<JournalEntry>, String> {
263            let entries = self.entries.lock().unwrap();
264            Ok(entries
265                .values()
266                .filter(|e| e.expense_id == Some(expense_id))
267                .cloned()
268                .collect())
269        }
270
271        async fn find_by_date_range(
272            &self,
273            organization_id: Uuid,
274            start_date: DateTime<Utc>,
275            end_date: DateTime<Utc>,
276        ) -> Result<Vec<JournalEntry>, String> {
277            let entries = self.entries.lock().unwrap();
278            Ok(entries
279                .values()
280                .filter(|e| {
281                    e.organization_id == organization_id
282                        && e.entry_date >= start_date
283                        && e.entry_date <= end_date
284                })
285                .cloned()
286                .collect())
287        }
288
289        async fn calculate_account_balances(
290            &self,
291            _organization_id: Uuid,
292        ) -> Result<HashMap<String, f64>, String> {
293            Ok(HashMap::new())
294        }
295
296        async fn calculate_account_balances_for_period(
297            &self,
298            _organization_id: Uuid,
299            _start_date: DateTime<Utc>,
300            _end_date: DateTime<Utc>,
301        ) -> Result<HashMap<String, f64>, String> {
302            Ok(HashMap::new())
303        }
304
305        async fn find_lines_by_account(
306            &self,
307            _organization_id: Uuid,
308            _account_code: &str,
309        ) -> Result<Vec<JournalEntryLine>, String> {
310            Ok(Vec::new())
311        }
312
313        async fn validate_balance(&self, entry_id: Uuid) -> Result<bool, String> {
314            let entries = self.entries.lock().unwrap();
315            match entries.get(&entry_id) {
316                Some(entry) => Ok(entry.is_balanced()),
317                None => Err("Entry not found".to_string()),
318            }
319        }
320
321        async fn calculate_account_balances_for_building(
322            &self,
323            _organization_id: Uuid,
324            _building_id: Uuid,
325        ) -> Result<HashMap<String, f64>, String> {
326            Ok(HashMap::new())
327        }
328
329        async fn calculate_account_balances_for_building_and_period(
330            &self,
331            _organization_id: Uuid,
332            _building_id: Uuid,
333            _start_date: DateTime<Utc>,
334            _end_date: DateTime<Utc>,
335        ) -> Result<HashMap<String, f64>, String> {
336            Ok(HashMap::new())
337        }
338
339        async fn create_manual_entry(
340            &self,
341            entry: &JournalEntry,
342            entry_lines: &[JournalEntryLine],
343        ) -> Result<(), String> {
344            let mut entries = self.entries.lock().unwrap();
345            entries.insert(entry.id, entry.clone());
346            let mut lines = self.lines.lock().unwrap();
347            lines.insert(entry.id, entry_lines.to_vec());
348            Ok(())
349        }
350
351        async fn list_entries(
352            &self,
353            organization_id: Uuid,
354            _building_id: Option<Uuid>,
355            _journal_type: Option<String>,
356            _start_date: Option<DateTime<Utc>>,
357            _end_date: Option<DateTime<Utc>>,
358            _limit: i64,
359            _offset: i64,
360        ) -> Result<Vec<JournalEntry>, String> {
361            let entries = self.entries.lock().unwrap();
362            Ok(entries
363                .values()
364                .filter(|e| e.organization_id == organization_id)
365                .cloned()
366                .collect())
367        }
368
369        async fn find_by_id(
370            &self,
371            entry_id: Uuid,
372            _organization_id: Uuid,
373        ) -> Result<JournalEntry, String> {
374            let entries = self.entries.lock().unwrap();
375            entries
376                .get(&entry_id)
377                .cloned()
378                .ok_or_else(|| "Journal entry not found".to_string())
379        }
380
381        async fn find_lines_by_entry(
382            &self,
383            entry_id: Uuid,
384            _organization_id: Uuid,
385        ) -> Result<Vec<JournalEntryLine>, String> {
386            let lines = self.lines.lock().unwrap();
387            Ok(lines.get(&entry_id).cloned().unwrap_or_default())
388        }
389
390        async fn delete_entry(&self, entry_id: Uuid, _organization_id: Uuid) -> Result<(), String> {
391            let mut entries = self.entries.lock().unwrap();
392            let mut lines = self.lines.lock().unwrap();
393            entries.remove(&entry_id);
394            lines.remove(&entry_id);
395            Ok(())
396        }
397    }
398
399    // ========== Helpers ==========
400
401    fn make_use_cases(repo: MockJournalEntryRepository) -> JournalEntryUseCases {
402        JournalEntryUseCases::new(Arc::new(repo))
403    }
404
405    /// Balanced lines: 1000 debit on 6100, 1000 credit on 4400
406    fn balanced_lines() -> Vec<(String, f64, f64, String)> {
407        vec![
408            (
409                "6100".to_string(),
410                1000.0,
411                0.0,
412                "Utilities expense".to_string(),
413            ),
414            (
415                "4400".to_string(),
416                0.0,
417                1000.0,
418                "Supplier payable".to_string(),
419            ),
420        ]
421    }
422
423    // ========== Tests ==========
424
425    #[tokio::test]
426    async fn test_create_manual_entry_success_balanced() {
427        let repo = MockJournalEntryRepository::new();
428        let uc = make_use_cases(repo);
429        let org_id = Uuid::new_v4();
430
431        let result = uc
432            .create_manual_entry(
433                org_id,
434                None,
435                Some("ACH".to_string()),
436                Utc::now(),
437                Some("Facture eau janvier".to_string()),
438                Some("INV-2026-001".to_string()),
439                balanced_lines(),
440            )
441            .await;
442
443        assert!(result.is_ok());
444        let entry = result.unwrap();
445        assert_eq!(entry.organization_id, org_id);
446        assert_eq!(entry.journal_type, Some("ACH".to_string()));
447        assert_eq!(entry.description, Some("Facture eau janvier".to_string()));
448        assert_eq!(entry.document_ref, Some("INV-2026-001".to_string()));
449        assert!(entry.expense_id.is_none());
450        assert!(entry.contribution_id.is_none());
451        assert_eq!(entry.lines.len(), 2);
452    }
453
454    #[tokio::test]
455    async fn test_create_manual_entry_fail_unbalanced() {
456        let repo = MockJournalEntryRepository::new();
457        let uc = make_use_cases(repo);
458        let org_id = Uuid::new_v4();
459
460        let unbalanced_lines = vec![
461            ("6100".to_string(), 1000.0, 0.0, "Debit".to_string()),
462            ("4400".to_string(), 0.0, 800.0, "Credit".to_string()),
463        ];
464
465        let result = uc
466            .create_manual_entry(
467                org_id,
468                None,
469                Some("ACH".to_string()),
470                Utc::now(),
471                Some("Test unbalanced".to_string()),
472                None,
473                unbalanced_lines,
474            )
475            .await;
476
477        assert!(result.is_err());
478        let err = result.unwrap_err();
479        assert!(err.contains("unbalanced"));
480        assert!(err.contains("debits=1000.00"));
481        assert!(err.contains("credits=800.00"));
482    }
483
484    #[tokio::test]
485    async fn test_create_manual_entry_fail_invalid_journal_type() {
486        let repo = MockJournalEntryRepository::new();
487        let uc = make_use_cases(repo);
488        let org_id = Uuid::new_v4();
489
490        let result = uc
491            .create_manual_entry(
492                org_id,
493                None,
494                Some("INVALID".to_string()),
495                Utc::now(),
496                Some("Test invalid type".to_string()),
497                None,
498                balanced_lines(),
499            )
500            .await;
501
502        assert!(result.is_err());
503        let err = result.unwrap_err();
504        assert!(err.contains("Invalid journal type: INVALID"));
505        assert!(err.contains("ACH"));
506        assert!(err.contains("VEN"));
507        assert!(err.contains("FIN"));
508        assert!(err.contains("ODS"));
509    }
510
511    #[tokio::test]
512    async fn test_create_manual_entry_fail_less_than_2_lines() {
513        let repo = MockJournalEntryRepository::new();
514        let uc = make_use_cases(repo);
515        let org_id = Uuid::new_v4();
516
517        let single_line = vec![("6100".to_string(), 1000.0, 0.0, "Only debit".to_string())];
518
519        let result = uc
520            .create_manual_entry(
521                org_id,
522                None,
523                Some("ODS".to_string()),
524                Utc::now(),
525                Some("Test single line".to_string()),
526                None,
527                single_line,
528            )
529            .await;
530
531        assert!(result.is_err());
532        assert!(result.unwrap_err().contains("must have at least 2 lines"));
533    }
534
535    #[tokio::test]
536    async fn test_delete_manual_entry_success() {
537        let repo = MockJournalEntryRepository::new();
538        let uc = make_use_cases(repo);
539        let org_id = Uuid::new_v4();
540
541        // First create a manual entry
542        let created = uc
543            .create_manual_entry(
544                org_id,
545                None,
546                Some("FIN".to_string()),
547                Utc::now(),
548                Some("Manual entry to delete".to_string()),
549                None,
550                balanced_lines(),
551            )
552            .await
553            .unwrap();
554
555        // Delete it
556        let result = uc.delete_manual_entry(created.id, org_id).await;
557        assert!(result.is_ok());
558
559        // Verify it was deleted (find_by_id should fail)
560        let find_result = uc.get_entry_with_lines(created.id, org_id).await;
561        assert!(find_result.is_err());
562    }
563
564    #[tokio::test]
565    async fn test_delete_manual_entry_fail_auto_generated_with_expense_id() {
566        let repo = MockJournalEntryRepository::new();
567        let org_id = Uuid::new_v4();
568        let entry_id = Uuid::new_v4();
569        let expense_id = Uuid::new_v4();
570
571        // Insert an auto-generated entry (has expense_id set)
572        {
573            let mut entries = repo.entries.lock().unwrap();
574            let auto_entry = JournalEntry {
575                id: entry_id,
576                organization_id: org_id,
577                building_id: None,
578                entry_date: Utc::now(),
579                description: Some("Auto-generated from expense".to_string()),
580                document_ref: None,
581                journal_type: Some("ACH".to_string()),
582                expense_id: Some(expense_id),
583                contribution_id: None,
584                lines: Vec::new(),
585                created_at: Utc::now(),
586                updated_at: Utc::now(),
587                created_by: None,
588            };
589            entries.insert(entry_id, auto_entry);
590        }
591
592        let uc = make_use_cases(repo);
593
594        let result = uc.delete_manual_entry(entry_id, org_id).await;
595
596        assert!(result.is_err());
597        assert!(result
598            .unwrap_err()
599            .contains("Cannot delete auto-generated journal entries"));
600    }
601}