koprogo_api/infrastructure/web/handlers/
payment_reminder_handlers.rs1use 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
11fn 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#[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 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 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 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("/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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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-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#[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#[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("/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}