Skip to main content

koprogo_api/infrastructure/database/repositories/
fund_repository_impl.rs

1//! PostgreSQL implementation of [`FundRepository`] (issue #635, ADR-0054).
2//!
3//! All `sqlx::Error` paths are wrapped in `AppError::Database(_)` — no
4//! `Result<_, String>` debt (CRITICAL.md #4 / #555).
5
6use crate::application::error::AppError;
7use crate::application::ports::FundRepository;
8use crate::domain::entities::{Fund, FundKind, FundReassignment};
9use crate::infrastructure::database::pool::DbPool;
10use async_trait::async_trait;
11use sqlx::Row;
12use uuid::Uuid;
13
14pub struct PostgresFundRepository {
15    pool: DbPool,
16}
17
18impl PostgresFundRepository {
19    pub fn new(pool: DbPool) -> Self {
20        Self { pool }
21    }
22
23    fn kind_str(kind: FundKind) -> &'static str {
24        match kind {
25            FundKind::WorkingCapital => "working_capital",
26            FundKind::Reserve => "reserve",
27            FundKind::Earmarked => "earmarked",
28        }
29    }
30
31    fn row_to_fund(row: &sqlx::postgres::PgRow) -> Fund {
32        let kind_str: String = row.get("kind");
33        let kind = match kind_str.as_str() {
34            "reserve" => FundKind::Reserve,
35            "earmarked" => FundKind::Earmarked,
36            _ => FundKind::WorkingCapital,
37        };
38        Fund {
39            id: row.get("id"),
40            acp_id: row.get("acp_id"),
41            kind,
42            name: row.get("name"),
43            purpose: row.try_get("purpose").ok(),
44            target_amount: row.try_get("target_amount").ok(),
45            balance: row.get("balance"),
46            created_at: row.get("created_at"),
47            updated_at: row.get("updated_at"),
48        }
49    }
50
51    fn row_to_reassignment(row: &sqlx::postgres::PgRow) -> FundReassignment {
52        FundReassignment {
53            id: row.get("id"),
54            fund_id: row.get("fund_id"),
55            previous_purpose: row.get("previous_purpose"),
56            new_purpose: row.get("new_purpose"),
57            amount: row.get("amount"),
58            resolution_id: row.get("resolution_id"),
59            reassigned_at: row.get("reassigned_at"),
60        }
61    }
62}
63
64#[async_trait]
65impl FundRepository for PostgresFundRepository {
66    async fn create(&self, fund: &Fund) -> Result<Fund, AppError> {
67        sqlx::query(
68            r#"
69            INSERT INTO funds (
70                id, acp_id, kind, name, purpose, target_amount, balance,
71                created_at, updated_at
72            )
73            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
74            "#,
75        )
76        .bind(fund.id)
77        .bind(fund.acp_id)
78        .bind(Self::kind_str(fund.kind))
79        .bind(&fund.name)
80        .bind(&fund.purpose)
81        .bind(fund.target_amount)
82        .bind(fund.balance)
83        .bind(fund.created_at)
84        .bind(fund.updated_at)
85        .execute(&self.pool)
86        .await
87        .map_err(|e| AppError::Database(e.to_string()))?;
88
89        Ok(fund.clone())
90    }
91
92    async fn find_by_id(&self, id: Uuid) -> Result<Option<Fund>, AppError> {
93        let row = sqlx::query(
94            r#"
95            SELECT id, acp_id, kind, name, purpose, target_amount, balance,
96                   created_at, updated_at
97            FROM funds
98            WHERE id = $1
99            "#,
100        )
101        .bind(id)
102        .fetch_optional(&self.pool)
103        .await
104        .map_err(|e| AppError::Database(e.to_string()))?;
105
106        Ok(row.map(|r| Self::row_to_fund(&r)))
107    }
108
109    async fn find_by_acp_id(&self, acp_id: Uuid) -> Result<Vec<Fund>, AppError> {
110        let rows = sqlx::query(
111            r#"
112            SELECT id, acp_id, kind, name, purpose, target_amount, balance,
113                   created_at, updated_at
114            FROM funds
115            WHERE acp_id = $1
116            ORDER BY created_at ASC
117            "#,
118        )
119        .bind(acp_id)
120        .fetch_all(&self.pool)
121        .await
122        .map_err(|e| AppError::Database(e.to_string()))?;
123
124        Ok(rows.iter().map(Self::row_to_fund).collect())
125    }
126
127    async fn update(&self, fund: &Fund) -> Result<Fund, AppError> {
128        sqlx::query(
129            r#"
130            UPDATE funds
131            SET name = $2,
132                purpose = $3,
133                target_amount = $4,
134                balance = $5,
135                updated_at = $6
136            WHERE id = $1
137            "#,
138        )
139        .bind(fund.id)
140        .bind(&fund.name)
141        .bind(&fund.purpose)
142        .bind(fund.target_amount)
143        .bind(fund.balance)
144        .bind(fund.updated_at)
145        .execute(&self.pool)
146        .await
147        .map_err(|e| AppError::Database(e.to_string()))?;
148
149        Ok(fund.clone())
150    }
151
152    async fn record_reassignment(&self, reassignment: &FundReassignment) -> Result<(), AppError> {
153        sqlx::query(
154            r#"
155            INSERT INTO fund_reassignments (
156                id, fund_id, previous_purpose, new_purpose, amount,
157                resolution_id, reassigned_at
158            )
159            VALUES ($1, $2, $3, $4, $5, $6, $7)
160            "#,
161        )
162        .bind(reassignment.id)
163        .bind(reassignment.fund_id)
164        .bind(&reassignment.previous_purpose)
165        .bind(&reassignment.new_purpose)
166        .bind(reassignment.amount)
167        .bind(reassignment.resolution_id)
168        .bind(reassignment.reassigned_at)
169        .execute(&self.pool)
170        .await
171        .map_err(|e| AppError::Database(e.to_string()))?;
172
173        Ok(())
174    }
175
176    async fn list_reassignments(&self, fund_id: Uuid) -> Result<Vec<FundReassignment>, AppError> {
177        let rows = sqlx::query(
178            r#"
179            SELECT id, fund_id, previous_purpose, new_purpose, amount,
180                   resolution_id, reassigned_at
181            FROM fund_reassignments
182            WHERE fund_id = $1
183            ORDER BY reassigned_at DESC
184            "#,
185        )
186        .bind(fund_id)
187        .fetch_all(&self.pool)
188        .await
189        .map_err(|e| AppError::Database(e.to_string()))?;
190
191        Ok(rows.iter().map(Self::row_to_reassignment).collect())
192    }
193}