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