1use crate::application::error::AppError;
19use crate::application::ports::journal_entry_repository::JournalEntryRepository;
20use crate::domain::entities::journal_entry::{JournalEntry, JournalEntryLine};
21use chrono::{DateTime, Utc};
22use rust_decimal::Decimal;
23use rust_decimal_macros::dec;
24use std::sync::Arc;
25use uuid::Uuid;
26
27pub struct JournalEntryUseCases {
28 journal_entry_repo: Arc<dyn JournalEntryRepository>,
29 building_repository: Option<Arc<dyn crate::application::ports::BuildingRepository>>,
35}
36
37impl JournalEntryUseCases {
38 pub fn new(journal_entry_repo: Arc<dyn JournalEntryRepository>) -> Self {
39 Self {
40 journal_entry_repo,
41 building_repository: None,
42 }
43 }
44
45 pub fn with_acp_resolution(
47 mut self,
48 building_repository: Arc<dyn crate::application::ports::BuildingRepository>,
49 ) -> Self {
50 self.building_repository = Some(building_repository);
51 self
52 }
53
54 async fn resoudre_lacp(&self, building_id: Option<Uuid>) -> Result<Uuid, AppError> {
73 let building_id = building_id.ok_or_else(|| {
74 AppError::Validation(
75 "Impossible de déterminer l'ACP : une écriture manuelle doit désigner un immeuble"
76 .to_string(),
77 )
78 })?;
79 let Some(building_repo) = &self.building_repository else {
80 return Err(AppError::Internal(
81 "Impossible de déterminer l'ACP : dépôt d'immeubles non câblé".to_string(),
82 ));
83 };
84 let building = building_repo
85 .find_by_id(building_id)
86 .await
87 .map_err(AppError::from)?
88 .ok_or_else(|| AppError::NotFound("Immeuble introuvable".to_string()))?;
89 Ok(building.acp_id)
90 }
91
92 #[allow(clippy::too_many_arguments)]
106 pub async fn create_manual_entry(
107 &self,
108 organization_id: Uuid,
109 building_id: Option<Uuid>,
110 journal_type: Option<String>,
111 entry_date: DateTime<Utc>,
112 description: Option<String>,
113 document_ref: Option<String>,
114 lines: Vec<(String, Decimal, Decimal, String)>, ) -> Result<JournalEntry, AppError> {
116 if let Some(ref jtype) = journal_type {
122 if !["ACH", "VEN", "FIN", "ODS"].contains(&jtype.as_str()) {
123 return Err(AppError::Validation(format!(
124 "Invalid journal type: {}. Must be one of: ACH (Purchases), VEN (Sales), FIN (Financial), ODS (Miscellaneous)",
125 jtype
126 )));
127 }
128 }
129
130 if lines.len() < 2 {
132 return Err(AppError::Validation(
133 "Journal entry must have at least 2 lines (debit and credit)".to_string(),
134 ));
135 }
136
137 let total_debit: Decimal = lines.iter().map(|(_, debit, _, _)| *debit).sum();
139 let total_credit: Decimal = lines.iter().map(|(_, _, credit, _)| *credit).sum();
140
141 if (total_debit - total_credit).abs() > dec!(0.01) {
142 return Err(AppError::Validation(format!(
143 "Journal entry is unbalanced: debits={:.2} credits={:.2}. Debits must equal credits.",
144 total_debit, total_credit
145 )));
146 }
147
148 let entry_id = Uuid::new_v4();
150
151 let mut journal_lines = Vec::new();
153 for (account_code, debit, credit, line_desc) in lines {
154 let line = JournalEntryLine {
155 id: Uuid::new_v4(),
156 journal_entry_id: entry_id,
157 organization_id,
158 account_code: account_code.clone(),
159 debit,
160 credit,
161 description: Some(line_desc),
162 created_at: Utc::now(),
163 };
164 journal_lines.push(line);
165 }
166
167 let acp_id = self.resoudre_lacp(building_id).await?;
171
172 let journal_entry = JournalEntry {
174 id: entry_id,
175 acp_id,
176 organization_id,
177 building_id,
178 entry_date,
179 description,
180 document_ref,
181 journal_type,
182 expense_id: None,
183 contribution_id: None,
184 lines: journal_lines.clone(),
185 created_at: Utc::now(),
186 updated_at: Utc::now(),
187 created_by: None,
188 };
189
190 self.journal_entry_repo
200 .create_manual_entry(&journal_entry, &journal_lines)
201 .await
202 .map_err(AppError::from)?;
203
204 Ok(journal_entry)
205 }
206
207 #[allow(clippy::too_many_arguments)]
218 pub async fn list_entries(
219 &self,
220 organization_id: Uuid,
221 building_id: Option<Uuid>,
222 journal_type: Option<String>,
223 start_date: Option<DateTime<Utc>>,
224 end_date: Option<DateTime<Utc>>,
225 limit: i64,
226 offset: i64,
227 ) -> Result<Vec<JournalEntry>, String> {
228 self.journal_entry_repo
229 .list_entries(
230 organization_id,
231 building_id,
232 journal_type,
233 start_date,
234 end_date,
235 limit,
236 offset,
237 )
238 .await
239 }
240
241 pub async fn get_entry_with_lines(
247 &self,
248 entry_id: Uuid,
249 organization_id: Uuid,
250 ) -> Result<(JournalEntry, Vec<JournalEntryLine>), String> {
251 let entry = self
252 .journal_entry_repo
253 .find_by_id(entry_id, organization_id)
254 .await?;
255
256 let lines = self
257 .journal_entry_repo
258 .find_lines_by_entry(entry_id, organization_id)
259 .await?;
260
261 Ok((entry, lines))
262 }
263
264 pub async fn delete_manual_entry(
272 &self,
273 entry_id: Uuid,
274 organization_id: Uuid,
275 ) -> Result<(), String> {
276 let entry = self
278 .journal_entry_repo
279 .find_by_id(entry_id, organization_id)
280 .await?;
281
282 if entry.expense_id.is_some() || entry.contribution_id.is_some() {
283 return Err(
284 "Cannot delete auto-generated journal entries. Only manual entries can be deleted."
285 .to_string(),
286 );
287 }
288
289 self.journal_entry_repo
290 .delete_entry(entry_id, organization_id)
291 .await
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use crate::application::ports::journal_entry_repository::JournalEntryRepository;
299 use crate::domain::entities::journal_entry::{JournalEntry, JournalEntryLine};
300 use async_trait::async_trait;
301 use std::collections::HashMap;
302 use std::sync::Mutex;
303
304 struct MockJournalEntryRepository {
307 entries: Mutex<HashMap<Uuid, JournalEntry>>,
308 lines: Mutex<HashMap<Uuid, Vec<JournalEntryLine>>>,
309 echoue_a_lecriture_comme_la_base: bool,
315 }
316
317 impl MockJournalEntryRepository {
318 fn new() -> Self {
319 Self {
320 entries: Mutex::new(HashMap::new()),
321 lines: Mutex::new(HashMap::new()),
322 echoue_a_lecriture_comme_la_base: false,
323 }
324 }
325
326 fn qui_echoue_comme_la_base() -> Self {
329 Self {
330 echoue_a_lecriture_comme_la_base: true,
331 ..Self::new()
332 }
333 }
334 }
335
336 #[async_trait]
337 impl JournalEntryRepository for MockJournalEntryRepository {
338 async fn create(&self, entry: &JournalEntry) -> Result<JournalEntry, String> {
339 let mut entries = self.entries.lock().unwrap();
340 entries.insert(entry.id, entry.clone());
341 let mut lines = self.lines.lock().unwrap();
342 lines.insert(entry.id, entry.lines.clone());
343 Ok(entry.clone())
344 }
345
346 async fn find_by_organization(
347 &self,
348 organization_id: Uuid,
349 ) -> Result<Vec<JournalEntry>, String> {
350 let entries = self.entries.lock().unwrap();
351 Ok(entries
352 .values()
353 .filter(|e| e.organization_id == organization_id)
354 .cloned()
355 .collect())
356 }
357
358 async fn find_by_expense(&self, expense_id: Uuid) -> Result<Vec<JournalEntry>, String> {
359 let entries = self.entries.lock().unwrap();
360 Ok(entries
361 .values()
362 .filter(|e| e.expense_id == Some(expense_id))
363 .cloned()
364 .collect())
365 }
366
367 async fn find_by_contribution(
368 &self,
369 contribution_id: Uuid,
370 ) -> Result<Vec<JournalEntry>, String> {
371 let entries = self.entries.lock().unwrap();
372 Ok(entries
373 .values()
374 .filter(|e| e.contribution_id == Some(contribution_id))
375 .cloned()
376 .collect())
377 }
378
379 async fn find_by_date_range(
380 &self,
381 organization_id: Uuid,
382 start_date: DateTime<Utc>,
383 end_date: DateTime<Utc>,
384 ) -> Result<Vec<JournalEntry>, String> {
385 let entries = self.entries.lock().unwrap();
386 Ok(entries
387 .values()
388 .filter(|e| {
389 e.organization_id == organization_id
390 && e.entry_date >= start_date
391 && e.entry_date <= end_date
392 })
393 .cloned()
394 .collect())
395 }
396
397 async fn calculate_account_balances(
398 &self,
399 _organization_id: Uuid,
400 ) -> Result<HashMap<String, Decimal>, String> {
401 Ok(HashMap::new())
402 }
403
404 async fn calculate_account_balances_for_period(
405 &self,
406 _organization_id: Uuid,
407 _start_date: DateTime<Utc>,
408 _end_date: DateTime<Utc>,
409 ) -> Result<HashMap<String, Decimal>, String> {
410 Ok(HashMap::new())
411 }
412
413 async fn find_lines_by_account(
414 &self,
415 _organization_id: Uuid,
416 _account_code: &str,
417 ) -> Result<Vec<JournalEntryLine>, String> {
418 Ok(Vec::new())
419 }
420
421 async fn validate_balance(&self, entry_id: Uuid) -> Result<bool, String> {
422 let entries = self.entries.lock().unwrap();
423 match entries.get(&entry_id) {
424 Some(entry) => Ok(entry.is_balanced()),
425 None => Err("Entry not found".to_string()),
426 }
427 }
428
429 async fn calculate_account_balances_for_building(
430 &self,
431 _organization_id: Uuid,
432 _building_id: Uuid,
433 ) -> Result<HashMap<String, Decimal>, String> {
434 Ok(HashMap::new())
435 }
436
437 async fn calculate_account_balances_for_building_and_period(
438 &self,
439 _organization_id: Uuid,
440 _building_id: Uuid,
441 _start_date: DateTime<Utc>,
442 _end_date: DateTime<Utc>,
443 ) -> Result<HashMap<String, Decimal>, String> {
444 Ok(HashMap::new())
445 }
446
447 async fn create_manual_entry(
448 &self,
449 entry: &JournalEntry,
450 entry_lines: &[JournalEntryLine],
451 ) -> Result<(), String> {
452 if self.echoue_a_lecriture_comme_la_base {
453 return Err("Failed to insert journal entry: insert or update on table \
454 \"journal_entry_lines\" violates foreign key constraint \
455 \"fk_account\""
456 .to_string());
457 }
458 let mut entries = self.entries.lock().unwrap();
459 entries.insert(entry.id, entry.clone());
460 let mut lines = self.lines.lock().unwrap();
461 lines.insert(entry.id, entry_lines.to_vec());
462 Ok(())
463 }
464
465 async fn list_entries(
466 &self,
467 organization_id: Uuid,
468 _building_id: Option<Uuid>,
469 _journal_type: Option<String>,
470 _start_date: Option<DateTime<Utc>>,
471 _end_date: Option<DateTime<Utc>>,
472 _limit: i64,
473 _offset: i64,
474 ) -> Result<Vec<JournalEntry>, String> {
475 let entries = self.entries.lock().unwrap();
476 Ok(entries
477 .values()
478 .filter(|e| e.organization_id == organization_id)
479 .cloned()
480 .collect())
481 }
482
483 async fn find_by_id(
484 &self,
485 entry_id: Uuid,
486 _organization_id: Uuid,
487 ) -> Result<JournalEntry, String> {
488 let entries = self.entries.lock().unwrap();
489 entries
490 .get(&entry_id)
491 .cloned()
492 .ok_or_else(|| "Journal entry not found".to_string())
493 }
494
495 async fn find_lines_by_entry(
496 &self,
497 entry_id: Uuid,
498 _organization_id: Uuid,
499 ) -> Result<Vec<JournalEntryLine>, String> {
500 let lines = self.lines.lock().unwrap();
501 Ok(lines.get(&entry_id).cloned().unwrap_or_default())
502 }
503
504 async fn delete_entry(&self, entry_id: Uuid, _organization_id: Uuid) -> Result<(), String> {
505 let mut entries = self.entries.lock().unwrap();
506 let mut lines = self.lines.lock().unwrap();
507 entries.remove(&entry_id);
508 lines.remove(&entry_id);
509 Ok(())
510 }
511 }
512
513 struct MockBuildingRepository {
520 acp_id: Uuid,
521 }
522
523 impl MockBuildingRepository {
524 fn immeuble_de(acp_id: Uuid) -> Self {
525 Self { acp_id }
526 }
527
528 fn immeuble(&self) -> crate::domain::entities::Building {
529 crate::domain::entities::Building::new(
530 self.acp_id,
531 "Résidence du Parc".to_string(),
532 "12 Rue de la Loi".to_string(),
533 "Brussels".to_string(),
534 "1000".to_string(),
535 "Belgium".to_string(),
536 10,
537 1000,
538 Some(2015),
539 )
540 .expect("immeuble valide")
541 }
542 }
543
544 #[async_trait::async_trait]
545 impl crate::application::ports::BuildingRepository for MockBuildingRepository {
546 async fn create(
547 &self,
548 b: &crate::domain::entities::Building,
549 ) -> Result<crate::domain::entities::Building, String> {
550 Ok(b.clone())
551 }
552 async fn find_by_id(
553 &self,
554 _id: Uuid,
555 ) -> Result<Option<crate::domain::entities::Building>, String> {
556 Ok(Some(self.immeuble()))
557 }
558 async fn find_all(&self) -> Result<Vec<crate::domain::entities::Building>, String> {
559 Ok(vec![self.immeuble()])
560 }
561 async fn find_all_paginated(
562 &self,
563 _p: &crate::application::dto::PageRequest,
564 _f: &crate::application::dto::BuildingFilters,
565 ) -> Result<(Vec<crate::domain::entities::Building>, i64), String> {
566 Ok((vec![self.immeuble()], 1))
567 }
568 async fn update(
569 &self,
570 b: &crate::domain::entities::Building,
571 ) -> Result<crate::domain::entities::Building, String> {
572 Ok(b.clone())
573 }
574 async fn delete(&self, _id: Uuid) -> Result<bool, String> {
575 Ok(true)
576 }
577 async fn find_by_slug(
578 &self,
579 _s: &str,
580 ) -> Result<Option<crate::domain::entities::Building>, String> {
581 Ok(Some(self.immeuble()))
582 }
583 async fn find_by_id_with_metrics(
584 &self,
585 _id: Uuid,
586 ) -> Result<
587 Option<(
588 crate::domain::entities::Building,
589 crate::domain::entities::BuildingMetrics,
590 )>,
591 String,
592 > {
593 Ok(None)
594 }
595 }
596
597 fn make_use_cases(repo: MockJournalEntryRepository) -> JournalEntryUseCases {
598 make_use_cases_pour_lacp(repo, Uuid::new_v4())
599 }
600
601 fn make_use_cases_pour_lacp(
603 repo: MockJournalEntryRepository,
604 acp_id: Uuid,
605 ) -> JournalEntryUseCases {
606 JournalEntryUseCases::new(Arc::new(repo))
607 .with_acp_resolution(Arc::new(MockBuildingRepository::immeuble_de(acp_id)))
608 }
609
610 fn balanced_lines() -> Vec<(String, Decimal, Decimal, String)> {
612 vec![
613 (
614 "6100".to_string(),
615 dec!(1000),
616 Decimal::ZERO,
617 "Utilities expense".to_string(),
618 ),
619 (
620 "4400".to_string(),
621 Decimal::ZERO,
622 dec!(1000),
623 "Supplier payable".to_string(),
624 ),
625 ]
626 }
627
628 #[tokio::test]
634 async fn test_lecriture_sinscrit_dans_les_livres_de_lacp_pas_du_syndic() {
635 let acp_des_livres = Uuid::new_v4();
636 let cabinet_qui_saisit = Uuid::new_v4();
637 let uc = make_use_cases_pour_lacp(MockJournalEntryRepository::new(), acp_des_livres);
638
639 let ecriture = uc
640 .create_manual_entry(
641 cabinet_qui_saisit,
642 Some(Uuid::new_v4()),
643 Some("ODS".to_string()),
644 Utc::now(),
645 Some("Régularisation".to_string()),
646 None,
647 balanced_lines(),
648 )
649 .await
650 .expect("écriture valide");
651
652 assert_eq!(
653 ecriture.acp_id, acp_des_livres,
654 "l'écriture s'inscrit dans les livres de l'ACP de l'immeuble"
655 );
656 assert_eq!(
657 ecriture.organization_id, cabinet_qui_saisit,
658 "le syndic reste tracé comme auteur de la saisie"
659 );
660 }
661
662 #[tokio::test]
669 async fn test_pas_decriture_sans_livres_identifiables() {
670 let uc = make_use_cases(MockJournalEntryRepository::new());
671
672 let erreur = uc
673 .create_manual_entry(
674 Uuid::new_v4(),
675 None, Some("ODS".to_string()),
677 Utc::now(),
678 Some("Régularisation".to_string()),
679 None,
680 balanced_lines(),
681 )
682 .await
683 .expect_err("doit refuser");
684
685 assert!(
690 matches!(erreur, AppError::Validation(ref msg) if msg.contains("ACP")),
691 "le refus doit être une erreur de VALIDATION nommant ce qui manque : {erreur:?}"
692 );
693 }
694
695 #[tokio::test]
700 async fn test_le_desequilibre_est_signale_avant_la_resolution_de_lacp() {
701 let uc = make_use_cases(MockJournalEntryRepository::new());
702
703 let erreur = uc
704 .create_manual_entry(
705 Uuid::new_v4(),
706 None, Some("ODS".to_string()),
708 Utc::now(),
709 None,
710 None,
711 vec![
712 ("600".to_string(), dec!(100), dec!(0), "débit".to_string()),
713 ("440".to_string(), dec!(0), dec!(50), "crédit".to_string()),
714 ],
715 )
716 .await
717 .expect_err("doit refuser");
718
719 assert!(
722 matches!(erreur, AppError::Validation(ref msg) if msg.contains("unbalanced")),
723 "…c'est le déséquilibre qui doit être signalé, pas l'ACP : {erreur:?}"
724 );
725 }
726
727 #[tokio::test]
728 async fn test_create_manual_entry_success_balanced() {
729 let repo = MockJournalEntryRepository::new();
730 let uc = make_use_cases(repo);
731 let org_id = Uuid::new_v4();
732
733 let result = uc
734 .create_manual_entry(
735 org_id,
736 Some(Uuid::new_v4()), Some("ACH".to_string()),
738 Utc::now(),
739 Some("Facture eau janvier".to_string()),
740 Some("INV-2026-001".to_string()),
741 balanced_lines(),
742 )
743 .await;
744
745 assert!(result.is_ok());
746 let entry = result.unwrap();
747 assert_eq!(entry.organization_id, org_id);
748 assert_eq!(entry.journal_type, Some("ACH".to_string()));
749 assert_eq!(entry.description, Some("Facture eau janvier".to_string()));
750 assert_eq!(entry.document_ref, Some("INV-2026-001".to_string()));
751 assert!(entry.expense_id.is_none());
752 assert!(entry.contribution_id.is_none());
753 assert_eq!(entry.lines.len(), 2);
754 }
755
756 #[tokio::test]
760 async fn test_create_manual_entry_fail_unbalanced() {
761 let repo = MockJournalEntryRepository::new();
762 let uc = make_use_cases(repo);
763 let org_id = Uuid::new_v4();
764
765 let unbalanced_lines = vec![
766 (
767 "6100".to_string(),
768 dec!(1000),
769 Decimal::ZERO,
770 "Debit".to_string(),
771 ),
772 (
773 "4400".to_string(),
774 Decimal::ZERO,
775 dec!(800),
776 "Credit".to_string(),
777 ),
778 ];
779
780 let result = uc
781 .create_manual_entry(
782 org_id,
783 None,
784 Some("ACH".to_string()),
785 Utc::now(),
786 Some("Test unbalanced".to_string()),
787 None,
788 unbalanced_lines,
789 )
790 .await;
791
792 assert!(result.is_err());
793 let err = result.unwrap_err();
794 assert!(matches!(err, AppError::Validation(_)), "{err:?}");
798 let msg = err.to_string();
799 assert!(msg.contains("unbalanced"));
800 assert!(msg.contains("debits=1000.00"));
801 assert!(msg.contains("credits=800.00"));
802 }
803
804 #[tokio::test]
805 async fn test_create_manual_entry_fail_invalid_journal_type() {
806 let repo = MockJournalEntryRepository::new();
807 let uc = make_use_cases(repo);
808 let org_id = Uuid::new_v4();
809
810 let result = uc
811 .create_manual_entry(
812 org_id,
813 None,
814 Some("INVALID".to_string()),
815 Utc::now(),
816 Some("Test invalid type".to_string()),
817 None,
818 balanced_lines(),
819 )
820 .await;
821
822 assert!(result.is_err());
823 let err = result.unwrap_err();
824 assert!(matches!(err, AppError::Validation(_)), "{err:?}");
826 let msg = err.to_string();
827 assert!(msg.contains("Invalid journal type: INVALID"));
828 assert!(msg.contains("ACH"));
829 assert!(msg.contains("VEN"));
830 assert!(msg.contains("FIN"));
831 assert!(msg.contains("ODS"));
832 }
833
834 #[tokio::test]
840 async fn test_create_manual_entry_fail_less_than_2_lines() {
841 let repo = MockJournalEntryRepository::new();
842 let uc = make_use_cases(repo);
843 let org_id = Uuid::new_v4();
844
845 let single_line = vec![(
846 "6100".to_string(),
847 dec!(1000),
848 Decimal::ZERO,
849 "Only debit".to_string(),
850 )];
851
852 let result = uc
853 .create_manual_entry(
854 org_id,
855 None,
856 Some("ODS".to_string()),
857 Utc::now(),
858 Some("Test single line".to_string()),
859 None,
860 single_line,
861 )
862 .await;
863
864 assert!(result.is_err());
865 let err = result.unwrap_err();
866 assert!(matches!(err, AppError::Validation(_)), "{err:?}");
867 assert!(err.to_string().contains("must have at least 2 lines"));
868 }
869
870 #[tokio::test]
871 async fn test_delete_manual_entry_success() {
872 let repo = MockJournalEntryRepository::new();
873 let uc = make_use_cases(repo);
874 let org_id = Uuid::new_v4();
875
876 let created = uc
878 .create_manual_entry(
879 org_id,
880 Some(Uuid::new_v4()), Some("FIN".to_string()),
882 Utc::now(),
883 Some("Manual entry to delete".to_string()),
884 None,
885 balanced_lines(),
886 )
887 .await
888 .unwrap();
889
890 let result = uc.delete_manual_entry(created.id, org_id).await;
892 assert!(result.is_ok());
893
894 let find_result = uc.get_entry_with_lines(created.id, org_id).await;
896 assert!(find_result.is_err());
897 }
898
899 #[tokio::test]
900 async fn test_delete_manual_entry_fail_auto_generated_with_expense_id() {
901 let repo = MockJournalEntryRepository::new();
902 let org_id = Uuid::new_v4();
903 let entry_id = Uuid::new_v4();
904 let expense_id = Uuid::new_v4();
905
906 {
908 let mut entries = repo.entries.lock().unwrap();
909 let auto_entry = JournalEntry {
910 acp_id: Uuid::new_v4(),
911 id: entry_id,
912 organization_id: org_id,
913 building_id: None,
914 entry_date: Utc::now(),
915 description: Some("Auto-generated from expense".to_string()),
916 document_ref: None,
917 journal_type: Some("ACH".to_string()),
918 expense_id: Some(expense_id),
919 contribution_id: None,
920 lines: Vec::new(),
921 created_at: Utc::now(),
922 updated_at: Utc::now(),
923 created_by: None,
924 };
925 entries.insert(entry_id, auto_entry);
926 }
927
928 let uc = make_use_cases(repo);
929
930 let result = uc.delete_manual_entry(entry_id, org_id).await;
931
932 assert!(result.is_err());
933 assert!(result
934 .unwrap_err()
935 .contains("Cannot delete auto-generated journal entries"));
936 }
937
938 #[tokio::test]
947 async fn negative_762_echec_de_depot_ne_devient_pas_une_validation_qui_exposerait_le_message_brut(
948 ) {
949 let uc = make_use_cases(MockJournalEntryRepository::qui_echoue_comme_la_base());
950 let org_id = Uuid::new_v4();
951
952 let erreur = uc
953 .create_manual_entry(
954 org_id,
955 Some(Uuid::new_v4()),
956 Some("ACH".to_string()),
957 Utc::now(),
958 Some("Test échec dépôt".to_string()),
959 None,
960 balanced_lines(),
961 )
962 .await
963 .expect_err("le dépôt doit échouer");
964
965 assert!(
966 matches!(erreur, AppError::Internal(_)),
967 "un échec de dépôt (base, contrainte SQL) reste interne — jamais \
968 une catégorie déduite du contenu de son message : {erreur:?}"
969 );
970 assert!(
971 !matches!(
972 erreur,
973 AppError::Validation(_) | AppError::Conflict(_) | AppError::NotFound(_)
974 ),
975 "ces variantes affichent leur contenu tel quel au client : le \
976 message brut de la base y fuirait : {erreur:?}"
977 );
978 }
979}