koprogo_api/infrastructure/database/repositories/
payment_reminder_repository_impl.rs

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