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 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 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 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 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 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 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 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}