1use 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 #[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)>, ) -> Result<JournalEntry, String> {
59 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 if lines.len() < 2 {
71 return Err("Journal entry must have at least 2 lines (debit and credit)".to_string());
72 }
73
74 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 let entry_id = Uuid::new_v4();
87
88 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 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 self.journal_entry_repo
123 .create_manual_entry(&journal_entry, &journal_lines)
124 .await?;
125
126 Ok(journal_entry)
127 }
128
129 #[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 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 pub async fn delete_manual_entry(
194 &self,
195 entry_id: Uuid,
196 organization_id: Uuid,
197 ) -> Result<(), String> {
198 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 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 fn make_use_cases(repo: MockJournalEntryRepository) -> JournalEntryUseCases {
404 JournalEntryUseCases::new(Arc::new(repo))
405 }
406
407 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 #[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 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 let result = uc.delete_manual_entry(created.id, org_id).await;
574 assert!(result.is_ok());
575
576 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 {
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}