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}