koprogo_api/infrastructure/database/repositories/
charge_distribution_repository_impl.rs1use crate::application::ports::ChargeDistributionRepository;
2use crate::domain::entities::ChargeDistribution;
3use async_trait::async_trait;
4use chrono::{DateTime, Utc};
5use sqlx::PgPool;
6use uuid::Uuid;
7
8pub struct PostgresChargeDistributionRepository {
13 pool: PgPool,
14}
15
16impl PostgresChargeDistributionRepository {
17 pub fn new(pool: PgPool) -> Self {
18 Self { pool }
19 }
20}
21
22#[async_trait]
23impl ChargeDistributionRepository for PostgresChargeDistributionRepository {
24 async fn create(
25 &self,
26 distribution: &ChargeDistribution,
27 ) -> Result<ChargeDistribution, String> {
28 let result = sqlx::query_as::<_, ChargeDistributionRow>(
29 r#"
30 INSERT INTO charge_distributions (
31 id, expense_id, unit_id, owner_id, quota_percentage, amount_due, created_at
32 )
33 VALUES ($1, $2, $3, $4, $5, $6, $7)
34 RETURNING id, expense_id, unit_id, owner_id, quota_percentage, amount_due, created_at
35 "#,
36 )
37 .bind(distribution.id)
38 .bind(distribution.expense_id)
39 .bind(distribution.unit_id)
40 .bind(distribution.owner_id)
41 .bind(
42 rust_decimal::Decimal::from_f64_retain(distribution.quota_percentage)
43 .unwrap_or(rust_decimal::Decimal::ZERO),
44 )
45 .bind(
46 rust_decimal::Decimal::from_f64_retain(distribution.amount_due)
47 .unwrap_or(rust_decimal::Decimal::ZERO),
48 )
49 .bind(distribution.created_at)
50 .fetch_one(&self.pool)
51 .await
52 .map_err(|e| format!("Failed to create charge distribution: {}", e))?;
53
54 Ok(result.into_entity())
55 }
56
57 async fn create_bulk(
58 &self,
59 distributions: &[ChargeDistribution],
60 ) -> Result<Vec<ChargeDistribution>, String> {
61 if distributions.is_empty() {
62 return Ok(Vec::new());
63 }
64
65 let mut tx = self
67 .pool
68 .begin()
69 .await
70 .map_err(|e| format!("Failed to begin transaction: {}", e))?;
71
72 let mut created = Vec::new();
73
74 for dist in distributions {
75 let result = sqlx::query_as::<_, ChargeDistributionRow>(
76 r#"
77 INSERT INTO charge_distributions (
78 id, expense_id, unit_id, owner_id, quota_percentage, amount_due, created_at
79 )
80 VALUES ($1, $2, $3, $4, $5, $6, $7)
81 RETURNING id, expense_id, unit_id, owner_id, quota_percentage, amount_due, created_at
82 "#
83 )
84 .bind(dist.id)
85 .bind(dist.expense_id)
86 .bind(dist.unit_id)
87 .bind(dist.owner_id)
88 .bind(rust_decimal::Decimal::from_f64_retain(dist.quota_percentage).unwrap_or(rust_decimal::Decimal::ZERO))
89 .bind(rust_decimal::Decimal::from_f64_retain(dist.amount_due).unwrap_or(rust_decimal::Decimal::ZERO))
90 .bind(dist.created_at)
91 .fetch_one(&mut *tx)
92 .await
93 .map_err(|e| format!("Failed to create charge distribution in bulk: {}", e))?;
94
95 created.push(result.into_entity());
96 }
97
98 tx.commit()
99 .await
100 .map_err(|e| format!("Failed to commit transaction: {}", e))?;
101
102 Ok(created)
103 }
104
105 async fn find_by_id(&self, id: Uuid) -> Result<Option<ChargeDistribution>, String> {
106 let result = sqlx::query_as::<_, ChargeDistributionRow>(
107 r#"
108 SELECT id, expense_id, unit_id, owner_id, quota_percentage, amount_due, created_at
109 FROM charge_distributions
110 WHERE id = $1
111 "#,
112 )
113 .bind(id)
114 .fetch_optional(&self.pool)
115 .await
116 .map_err(|e| format!("Failed to find charge distribution by id: {}", e))?;
117
118 Ok(result.map(|r| r.into_entity()))
119 }
120
121 async fn find_by_expense(&self, expense_id: Uuid) -> Result<Vec<ChargeDistribution>, String> {
122 let results = sqlx::query_as::<_, ChargeDistributionRow>(
123 r#"
124 SELECT id, expense_id, unit_id, owner_id, quota_percentage, amount_due, created_at
125 FROM charge_distributions
126 WHERE expense_id = $1
127 ORDER BY created_at DESC
128 "#,
129 )
130 .bind(expense_id)
131 .fetch_all(&self.pool)
132 .await
133 .map_err(|e| format!("Failed to find charge distributions by expense: {}", e))?;
134
135 Ok(results.into_iter().map(|r| r.into_entity()).collect())
136 }
137
138 async fn find_by_unit(&self, unit_id: Uuid) -> Result<Vec<ChargeDistribution>, String> {
139 let results = sqlx::query_as::<_, ChargeDistributionRow>(
140 r#"
141 SELECT id, expense_id, unit_id, owner_id, quota_percentage, amount_due, created_at
142 FROM charge_distributions
143 WHERE unit_id = $1
144 ORDER BY created_at DESC
145 "#,
146 )
147 .bind(unit_id)
148 .fetch_all(&self.pool)
149 .await
150 .map_err(|e| format!("Failed to find charge distributions by unit: {}", e))?;
151
152 Ok(results.into_iter().map(|r| r.into_entity()).collect())
153 }
154
155 async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<ChargeDistribution>, String> {
156 let results = sqlx::query_as::<_, ChargeDistributionRow>(
157 r#"
158 SELECT id, expense_id, unit_id, owner_id, quota_percentage, amount_due, created_at
159 FROM charge_distributions
160 WHERE owner_id = $1
161 ORDER BY created_at DESC
162 "#,
163 )
164 .bind(owner_id)
165 .fetch_all(&self.pool)
166 .await
167 .map_err(|e| format!("Failed to find charge distributions by owner: {}", e))?;
168
169 Ok(results.into_iter().map(|r| r.into_entity()).collect())
170 }
171
172 async fn delete_by_expense(&self, expense_id: Uuid) -> Result<(), String> {
173 sqlx::query(
174 r#"
175 DELETE FROM charge_distributions
176 WHERE expense_id = $1
177 "#,
178 )
179 .bind(expense_id)
180 .execute(&self.pool)
181 .await
182 .map_err(|e| format!("Failed to delete charge distributions by expense: {}", e))?;
183
184 Ok(())
185 }
186
187 async fn get_total_due_by_owner(&self, owner_id: Uuid) -> Result<f64, String> {
188 let result: (rust_decimal::Decimal,) = sqlx::query_as(
189 r#"
190 SELECT COALESCE(SUM(amount_due), 0)
191 FROM charge_distributions
192 WHERE owner_id = $1
193 "#,
194 )
195 .bind(owner_id)
196 .fetch_one(&self.pool)
197 .await
198 .map_err(|e| format!("Failed to get total due by owner: {}", e))?;
199
200 Ok(result.0.to_string().parse().unwrap_or(0.0))
201 }
202}
203
204#[derive(Debug, sqlx::FromRow)]
206struct ChargeDistributionRow {
207 id: Uuid,
208 expense_id: Uuid,
209 unit_id: Uuid,
210 owner_id: Uuid,
211 quota_percentage: rust_decimal::Decimal,
212 amount_due: rust_decimal::Decimal,
213 created_at: DateTime<Utc>,
214}
215
216impl ChargeDistributionRow {
217 fn into_entity(self) -> ChargeDistribution {
218 ChargeDistribution {
219 id: self.id,
220 expense_id: self.expense_id,
221 unit_id: self.unit_id,
222 owner_id: self.owner_id,
223 quota_percentage: self.quota_percentage.to_string().parse().unwrap_or(0.0),
224 amount_due: self.amount_due.to_string().parse().unwrap_or(0.0),
225 created_at: self.created_at,
226 }
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn test_charge_distribution_row_to_entity() {
236 use rust_decimal::Decimal;
237
238 let row = ChargeDistributionRow {
239 id: Uuid::new_v4(),
240 expense_id: Uuid::new_v4(),
241 unit_id: Uuid::new_v4(),
242 owner_id: Uuid::new_v4(),
243 quota_percentage: Decimal::new(2500, 4), amount_due: Decimal::new(50000, 2), created_at: Utc::now(),
246 };
247
248 let entity = row.into_entity();
249 assert_eq!(entity.quota_percentage, 0.25);
250 assert_eq!(entity.amount_due, 500.0);
251 }
252
253 #[test]
254 fn test_charge_distribution_row_to_entity_edge_cases() {
255 use rust_decimal::Decimal;
256
257 let row = ChargeDistributionRow {
258 id: Uuid::new_v4(),
259 expense_id: Uuid::new_v4(),
260 unit_id: Uuid::new_v4(),
261 owner_id: Uuid::new_v4(),
262 quota_percentage: Decimal::new(10000, 4), amount_due: Decimal::new(0, 2), created_at: Utc::now(),
265 };
266
267 let entity = row.into_entity();
268 assert_eq!(entity.quota_percentage, 1.0);
269 assert_eq!(entity.amount_due, 0.0);
270 }
271}