1use 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 #[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)>, ) -> Result<JournalEntry, String> {
57 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 if lines.len() < 2 {
69 return Err("Journal entry must have at least 2 lines (debit and credit)".to_string());
70 }
71
72 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 let entry_id = Uuid::new_v4();
85
86 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 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 self.journal_entry_repo
121 .create_manual_entry(&journal_entry, &journal_lines)
122 .await?;
123
124 Ok(journal_entry)
125 }
126
127 #[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 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 pub async fn delete_manual_entry(
192 &self,
193 entry_id: Uuid,
194 organization_id: Uuid,
195 ) -> Result<(), String> {
196 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 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 fn make_use_cases(repo: MockJournalEntryRepository) -> JournalEntryUseCases {
402 JournalEntryUseCases::new(Arc::new(repo))
403 }
404
405 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 #[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 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 let result = uc.delete_manual_entry(created.id, org_id).await;
557 assert!(result.is_ok());
558
559 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 {
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}