Skip to main content

koprogo_api/infrastructure/database/repositories/
unit_owner_repository_impl.rs

1//! PostgreSQL impl du UnitOwnerRepository.
2//!
3//! ADR-0007/0008 : `ownership_percentage` est `Decimal` end-to-end
4//! (domain + SQL NUMERIC(6,5) depuis migration `20260501000000`).
5
6use crate::application::ports::UnitOwnerRepository;
7use crate::domain::entities::{ChargeDistribution, LotHolder, OwnershipType, UnitOwner};
8use async_trait::async_trait;
9use rust_decimal::Decimal;
10use sqlx::{PgPool, Row};
11use std::str::FromStr;
12use uuid::Uuid;
13
14pub struct PostgresUnitOwnerRepository {
15    pool: PgPool,
16}
17
18impl PostgresUnitOwnerRepository {
19    pub fn new(pool: PgPool) -> Self {
20        Self { pool }
21    }
22}
23
24#[async_trait]
25impl UnitOwnerRepository for PostgresUnitOwnerRepository {
26    async fn create(&self, unit_owner: &UnitOwner) -> Result<UnitOwner, String> {
27        let result = sqlx::query!(
28            r#"
29            INSERT INTO unit_owners (
30                id, unit_id, owner_id, ownership_percentage,
31                start_date, end_date, is_primary_contact,
32                created_at, updated_at
33            )
34            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
35            RETURNING *
36            "#,
37            unit_owner.id,
38            unit_owner.unit_id,
39            unit_owner.owner_id,
40            unit_owner.ownership_percentage,
41            unit_owner.start_date,
42            unit_owner.end_date,
43            unit_owner.is_primary_contact,
44            unit_owner.created_at,
45            unit_owner.updated_at
46        )
47        .fetch_one(&self.pool)
48        .await
49        .map_err(|e| format!("Failed to create unit_owner: {}", e))?;
50
51        Ok(UnitOwner {
52            id: result.id,
53            unit_id: result.unit_id,
54            owner_id: result.owner_id,
55            ownership_percentage: result.ownership_percentage,
56            start_date: result.start_date,
57            end_date: result.end_date,
58            is_primary_contact: result.is_primary_contact,
59            created_at: result.created_at,
60            updated_at: result.updated_at,
61        })
62    }
63
64    async fn find_by_id(&self, id: Uuid) -> Result<Option<UnitOwner>, String> {
65        let result = sqlx::query!(
66            r#"
67            SELECT * FROM unit_owners WHERE id = $1
68            "#,
69            id
70        )
71        .fetch_optional(&self.pool)
72        .await
73        .map_err(|e| format!("Failed to find unit_owner: {}", e))?;
74
75        Ok(result.map(|row| UnitOwner {
76            id: row.id,
77            unit_id: row.unit_id,
78            owner_id: row.owner_id,
79            ownership_percentage: row.ownership_percentage,
80            start_date: row.start_date,
81            end_date: row.end_date,
82            is_primary_contact: row.is_primary_contact,
83            created_at: row.created_at,
84            updated_at: row.updated_at,
85        }))
86    }
87
88    async fn find_current_owners_by_unit(&self, unit_id: Uuid) -> Result<Vec<UnitOwner>, String> {
89        let results = sqlx::query!(
90            r#"
91            SELECT * FROM unit_owners
92            WHERE unit_id = $1 AND end_date IS NULL
93            ORDER BY is_primary_contact DESC, created_at ASC
94            "#,
95            unit_id
96        )
97        .fetch_all(&self.pool)
98        .await
99        .map_err(|e| format!("Failed to find owners by unit: {}", e))?;
100
101        Ok(results
102            .into_iter()
103            .map(|row| UnitOwner {
104                id: row.id,
105                unit_id: row.unit_id,
106                owner_id: row.owner_id,
107                ownership_percentage: row.ownership_percentage,
108                start_date: row.start_date,
109                end_date: row.end_date,
110                is_primary_contact: row.is_primary_contact,
111                created_at: row.created_at,
112                updated_at: row.updated_at,
113            })
114            .collect())
115    }
116
117    async fn find_current_units_by_owner(&self, owner_id: Uuid) -> Result<Vec<UnitOwner>, String> {
118        let results = sqlx::query!(
119            r#"
120            SELECT * FROM unit_owners
121            WHERE owner_id = $1 AND end_date IS NULL
122            ORDER BY created_at ASC
123            "#,
124            owner_id
125        )
126        .fetch_all(&self.pool)
127        .await
128        .map_err(|e| format!("Failed to find units by owner: {}", e))?;
129
130        Ok(results
131            .into_iter()
132            .map(|row| UnitOwner {
133                id: row.id,
134                unit_id: row.unit_id,
135                owner_id: row.owner_id,
136                ownership_percentage: row.ownership_percentage,
137                start_date: row.start_date,
138                end_date: row.end_date,
139                is_primary_contact: row.is_primary_contact,
140                created_at: row.created_at,
141                updated_at: row.updated_at,
142            })
143            .collect())
144    }
145
146    async fn find_all_owners_by_unit(&self, unit_id: Uuid) -> Result<Vec<UnitOwner>, String> {
147        let results = sqlx::query!(
148            r#"
149            SELECT * FROM unit_owners
150            WHERE unit_id = $1
151            ORDER BY start_date DESC
152            "#,
153            unit_id
154        )
155        .fetch_all(&self.pool)
156        .await
157        .map_err(|e| format!("Failed to find all owners by unit: {}", e))?;
158
159        Ok(results
160            .into_iter()
161            .map(|row| UnitOwner {
162                id: row.id,
163                unit_id: row.unit_id,
164                owner_id: row.owner_id,
165                ownership_percentage: row.ownership_percentage,
166                start_date: row.start_date,
167                end_date: row.end_date,
168                is_primary_contact: row.is_primary_contact,
169                created_at: row.created_at,
170                updated_at: row.updated_at,
171            })
172            .collect())
173    }
174
175    async fn find_all_units_by_owner(&self, owner_id: Uuid) -> Result<Vec<UnitOwner>, String> {
176        let results = sqlx::query!(
177            r#"
178            SELECT * FROM unit_owners
179            WHERE owner_id = $1
180            ORDER BY start_date DESC
181            "#,
182            owner_id
183        )
184        .fetch_all(&self.pool)
185        .await
186        .map_err(|e| format!("Failed to find all units by owner: {}", e))?;
187
188        Ok(results
189            .into_iter()
190            .map(|row| UnitOwner {
191                id: row.id,
192                unit_id: row.unit_id,
193                owner_id: row.owner_id,
194                ownership_percentage: row.ownership_percentage,
195                start_date: row.start_date,
196                end_date: row.end_date,
197                is_primary_contact: row.is_primary_contact,
198                created_at: row.created_at,
199                updated_at: row.updated_at,
200            })
201            .collect())
202    }
203
204    async fn update(&self, unit_owner: &UnitOwner) -> Result<UnitOwner, String> {
205        let result = sqlx::query!(
206            r#"
207            UPDATE unit_owners
208            SET ownership_percentage = $2,
209                end_date = $3,
210                is_primary_contact = $4,
211                updated_at = $5
212            WHERE id = $1
213            RETURNING *
214            "#,
215            unit_owner.id,
216            unit_owner.ownership_percentage,
217            unit_owner.end_date,
218            unit_owner.is_primary_contact,
219            unit_owner.updated_at
220        )
221        .fetch_one(&self.pool)
222        .await
223        .map_err(|e| format!("Failed to update unit_owner: {}", e))?;
224
225        Ok(UnitOwner {
226            id: result.id,
227            unit_id: result.unit_id,
228            owner_id: result.owner_id,
229            ownership_percentage: result.ownership_percentage,
230            start_date: result.start_date,
231            end_date: result.end_date,
232            is_primary_contact: result.is_primary_contact,
233            created_at: result.created_at,
234            updated_at: result.updated_at,
235        })
236    }
237
238    async fn delete(&self, id: Uuid) -> Result<(), String> {
239        sqlx::query!(
240            r#"
241            DELETE FROM unit_owners WHERE id = $1
242            "#,
243            id
244        )
245        .execute(&self.pool)
246        .await
247        .map_err(|e| format!("Failed to delete unit_owner: {}", e))?;
248
249        Ok(())
250    }
251
252    async fn has_active_owners(&self, unit_id: Uuid) -> Result<bool, String> {
253        let result = sqlx::query!(
254            r#"
255            SELECT EXISTS(SELECT 1 FROM unit_owners WHERE unit_id = $1 AND end_date IS NULL) as "exists!"
256            "#,
257            unit_id
258        )
259        .fetch_one(&self.pool)
260        .await
261        .map_err(|e| format!("Failed to check active owners: {}", e))?;
262
263        Ok(result.exists)
264    }
265
266    async fn get_total_ownership_percentage(&self, unit_id: Uuid) -> Result<Decimal, String> {
267        let result = sqlx::query!(
268            r#"
269            SELECT COALESCE(SUM(ownership_percentage), 0) as "total!"
270            FROM unit_owners
271            WHERE unit_id = $1 AND end_date IS NULL
272            "#,
273            unit_id
274        )
275        .fetch_one(&self.pool)
276        .await
277        .map_err(|e| format!("Failed to get total ownership percentage: {}", e))?;
278
279        Ok(result.total)
280    }
281
282    async fn find_active_by_unit_and_owner(
283        &self,
284        unit_id: Uuid,
285        owner_id: Uuid,
286    ) -> Result<Option<UnitOwner>, String> {
287        let result = sqlx::query!(
288            r#"
289            SELECT * FROM unit_owners
290            WHERE unit_id = $1 AND owner_id = $2 AND end_date IS NULL
291            "#,
292            unit_id,
293            owner_id
294        )
295        .fetch_optional(&self.pool)
296        .await
297        .map_err(|e| format!("Failed to find active unit_owner: {}", e))?;
298
299        Ok(result.map(|row| UnitOwner {
300            id: row.id,
301            unit_id: row.unit_id,
302            owner_id: row.owner_id,
303            ownership_percentage: row.ownership_percentage,
304            start_date: row.start_date,
305            end_date: row.end_date,
306            is_primary_contact: row.is_primary_contact,
307            created_at: row.created_at,
308            updated_at: row.updated_at,
309        }))
310    }
311
312    async fn find_active_by_building(
313        &self,
314        building_id: Uuid,
315    ) -> Result<Vec<(Uuid, Uuid, Decimal)>, String> {
316        let results = sqlx::query!(
317            r#"
318            SELECT uo.unit_id, uo.owner_id, uo.ownership_percentage
319            FROM unit_owners uo
320            JOIN units u ON uo.unit_id = u.id
321            WHERE u.building_id = $1 AND uo.end_date IS NULL
322            "#,
323            building_id
324        )
325        .fetch_all(&self.pool)
326        .await
327        .map_err(|e| format!("Failed to find active unit_owners by building: {}", e))?;
328
329        Ok(results
330            .into_iter()
331            .map(|row| (row.unit_id, row.owner_id, row.ownership_percentage))
332            .collect())
333    }
334
335    async fn find_active_quota_shares_by_building(
336        &self,
337        building_id: Uuid,
338    ) -> Result<Vec<(Uuid, Uuid, Decimal)>, String> {
339        // Le calcul est fait EN RUST, pas en SQL, pour passer par
340        // `ChargeDistribution::resolve_owner_quota` — la formule de l'Art. 3.84
341        // qui porte déjà ses gardes (base de tantièmes > 0, pourcentage dans
342        // [0,1]) et ses tests. Dupliquer la division en SQL serait une seconde
343        // source de vérité pour une règle légale.
344        let rows = sqlx::query!(
345            r#"
346            SELECT uo.unit_id, uo.owner_id, uo.ownership_percentage,
347                   u.quota AS unit_quota,
348                   b.total_tantiemes
349            FROM unit_owners uo
350            JOIN units u ON uo.unit_id = u.id
351            JOIN buildings b ON u.building_id = b.id
352            WHERE u.building_id = $1 AND uo.end_date IS NULL
353            "#,
354            building_id
355        )
356        .fetch_all(&self.pool)
357        .await
358        .map_err(|e| format!("Failed to find active quota shares by building: {}", e))?;
359
360        let mut parts = Vec::with_capacity(rows.len());
361        for row in rows {
362            let part = ChargeDistribution::resolve_owner_quota(
363                row.unit_quota,
364                Decimal::from(row.total_tantiemes),
365                row.ownership_percentage,
366            )
367            .map_err(|e| format!("Invalid quota share for unit {}: {}", row.unit_id, e))?;
368            parts.push((row.unit_id, row.owner_id, part));
369        }
370
371        Ok(parts)
372    }
373
374    async fn find_voting_holders_by_unit(&self, unit_id: Uuid) -> Result<Vec<LotHolder>, String> {
375        // Story H17 — runtime query (`sqlx::query`, pas la macro `query!`) :
376        // évite de régénérer le cache `.sqlx` pour les colonnes ajoutées par la
377        // migration 20260621000000. On ne lit que les titulaires actifs.
378        let rows = sqlx::query(
379            r#"
380            SELECT ownership_type, is_voting_representative
381            FROM unit_owners
382            WHERE unit_id = $1 AND end_date IS NULL
383            "#,
384        )
385        .bind(unit_id)
386        .fetch_all(&self.pool)
387        .await
388        .map_err(|e| format!("Failed to find voting holders by unit: {}", e))?;
389
390        rows.into_iter()
391            .map(|row| {
392                let raw: String = row
393                    .try_get("ownership_type")
394                    .map_err(|e| format!("ownership_type read error: {}", e))?;
395                // Parse strict (mémoire `validate-before-compute`) : une valeur
396                // hors enum remonte une erreur typée, jamais un fallback muet.
397                let ownership_type = OwnershipType::from_str(&raw).map_err(|e| e.to_string())?;
398                let is_voting_representative: bool = row
399                    .try_get("is_voting_representative")
400                    .map_err(|e| format!("is_voting_representative read error: {}", e))?;
401                Ok(LotHolder::new(ownership_type, is_voting_representative))
402            })
403            .collect()
404    }
405
406    async fn is_voting_representative(&self, unit_owner_id: Uuid) -> Result<bool, String> {
407        // Story #848 — runtime query (comme `find_voting_holders_by_unit`) :
408        // évite de régénérer le cache `.sqlx` pour une colonne ajoutée par la
409        // migration 20260621000000.
410        let row = sqlx::query(r#"SELECT is_voting_representative FROM unit_owners WHERE id = $1"#)
411            .bind(unit_owner_id)
412            .fetch_optional(&self.pool)
413            .await
414            .map_err(|e| format!("Failed to read is_voting_representative: {}", e))?;
415
416        let Some(row) = row else {
417            return Err(format!("unit_owner {} not found", unit_owner_id));
418        };
419        row.try_get("is_voting_representative")
420            .map_err(|e| format!("is_voting_representative read error: {}", e))
421    }
422
423    async fn set_voting_representative(&self, unit_owner_id: Uuid) -> Result<(), String> {
424        let result = sqlx::query(
425            r#"UPDATE unit_owners SET is_voting_representative = true, updated_at = now()
426               WHERE id = $1"#,
427        )
428        .bind(unit_owner_id)
429        .execute(&self.pool)
430        .await
431        .map_err(|e| format!("Failed to set voting representative: {}", e))?;
432
433        if result.rows_affected() == 0 {
434            return Err(format!("unit_owner {} not found", unit_owner_id));
435        }
436        Ok(())
437    }
438}