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