1use crate::application::ports::JournalEntryRepository;
13use crate::domain::entities::{Expense, JournalEntry, JournalEntryLine};
14use chrono::Utc;
15use std::sync::Arc;
16use uuid::Uuid;
17
18pub struct ExpenseAccountingService {
27 journal_entry_repo: Arc<dyn JournalEntryRepository>,
28}
29
30impl ExpenseAccountingService {
31 pub fn new(journal_entry_repo: Arc<dyn JournalEntryRepository>) -> Self {
32 Self { journal_entry_repo }
33 }
34
35 pub async fn generate_journal_entry_for_expense(
55 &self,
56 expense: &Expense,
57 created_by: Option<Uuid>,
58 ) -> Result<JournalEntry, String> {
59 let account_code = expense
61 .account_code
62 .as_ref()
63 .ok_or("Expense must have an account_code to generate journal entry")?;
64
65 let amount_excl_vat = expense.amount_excl_vat.unwrap_or(expense.amount);
67 let vat_amount = expense.amount - amount_excl_vat;
68 let total_amount = expense.amount;
69
70 let mut lines = Vec::new();
72 let entry_id = Uuid::new_v4();
73
74 lines.push(
76 JournalEntryLine::new_debit(
77 entry_id,
78 expense.organization_id,
79 account_code.clone(),
80 amount_excl_vat,
81 Some(format!("Dépense: {}", expense.description)),
82 )
83 .map_err(|e| format!("Failed to create expense debit line: {}", e))?,
84 );
85
86 if vat_amount > 0.01 {
88 lines.push(
89 JournalEntryLine::new_debit(
90 entry_id,
91 expense.organization_id,
92 "4110".to_string(), vat_amount,
94 Some(format!(
95 "TVA récupérable {}%",
96 expense.vat_rate.unwrap_or(0.0) * 100.0
97 )),
98 )
99 .map_err(|e| format!("Failed to create VAT debit line: {}", e))?,
100 );
101 }
102
103 lines.push(
105 JournalEntryLine::new_credit(
106 entry_id,
107 expense.organization_id,
108 "4400".to_string(), total_amount,
110 expense
111 .supplier
112 .as_ref()
113 .map(|s| format!("Fournisseur: {}", s)),
114 )
115 .map_err(|e| format!("Failed to create supplier credit line: {}", e))?,
116 );
117
118 let journal_entry = JournalEntry::new(
120 expense.organization_id,
121 Some(expense.building_id), expense.expense_date,
123 Some(format!("{} - {:?}", expense.description, expense.category)),
124 expense.invoice_number.clone(), Some("ACH".to_string()), Some(expense.id),
127 None, lines,
129 created_by,
130 )
131 .map_err(|e| format!("Failed to create journal entry: {}", e))?;
132
133 self.journal_entry_repo
135 .create(&journal_entry)
136 .await
137 .map_err(|e| format!("Failed to persist journal entry: {}", e))
138 }
139
140 pub async fn generate_payment_entry(
154 &self,
155 expense: &Expense,
156 payment_account: Option<String>,
157 created_by: Option<Uuid>,
158 ) -> Result<JournalEntry, String> {
159 let payment_account = payment_account.unwrap_or_else(|| "5500".to_string());
160 let total_amount = expense.amount;
161 let entry_id = Uuid::new_v4();
162
163 let mut lines = Vec::new();
164
165 lines.push(
167 JournalEntryLine::new_debit(
168 entry_id,
169 expense.organization_id,
170 "4400".to_string(),
171 total_amount,
172 Some(format!("Paiement: {}", expense.description)),
173 )
174 .map_err(|e| format!("Failed to create supplier debit line: {}", e))?,
175 );
176
177 lines.push(
179 JournalEntryLine::new_credit(
180 entry_id,
181 expense.organization_id,
182 payment_account.clone(),
183 total_amount,
184 Some(format!(
185 "Paiement via {}",
186 if payment_account == "5500" {
187 "Banque"
188 } else if payment_account == "5700" {
189 "Caisse"
190 } else {
191 "Autre"
192 }
193 )),
194 )
195 .map_err(|e| format!("Failed to create payment credit line: {}", e))?,
196 );
197
198 let journal_entry = JournalEntry::new(
200 expense.organization_id,
201 Some(expense.building_id), expense.paid_date.unwrap_or_else(Utc::now),
203 Some(format!("Paiement: {}", expense.description)),
204 expense.invoice_number.clone(),
205 Some("FIN".to_string()), Some(expense.id),
207 None, lines,
209 created_by,
210 )
211 .map_err(|e| format!("Failed to create payment journal entry: {}", e))?;
212
213 self.journal_entry_repo
215 .create(&journal_entry)
216 .await
217 .map_err(|e| format!("Failed to persist payment journal entry: {}", e))
218 }
219
220 pub async fn expense_has_journal_entries(&self, expense_id: Uuid) -> Result<bool, String> {
224 let entries = self.journal_entry_repo.find_by_expense(expense_id).await?;
225 Ok(!entries.is_empty())
226 }
227
228 pub async fn get_expense_journal_entries(
232 &self,
233 expense_id: Uuid,
234 ) -> Result<Vec<JournalEntry>, String> {
235 self.journal_entry_repo.find_by_expense(expense_id).await
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use crate::domain::entities::{ApprovalStatus, ExpenseCategory, PaymentStatus};
243
244 struct MockJournalEntryRepository {
246 entries: std::sync::Mutex<Vec<JournalEntry>>,
247 }
248
249 impl MockJournalEntryRepository {
250 fn new() -> Self {
251 Self {
252 entries: std::sync::Mutex::new(Vec::new()),
253 }
254 }
255 }
256
257 #[async_trait::async_trait]
258 impl JournalEntryRepository for MockJournalEntryRepository {
259 async fn create(&self, entry: &JournalEntry) -> Result<JournalEntry, String> {
260 let mut entries = self.entries.lock().unwrap();
261 entries.push(entry.clone());
262 Ok(entry.clone())
263 }
264
265 async fn find_by_expense(&self, expense_id: Uuid) -> Result<Vec<JournalEntry>, String> {
266 let entries = self.entries.lock().unwrap();
267 Ok(entries
268 .iter()
269 .filter(|e| e.expense_id == Some(expense_id))
270 .cloned()
271 .collect())
272 }
273
274 async fn find_by_id(
276 &self,
277 _id: Uuid,
278 _organization_id: Uuid,
279 ) -> Result<JournalEntry, String> {
280 unimplemented!()
281 }
282 async fn find_by_organization(
283 &self,
284 _organization_id: Uuid,
285 ) -> Result<Vec<JournalEntry>, String> {
286 unimplemented!()
287 }
288 async fn find_by_date_range(
289 &self,
290 _organization_id: Uuid,
291 _start_date: chrono::DateTime<chrono::Utc>,
292 _end_date: chrono::DateTime<chrono::Utc>,
293 ) -> Result<Vec<JournalEntry>, String> {
294 unimplemented!()
295 }
296 async fn calculate_account_balances(
297 &self,
298 _organization_id: Uuid,
299 ) -> Result<std::collections::HashMap<String, f64>, String> {
300 unimplemented!()
301 }
302 async fn calculate_account_balances_for_period(
303 &self,
304 _organization_id: Uuid,
305 _start_date: chrono::DateTime<chrono::Utc>,
306 _end_date: chrono::DateTime<chrono::Utc>,
307 ) -> Result<std::collections::HashMap<String, f64>, String> {
308 unimplemented!()
309 }
310 async fn calculate_account_balances_for_building(
311 &self,
312 _organization_id: Uuid,
313 _building_id: Uuid,
314 ) -> Result<std::collections::HashMap<String, f64>, String> {
315 unimplemented!()
316 }
317 async fn calculate_account_balances_for_building_and_period(
318 &self,
319 _organization_id: Uuid,
320 _building_id: Uuid,
321 _start_date: chrono::DateTime<chrono::Utc>,
322 _end_date: chrono::DateTime<chrono::Utc>,
323 ) -> Result<std::collections::HashMap<String, f64>, String> {
324 unimplemented!()
325 }
326 async fn create_manual_entry(
327 &self,
328 _entry: &JournalEntry,
329 _lines: &[JournalEntryLine],
330 ) -> Result<(), String> {
331 unimplemented!()
332 }
333 #[allow(clippy::too_many_arguments)]
334 async fn list_entries(
335 &self,
336 _organization_id: Uuid,
337 _building_id: Option<Uuid>,
338 _journal_type: Option<String>,
339 _start_date: Option<chrono::DateTime<chrono::Utc>>,
340 _end_date: Option<chrono::DateTime<chrono::Utc>>,
341 _limit: i64,
342 _offset: i64,
343 ) -> Result<Vec<JournalEntry>, String> {
344 unimplemented!()
345 }
346 async fn find_lines_by_account(
347 &self,
348 _organization_id: Uuid,
349 _account_code: &str,
350 ) -> Result<Vec<JournalEntryLine>, String> {
351 unimplemented!()
352 }
353 async fn find_lines_by_entry(
354 &self,
355 _entry_id: Uuid,
356 _organization_id: Uuid,
357 ) -> Result<Vec<JournalEntryLine>, String> {
358 unimplemented!()
359 }
360 async fn delete_entry(
361 &self,
362 _entry_id: Uuid,
363 _organization_id: Uuid,
364 ) -> Result<(), String> {
365 unimplemented!()
366 }
367 async fn validate_balance(&self, _entry_id: Uuid) -> Result<bool, String> {
368 unimplemented!()
369 }
370 }
371
372 #[tokio::test]
373 async fn test_generate_journal_entry_for_expense_with_vat() {
374 let repo = Arc::new(MockJournalEntryRepository::new());
375 let service = ExpenseAccountingService::new(repo.clone());
376
377 let org_id = Uuid::new_v4();
378 let expense = Expense {
379 id: Uuid::new_v4(),
380 organization_id: org_id,
381 building_id: Uuid::new_v4(),
382 description: "Facture eau".to_string(),
383 amount: 1210.0, amount_excl_vat: Some(1000.0), vat_rate: Some(0.21),
386 vat_amount: Some(210.0),
387 amount_incl_vat: Some(1210.0),
388 expense_date: Utc::now(),
389 invoice_date: None,
390 due_date: None,
391 paid_date: None,
392 category: ExpenseCategory::Utilities,
393 payment_status: PaymentStatus::Pending,
394 approval_status: ApprovalStatus::Approved,
395 supplier: Some("Vivaqua".to_string()),
396 invoice_number: Some("INV-2025-001".to_string()),
397 account_code: Some("6100".to_string()),
398 created_at: Utc::now(),
399 updated_at: Utc::now(),
400 submitted_at: None,
401 approved_at: Some(Utc::now()),
402 approved_by: None,
403 rejection_reason: None,
404 };
405
406 let result = service
407 .generate_journal_entry_for_expense(&expense, None)
408 .await;
409
410 assert!(result.is_ok());
411 let entry = result.unwrap();
412
413 assert_eq!(entry.lines.len(), 3);
415
416 assert!(entry.is_balanced());
418 assert_eq!(entry.total_debits(), 1210.0);
419 assert_eq!(entry.total_credits(), 1210.0);
420
421 let expense_line = entry
423 .lines
424 .iter()
425 .find(|l| l.account_code == "6100")
426 .unwrap();
427 assert_eq!(expense_line.debit, 1000.0);
428
429 let vat_line = entry
430 .lines
431 .iter()
432 .find(|l| l.account_code == "4110")
433 .unwrap();
434 assert_eq!(vat_line.debit, 210.0);
435
436 let supplier_line = entry
437 .lines
438 .iter()
439 .find(|l| l.account_code == "4400")
440 .unwrap();
441 assert_eq!(supplier_line.credit, 1210.0);
442 }
443
444 #[tokio::test]
445 async fn test_generate_payment_entry() {
446 let repo = Arc::new(MockJournalEntryRepository::new());
447 let service = ExpenseAccountingService::new(repo.clone());
448
449 let org_id = Uuid::new_v4();
450 let expense = Expense {
451 id: Uuid::new_v4(),
452 organization_id: org_id,
453 building_id: Uuid::new_v4(),
454 description: "Facture eau".to_string(),
455 amount: 1210.0,
456 amount_excl_vat: Some(1000.0),
457 vat_rate: Some(0.21),
458 vat_amount: Some(210.0),
459 amount_incl_vat: Some(1210.0),
460 expense_date: Utc::now(),
461 invoice_date: None,
462 due_date: None,
463 paid_date: Some(Utc::now()),
464 category: ExpenseCategory::Utilities,
465 payment_status: PaymentStatus::Paid,
466 approval_status: ApprovalStatus::Approved,
467 supplier: Some("Vivaqua".to_string()),
468 invoice_number: Some("INV-2025-001".to_string()),
469 account_code: Some("6100".to_string()),
470 created_at: Utc::now(),
471 updated_at: Utc::now(),
472 submitted_at: None,
473 approved_at: Some(Utc::now()),
474 approved_by: None,
475 rejection_reason: None,
476 };
477
478 let result = service.generate_payment_entry(&expense, None, None).await;
479
480 assert!(result.is_ok());
481 let entry = result.unwrap();
482
483 assert_eq!(entry.lines.len(), 2);
485
486 assert!(entry.is_balanced());
488 assert_eq!(entry.total_debits(), 1210.0);
489 assert_eq!(entry.total_credits(), 1210.0);
490
491 let supplier_line = entry
493 .lines
494 .iter()
495 .find(|l| l.account_code == "4400")
496 .unwrap();
497 assert_eq!(supplier_line.debit, 1210.0);
498
499 let bank_line = entry
500 .lines
501 .iter()
502 .find(|l| l.account_code == "5500")
503 .unwrap();
504 assert_eq!(bank_line.credit, 1210.0);
505 }
506}