Skip to main content

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