Skip to main content

koprogo_api/infrastructure/database/repositories/
payment_reminder_repository_impl.rs

1use crate::application::error::AppError;
2use crate::application::ports::PaymentReminderRepository;
3use crate::domain::entities::{DeliveryMethod, PaymentReminder, ReminderLevel, ReminderStatus};
4use crate::infrastructure::database::pool::DbPool;
5use async_trait::async_trait;
6use chrono::{DateTime, Utc};
7use rust_decimal::Decimal;
8use sqlx::Row;
9use uuid::Uuid;
10
11pub struct PostgresPaymentReminderRepository {
12    pool: DbPool,
13}
14
15impl PostgresPaymentReminderRepository {
16    pub fn new(pool: DbPool) -> Self {
17        Self { pool }
18    }
19
20    /// Helper function to convert ReminderLevel enum to string for SQL
21    fn level_to_str(level: &ReminderLevel) -> &'static str {
22        match level {
23            ReminderLevel::FirstReminder => "FirstReminder",
24            ReminderLevel::SecondReminder => "SecondReminder",
25            ReminderLevel::FormalNotice => "FormalNotice",
26        }
27    }
28
29    /// Helper function to convert string to ReminderLevel enum
30    fn str_to_level(s: &str) -> ReminderLevel {
31        match s {
32            "SecondReminder" => ReminderLevel::SecondReminder,
33            "FormalNotice" => ReminderLevel::FormalNotice,
34            _ => ReminderLevel::FirstReminder,
35        }
36    }
37
38    /// Helper function to convert ReminderStatus enum to string for SQL
39    fn status_to_str(status: &ReminderStatus) -> &'static str {
40        match status {
41            ReminderStatus::Pending => "Pending",
42            ReminderStatus::Sent => "Sent",
43            ReminderStatus::Opened => "Opened",
44            ReminderStatus::Paid => "Paid",
45            ReminderStatus::Escalated => "Escalated",
46            ReminderStatus::Cancelled => "Cancelled",
47        }
48    }
49
50    /// Helper function to convert string to ReminderStatus enum
51    fn str_to_status(s: &str) -> ReminderStatus {
52        match s {
53            "Sent" => ReminderStatus::Sent,
54            "Opened" => ReminderStatus::Opened,
55            "Paid" => ReminderStatus::Paid,
56            "Escalated" => ReminderStatus::Escalated,
57            "Cancelled" => ReminderStatus::Cancelled,
58            _ => ReminderStatus::Pending,
59        }
60    }
61
62    /// Helper function to convert DeliveryMethod enum to string for SQL
63    fn delivery_method_to_str(method: &DeliveryMethod) -> &'static str {
64        match method {
65            DeliveryMethod::Email => "Email",
66            DeliveryMethod::RegisteredLetter => "RegisteredLetter",
67            DeliveryMethod::Bailiff => "Bailiff",
68        }
69    }
70
71    /// Helper function to convert string to DeliveryMethod enum
72    fn str_to_delivery_method(s: &str) -> DeliveryMethod {
73        match s {
74            "RegisteredLetter" => DeliveryMethod::RegisteredLetter,
75            "Bailiff" => DeliveryMethod::Bailiff,
76            _ => DeliveryMethod::Email,
77        }
78    }
79
80    /// Helper function to map SQL row to PaymentReminder entity
81    fn row_to_reminder(&self, row: &sqlx::postgres::PgRow) -> PaymentReminder {
82        let level_str: String = row.get("level");
83        let status_str: String = row.get("status");
84        let delivery_method_str: String = row.get("delivery_method");
85
86        PaymentReminder {
87            id: row.get("id"),
88            acp_id: row.get("acp_id"),
89            organization_id: row.get("organization_id"),
90            expense_id: row.get("expense_id"),
91            owner_id: row.get("owner_id"),
92            level: Self::str_to_level(&level_str),
93            status: Self::str_to_status(&status_str),
94            amount_owed: row.get("amount_owed"),
95            penalty_amount: row.get("penalty_amount"),
96            total_amount: row.get("total_amount"),
97            due_date: row.get("due_date"),
98            days_overdue: row.get::<i32, _>("days_overdue") as i64,
99            delivery_method: Self::str_to_delivery_method(&delivery_method_str),
100            sent_date: row.get("sent_date"),
101            opened_date: row.get("opened_date"),
102            pdf_path: row.get("pdf_path"),
103            tracking_number: row.get("tracking_number"),
104            notes: row.get("notes"),
105            created_at: row.get("created_at"),
106            updated_at: row.get("updated_at"),
107        }
108    }
109}
110
111#[async_trait]
112impl PaymentReminderRepository for PostgresPaymentReminderRepository {
113    async fn create(&self, reminder: &PaymentReminder) -> Result<PaymentReminder, AppError> {
114        sqlx::query(
115            r#"
116            INSERT INTO payment_reminders (
117                id, acp_id, organization_id, expense_id, owner_id, level, status,
118                amount_owed, penalty_amount, total_amount, due_date, days_overdue,
119                delivery_method, sent_date, opened_date, pdf_path, tracking_number, notes,
120                created_at, updated_at
121            )
122            VALUES (
123                $1, $2, $3, $4, $5, CAST($6 AS reminder_level), CAST($7 AS reminder_status),
124                $8, $9, $10, $11, $12,
125                CAST($13 AS delivery_method), $14, $15, $16, $17, $18,
126                $19, $20
127            )
128            "#,
129        )
130        .bind(reminder.id)
131        .bind(reminder.acp_id)
132        .bind(reminder.organization_id)
133        .bind(reminder.expense_id)
134        .bind(reminder.owner_id)
135        .bind(Self::level_to_str(&reminder.level))
136        .bind(Self::status_to_str(&reminder.status))
137        .bind(reminder.amount_owed)
138        .bind(reminder.penalty_amount)
139        .bind(reminder.total_amount)
140        .bind(reminder.due_date)
141        .bind(reminder.days_overdue as i32)
142        .bind(Self::delivery_method_to_str(&reminder.delivery_method))
143        .bind(reminder.sent_date)
144        .bind(reminder.opened_date)
145        .bind(&reminder.pdf_path)
146        .bind(&reminder.tracking_number)
147        .bind(&reminder.notes)
148        .bind(reminder.created_at)
149        .bind(reminder.updated_at)
150        .execute(&self.pool)
151        .await
152        .map_err(|e| format!("Database error creating reminder: {}", e))?;
153
154        Ok(reminder.clone())
155    }
156
157    async fn find_by_id(&self, id: Uuid) -> Result<Option<PaymentReminder>, AppError> {
158        let row = sqlx::query(
159            r#"
160            SELECT id, acp_id, organization_id, expense_id, owner_id,
161                   level::text AS level, status::text AS status,
162                   amount_owed, penalty_amount, total_amount, due_date, days_overdue,
163                   delivery_method::text AS delivery_method,
164                   sent_date, opened_date, pdf_path, tracking_number, notes,
165                   created_at, updated_at
166            FROM payment_reminders
167            WHERE id = $1
168            "#,
169        )
170        .bind(id)
171        .fetch_optional(&self.pool)
172        .await
173        .map_err(|e| format!("Database error finding reminder: {}", e))?;
174
175        Ok(row.as_ref().map(|r| self.row_to_reminder(r)))
176    }
177
178    async fn find_by_expense(&self, expense_id: Uuid) -> Result<Vec<PaymentReminder>, AppError> {
179        let rows = sqlx::query(
180            r#"
181            SELECT id, acp_id, organization_id, expense_id, owner_id,
182                   level::text AS level, status::text AS status,
183                   amount_owed, penalty_amount, total_amount, due_date, days_overdue,
184                   delivery_method::text AS delivery_method,
185                   sent_date, opened_date, pdf_path, tracking_number, notes,
186                   created_at, updated_at
187            FROM payment_reminders
188            WHERE expense_id = $1
189            ORDER BY created_at DESC
190            "#,
191        )
192        .bind(expense_id)
193        .fetch_all(&self.pool)
194        .await
195        .map_err(|e| format!("Database error finding reminders by expense: {}", e))?;
196
197        Ok(rows.iter().map(|r| self.row_to_reminder(r)).collect())
198    }
199
200    async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<PaymentReminder>, AppError> {
201        let rows = sqlx::query(
202            r#"
203            SELECT id, acp_id, organization_id, expense_id, owner_id,
204                   level::text AS level, status::text AS status,
205                   amount_owed, penalty_amount, total_amount, due_date, days_overdue,
206                   delivery_method::text AS delivery_method,
207                   sent_date, opened_date, pdf_path, tracking_number, notes,
208                   created_at, updated_at
209            FROM payment_reminders
210            WHERE owner_id = $1
211            ORDER BY created_at DESC
212            "#,
213        )
214        .bind(owner_id)
215        .fetch_all(&self.pool)
216        .await
217        .map_err(|e| format!("Database error finding reminders by owner: {}", e))?;
218
219        Ok(rows.iter().map(|r| self.row_to_reminder(r)).collect())
220    }
221
222    async fn find_by_organization(
223        &self,
224        organization_id: Uuid,
225    ) -> Result<Vec<PaymentReminder>, AppError> {
226        let rows = sqlx::query(
227            r#"
228            SELECT id, acp_id, organization_id, expense_id, owner_id,
229                   level::text AS level, status::text AS status,
230                   amount_owed, penalty_amount, total_amount, due_date, days_overdue,
231                   delivery_method::text AS delivery_method,
232                   sent_date, opened_date, pdf_path, tracking_number, notes,
233                   created_at, updated_at
234            FROM payment_reminders
235            WHERE acp_id IN (SELECT id FROM acps WHERE organization_id = $1)
236            ORDER BY created_at DESC
237            "#,
238        )
239        .bind(organization_id)
240        .fetch_all(&self.pool)
241        .await
242        .map_err(|e| format!("Database error finding reminders by organization: {}", e))?;
243
244        Ok(rows.iter().map(|r| self.row_to_reminder(r)).collect())
245    }
246
247    async fn find_by_status(
248        &self,
249        status: ReminderStatus,
250    ) -> Result<Vec<PaymentReminder>, AppError> {
251        let rows = sqlx::query(
252            r#"
253            SELECT id, acp_id, organization_id, expense_id, owner_id,
254                   level::text AS level, status::text AS status,
255                   amount_owed, penalty_amount, total_amount, due_date, days_overdue,
256                   delivery_method::text AS delivery_method,
257                   sent_date, opened_date, pdf_path, tracking_number, notes,
258                   created_at, updated_at
259            FROM payment_reminders
260            WHERE status = CAST($1 AS reminder_status)
261            ORDER BY created_at DESC
262            "#,
263        )
264        .bind(Self::status_to_str(&status))
265        .fetch_all(&self.pool)
266        .await
267        .map_err(|e| format!("Database error finding reminders by status: {}", e))?;
268
269        Ok(rows.iter().map(|r| self.row_to_reminder(r)).collect())
270    }
271
272    async fn find_by_organization_and_status(
273        &self,
274        organization_id: Uuid,
275        status: ReminderStatus,
276    ) -> Result<Vec<PaymentReminder>, AppError> {
277        let rows = sqlx::query(
278            r#"
279            SELECT id, acp_id, organization_id, expense_id, owner_id,
280                   level::text AS level, status::text AS status,
281                   amount_owed, penalty_amount, total_amount, due_date, days_overdue,
282                   delivery_method::text AS delivery_method,
283                   sent_date, opened_date, pdf_path, tracking_number, notes,
284                   created_at, updated_at
285            FROM payment_reminders
286            WHERE acp_id IN (SELECT id FROM acps WHERE organization_id = $1) AND status = CAST($2 AS reminder_status)
287            ORDER BY created_at DESC
288            "#,
289        )
290        .bind(organization_id)
291        .bind(Self::status_to_str(&status))
292        .fetch_all(&self.pool)
293        .await
294        .map_err(|e| format!("Database error finding reminders: {}", e))?;
295
296        Ok(rows.iter().map(|r| self.row_to_reminder(r)).collect())
297    }
298
299    async fn find_pending_reminders(&self) -> Result<Vec<PaymentReminder>, AppError> {
300        let rows = sqlx::query(
301            r#"
302            SELECT id, acp_id, organization_id, expense_id, owner_id,
303                   level::text AS level, status::text AS status,
304                   amount_owed, penalty_amount, total_amount, due_date, days_overdue,
305                   delivery_method::text AS delivery_method,
306                   sent_date, opened_date, pdf_path, tracking_number, notes,
307                   created_at, updated_at
308            FROM payment_reminders
309            WHERE status = 'Pending'::reminder_status
310            ORDER BY created_at ASC
311            "#,
312        )
313        .fetch_all(&self.pool)
314        .await
315        .map_err(|e| format!("Database error finding pending reminders: {}", e))?;
316
317        Ok(rows.iter().map(|r| self.row_to_reminder(r)).collect())
318    }
319
320    async fn find_reminders_needing_escalation(
321        &self,
322        cutoff_date: DateTime<Utc>,
323    ) -> Result<Vec<PaymentReminder>, AppError> {
324        let rows = sqlx::query(
325            r#"
326            SELECT id, acp_id, organization_id, expense_id, owner_id,
327                   level::text AS level, status::text AS status,
328                   amount_owed, penalty_amount, total_amount, due_date, days_overdue,
329                   delivery_method::text AS delivery_method,
330                   sent_date, opened_date, pdf_path, tracking_number, notes,
331                   created_at, updated_at
332            FROM payment_reminders
333            WHERE status IN ('Sent'::reminder_status, 'Opened'::reminder_status)
334              AND sent_date <= $1
335              AND level != 'FormalNotice'::reminder_level
336            ORDER BY sent_date ASC
337            "#,
338        )
339        .bind(cutoff_date)
340        .fetch_all(&self.pool)
341        .await
342        .map_err(|e| format!("Database error finding reminders needing escalation: {}", e))?;
343
344        Ok(rows.iter().map(|r| self.row_to_reminder(r)).collect())
345    }
346
347    async fn find_latest_by_expense(
348        &self,
349        expense_id: Uuid,
350    ) -> Result<Option<PaymentReminder>, AppError> {
351        let row = sqlx::query(
352            r#"
353            SELECT id, acp_id, organization_id, expense_id, owner_id,
354                   level::text AS level, status::text AS status,
355                   amount_owed, penalty_amount, total_amount, due_date, days_overdue,
356                   delivery_method::text AS delivery_method,
357                   sent_date, opened_date, pdf_path, tracking_number, notes,
358                   created_at, updated_at
359            FROM payment_reminders
360            WHERE expense_id = $1
361            ORDER BY created_at DESC
362            LIMIT 1
363            "#,
364        )
365        .bind(expense_id)
366        .fetch_optional(&self.pool)
367        .await
368        .map_err(|e| format!("Database error finding latest reminder: {}", e))?;
369
370        Ok(row.as_ref().map(|r| self.row_to_reminder(r)))
371    }
372
373    async fn find_active_by_owner(&self, owner_id: Uuid) -> Result<Vec<PaymentReminder>, AppError> {
374        let rows = sqlx::query(
375            r#"
376            SELECT id, acp_id, organization_id, expense_id, owner_id,
377                   level::text AS level, status::text AS status,
378                   amount_owed, penalty_amount, total_amount, due_date, days_overdue,
379                   delivery_method::text AS delivery_method,
380                   sent_date, opened_date, pdf_path, tracking_number, notes,
381                   created_at, updated_at
382            FROM payment_reminders
383            WHERE owner_id = $1
384              AND status NOT IN ('Paid'::reminder_status, 'Cancelled'::reminder_status)
385            ORDER BY created_at DESC
386            "#,
387        )
388        .bind(owner_id)
389        .fetch_all(&self.pool)
390        .await
391        .map_err(|e| format!("Database error finding active reminders: {}", e))?;
392
393        Ok(rows.iter().map(|r| self.row_to_reminder(r)).collect())
394    }
395
396    async fn count_by_status(
397        &self,
398        organization_id: Uuid,
399    ) -> Result<Vec<(ReminderStatus, i64)>, AppError> {
400        let rows = sqlx::query(
401            r#"
402            SELECT status::text AS status, COUNT(*) as count
403            FROM payment_reminders
404            WHERE acp_id IN (SELECT id FROM acps WHERE organization_id = $1)
405            GROUP BY status
406            "#,
407        )
408        .bind(organization_id)
409        .fetch_all(&self.pool)
410        .await
411        .map_err(|e| format!("Database error counting reminders by status: {}", e))?;
412
413        Ok(rows
414            .iter()
415            .map(|row| {
416                let status_str: String = row.get("status");
417                let count: i64 = row.get("count");
418                (Self::str_to_status(&status_str), count)
419            })
420            .collect())
421    }
422
423    async fn get_total_owed_by_organization(
424        &self,
425        organization_id: Uuid,
426    ) -> Result<Decimal, AppError> {
427        let row = sqlx::query(
428            r#"
429            SELECT COALESCE(SUM(amount_owed), 0) as total
430            FROM payment_reminders
431            WHERE acp_id IN (SELECT id FROM acps WHERE organization_id = $1)
432              AND status NOT IN ('Paid'::reminder_status, 'Cancelled'::reminder_status)
433            "#,
434        )
435        .bind(organization_id)
436        .fetch_one(&self.pool)
437        .await
438        .map_err(|e| format!("Database error calculating total owed: {}", e))?;
439
440        Ok(row.get("total"))
441    }
442
443    async fn get_total_penalties_by_organization(
444        &self,
445        organization_id: Uuid,
446    ) -> Result<Decimal, AppError> {
447        let row = sqlx::query(
448            r#"
449            SELECT COALESCE(SUM(penalty_amount), 0) as total
450            FROM payment_reminders
451            WHERE acp_id IN (SELECT id FROM acps WHERE organization_id = $1)
452              AND status NOT IN ('Paid'::reminder_status, 'Cancelled'::reminder_status)
453            "#,
454        )
455        .bind(organization_id)
456        .fetch_one(&self.pool)
457        .await
458        .map_err(|e| format!("Database error calculating total penalties: {}", e))?;
459
460        Ok(row.get("total"))
461    }
462
463    async fn find_overdue_expenses_without_reminders(
464        &self,
465        organization_id: Uuid,
466        min_days_overdue: i64,
467    ) -> Result<Vec<(Uuid, Uuid, i64, Decimal)>, AppError> {
468        let rows = sqlx::query(
469            r#"
470            SELECT
471                e.id as expense_id,
472                uo.owner_id,
473                EXTRACT(DAY FROM (NOW() - e.expense_date))::bigint as days_overdue,
474                e.amount
475            FROM expenses e
476            INNER JOIN units u ON e.building_id = (SELECT building_id FROM units WHERE id = u.id LIMIT 1)
477            INNER JOIN unit_owners uo ON u.id = uo.unit_id AND uo.end_date IS NULL
478            WHERE e.organization_id = $1
479              AND e.payment_status = 'overdue'::payment_status
480              AND EXTRACT(DAY FROM (NOW() - e.expense_date)) >= $2
481              AND NOT EXISTS (
482                  SELECT 1 FROM payment_reminders pr
483                  WHERE pr.expense_id = e.id
484                    AND pr.owner_id = uo.owner_id
485                    AND pr.status NOT IN ('Paid'::reminder_status, 'Cancelled'::reminder_status)
486              )
487            ORDER BY days_overdue DESC
488            "#,
489        )
490        .bind(organization_id)
491        .bind(min_days_overdue as i32)
492        .fetch_all(&self.pool)
493        .await
494        .map_err(|e| format!("Database error finding overdue expenses: {}", e))?;
495
496        rows.iter()
497            .map(|row| -> Result<(Uuid, Uuid, i64, Decimal), AppError> {
498                let expense_id: Uuid = row.try_get("expense_id")?;
499                let owner_id: Uuid = row.try_get("owner_id")?;
500                let days_overdue: i64 = row.try_get("days_overdue")?;
501                // `expenses.amount` est NUMERIC : lu directement en Decimal,
502                // sans l'aller-retour `to_f64()` que faisait le code précédent.
503                let amount: Decimal = row.try_get("amount")?;
504                Ok((expense_id, owner_id, days_overdue, amount))
505            })
506            .collect()
507    }
508
509    async fn update(&self, reminder: &PaymentReminder) -> Result<PaymentReminder, AppError> {
510        sqlx::query(
511            r#"
512            UPDATE payment_reminders
513            SET status = CAST($2 AS reminder_status),
514                amount_owed = $3,
515                penalty_amount = $4,
516                total_amount = $5,
517                days_overdue = $6,
518                sent_date = $7,
519                opened_date = $8,
520                pdf_path = $9,
521                tracking_number = $10,
522                notes = $11,
523                updated_at = $12
524            WHERE id = $1
525            "#,
526        )
527        .bind(reminder.id)
528        .bind(Self::status_to_str(&reminder.status))
529        .bind(reminder.amount_owed)
530        .bind(reminder.penalty_amount)
531        .bind(reminder.total_amount)
532        .bind(reminder.days_overdue as i32)
533        .bind(reminder.sent_date)
534        .bind(reminder.opened_date)
535        .bind(&reminder.pdf_path)
536        .bind(&reminder.tracking_number)
537        .bind(&reminder.notes)
538        .bind(reminder.updated_at)
539        .execute(&self.pool)
540        .await
541        .map_err(|e| format!("Database error updating reminder: {}", e))?;
542
543        Ok(reminder.clone())
544    }
545
546    async fn delete(&self, id: Uuid) -> Result<bool, AppError> {
547        let result = sqlx::query("DELETE FROM payment_reminders WHERE id = $1")
548            .bind(id)
549            .execute(&self.pool)
550            .await
551            .map_err(|e| format!("Database error deleting reminder: {}", e))?;
552
553        Ok(result.rows_affected() > 0)
554    }
555
556    async fn get_dashboard_stats(
557        &self,
558        organization_id: Uuid,
559    ) -> Result<(Decimal, Decimal, Vec<(ReminderLevel, i64)>), AppError> {
560        let total_owed = self.get_total_owed_by_organization(organization_id).await?;
561        let total_penalties = self
562            .get_total_penalties_by_organization(organization_id)
563            .await?;
564
565        let rows = sqlx::query(
566            r#"
567            SELECT level::text AS level, COUNT(*) as count
568            FROM payment_reminders
569            WHERE acp_id IN (SELECT id FROM acps WHERE organization_id = $1)
570              AND status NOT IN ('Paid'::reminder_status, 'Cancelled'::reminder_status)
571            GROUP BY level
572            "#,
573        )
574        .bind(organization_id)
575        .fetch_all(&self.pool)
576        .await
577        .map_err(|e| format!("Database error getting dashboard stats: {}", e))?;
578
579        let level_counts = rows
580            .iter()
581            .map(|row| {
582                let level_str: String = row.get("level");
583                let count: i64 = row.get("count");
584                (Self::str_to_level(&level_str), count)
585            })
586            .collect();
587
588        Ok((total_owed, total_penalties, level_counts))
589    }
590}