Skip to main content

koprogo_api/infrastructure/database/repositories/
charge_distribution_repository_impl.rs

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