Skip to main content

koprogo_api/application/use_cases/
journal_entry_use_cases.rs

1// Use Cases: Journal Entry (Manual Accounting Operations)
2//
3// CREDITS & ATTRIBUTION:
4// This implementation is inspired by the Noalyss project (https://gitlab.com/noalyss/noalyss)
5// Noalyss is a free accounting software for Belgian and French accounting
6// License: GPL-2.0-or-later (GNU General Public License version 2 or later)
7// Copyright: (C) 1989, 1991 Free Software Foundation, Inc.
8// Copyright: Dany De Bontridder <dany@alchimerys.eu>
9//
10// Noalyss features that inspired this implementation:
11// - Journal types (ACH=Purchases, VEN=Sales, FIN=Financial, ODS=Miscellaneous)
12// - Double-entry bookkeeping with debit/credit columns
13// - Quick codes for account selection
14// - Automatic balance validation
15//
16// Use cases for manual journal entry creation and retrieval
17
18use 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    /// Résolution de l'ACP dont on tient les comptes.
30    ///
31    /// Optionnel pour ne pas casser les constructeurs des tests, mais son
32    /// absence fait échouer la saisie : une écriture sans ACP est une écriture
33    /// dans les livres de personne (ADR-0045).
34    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    /// Câble la résolution de l'ACP depuis l'immeuble.
46    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    /// L'ACP dont on tient les comptes, résolue depuis l'immeuble.
55    ///
56    /// Art. 3.89 § 5, 15° : le syndic tient les comptes *de l'association*.
57    /// Une saisie manuelle qui ne désigne pas d'immeuble ne dit pas dans quels
58    /// livres elle s'inscrit — on refuse plutôt que d'écrire au hasard.
59    ///
60    /// Limite connue : une ACP à plusieurs immeubles (Art. 3.84) peut avoir des
61    /// écritures qui ne se rattachent à aucun bloc. Elles ne sont pas encore
62    /// saisissables par cette voie ; il faudra désigner l'ACP directement.
63    ///
64    /// #762 : le résultat porte sa catégorie dans le TYPE (`AppError`), pas
65    /// dans un motif à chercher dans le message. Une saisie sans immeuble et
66    /// un immeuble inexistant sont tous deux des erreurs d'entrée client,
67    /// mais de nature différente (`Validation` : rien n'a été désigné ;
68    /// `NotFound` : ce qui a été désigné n'existe pas) — la distinction ne
69    /// se lit plus a posteriori dans le texte, elle est faite une fois ici.
70    /// Seul le câblage manquant (`building_repository` absent, une erreur de
71    /// configuration et non de saisie) reste `Internal`.
72    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    /// Create a manual journal entry with multiple lines
93    ///
94    /// This follows the Noalyss approach where each journal entry can have multiple lines
95    /// with debit and credit columns. The total debits must equal total credits.
96    ///
97    /// # Arguments
98    /// * `organization_id` - Organization ID
99    /// * `building_id` - Optional building ID for building-specific entries
100    /// * `journal_type` - Type of journal (ACH, VEN, FIN, ODS)
101    /// * `entry_date` - Date of the accounting operation
102    /// * `description` - Description of the operation
103    /// * `reference` - Optional reference number (invoice, receipt, etc.)
104    /// * `lines` - Vector of journal entry lines with account_code, debit, credit, description
105    #[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)>, // (account_code, debit, credit, line_description)
115    ) -> Result<JournalEntry, AppError> {
116        // #762 : chaque refus est un `AppError::Validation` — une erreur de
117        // saisie typée, pas une String que le gestionnaire HTTP devrait
118        // ensuite reclasser en devinant sur son contenu.
119        //
120        // Validate journal type if provided (inspired by Noalyss journal types)
121        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        // Validate that we have at least 2 lines (double-entry principle)
131        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        // Calculate totals and validate balance (Noalyss principle)
138        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        // Create journal entry ID
149        let entry_id = Uuid::new_v4();
150
151        // Create journal entry lines
152        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        // L'ACP est résolue APRÈS les validations de forme : une écriture
168        // déséquilibrée ou à une seule ligne doit échouer sur son motif, pas
169        // sur une résolution d'ACP qu'elle n'aurait jamais dû atteindre.
170        let acp_id = self.resoudre_lacp(building_id).await?;
171
172        // Create journal entry
173        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        // Save to repository
191        //
192        // #762 @security : le dépôt (Postgres) renvoie encore `Result<_,
193        // String>` — hors du périmètre borné de cette story, qui porte sur
194        // le CLASSEMENT dans le gestionnaire, pas sur la migration complète
195        // du port (#555). Mais on ne relit pas ce message pour le classer :
196        // `AppError::from(String)` le range en `Internal`, qu'`error_response`
197        // masque avant de répondre au client (jamais de nom de contrainte
198        // SQL ni de table renvoyé tel quel).
199        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    /// List journal entries for an organization
208    ///
209    /// # Arguments
210    /// * `organization_id` - Organization ID
211    /// * `building_id` - Optional building ID filter
212    /// * `journal_type` - Optional journal type filter
213    /// * `start_date` - Optional start date filter
214    /// * `end_date` - Optional end date filter
215    /// * `limit` - Maximum number of entries to return
216    /// * `offset` - Number of entries to skip
217    #[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    /// Get a single journal entry with its lines
242    ///
243    /// # Arguments
244    /// * `entry_id` - Journal entry ID
245    /// * `organization_id` - Organization ID for authorization
246    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    /// Delete a manual journal entry
265    ///
266    /// Only manual entries (not auto-generated from expenses/contributions) can be deleted.
267    ///
268    /// # Arguments
269    /// * `entry_id` - Journal entry ID
270    /// * `organization_id` - Organization ID for authorization
271    pub async fn delete_manual_entry(
272        &self,
273        entry_id: Uuid,
274        organization_id: Uuid,
275    ) -> Result<(), String> {
276        // Check if entry exists and is manual
277        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    // ========== Mock Repository ==========
305
306    struct MockJournalEntryRepository {
307        entries: Mutex<HashMap<Uuid, JournalEntry>>,
308        lines: Mutex<HashMap<Uuid, Vec<JournalEntryLine>>>,
309        /// #762 @security : simule un dépôt (Postgres) qui échoue à
310        /// l'écriture avec un message brut de contrainte SQL — le genre de
311        /// message que ce dépôt renvoie réellement aujourd'hui (voir
312        /// `journal_entry_repository_impl.rs::create_manual_entry`,
313        /// `format!("Failed to insert journal entry: {}", e)`).
314        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        /// #762 @security : un dépôt qui échoue comme Postgres échoue —
327        /// avec un message qui nomme une contrainte et une table.
328        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    // ========== Helpers ==========
514
515    /// Dépôt d'immeubles minimal : un immeuble rattaché à une ACP nommée.
516    ///
517    /// Il n'existait pas ici parce que l'écriture ne cherchait pas ses livres.
518    /// Elle les cherche désormais (ADR-0045).
519    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    /// Les mêmes use-cases, en nommant l'ACP dont on tient les comptes.
602    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    /// Balanced lines: 1000 debit on 6100, 1000 credit on 4400
611    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    // ========== Tests ==========
629
630    /// Art. 3.89 § 5, 15° et ADR-0045 : le grand livre est celui de l'ACP.
631    ///
632    /// L'ACP se lit sur l'immeuble, jamais sur l'appelant.
633    #[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    /// @negative — Issue #762, cas constaté le 2026-09-04 : une écriture qui
663    /// ne désigne pas d'immeuble ne dit pas dans quels livres elle s'inscrit.
664    /// Le refus, en français (« Impossible de déterminer l'ACP… »), ne
665    /// correspondait à aucun motif anglais cherché par le gestionnaire HTTP
666    /// et ressortait en 500. Il est maintenant typé `AppError::Validation` :
667    /// sa langue n'a plus d'incidence sur le code retourné.
668    #[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, // aucun immeuble
676                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        // #762 : la catégorie de l'erreur (400, saisie incomplète) tient au
686        // TYPE `AppError::Validation`, pas à un mot cherché dans le message.
687        // Le message reste utile pour l'humain ; ce n'est plus sur lui que
688        // le gestionnaire HTTP s'appuie pour choisir le code.
689        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    /// Une écriture mal formée échoue sur son motif, pas sur l'ACP.
696    ///
697    /// L'ordre compte : si la résolution passait avant les validations de
698    /// forme, un déséquilibre remonterait comme un problème de rattachement.
699    #[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, // pas d'immeuble non plus, et pourtant…
707                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        // #762 : même remarque — le TYPE dit déjà « validation », le message
720        // ne sert plus qu'à l'humain qui lit la réponse.
721        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()), // l'écriture désigne l'immeuble dont elle relève
737                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    /// @happy — Issue #762 : le chemin nominal d'une erreur applicative
757    /// typée. Le déséquilibre remonte en `AppError::Validation`, la variante
758    /// que le futur gestionnaire HTTP traduira en 400 sans lire le message.
759    #[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        // #762 : le déséquilibre est une erreur de VALIDATION typée — plus
795        // une String que le gestionnaire HTTP devrait reclasser en devinant
796        // sur "unbalanced".
797        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        // #762 : type de journal invalide → VALIDATION typée, jamais Internal.
825        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    /// @edge — Issue #762 : avant cette story, ce cas n'était même pas dans
835    /// la liste de motifs `.contains()` reconnue par le gestionnaire HTTP —
836    /// il tombait en 500 par défaut, sans qu'on l'ait jamais remarqué. Le
837    /// TYPE (`AppError::Validation`) suffit désormais, sans qu'il ait fallu
838    /// l'y ajouter nommément : c'est tout le point d'une erreur typée.
839    #[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        // First create a manual entry
877        let created = uc
878            .create_manual_entry(
879                org_id,
880                Some(Uuid::new_v4()), // l'écriture désigne l'immeuble dont elle relève
881                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        // Delete it
891        let result = uc.delete_manual_entry(created.id, org_id).await;
892        assert!(result.is_ok());
893
894        // Verify it was deleted (find_by_id should fail)
895        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        // Insert an auto-generated entry (has expense_id set)
907        {
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    /// @security — Issue #762 : classer par sous-chaîne (`foreign key`,
939    /// `violates`) suppose qu'on LIT le message brut de la base — donc qu'on
940    /// est à un `?` près de le renvoyer tel quel au client. Un échec de
941    /// dépôt (contrainte SQL, table interne) doit devenir `AppError::Internal`,
942    /// que `AppError::error_response()` masque avant de répondre («
943    /// Internal server error », voir application/error.rs) — jamais une
944    /// variante qui affiche son contenu tel quel (`Validation`, `Conflict`,
945    /// `NotFound`), ce qui exposerait le nom de la contrainte et de la table.
946    #[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}