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 sqlx::PgPool;
6use uuid::Uuid;
7
8/// PostgreSQL implementation of ChargeDistributionRepository
9///
10/// Handles automatic charge distribution calculation based on ownership percentages.
11/// Part of Issue #73 - Invoice Workflow with charge distribution.
12pub 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        // Use transaction for atomicity
66        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/// Database row representation for charge_distributions table
205#[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), // 0.2500
244            amount_due: Decimal::new(50000, 2),      // 500.00
245            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), // 1.0000 (100%)
263            amount_due: Decimal::new(0, 2),           // 0.00
264            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}