1use 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 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 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 let mut tx = self
69 .pool
70 .begin()
71 .await
72 .map_err(|e| format!("Failed to begin transaction: {}", e))?;
73
74 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 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 tx.commit()
124 .await
125 .map_err(|e| format!("Failed to commit transaction: {}", e))?;
126
127 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 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 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 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 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 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 } else {
418 credit - debit };
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 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 } else {
479 credit - debit };
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 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 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 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 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 let mut tx = self
658 .pool
659 .begin()
660 .await
661 .map_err(|e| format!("Failed to begin transaction: {}", e))?;
662
663 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 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 tx.commit()
695 .await
696 .map_err(|e| format!("Failed to commit transaction: {}", e))?;
697
698 Ok(())
699 }
700}
701
702#[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}