1use crate::application::ports::JournalEntryRepository;
13use crate::domain::entities::{Expense, JournalEntry, JournalEntryLine, OwnerContribution};
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
32const COMPTE_TVA_RECUPERABLE: &str = "411";
56const COMPTE_FOURNISSEURS: &str = "440";
58const COMPTE_BANQUE: &str = "550";
60const COMPTE_COPROPRIETAIRES: &str = "400";
63
64impl ExpenseAccountingService {
65 pub fn new(journal_entry_repo: Arc<dyn JournalEntryRepository>) -> Self {
66 Self { journal_entry_repo }
67 }
68
69 pub async fn generate_journal_entry_for_expense(
89 &self,
90 expense: &Expense,
91 created_by: Option<Uuid>,
92 ) -> Result<JournalEntry, String> {
93 if self.has_entry_in_journal(expense.id, "ACH").await? {
99 return Err(format!(
100 "Journal entry already exists for expense {} (ACH)",
101 expense.id
102 ));
103 }
104
105 let account_code = expense
107 .account_code
108 .as_ref()
109 .ok_or("Expense must have an account_code to generate journal entry")?;
110
111 let amount_excl_vat = expense.amount_excl_vat.unwrap_or(expense.amount);
113 let vat_amount = expense.amount - amount_excl_vat;
114 let total_amount = expense.amount;
115
116 let mut lines = Vec::new();
118 let entry_id = Uuid::new_v4();
119
120 lines.push(
122 JournalEntryLine::new_debit(
123 entry_id,
124 expense.organization_id,
125 account_code.clone(),
126 amount_excl_vat,
127 Some(format!("Dépense: {}", expense.description)),
128 )
129 .map_err(|e| format!("Failed to create expense debit line: {}", e))?,
130 );
131
132 if vat_amount > dec!(0.01) {
134 lines.push(
135 JournalEntryLine::new_debit(
136 entry_id,
137 expense.organization_id,
138 COMPTE_TVA_RECUPERABLE.to_string(),
139 vat_amount,
140 Some(format!(
145 "TVA récupérable {} %",
146 expense.vat_rate.unwrap_or(Decimal::ZERO)
147 )),
148 )
149 .map_err(|e| format!("Failed to create VAT debit line: {}", e))?,
150 );
151 }
152
153 lines.push(
155 JournalEntryLine::new_credit(
156 entry_id,
157 expense.organization_id,
158 COMPTE_FOURNISSEURS.to_string(),
159 total_amount,
160 expense
161 .supplier
162 .as_ref()
163 .map(|s| format!("Fournisseur: {}", s)),
164 )
165 .map_err(|e| format!("Failed to create supplier credit line: {}", e))?,
166 );
167
168 let journal_entry = JournalEntry::new(
170 expense.acp_id,
171 expense.organization_id,
172 Some(expense.building_id), expense.expense_date,
174 Some(format!("{} - {:?}", expense.description, expense.category)),
175 expense.invoice_number.clone(), Some("ACH".to_string()), Some(expense.id),
178 None, lines,
180 created_by,
181 )
182 .map_err(|e| format!("Failed to create journal entry: {}", e))?;
183
184 self.journal_entry_repo
186 .create(&journal_entry)
187 .await
188 .map_err(|e| format!("Failed to persist journal entry: {}", e))
189 }
190
191 pub async fn generate_payment_entry(
205 &self,
206 expense: &Expense,
207 payment_account: Option<String>,
208 created_by: Option<Uuid>,
209 ) -> Result<JournalEntry, String> {
210 if self.has_entry_in_journal(expense.id, "FIN").await? {
222 return Err(format!(
223 "Payment journal entry already exists for expense {} (FIN)",
224 expense.id
225 ));
226 }
227
228 let payment_account = payment_account.unwrap_or_else(|| COMPTE_BANQUE.to_string());
229 let total_amount = expense.amount;
230 let entry_id = Uuid::new_v4();
231
232 let mut lines = Vec::new();
233
234 lines.push(
236 JournalEntryLine::new_debit(
237 entry_id,
238 expense.organization_id,
239 COMPTE_FOURNISSEURS.to_string(),
240 total_amount,
241 Some(format!("Paiement: {}", expense.description)),
242 )
243 .map_err(|e| format!("Failed to create supplier debit line: {}", e))?,
244 );
245
246 lines.push(
248 JournalEntryLine::new_credit(
249 entry_id,
250 expense.organization_id,
251 payment_account.clone(),
252 total_amount,
253 Some(format!(
254 "Paiement via {}",
255 if payment_account == COMPTE_BANQUE {
256 "Banque"
257 } else {
258 "Autre"
259 }
260 )),
261 )
262 .map_err(|e| format!("Failed to create payment credit line: {}", e))?,
263 );
264
265 let journal_entry = JournalEntry::new(
267 expense.acp_id,
268 expense.organization_id,
269 Some(expense.building_id), expense.paid_date.unwrap_or_else(Utc::now),
271 Some(format!("Paiement: {}", expense.description)),
272 expense.invoice_number.clone(),
273 Some("FIN".to_string()), Some(expense.id),
275 None, lines,
277 created_by,
278 )
279 .map_err(|e| format!("Failed to create payment journal entry: {}", e))?;
280
281 self.journal_entry_repo
283 .create(&journal_entry)
284 .await
285 .map_err(|e| format!("Failed to persist payment journal entry: {}", e))
286 }
287
288 pub async fn generate_contribution_receipt_entry(
315 &self,
316 contribution: &OwnerContribution,
317 building_id: Option<Uuid>,
318 payment_account: Option<String>,
319 created_by: Option<Uuid>,
320 ) -> Result<JournalEntry, String> {
321 let existantes = self
322 .journal_entry_repo
323 .find_by_contribution(contribution.id)
324 .await?;
325 if existantes
326 .iter()
327 .any(|e| e.journal_type.as_deref() == Some("FIN"))
328 {
329 return Err(format!(
330 "Receipt journal entry already exists for contribution {} (FIN)",
331 contribution.id
332 ));
333 }
334
335 let payment_account = payment_account.unwrap_or_else(|| COMPTE_BANQUE.to_string());
336 let entry_id = Uuid::new_v4();
337 let montant = contribution.amount;
338
339 let lines = vec![
340 JournalEntryLine::new_debit(
341 entry_id,
342 contribution.organization_id,
343 payment_account,
344 montant,
345 Some(format!("Encaissement: {}", contribution.description)),
346 )
347 .map_err(|e| format!("Failed to create bank debit line: {}", e))?,
348 JournalEntryLine::new_credit(
349 entry_id,
350 contribution.organization_id,
351 COMPTE_COPROPRIETAIRES.to_string(),
352 montant,
353 Some(format!("Quote-part {}", contribution.owner_id)),
354 )
355 .map_err(|e| format!("Failed to create owner credit line: {}", e))?,
356 ];
357
358 let journal_entry = JournalEntry::new(
359 contribution.acp_id,
360 contribution.organization_id,
361 building_id,
362 contribution.payment_date.unwrap_or_else(Utc::now),
363 Some(format!("Encaissement: {}", contribution.description)),
364 contribution.payment_reference.clone(),
365 Some("FIN".to_string()),
366 None,
367 Some(contribution.id),
368 lines,
369 created_by,
370 )
371 .map_err(|e| format!("Failed to create contribution journal entry: {}", e))?;
372
373 self.journal_entry_repo
374 .create(&journal_entry)
375 .await
376 .map_err(|e| format!("Failed to persist contribution journal entry: {}", e))
377 }
378
379 pub async fn expense_has_journal_entries(&self, expense_id: Uuid) -> Result<bool, String> {
383 let entries = self.journal_entry_repo.find_by_expense(expense_id).await?;
384 Ok(!entries.is_empty())
385 }
386
387 async fn has_entry_in_journal(
394 &self,
395 expense_id: Uuid,
396 journal_type: &str,
397 ) -> Result<bool, String> {
398 let entries = self.journal_entry_repo.find_by_expense(expense_id).await?;
399 Ok(entries
400 .iter()
401 .any(|e| e.journal_type.as_deref() == Some(journal_type)))
402 }
403
404 pub async fn get_expense_journal_entries(
408 &self,
409 expense_id: Uuid,
410 ) -> Result<Vec<JournalEntry>, String> {
411 self.journal_entry_repo.find_by_expense(expense_id).await
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418 use crate::domain::entities::{ApprovalStatus, ExpenseCategory, PaymentStatus};
419
420 struct MockJournalEntryRepository {
422 entries: std::sync::Mutex<Vec<JournalEntry>>,
423 }
424
425 impl MockJournalEntryRepository {
426 fn new() -> Self {
427 Self {
428 entries: std::sync::Mutex::new(Vec::new()),
429 }
430 }
431 }
432
433 #[async_trait::async_trait]
434 impl JournalEntryRepository for MockJournalEntryRepository {
435 async fn create(&self, entry: &JournalEntry) -> Result<JournalEntry, String> {
436 let mut entries = self.entries.lock().unwrap();
437 entries.push(entry.clone());
438 Ok(entry.clone())
439 }
440
441 async fn find_by_expense(&self, expense_id: Uuid) -> Result<Vec<JournalEntry>, String> {
442 let entries = self.entries.lock().unwrap();
443 Ok(entries
444 .iter()
445 .filter(|e| e.expense_id == Some(expense_id))
446 .cloned()
447 .collect())
448 }
449
450 async fn find_by_contribution(
451 &self,
452 contribution_id: Uuid,
453 ) -> Result<Vec<JournalEntry>, String> {
454 let entries = self.entries.lock().unwrap();
455 Ok(entries
456 .iter()
457 .filter(|e| e.contribution_id == Some(contribution_id))
458 .cloned()
459 .collect())
460 }
461
462 async fn find_by_id(
464 &self,
465 _id: Uuid,
466 _organization_id: Uuid,
467 ) -> Result<JournalEntry, String> {
468 unimplemented!()
469 }
470 async fn find_by_organization(
471 &self,
472 _organization_id: Uuid,
473 ) -> Result<Vec<JournalEntry>, String> {
474 unimplemented!()
475 }
476 async fn find_by_date_range(
477 &self,
478 _organization_id: Uuid,
479 _start_date: chrono::DateTime<chrono::Utc>,
480 _end_date: chrono::DateTime<chrono::Utc>,
481 ) -> Result<Vec<JournalEntry>, String> {
482 unimplemented!()
483 }
484 async fn calculate_account_balances(
485 &self,
486 _organization_id: Uuid,
487 ) -> Result<std::collections::HashMap<String, Decimal>, String> {
488 unimplemented!()
489 }
490 async fn calculate_account_balances_for_period(
491 &self,
492 _organization_id: Uuid,
493 _start_date: chrono::DateTime<chrono::Utc>,
494 _end_date: chrono::DateTime<chrono::Utc>,
495 ) -> Result<std::collections::HashMap<String, Decimal>, String> {
496 unimplemented!()
497 }
498 async fn calculate_account_balances_for_building(
499 &self,
500 _organization_id: Uuid,
501 _building_id: Uuid,
502 ) -> Result<std::collections::HashMap<String, Decimal>, String> {
503 unimplemented!()
504 }
505 async fn calculate_account_balances_for_building_and_period(
506 &self,
507 _organization_id: Uuid,
508 _building_id: Uuid,
509 _start_date: chrono::DateTime<chrono::Utc>,
510 _end_date: chrono::DateTime<chrono::Utc>,
511 ) -> Result<std::collections::HashMap<String, Decimal>, String> {
512 unimplemented!()
513 }
514 async fn create_manual_entry(
515 &self,
516 _entry: &JournalEntry,
517 _lines: &[JournalEntryLine],
518 ) -> Result<(), String> {
519 unimplemented!()
520 }
521 #[allow(clippy::too_many_arguments)]
522 async fn list_entries(
523 &self,
524 _organization_id: Uuid,
525 _building_id: Option<Uuid>,
526 _journal_type: Option<String>,
527 _start_date: Option<chrono::DateTime<chrono::Utc>>,
528 _end_date: Option<chrono::DateTime<chrono::Utc>>,
529 _limit: i64,
530 _offset: i64,
531 ) -> Result<Vec<JournalEntry>, String> {
532 unimplemented!()
533 }
534 async fn find_lines_by_account(
535 &self,
536 _organization_id: Uuid,
537 _account_code: &str,
538 ) -> Result<Vec<JournalEntryLine>, String> {
539 unimplemented!()
540 }
541 async fn find_lines_by_entry(
542 &self,
543 _entry_id: Uuid,
544 _organization_id: Uuid,
545 ) -> Result<Vec<JournalEntryLine>, String> {
546 unimplemented!()
547 }
548 async fn delete_entry(
549 &self,
550 _entry_id: Uuid,
551 _organization_id: Uuid,
552 ) -> Result<(), String> {
553 unimplemented!()
554 }
555 async fn validate_balance(&self, _entry_id: Uuid) -> Result<bool, String> {
556 unimplemented!()
557 }
558 }
559
560 #[tokio::test]
561 async fn test_generate_journal_entry_for_expense_with_vat() {
562 let repo = Arc::new(MockJournalEntryRepository::new());
563 let service = ExpenseAccountingService::new(repo.clone());
564
565 let org_id = Uuid::new_v4();
566 let expense = Expense {
567 id: Uuid::new_v4(),
568 acp_id: Uuid::new_v4(),
569 organization_id: org_id,
570 building_id: Uuid::new_v4(),
571 description: "Facture eau".to_string(),
572 amount: dec!(1210), amount_excl_vat: Some(dec!(1000)), vat_rate: Some(dec!(21)),
575 vat_amount: Some(dec!(210)),
576 amount_incl_vat: Some(dec!(1210)),
577 expense_date: Utc::now(),
578 invoice_date: None,
579 due_date: None,
580 paid_date: None,
581 category: ExpenseCategory::Utilities,
582 payment_status: PaymentStatus::Pending,
583 approval_status: ApprovalStatus::Approved,
584 supplier: Some("Vivaqua".to_string()),
585 invoice_number: Some("INV-2025-001".to_string()),
586 account_code: Some("6100".to_string()),
587 created_at: Utc::now(),
588 updated_at: Utc::now(),
589 submitted_at: None,
590 approved_at: Some(Utc::now()),
591 approved_by: None,
592 rejection_reason: None,
593 contractor_report_id: None,
594 };
595
596 let result = service
597 .generate_journal_entry_for_expense(&expense, None)
598 .await;
599
600 assert!(result.is_ok());
601 let entry = result.unwrap();
602
603 assert_eq!(entry.lines.len(), 3);
605
606 assert!(entry.is_balanced());
608 assert_eq!(entry.total_debits(), dec!(1210));
609 assert_eq!(entry.total_credits(), dec!(1210));
610
611 let expense_line = entry
613 .lines
614 .iter()
615 .find(|l| l.account_code == "6100")
616 .unwrap();
617 assert_eq!(expense_line.debit, dec!(1000));
618
619 let vat_line = entry
620 .lines
621 .iter()
622 .find(|l| l.account_code == COMPTE_TVA_RECUPERABLE)
623 .unwrap();
624 assert_eq!(vat_line.debit, dec!(210));
625
626 let supplier_line = entry
627 .lines
628 .iter()
629 .find(|l| l.account_code == COMPTE_FOURNISSEURS)
630 .unwrap();
631 assert_eq!(supplier_line.credit, dec!(1210));
632 }
633
634 #[tokio::test]
635 async fn test_generate_payment_entry() {
636 let repo = Arc::new(MockJournalEntryRepository::new());
637 let service = ExpenseAccountingService::new(repo.clone());
638
639 let org_id = Uuid::new_v4();
640 let expense = Expense {
641 id: Uuid::new_v4(),
642 acp_id: Uuid::new_v4(),
643 organization_id: org_id,
644 building_id: Uuid::new_v4(),
645 description: "Facture eau".to_string(),
646 amount: dec!(1210),
647 amount_excl_vat: Some(dec!(1000)),
648 vat_rate: Some(dec!(21)),
649 vat_amount: Some(dec!(210)),
650 amount_incl_vat: Some(dec!(1210)),
651 expense_date: Utc::now(),
652 invoice_date: None,
653 due_date: None,
654 paid_date: Some(Utc::now()),
655 category: ExpenseCategory::Utilities,
656 payment_status: PaymentStatus::Paid,
657 approval_status: ApprovalStatus::Approved,
658 supplier: Some("Vivaqua".to_string()),
659 invoice_number: Some("INV-2025-001".to_string()),
660 account_code: Some("6100".to_string()),
661 created_at: Utc::now(),
662 updated_at: Utc::now(),
663 submitted_at: None,
664 approved_at: Some(Utc::now()),
665 approved_by: None,
666 rejection_reason: None,
667 contractor_report_id: None,
668 };
669
670 let result = service.generate_payment_entry(&expense, None, None).await;
671
672 assert!(result.is_ok());
673 let entry = result.unwrap();
674
675 assert_eq!(entry.lines.len(), 2);
677
678 assert!(entry.is_balanced());
680 assert_eq!(entry.total_debits(), dec!(1210));
681 assert_eq!(entry.total_credits(), dec!(1210));
682
683 let supplier_line = entry
685 .lines
686 .iter()
687 .find(|l| l.account_code == COMPTE_FOURNISSEURS)
688 .unwrap();
689 assert_eq!(supplier_line.debit, dec!(1210));
690
691 let bank_line = entry
692 .lines
693 .iter()
694 .find(|l| l.account_code == COMPTE_BANQUE)
695 .unwrap();
696 assert_eq!(bank_line.credit, dec!(1210));
697 }
698
699 #[tokio::test]
701 async fn test_encaissement_quote_part_genere_lecriture() {
702 let repo = Arc::new(MockJournalEntryRepository::new());
703 let service = ExpenseAccountingService::new(repo.clone());
704
705 let org_id = Uuid::new_v4();
706 let mut contribution = crate::domain::entities::OwnerContribution::new(
707 Uuid::new_v4(), org_id,
709 Uuid::new_v4(),
710 None,
711 "Charges Q3 2026".to_string(),
712 dec!(2000),
713 crate::domain::entities::ContributionType::Regular,
714 Utc::now(),
715 Some("700001".to_string()),
716 )
717 .unwrap();
718 contribution.mark_as_paid(
719 Utc::now(),
720 crate::domain::entities::ContributionPaymentMethod::BankTransfer,
721 Some("VIR-2026-42".to_string()),
722 );
723
724 let entry = service
725 .generate_contribution_receipt_entry(&contribution, None, None, None)
726 .await
727 .expect("écriture générée");
728
729 assert!(entry.is_balanced());
730 assert_eq!(entry.total_debits(), dec!(2000));
731 assert_eq!(entry.journal_type.as_deref(), Some("FIN"));
732 assert_eq!(entry.contribution_id, Some(contribution.id));
733
734 let banque = entry
736 .lines
737 .iter()
738 .find(|l| l.account_code == COMPTE_BANQUE)
739 .expect("ligne banque");
740 assert_eq!(banque.debit, dec!(2000));
741
742 let copro = entry
744 .lines
745 .iter()
746 .find(|l| l.account_code == COMPTE_COPROPRIETAIRES)
747 .expect("ligne copropriétaires");
748 assert_eq!(copro.credit, dec!(2000));
749
750 assert!(
753 !entry.lines.iter().any(|l| l.account_code.starts_with('7')),
754 "l'encaissement ne constate pas de produit"
755 );
756 }
757
758 #[tokio::test]
764 async fn test_encaissement_non_duplique() {
765 let repo = Arc::new(MockJournalEntryRepository::new());
766 let service = ExpenseAccountingService::new(repo.clone());
767
768 let org_id = Uuid::new_v4();
769 let mut contribution = crate::domain::entities::OwnerContribution::new(
770 Uuid::new_v4(), org_id,
772 Uuid::new_v4(),
773 None,
774 "Charges Q3 2026".to_string(),
775 dec!(2000),
776 crate::domain::entities::ContributionType::Regular,
777 Utc::now(),
778 Some("700001".to_string()),
779 )
780 .unwrap();
781 contribution.mark_as_paid(
782 Utc::now(),
783 crate::domain::entities::ContributionPaymentMethod::BankTransfer,
784 None,
785 );
786
787 service
788 .generate_contribution_receipt_entry(&contribution, None, None, None)
789 .await
790 .expect("première écriture");
791
792 let err = service
793 .generate_contribution_receipt_entry(&contribution, None, None, None)
794 .await
795 .expect_err("le doublon doit être refusé");
796 assert!(format!("{err}").contains("already exists"), "{err}");
797 }
798
799 #[test]
817 fn test_les_comptes_utilises_existent_dans_le_plan() {
818 let plan: Vec<&str> =
819 crate::application::use_cases::account_use_cases::get_belgian_pcmn_seed_data()
820 .into_iter()
821 .map(|(code, ..)| code)
822 .collect();
823
824 for compte in [
825 COMPTE_TVA_RECUPERABLE,
826 COMPTE_FOURNISSEURS,
827 COMPTE_BANQUE,
828 COMPTE_COPROPRIETAIRES,
829 ] {
830 assert!(
831 plan.contains(&compte),
832 "le compte {compte} est utilisé par la génération automatique \
833 mais absent du plan provisionné par `seed_belgian_pcmn` : \
834 la clé étrangère de `journal_entry_lines` rejettera l'écriture, \
835 et l'échec sera avalé par le `warn!` de l'appelant"
836 );
837 }
838 }
839}