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}