koprogo_api/infrastructure/database/repositories/
journal_entry_repository_impl.rs

1// Infrastructure: PostgreSQL Journal Entry Repository Implementation
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
10use crate::application::ports::JournalEntryRepository;
11use crate::domain::entities::{JournalEntry, JournalEntryLine};
12use async_trait::async_trait;
13use chrono::{DateTime, Utc};
14use sqlx::PgPool;
15use std::collections::HashMap;
16use uuid::Uuid;
17
18pub struct PostgresJournalEntryRepository {
19    pool: PgPool,
20}
21
22impl PostgresJournalEntryRepository {
23    pub fn new(pool: PgPool) -> Self {
24        Self { pool }
25    }
26
27    /// Load all lines for a journal entry
28    async fn load_lines(&self, journal_entry_id: Uuid) -> Result<Vec<JournalEntryLine>, String> {
29        let lines = sqlx::query_as!(
30            JournalEntryLineRow,
31            r#"
32            SELECT
33                id,
34                journal_entry_id,
35                organization_id,
36                account_code,
37                debit,
38                credit,
39                description,
40                created_at
41            FROM journal_entry_lines
42            WHERE journal_entry_id = $1
43            ORDER BY created_at
44            "#,
45            journal_entry_id
46        )
47        .fetch_all(&self.pool)
48        .await
49        .map_err(|e| format!("Failed to load journal entry lines: {}", e))?;
50
51        Ok(lines.into_iter().map(Into::into).collect())
52    }
53}
54
55#[async_trait]
56impl JournalEntryRepository for PostgresJournalEntryRepository {
57    async fn create(&self, entry: &JournalEntry) -> Result<JournalEntry, String> {
58        // Validate entry is balanced before inserting
59        if !entry.is_balanced() {
60            return Err(format!(
61                "Journal entry is unbalanced: debits={:.2}€ credits={:.2}€",
62                entry.total_debits(),
63                entry.total_credits()
64            ));
65        }
66
67        // Start transaction
68        let mut tx = self
69            .pool
70            .begin()
71            .await
72            .map_err(|e| format!("Failed to begin transaction: {}", e))?;
73
74        // Insert journal entry
75        let entry_row = sqlx::query_as!(
76            JournalEntryRow,
77            r#"
78            INSERT INTO journal_entries (
79                organization_id, building_id, entry_date, description, document_ref,
80                journal_type, expense_id, contribution_id, created_by
81            )
82            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
83            RETURNING id, organization_id, building_id, entry_date, description, document_ref,
84                      journal_type, expense_id, contribution_id, created_at, updated_at, created_by
85            "#,
86            entry.organization_id,
87            entry.building_id,
88            entry.entry_date,
89            entry.description,
90            entry.document_ref,
91            entry.journal_type,
92            entry.expense_id,
93            entry.contribution_id,
94            entry.created_by
95        )
96        .fetch_one(&mut *tx)
97        .await
98        .map_err(|e| format!("Failed to insert journal entry: {}", e))?;
99
100        // Insert journal entry lines
101        for line in &entry.lines {
102            sqlx::query!(
103                r#"
104                INSERT INTO journal_entry_lines (
105                    journal_entry_id, organization_id, account_code,
106                    debit, credit, description
107                )
108                VALUES ($1, $2, $3, $4, $5, $6)
109                "#,
110                entry_row.id,
111                line.organization_id,
112                line.account_code,
113                rust_decimal::Decimal::from_f64_retain(line.debit).unwrap_or_default(),
114                rust_decimal::Decimal::from_f64_retain(line.credit).unwrap_or_default(),
115                line.description
116            )
117            .execute(&mut *tx)
118            .await
119            .map_err(|e| format!("Failed to insert journal entry line: {}", e))?;
120        }
121
122        // Commit transaction (database trigger validates balance)
123        tx.commit()
124            .await
125            .map_err(|e| format!("Failed to commit transaction: {}", e))?;
126
127        // Load lines to return complete entry
128        let lines = self.load_lines(entry_row.id).await?;
129
130        Ok(entry_row.into_journal_entry(lines))
131    }
132
133    async fn find_by_organization(
134        &self,
135        organization_id: Uuid,
136    ) -> Result<Vec<JournalEntry>, String> {
137        let entry_rows = sqlx::query_as!(
138            JournalEntryRow,
139            r#"
140            SELECT
141                id, organization_id, building_id, entry_date, description, document_ref,
142                journal_type, expense_id, contribution_id, created_at, updated_at, created_by
143            FROM journal_entries
144            WHERE organization_id = $1
145            ORDER BY entry_date DESC, created_at DESC
146            "#,
147            organization_id
148        )
149        .fetch_all(&self.pool)
150        .await
151        .map_err(|e| format!("Failed to find journal entries: {}", e))?;
152
153        let mut entries = Vec::new();
154        for row in entry_rows {
155            let lines = self.load_lines(row.id).await?;
156            entries.push(row.into_journal_entry(lines));
157        }
158
159        Ok(entries)
160    }
161
162    async fn find_by_expense(&self, expense_id: Uuid) -> Result<Vec<JournalEntry>, String> {
163        let entry_rows = sqlx::query_as!(
164            JournalEntryRow,
165            r#"
166            SELECT
167                id, organization_id, building_id, entry_date, description, document_ref,
168                journal_type, expense_id, contribution_id, created_at, updated_at, created_by
169            FROM journal_entries
170            WHERE expense_id = $1
171            ORDER BY entry_date DESC, created_at DESC
172            "#,
173            expense_id
174        )
175        .fetch_all(&self.pool)
176        .await
177        .map_err(|e| format!("Failed to find journal entries for expense: {}", e))?;
178
179        let mut entries = Vec::new();
180        for row in entry_rows {
181            let lines = self.load_lines(row.id).await?;
182            entries.push(row.into_journal_entry(lines));
183        }
184
185        Ok(entries)
186    }
187
188    async fn find_by_date_range(
189        &self,
190        organization_id: Uuid,
191        start_date: DateTime<Utc>,
192        end_date: DateTime<Utc>,
193    ) -> Result<Vec<JournalEntry>, String> {
194        let entry_rows = sqlx::query_as!(
195            JournalEntryRow,
196            r#"
197            SELECT
198                id, organization_id, building_id, entry_date, description, document_ref,
199                journal_type, expense_id, contribution_id, created_at, updated_at, created_by
200            FROM journal_entries
201            WHERE organization_id = $1
202              AND entry_date >= $2
203              AND entry_date <= $3
204            ORDER BY entry_date, created_at
205            "#,
206            organization_id,
207            start_date,
208            end_date
209        )
210        .fetch_all(&self.pool)
211        .await
212        .map_err(|e| format!("Failed to find journal entries by date range: {}", e))?;
213
214        let mut entries = Vec::new();
215        for row in entry_rows {
216            let lines = self.load_lines(row.id).await?;
217            entries.push(row.into_journal_entry(lines));
218        }
219
220        Ok(entries)
221    }
222
223    async fn calculate_account_balances(
224        &self,
225        organization_id: Uuid,
226    ) -> Result<HashMap<String, f64>, String> {
227        // Use the account_balances view created in migration
228        let balances = sqlx::query!(
229            r#"
230            SELECT account_code, balance
231            FROM account_balances
232            WHERE organization_id = $1
233            "#,
234            organization_id
235        )
236        .fetch_all(&self.pool)
237        .await
238        .map_err(|e| format!("Failed to calculate account balances: {}", e))?;
239
240        let mut result = HashMap::new();
241        for row in balances {
242            if let Some(code) = row.account_code {
243                result.insert(
244                    code,
245                    row.balance
246                        .map(|b| b.to_string().parse::<f64>().unwrap_or(0.0))
247                        .unwrap_or(0.0),
248                );
249            }
250        }
251
252        Ok(result)
253    }
254
255    async fn calculate_account_balances_for_period(
256        &self,
257        organization_id: Uuid,
258        start_date: DateTime<Utc>,
259        end_date: DateTime<Utc>,
260    ) -> Result<HashMap<String, f64>, String> {
261        // Similar to calculate_account_balances but filtered by date
262        let balances = sqlx::query!(
263            r#"
264            SELECT
265                jel.account_code,
266                a.account_type as "account_type: String",
267                SUM(jel.debit) as total_debit,
268                SUM(jel.credit) as total_credit
269            FROM journal_entry_lines jel
270            JOIN journal_entries je ON je.id = jel.journal_entry_id
271            JOIN accounts a ON a.organization_id = jel.organization_id
272                           AND a.code = jel.account_code
273            WHERE jel.organization_id = $1
274              AND je.entry_date >= $2
275              AND je.entry_date <= $3
276            GROUP BY jel.account_code, a.account_type
277            "#,
278            organization_id,
279            start_date,
280            end_date
281        )
282        .fetch_all(&self.pool)
283        .await
284        .map_err(|e| format!("Failed to calculate account balances for period: {}", e))?;
285
286        let mut result = HashMap::new();
287        for row in balances {
288            let total_debit = row
289                .total_debit
290                .map(|d| d.to_string().parse::<f64>().unwrap_or(0.0))
291                .unwrap_or(0.0);
292            let total_credit = row
293                .total_credit
294                .map(|c| c.to_string().parse::<f64>().unwrap_or(0.0))
295                .unwrap_or(0.0);
296
297            // Calculate balance based on account type
298            let balance = match row.account_type.as_str() {
299                "ASSET" | "EXPENSE" => total_debit - total_credit,
300                "LIABILITY" | "REVENUE" => total_credit - total_debit,
301                _ => 0.0,
302            };
303
304            result.insert(row.account_code, balance);
305        }
306
307        Ok(result)
308    }
309
310    async fn find_lines_by_account(
311        &self,
312        organization_id: Uuid,
313        account_code: &str,
314    ) -> Result<Vec<JournalEntryLine>, String> {
315        let lines = sqlx::query_as!(
316            JournalEntryLineRow,
317            r#"
318            SELECT
319                jel.id,
320                jel.journal_entry_id,
321                jel.organization_id,
322                jel.account_code,
323                jel.debit,
324                jel.credit,
325                jel.description,
326                jel.created_at
327            FROM journal_entry_lines jel
328            WHERE jel.organization_id = $1
329              AND jel.account_code = $2
330            ORDER BY jel.created_at
331            "#,
332            organization_id,
333            account_code
334        )
335        .fetch_all(&self.pool)
336        .await
337        .map_err(|e| format!("Failed to find lines by account: {}", e))?;
338
339        Ok(lines.into_iter().map(Into::into).collect())
340    }
341
342    async fn validate_balance(&self, entry_id: Uuid) -> Result<bool, String> {
343        let result = sqlx::query!(
344            r#"
345            SELECT
346                SUM(debit) as total_debits,
347                SUM(credit) as total_credits
348            FROM journal_entry_lines
349            WHERE journal_entry_id = $1
350            "#,
351            entry_id
352        )
353        .fetch_one(&self.pool)
354        .await
355        .map_err(|e| format!("Failed to validate balance: {}", e))?;
356
357        let total_debits = result
358            .total_debits
359            .map(|d| d.to_string().parse::<f64>().unwrap_or(0.0))
360            .unwrap_or(0.0);
361        let total_credits = result
362            .total_credits
363            .map(|c| c.to_string().parse::<f64>().unwrap_or(0.0))
364            .unwrap_or(0.0);
365
366        Ok((total_debits - total_credits).abs() <= 0.01)
367    }
368
369    async fn calculate_account_balances_for_building(
370        &self,
371        organization_id: Uuid,
372        building_id: Uuid,
373    ) -> Result<HashMap<String, f64>, String> {
374        // Calculate balances from journal entries linked to expenses or contributions for this building
375        let rows = sqlx::query!(
376            r#"
377            SELECT
378                jel.account_code,
379                SUM(jel.debit) as total_debit,
380                SUM(jel.credit) as total_credit
381            FROM journal_entry_lines jel
382            JOIN journal_entries je ON jel.journal_entry_id = je.id
383            LEFT JOIN expenses e ON je.expense_id = e.id
384            WHERE jel.organization_id = $1
385              AND (e.building_id = $2 OR e.building_id IS NULL)
386            GROUP BY jel.account_code
387            "#,
388            organization_id,
389            building_id
390        )
391        .fetch_all(&self.pool)
392        .await
393        .map_err(|e| format!("Failed to calculate building balances: {}", e))?;
394
395        let mut balances = HashMap::new();
396        for row in rows {
397            let debit = row
398                .total_debit
399                .map(|d| d.to_string().parse::<f64>().unwrap_or(0.0))
400                .unwrap_or(0.0);
401            let credit = row
402                .total_credit
403                .map(|c| c.to_string().parse::<f64>().unwrap_or(0.0))
404                .unwrap_or(0.0);
405
406            // For expenses/assets: positive balance = debit - credit
407            // For revenue/liabilities: positive balance = credit - debit
408            // We'll determine this based on account class
409            let account_code = &row.account_code;
410            let balance = if account_code.starts_with('6')
411                || account_code.starts_with('2')
412                || account_code.starts_with('3')
413                || account_code.starts_with('4')
414                || account_code.starts_with('5')
415            {
416                debit - credit // Assets/Expenses
417            } else {
418                credit - debit // Liabilities/Revenue (class 1, 7)
419            };
420
421            balances.insert(row.account_code.clone(), balance);
422        }
423
424        Ok(balances)
425    }
426
427    async fn calculate_account_balances_for_building_and_period(
428        &self,
429        organization_id: Uuid,
430        building_id: Uuid,
431        start_date: DateTime<Utc>,
432        end_date: DateTime<Utc>,
433    ) -> Result<HashMap<String, f64>, String> {
434        // Calculate balances from journal entries linked to expenses or contributions for this building and period
435        let rows = sqlx::query!(
436            r#"
437            SELECT
438                jel.account_code,
439                SUM(jel.debit) as total_debit,
440                SUM(jel.credit) as total_credit
441            FROM journal_entry_lines jel
442            JOIN journal_entries je ON jel.journal_entry_id = je.id
443            LEFT JOIN expenses e ON je.expense_id = e.id
444            WHERE jel.organization_id = $1
445              AND (e.building_id = $2 OR e.building_id IS NULL)
446              AND je.entry_date >= $3
447              AND je.entry_date <= $4
448            GROUP BY jel.account_code
449            "#,
450            organization_id,
451            building_id,
452            start_date,
453            end_date
454        )
455        .fetch_all(&self.pool)
456        .await
457        .map_err(|e| format!("Failed to calculate building period balances: {}", e))?;
458
459        let mut balances = HashMap::new();
460        for row in rows {
461            let debit = row
462                .total_debit
463                .map(|d| d.to_string().parse::<f64>().unwrap_or(0.0))
464                .unwrap_or(0.0);
465            let credit = row
466                .total_credit
467                .map(|c| c.to_string().parse::<f64>().unwrap_or(0.0))
468                .unwrap_or(0.0);
469
470            let account_code = &row.account_code;
471            let balance = if account_code.starts_with('6')
472                || account_code.starts_with('2')
473                || account_code.starts_with('3')
474                || account_code.starts_with('4')
475                || account_code.starts_with('5')
476            {
477                debit - credit // Assets/Expenses
478            } else {
479                credit - debit // Liabilities/Revenue
480            };
481
482            balances.insert(row.account_code.clone(), balance);
483        }
484
485        Ok(balances)
486    }
487
488    async fn create_manual_entry(
489        &self,
490        entry: &JournalEntry,
491        lines: &[JournalEntryLine],
492    ) -> Result<(), String> {
493        // Start transaction with deferred constraints
494        let mut tx = self
495            .pool
496            .begin()
497            .await
498            .map_err(|e| format!("Failed to begin transaction: {}", e))?;
499
500        sqlx::query("SET CONSTRAINTS ALL DEFERRED")
501            .execute(&mut *tx)
502            .await
503            .map_err(|e| format!("Failed to defer constraints: {}", e))?;
504
505        // Insert journal entry header
506        sqlx::query!(
507            r#"
508            INSERT INTO journal_entries (
509                id, organization_id, building_id, entry_date, description,
510                document_ref, journal_type, expense_id, contribution_id, created_at, updated_at, created_by
511            )
512            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
513            "#,
514            entry.id,
515            entry.organization_id,
516            entry.building_id,
517            entry.entry_date,
518            entry.description,
519            entry.document_ref,
520            entry.journal_type,
521            entry.expense_id,
522            entry.contribution_id,
523            entry.created_at,
524            entry.updated_at,
525            entry.created_by
526        )
527        .execute(&mut *tx)
528        .await
529        .map_err(|e| format!("Failed to insert journal entry: {}", e))?;
530
531        // Insert journal entry lines
532        for line in lines {
533            sqlx::query!(
534                r#"
535                INSERT INTO journal_entry_lines (
536                    id, journal_entry_id, organization_id, account_code,
537                    debit, credit, description, created_at
538                )
539                VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
540                "#,
541                line.id,
542                line.journal_entry_id,
543                line.organization_id,
544                line.account_code,
545                rust_decimal::Decimal::from_f64_retain(line.debit).unwrap_or_default(),
546                rust_decimal::Decimal::from_f64_retain(line.credit).unwrap_or_default(),
547                line.description,
548                line.created_at
549            )
550            .execute(&mut *tx)
551            .await
552            .map_err(|e| format!("Failed to insert journal entry line: {}", e))?;
553        }
554
555        // Commit transaction
556        tx.commit()
557            .await
558            .map_err(|e| format!("Failed to commit transaction: {}", e))?;
559
560        Ok(())
561    }
562
563    async fn list_entries(
564        &self,
565        organization_id: Uuid,
566        building_id: Option<Uuid>,
567        journal_type: Option<String>,
568        start_date: Option<DateTime<Utc>>,
569        end_date: Option<DateTime<Utc>>,
570        limit: i64,
571        offset: i64,
572    ) -> Result<Vec<JournalEntry>, String> {
573        let rows = sqlx::query_as!(
574            JournalEntryRow,
575            r#"
576            SELECT
577                id, organization_id, building_id, entry_date, description,
578                document_ref, journal_type, expense_id, contribution_id, created_at, updated_at, created_by
579            FROM journal_entries
580            WHERE organization_id = $1
581              AND ($2::uuid IS NULL OR building_id = $2)
582              AND ($3::text IS NULL OR journal_type = $3)
583              AND ($4::timestamptz IS NULL OR entry_date >= $4)
584              AND ($5::timestamptz IS NULL OR entry_date <= $5)
585            ORDER BY entry_date DESC, created_at DESC
586            LIMIT $6 OFFSET $7
587            "#,
588            organization_id,
589            building_id,
590            journal_type,
591            start_date,
592            end_date,
593            limit,
594            offset
595        )
596        .fetch_all(&self.pool)
597        .await
598        .map_err(|e| format!("Failed to list journal entries: {}", e))?;
599
600        Ok(rows
601            .into_iter()
602            .map(|row| row.into_journal_entry(vec![]))
603            .collect())
604    }
605
606    async fn find_by_id(
607        &self,
608        entry_id: Uuid,
609        organization_id: Uuid,
610    ) -> Result<JournalEntry, String> {
611        let row = sqlx::query_as!(
612            JournalEntryRow,
613            r#"
614            SELECT
615                id, organization_id, building_id, entry_date, description,
616                document_ref, journal_type, expense_id, contribution_id, created_at, updated_at, created_by
617            FROM journal_entries
618            WHERE id = $1 AND organization_id = $2
619            "#,
620            entry_id,
621            organization_id
622        )
623        .fetch_one(&self.pool)
624        .await
625        .map_err(|e| format!("Journal entry not found: {}", e))?;
626
627        Ok(row.into_journal_entry(vec![]))
628    }
629
630    async fn find_lines_by_entry(
631        &self,
632        entry_id: Uuid,
633        organization_id: Uuid,
634    ) -> Result<Vec<JournalEntryLine>, String> {
635        let rows = sqlx::query_as!(
636            JournalEntryLineRow,
637            r#"
638            SELECT
639                id, journal_entry_id, organization_id, account_code,
640                debit, credit, description, created_at
641            FROM journal_entry_lines
642            WHERE journal_entry_id = $1 AND organization_id = $2
643            ORDER BY created_at ASC
644            "#,
645            entry_id,
646            organization_id
647        )
648        .fetch_all(&self.pool)
649        .await
650        .map_err(|e| format!("Failed to fetch journal entry lines: {}", e))?;
651
652        Ok(rows.into_iter().map(JournalEntryLine::from).collect())
653    }
654
655    async fn delete_entry(&self, entry_id: Uuid, organization_id: Uuid) -> Result<(), String> {
656        // Start transaction
657        let mut tx = self
658            .pool
659            .begin()
660            .await
661            .map_err(|e| format!("Failed to begin transaction: {}", e))?;
662
663        // Delete journal entry lines first (foreign key constraint)
664        sqlx::query!(
665            r#"
666            DELETE FROM journal_entry_lines
667            WHERE journal_entry_id = $1 AND organization_id = $2
668            "#,
669            entry_id,
670            organization_id
671        )
672        .execute(&mut *tx)
673        .await
674        .map_err(|e| format!("Failed to delete journal entry lines: {}", e))?;
675
676        // Delete journal entry
677        let result = sqlx::query!(
678            r#"
679            DELETE FROM journal_entries
680            WHERE id = $1 AND organization_id = $2
681            "#,
682            entry_id,
683            organization_id
684        )
685        .execute(&mut *tx)
686        .await
687        .map_err(|e| format!("Failed to delete journal entry: {}", e))?;
688
689        if result.rows_affected() == 0 {
690            return Err("Journal entry not found or already deleted".to_string());
691        }
692
693        // Commit transaction
694        tx.commit()
695            .await
696            .map_err(|e| format!("Failed to commit transaction: {}", e))?;
697
698        Ok(())
699    }
700}
701
702// Database row structs
703#[derive(Debug)]
704struct JournalEntryRow {
705    id: Uuid,
706    organization_id: Uuid,
707    building_id: Option<Uuid>,
708    entry_date: DateTime<Utc>,
709    description: Option<String>,
710    document_ref: Option<String>,
711    journal_type: Option<String>,
712    expense_id: Option<Uuid>,
713    contribution_id: Option<Uuid>,
714    created_at: DateTime<Utc>,
715    updated_at: DateTime<Utc>,
716    created_by: Option<Uuid>,
717}
718
719impl JournalEntryRow {
720    fn into_journal_entry(self, lines: Vec<JournalEntryLine>) -> JournalEntry {
721        JournalEntry {
722            id: self.id,
723            organization_id: self.organization_id,
724            building_id: self.building_id,
725            entry_date: self.entry_date,
726            description: self.description,
727            document_ref: self.document_ref,
728            journal_type: self.journal_type,
729            expense_id: self.expense_id,
730            contribution_id: self.contribution_id,
731            lines,
732            created_at: self.created_at,
733            updated_at: self.updated_at,
734            created_by: self.created_by,
735        }
736    }
737}
738
739#[derive(Debug)]
740struct JournalEntryLineRow {
741    id: Uuid,
742    journal_entry_id: Uuid,
743    organization_id: Uuid,
744    account_code: String,
745    debit: rust_decimal::Decimal,
746    credit: rust_decimal::Decimal,
747    description: Option<String>,
748    created_at: DateTime<Utc>,
749}
750
751impl From<JournalEntryLineRow> for JournalEntryLine {
752    fn from(row: JournalEntryLineRow) -> Self {
753        Self {
754            id: row.id,
755            journal_entry_id: row.journal_entry_id,
756            organization_id: row.organization_id,
757            account_code: row.account_code,
758            debit: row.debit.to_string().parse::<f64>().unwrap_or(0.0),
759            credit: row.credit.to_string().parse::<f64>().unwrap_or(0.0),
760            description: row.description,
761            created_at: row.created_at,
762        }
763    }
764}