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