Skip to main content

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