koprogo_api/infrastructure/web/handlers/
payment_reminder_handlers.rs

1use crate::application::dto::{
2    AddTrackingNumberDto, BulkCreateRemindersDto, CancelReminderDto, CreatePaymentReminderDto,
3    EscalateReminderDto, MarkReminderSentDto,
4};
5use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
6use crate::infrastructure::web::{AppState, AuthenticatedUser};
7use actix_web::{delete, get, post, put, web, HttpResponse, Responder};
8use uuid::Uuid;
9use validator::Validate;
10
11/// Helper function to check if owner role is trying to modify data
12/// Owners can view their own reminders but cannot create/modify them
13fn check_write_permission(user: &AuthenticatedUser) -> Option<HttpResponse> {
14    if user.role == "owner" {
15        Some(HttpResponse::Forbidden().json(serde_json::json!({
16            "error": "Owner role cannot create or modify payment reminders"
17        })))
18    } else {
19        None
20    }
21}
22
23/// Create a new payment reminder
24#[post("/payment-reminders")]
25pub async fn create_reminder(
26    state: web::Data<AppState>,
27    user: AuthenticatedUser,
28    mut dto: web::Json<CreatePaymentReminderDto>,
29) -> impl Responder {
30    if let Some(response) = check_write_permission(&user) {
31        return response;
32    }
33
34    // Override the organization_id from DTO with the one from JWT token
35    let organization_id = match user.require_organization() {
36        Ok(org_id) => org_id,
37        Err(e) => {
38            return HttpResponse::Unauthorized().json(serde_json::json!({
39                "error": e.to_string()
40            }))
41        }
42    };
43    dto.organization_id = organization_id.to_string();
44
45    if let Err(errors) = dto.validate() {
46        return HttpResponse::BadRequest().json(serde_json::json!({
47            "error": "Validation failed",
48            "details": errors.to_string()
49        }));
50    }
51
52    match state
53        .payment_reminder_use_cases
54        .create_reminder(dto.into_inner())
55        .await
56    {
57        Ok(reminder) => {
58            // Audit log: successful reminder creation
59            AuditLogEntry::new(
60                AuditEventType::PaymentReminderCreated,
61                Some(user.user_id),
62                Some(organization_id),
63            )
64            .with_resource("PaymentReminder", Uuid::parse_str(&reminder.id).unwrap())
65            .log();
66
67            HttpResponse::Created().json(reminder)
68        }
69        Err(err) => {
70            // Audit log: failed reminder creation
71            AuditLogEntry::new(
72                AuditEventType::PaymentReminderCreated,
73                Some(user.user_id),
74                Some(organization_id),
75            )
76            .with_error(err.clone())
77            .log();
78
79            HttpResponse::BadRequest().json(serde_json::json!({
80                "error": err
81            }))
82        }
83    }
84}
85
86/// Get a payment reminder by ID
87#[get("/payment-reminders/{id}")]
88pub async fn get_reminder(
89    state: web::Data<AppState>,
90    user: AuthenticatedUser,
91    id: web::Path<Uuid>,
92) -> impl Responder {
93    let _ = match user.require_organization() {
94        Ok(org_id) => org_id,
95        Err(e) => {
96            return HttpResponse::Unauthorized().json(serde_json::json!({
97                "error": e.to_string()
98            }))
99        }
100    };
101
102    match state.payment_reminder_use_cases.get_reminder(*id).await {
103        Ok(Some(reminder)) => HttpResponse::Ok().json(reminder),
104        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
105            "error": "Payment reminder not found"
106        })),
107        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
108            "error": err
109        })),
110    }
111}
112
113/// List all payment reminders for an expense
114#[get("/expenses/{expense_id}/payment-reminders")]
115pub async fn list_by_expense(
116    state: web::Data<AppState>,
117    user: AuthenticatedUser,
118    expense_id: web::Path<Uuid>,
119) -> impl Responder {
120    let _ = match user.require_organization() {
121        Ok(org_id) => org_id,
122        Err(e) => {
123            return HttpResponse::Unauthorized().json(serde_json::json!({
124                "error": e.to_string()
125            }))
126        }
127    };
128
129    match state
130        .payment_reminder_use_cases
131        .list_by_expense(*expense_id)
132        .await
133    {
134        Ok(reminders) => HttpResponse::Ok().json(reminders),
135        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
136            "error": err
137        })),
138    }
139}
140
141/// List all payment reminders for an owner
142#[get("/owners/{owner_id}/payment-reminders")]
143pub async fn list_by_owner(
144    state: web::Data<AppState>,
145    user: AuthenticatedUser,
146    owner_id: web::Path<Uuid>,
147) -> impl Responder {
148    let _ = match user.require_organization() {
149        Ok(org_id) => org_id,
150        Err(e) => {
151            return HttpResponse::Unauthorized().json(serde_json::json!({
152                "error": e.to_string()
153            }))
154        }
155    };
156
157    match state
158        .payment_reminder_use_cases
159        .list_by_owner(*owner_id)
160        .await
161    {
162        Ok(reminders) => HttpResponse::Ok().json(reminders),
163        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
164            "error": err
165        })),
166    }
167}
168
169/// List all active payment reminders for an owner (non-paid, non-cancelled)
170#[get("/owners/{owner_id}/payment-reminders/active")]
171pub async fn list_active_by_owner(
172    state: web::Data<AppState>,
173    user: AuthenticatedUser,
174    owner_id: web::Path<Uuid>,
175) -> impl Responder {
176    let _ = match user.require_organization() {
177        Ok(org_id) => org_id,
178        Err(e) => {
179            return HttpResponse::Unauthorized().json(serde_json::json!({
180                "error": e.to_string()
181            }))
182        }
183    };
184
185    match state
186        .payment_reminder_use_cases
187        .list_active_by_owner(*owner_id)
188        .await
189    {
190        Ok(reminders) => HttpResponse::Ok().json(reminders),
191        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
192            "error": err
193        })),
194    }
195}
196
197/// List all payment reminders for an organization
198#[get("/payment-reminders")]
199pub async fn list_by_organization(
200    state: web::Data<AppState>,
201    user: AuthenticatedUser,
202) -> impl Responder {
203    let organization_id = match user.require_organization() {
204        Ok(org_id) => org_id,
205        Err(e) => {
206            return HttpResponse::Unauthorized().json(serde_json::json!({
207                "error": e.to_string()
208            }))
209        }
210    };
211
212    match state
213        .payment_reminder_use_cases
214        .list_by_organization(organization_id)
215        .await
216    {
217        Ok(reminders) => HttpResponse::Ok().json(reminders),
218        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
219            "error": err
220        })),
221    }
222}
223
224/// Mark a payment reminder as sent
225#[put("/payment-reminders/{id}/mark-sent")]
226pub async fn mark_as_sent(
227    state: web::Data<AppState>,
228    user: AuthenticatedUser,
229    id: web::Path<Uuid>,
230    dto: web::Json<MarkReminderSentDto>,
231) -> impl Responder {
232    if let Some(response) = check_write_permission(&user) {
233        return response;
234    }
235
236    let organization_id = match user.require_organization() {
237        Ok(org_id) => org_id,
238        Err(e) => {
239            return HttpResponse::Unauthorized().json(serde_json::json!({
240                "error": e.to_string()
241            }))
242        }
243    };
244
245    if let Err(errors) = dto.validate() {
246        return HttpResponse::BadRequest().json(serde_json::json!({
247            "error": "Validation failed",
248            "details": errors.to_string()
249        }));
250    }
251
252    match state
253        .payment_reminder_use_cases
254        .mark_as_sent(*id, dto.into_inner())
255        .await
256    {
257        Ok(reminder) => {
258            AuditLogEntry::new(
259                AuditEventType::PaymentReminderSent,
260                Some(user.user_id),
261                Some(organization_id),
262            )
263            .with_resource("PaymentReminder", *id)
264            .log();
265
266            HttpResponse::Ok().json(reminder)
267        }
268        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
269            "error": err
270        })),
271    }
272}
273
274/// Mark a payment reminder as opened (email opened)
275#[put("/payment-reminders/{id}/mark-opened")]
276pub async fn mark_as_opened(
277    state: web::Data<AppState>,
278    user: AuthenticatedUser,
279    id: web::Path<Uuid>,
280) -> impl Responder {
281    if let Some(response) = check_write_permission(&user) {
282        return response;
283    }
284
285    let organization_id = match user.require_organization() {
286        Ok(org_id) => org_id,
287        Err(e) => {
288            return HttpResponse::Unauthorized().json(serde_json::json!({
289                "error": e.to_string()
290            }))
291        }
292    };
293
294    match state.payment_reminder_use_cases.mark_as_opened(*id).await {
295        Ok(reminder) => {
296            AuditLogEntry::new(
297                AuditEventType::PaymentReminderOpened,
298                Some(user.user_id),
299                Some(organization_id),
300            )
301            .with_resource("PaymentReminder", *id)
302            .log();
303
304            HttpResponse::Ok().json(reminder)
305        }
306        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
307            "error": err
308        })),
309    }
310}
311
312/// Mark a payment reminder as paid
313#[put("/payment-reminders/{id}/mark-paid")]
314pub async fn mark_as_paid(
315    state: web::Data<AppState>,
316    user: AuthenticatedUser,
317    id: web::Path<Uuid>,
318) -> impl Responder {
319    if let Some(response) = check_write_permission(&user) {
320        return response;
321    }
322
323    let organization_id = match user.require_organization() {
324        Ok(org_id) => org_id,
325        Err(e) => {
326            return HttpResponse::Unauthorized().json(serde_json::json!({
327                "error": e.to_string()
328            }))
329        }
330    };
331
332    match state.payment_reminder_use_cases.mark_as_paid(*id).await {
333        Ok(reminder) => {
334            AuditLogEntry::new(
335                AuditEventType::PaymentReminderPaid,
336                Some(user.user_id),
337                Some(organization_id),
338            )
339            .with_resource("PaymentReminder", *id)
340            .log();
341
342            HttpResponse::Ok().json(reminder)
343        }
344        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
345            "error": err
346        })),
347    }
348}
349
350/// Cancel a payment reminder
351#[put("/payment-reminders/{id}/cancel")]
352pub async fn cancel_reminder(
353    state: web::Data<AppState>,
354    user: AuthenticatedUser,
355    id: web::Path<Uuid>,
356    dto: web::Json<CancelReminderDto>,
357) -> impl Responder {
358    if let Some(response) = check_write_permission(&user) {
359        return response;
360    }
361
362    let organization_id = match user.require_organization() {
363        Ok(org_id) => org_id,
364        Err(e) => {
365            return HttpResponse::Unauthorized().json(serde_json::json!({
366                "error": e.to_string()
367            }))
368        }
369    };
370
371    if let Err(errors) = dto.validate() {
372        return HttpResponse::BadRequest().json(serde_json::json!({
373            "error": "Validation failed",
374            "details": errors.to_string()
375        }));
376    }
377
378    match state
379        .payment_reminder_use_cases
380        .cancel_reminder(*id, dto.into_inner())
381        .await
382    {
383        Ok(reminder) => {
384            AuditLogEntry::new(
385                AuditEventType::PaymentReminderCancelled,
386                Some(user.user_id),
387                Some(organization_id),
388            )
389            .with_resource("PaymentReminder", *id)
390            .log();
391
392            HttpResponse::Ok().json(reminder)
393        }
394        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
395            "error": err
396        })),
397    }
398}
399
400/// Escalate a payment reminder to next level
401#[post("/payment-reminders/{id}/escalate")]
402pub async fn escalate_reminder(
403    state: web::Data<AppState>,
404    user: AuthenticatedUser,
405    id: web::Path<Uuid>,
406    dto: web::Json<EscalateReminderDto>,
407) -> impl Responder {
408    if let Some(response) = check_write_permission(&user) {
409        return response;
410    }
411
412    let organization_id = match user.require_organization() {
413        Ok(org_id) => org_id,
414        Err(e) => {
415            return HttpResponse::Unauthorized().json(serde_json::json!({
416                "error": e.to_string()
417            }))
418        }
419    };
420
421    match state
422        .payment_reminder_use_cases
423        .escalate_reminder(*id, dto.into_inner())
424        .await
425    {
426        Ok(Some(reminder)) => {
427            AuditLogEntry::new(
428                AuditEventType::PaymentReminderEscalated,
429                Some(user.user_id),
430                Some(organization_id),
431            )
432            .with_resource("PaymentReminder", *id)
433            .log();
434
435            HttpResponse::Ok().json(reminder)
436        }
437        Ok(None) => HttpResponse::Ok().json(serde_json::json!({
438            "message": "Reminder escalated (no next level created)"
439        })),
440        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
441            "error": err
442        })),
443    }
444}
445
446/// Add tracking number to a payment reminder (for registered letters)
447#[put("/payment-reminders/{id}/tracking-number")]
448pub async fn add_tracking_number(
449    state: web::Data<AppState>,
450    user: AuthenticatedUser,
451    id: web::Path<Uuid>,
452    dto: web::Json<AddTrackingNumberDto>,
453) -> impl Responder {
454    if let Some(response) = check_write_permission(&user) {
455        return response;
456    }
457
458    let organization_id = match user.require_organization() {
459        Ok(org_id) => org_id,
460        Err(e) => {
461            return HttpResponse::Unauthorized().json(serde_json::json!({
462                "error": e.to_string()
463            }))
464        }
465    };
466
467    if let Err(errors) = dto.validate() {
468        return HttpResponse::BadRequest().json(serde_json::json!({
469            "error": "Validation failed",
470            "details": errors.to_string()
471        }));
472    }
473
474    match state
475        .payment_reminder_use_cases
476        .add_tracking_number(*id, dto.into_inner())
477        .await
478    {
479        Ok(reminder) => {
480            AuditLogEntry::new(
481                AuditEventType::PaymentReminderTrackingAdded,
482                Some(user.user_id),
483                Some(organization_id),
484            )
485            .with_resource("PaymentReminder", *id)
486            .log();
487
488            HttpResponse::Ok().json(reminder)
489        }
490        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
491            "error": err
492        })),
493    }
494}
495
496/// Get payment recovery statistics for organization
497#[get("/payment-reminders/stats")]
498pub async fn get_recovery_stats(
499    state: web::Data<AppState>,
500    user: AuthenticatedUser,
501) -> impl Responder {
502    let organization_id = match user.require_organization() {
503        Ok(org_id) => org_id,
504        Err(e) => {
505            return HttpResponse::Unauthorized().json(serde_json::json!({
506                "error": e.to_string()
507            }))
508        }
509    };
510
511    match state
512        .payment_reminder_use_cases
513        .get_recovery_stats(organization_id)
514        .await
515    {
516        Ok(stats) => HttpResponse::Ok().json(stats),
517        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
518            "error": err
519        })),
520    }
521}
522
523/// Find overdue expenses without reminders (for manual review)
524#[get("/payment-reminders/overdue-without-reminders")]
525pub async fn find_overdue_without_reminders(
526    state: web::Data<AppState>,
527    user: AuthenticatedUser,
528    query: web::Query<serde_json::Value>,
529) -> impl Responder {
530    if let Some(response) = check_write_permission(&user) {
531        return response;
532    }
533
534    let organization_id = match user.require_organization() {
535        Ok(org_id) => org_id,
536        Err(e) => {
537            return HttpResponse::Unauthorized().json(serde_json::json!({
538                "error": e.to_string()
539            }))
540        }
541    };
542
543    let min_days_overdue = query
544        .get("min_days_overdue")
545        .and_then(|v| v.as_i64())
546        .unwrap_or(15);
547
548    match state
549        .payment_reminder_use_cases
550        .find_overdue_expenses_without_reminders(organization_id, min_days_overdue)
551        .await
552    {
553        Ok(overdue) => HttpResponse::Ok().json(overdue),
554        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
555            "error": err
556        })),
557    }
558}
559
560/// Bulk create reminders for all overdue expenses
561#[post("/payment-reminders/bulk-create")]
562pub async fn bulk_create_reminders(
563    state: web::Data<AppState>,
564    user: AuthenticatedUser,
565    mut dto: web::Json<BulkCreateRemindersDto>,
566) -> impl Responder {
567    if let Some(response) = check_write_permission(&user) {
568        return response;
569    }
570
571    let organization_id = match user.require_organization() {
572        Ok(org_id) => org_id,
573        Err(e) => {
574            return HttpResponse::Unauthorized().json(serde_json::json!({
575                "error": e.to_string()
576            }))
577        }
578    };
579    dto.organization_id = organization_id.to_string();
580
581    if let Err(errors) = dto.validate() {
582        return HttpResponse::BadRequest().json(serde_json::json!({
583            "error": "Validation failed",
584            "details": errors.to_string()
585        }));
586    }
587
588    match state
589        .payment_reminder_use_cases
590        .bulk_create_reminders(dto.into_inner())
591        .await
592    {
593        Ok(response) => {
594            AuditLogEntry::new(
595                AuditEventType::PaymentRemindersBulkCreated,
596                Some(user.user_id),
597                Some(organization_id),
598            )
599            .with_metadata(serde_json::json!({
600                "created_count": response.created_count,
601                "skipped_count": response.skipped_count
602            }))
603            .log();
604
605            HttpResponse::Ok().json(response)
606        }
607        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
608            "error": err
609        })),
610    }
611}
612
613/// Delete a payment reminder
614#[delete("/payment-reminders/{id}")]
615pub async fn delete_reminder(
616    state: web::Data<AppState>,
617    user: AuthenticatedUser,
618    id: web::Path<Uuid>,
619) -> impl Responder {
620    if let Some(response) = check_write_permission(&user) {
621        return response;
622    }
623
624    let organization_id = match user.require_organization() {
625        Ok(org_id) => org_id,
626        Err(e) => {
627            return HttpResponse::Unauthorized().json(serde_json::json!({
628                "error": e.to_string()
629            }))
630        }
631    };
632
633    match state.payment_reminder_use_cases.delete_reminder(*id).await {
634        Ok(true) => {
635            AuditLogEntry::new(
636                AuditEventType::PaymentReminderDeleted,
637                Some(user.user_id),
638                Some(organization_id),
639            )
640            .with_resource("PaymentReminder", *id)
641            .log();
642
643            HttpResponse::NoContent().finish()
644        }
645        Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
646            "error": "Payment reminder not found"
647        })),
648        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
649            "error": err
650        })),
651    }
652}