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