Skip to main content

koprogo_api/application/services/
expense_accounting_service.rs

1// Application Service: Expense Accounting Service
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// Auto-generates double-entry journal entries from expense transactions
11
12use 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
20/// Service for automatically generating journal entries from expenses
21///
22/// This service implements Belgian accounting logic based on PCMN (AR 12/07/2012):
23/// - Expense creates debit to expense account (class 6)
24/// - VAT creates debit to VAT recoverable account (4110)
25/// - Total amount creates credit to supplier account (4400)
26///
27/// Inspired by Noalyss' automatic journal entry generation
28pub struct ExpenseAccountingService {
29    journal_entry_repo: Arc<dyn JournalEntryRepository>,
30}
31
32// ── Comptes du PCMN utilisés par la génération automatique ──────────────────
33//
34// Ces codes DOIVENT exister dans le plan comptable provisionné par
35// `AccountUseCases::seed_belgian_pcmn`. `journal_entry_lines` porte une clé
36// étrangère `(organization_id, account_code) → accounts` : un code absent fait
37// échouer l'insertion.
38//
39// Ils étaient auparavant écrits en dur dans le corps des méthodes, sur QUATRE
40// chiffres — `4110`, `4400`, `5500` — alors que le plan en compte trois :
41// `411`, `440`, `550`. Aucune écriture automatique n'a donc jamais pu être
42// enregistrée contre une organisation correctement provisionnée. Et personne
43// ne l'a vu : les deux appelants (`approve_invoice` et `mark_as_paid`)
44// journalisent l'échec en `warn!` et poursuivent, pour ne pas faire échouer
45// une approbation à cause d'une écriture comptable.
46//
47// C'est ce que décrit le constat F7 du rapport du 2026-09-01 — non pas « la
48// saisie est 100 % manuelle », mais « l'automatisation existe et échoue en
49// silence ».
50//
51// `test_les_comptes_utilises_existent_dans_le_plan` verrouille la
52// correspondance.
53
54/// TVA récupérable (classe 4).
55const COMPTE_TVA_RECUPERABLE: &str = "411";
56/// Fournisseurs (classe 4) — dette envers le prestataire.
57const COMPTE_FOURNISSEURS: &str = "440";
58/// Compte courant bancaire (classe 5).
59const COMPTE_BANQUE: &str = "550";
60/// Copropriétaires — appels de fonds (classe 4) : la créance sur le
61/// copropriétaire, soldée quand sa quote-part est encaissée.
62const 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    /// Generate journal entry for an expense
70    ///
71    /// # Belgian Accounting Logic (PCMN)
72    ///
73    /// Example: 1,000€ HT + 210€ VAT (21%) = 1,210€ TTC
74    ///
75    /// ```text
76    /// Debit:  6100 (Expense account)     1,000.00€
77    /// Debit:  4110 (VAT Recoverable)       210.00€
78    /// Credit: 4400 (Suppliers)           1,210.00€
79    /// ```
80    ///
81    /// # Arguments
82    /// - `expense`: The expense to generate journal entry for
83    /// - `created_by`: User who created the expense
84    ///
85    /// # Returns
86    /// - `Ok(JournalEntry)` if generation successful
87    /// - `Err(String)` if validation fails or expense has no account_code
88    pub async fn generate_journal_entry_for_expense(
89        &self,
90        expense: &Expense,
91        created_by: Option<Uuid>,
92    ) -> Result<JournalEntry, String> {
93        // Idempotence : une seule écriture d'achat par dépense.
94        //
95        // `approve_invoice` refuse déjà d'approuver deux fois, mais la garde
96        // vaut aussi pour tout autre appelant — le seed, une reprise de
97        // données, un futur endpoint de régénération.
98        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        // Validate expense has account code
106        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        // Calculate amounts
112        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        // Create journal entry lines
117        let mut lines = Vec::new();
118        let entry_id = Uuid::new_v4();
119
120        // Line 1: Debit expense account (class 6)
121        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        // Line 2: Debit VAT recoverable (4110) if VAT > 0
133        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                    // `vat_rate` est DÉJÀ un pourcentage (21.0 pour 21 %,
141                    // cf. `Expense::vat_rate` et sa validation `0..=100`). Le
142                    // multiplier par 100 écrivait « TVA récupérable 2100% »
143                    // sur chaque pièce comptable générée.
144                    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        // Line 3: Credit supplier account (4400)
154        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        // Create journal entry
169        let journal_entry = JournalEntry::new(
170            expense.acp_id,
171            expense.organization_id,
172            Some(expense.building_id), // building_id
173            expense.expense_date,
174            Some(format!("{} - {:?}", expense.description, expense.category)),
175            expense.invoice_number.clone(), // Use invoice number as document ref
176            Some("ACH".to_string()),        // journal_type: ACH (Purchases/Achats)
177            Some(expense.id),
178            None, // contribution_id
179            lines,
180            created_by,
181        )
182        .map_err(|e| format!("Failed to create journal entry: {}", e))?;
183
184        // Persist to database
185        self.journal_entry_repo
186            .create(&journal_entry)
187            .await
188            .map_err(|e| format!("Failed to persist journal entry: {}", e))
189    }
190
191    /// Generate journal entry for expense payment
192    ///
193    /// When an expense is paid, we record the payment:
194    ///
195    /// ```text
196    /// Debit:  4400 (Suppliers)           1,210.00€
197    /// Credit: 5500 (Bank)                1,210.00€
198    /// ```
199    ///
200    /// # Arguments
201    /// - `expense`: The expense being paid
202    /// - `payment_account`: Account used for payment (default: 5500 Bank)
203    /// - `created_by`: User who recorded the payment
204    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        // Idempotence : une seule écriture de règlement par dépense.
211        //
212        // `unpay_expense` ramène une dépense de `Paid` à `Pending` SANS
213        // contre-passer l'écriture déjà passée. Un cycle payer → dépayer →
214        // repayer créditait donc la banque deux fois pour un seul règlement,
215        // et le compte fournisseur ne se soldait jamais.
216        //
217        // Contre-passer serait le traitement comptable complet, mais c'est une
218        // décision de méthode : une écriture passée ne se supprime pas, elle
219        // s'annule par une écriture inverse datée. En attendant cet arbitrage,
220        // refuser le doublon vaut mieux que fausser les comptes.
221        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        // Line 1: Debit supplier (reduce liability)
235        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        // Line 2: Credit bank/cash (reduce asset)
247        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        // Create journal entry
266        let journal_entry = JournalEntry::new(
267            expense.acp_id,
268            expense.organization_id,
269            Some(expense.building_id), // building_id
270            expense.paid_date.unwrap_or_else(Utc::now),
271            Some(format!("Paiement: {}", expense.description)),
272            expense.invoice_number.clone(),
273            Some("FIN".to_string()), // journal_type: FIN (Financial/Financier)
274            Some(expense.id),
275            None, // contribution_id
276            lines,
277            created_by,
278        )
279        .map_err(|e| format!("Failed to create payment journal entry: {}", e))?;
280
281        // Persist to database
282        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    /// Génère l'écriture d'ENCAISSEMENT d'une quote-part de copropriétaire.
289    ///
290    /// # Logique comptable (PCMN belge)
291    ///
292    /// Exemple : quote-part de 2 000 € reçue par virement.
293    ///
294    /// ```text
295    /// Débit :  550 (Compte courant bancaire)   2 000,00 €
296    /// Crédit : 400 (Copropriétaires)           2 000,00 €
297    /// ```
298    ///
299    /// L'argent entre (actif en hausse), la créance sur le copropriétaire
300    /// s'éteint (actif en baisse). Journal FIN.
301    ///
302    /// # Ce que cette écriture n'est PAS
303    ///
304    /// Elle ne constate pas le PRODUIT. L'appel de fonds est un produit de
305    /// classe 7 constaté à son émission, pas à son encaissement ; cette
306    /// écriture-ci ne fait que solder une créance déjà comptabilisée. Générer
307    /// un produit ici le compterait deux fois.
308    ///
309    /// # Idempotence
310    ///
311    /// Une quote-part peut être soldée par deux voies : `mark-paid` depuis
312    /// l'interface, ou la réussite d'un paiement portant `contribution_id`.
313    /// Sans ce contrôle, un même encaissement débiterait la banque deux fois.
314    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    /// Check if expense already has journal entries
380    ///
381    /// Prevents duplicate journal entries for the same expense.
382    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    /// Une écriture d'un journal donné existe-t-elle déjà pour cette dépense ?
388    ///
389    /// Distingue l'achat (ACH, généré à l'approbation) du règlement (FIN,
390    /// généré au paiement) : `expense_has_journal_entries` ne le pouvait pas,
391    /// et c'est pour cela qu'elle n'a jamais servi malgré son commentaire
392    /// « Prevents duplicate journal entries ».
393    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    /// Get journal entries for an expense
405    ///
406    /// Returns all entries (expense entry + payment entry if paid).
407    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    // Mock repository for testing
421    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        // Other methods not needed for tests
463        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),                // Total TTC
573            amount_excl_vat: Some(dec!(1000)), // HT
574            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        // Should have 3 lines: expense debit, VAT debit, supplier credit
604        assert_eq!(entry.lines.len(), 3);
605
606        // Verify balances
607        assert!(entry.is_balanced());
608        assert_eq!(entry.total_debits(), dec!(1210));
609        assert_eq!(entry.total_credits(), dec!(1210));
610
611        // Verify line details
612        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        // Should have 2 lines: supplier debit, bank credit
676        assert_eq!(entry.lines.len(), 2);
677
678        // Verify balances
679        assert!(entry.is_balanced());
680        assert_eq!(entry.total_debits(), dec!(1210));
681        assert_eq!(entry.total_credits(), dec!(1210));
682
683        // Verify line details
684        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    /// F7 — l'encaissement d'une quote-part produit son écriture.
700    #[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(), // acp_id
708            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        // D 550 (banque) : l'argent entre.
735        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        // C 400 (copropriétaires) : la créance s'éteint.
743        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        // Aucune ligne de classe 7 : l'appel de fonds est un produit constaté à
751        // son ÉMISSION. En constater un ici le compterait deux fois.
752        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    /// @edge — une quote-part peut être soldée par deux voies : la banque ne
759    /// doit être débitée qu'une fois.
760    ///
761    /// `mark-paid` depuis l'interface, ou la réussite d'un paiement portant
762    /// `contribution_id` : les deux appellent ce service.
763    #[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(), // acp_id
771            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    /// Le test qui aurait évité le défaut F7.
800    ///
801    /// `journal_entry_lines` porte une clé étrangère
802    /// `(organization_id, account_code) → accounts`. Un code absent du plan
803    /// comptable fait échouer l'insertion — et les deux appelants
804    /// (`approve_invoice`, `mark_as_paid`) journalisent l'échec en `warn!`
805    /// avant de poursuivre, pour ne pas faire échouer une approbation à cause
806    /// d'une écriture. L'automatisation pouvait donc ne JAMAIS produire une
807    /// seule écriture sans que rien ne s'en aperçoive.
808    ///
809    /// C'est ce qui s'est passé : le service référençait `4110`, `4400`,
810    /// `5500` et `5700` là où le plan provisionne `411`, `440` et `550`.
811    ///
812    /// Les deux tests ci-dessus ne pouvaient pas le voir : ils vérifient la
813    /// forme de l'écriture contre un dépôt simulé, sans base ni contrainte.
814    /// Une écriture parfaitement équilibrée sur des comptes inexistants leur
815    /// paraît correcte.
816    #[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}