Skip to main content

koprogo_api/infrastructure/database/repositories/
charge_distribution_repository_impl.rs

1use 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
10/// PostgreSQL implementation of ChargeDistributionRepository
11///
12/// Handles automatic charge distribution calculation based on ownership percentages.
13/// Part of Issue #73 - Invoice Workflow with charge distribution.
14///
15/// MONETARY: amount_due/quota_percentage use rust_decimal::Decimal (cf. ADR-0007/0008).
16pub 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        // Use transaction for atomicity
67        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/// Database row representation for charge_distributions table
213#[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    /// Story H12 — colonne TEXT 'value'/'utility'/'mixed' (parsée en enum).
222    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            // Texte DB → enum. Une valeur inattendue (hors contrainte CHECK)
236            // retombe sur le défaut `Value` plutôt que de paniquer.
237            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), // 0.2500
257            amount_due: Decimal::new(50000, 2),      // 500.00
258            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), // 1.0000 (100%)
279            amount_due: Decimal::new(0, 2),           // 0.00
280            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}