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 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 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 tx.commit()
126 .await
127 .map_err(|e| format!("Failed to commit transaction: {}", e))?;
128
129 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 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 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 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 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 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 } else {
397 credit - debit };
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 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 } else {
452 credit - debit };
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 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 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 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 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 let mut tx = self
631 .pool
632 .begin()
633 .await
634 .map_err(|e| format!("Failed to begin transaction: {}", e))?;
635
636 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 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 tx.commit()
668 .await
669 .map_err(|e| format!("Failed to commit transaction: {}", e))?;
670
671 Ok(())
672 }
673}
674
675#[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}