1use chrono::{DateTime, Utc};
2use rust_decimal::{Decimal, RoundingStrategy};
3use rust_decimal_macros::dec;
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
9pub enum ReminderLevel {
10 FirstReminder, SecondReminder, FormalNotice, }
14
15impl ReminderLevel {
16 pub fn days_after_due_date(&self) -> i64 {
18 match self {
19 ReminderLevel::FirstReminder => 15,
20 ReminderLevel::SecondReminder => 30,
21 ReminderLevel::FormalNotice => 60,
22 }
23 }
24
25 pub fn next_level(&self) -> Option<ReminderLevel> {
27 match self {
28 ReminderLevel::FirstReminder => Some(ReminderLevel::SecondReminder),
29 ReminderLevel::SecondReminder => Some(ReminderLevel::FormalNotice),
30 ReminderLevel::FormalNotice => None, }
32 }
33
34 pub fn tone(&self) -> &'static str {
36 match self {
37 ReminderLevel::FirstReminder => "aimable",
38 ReminderLevel::SecondReminder => "ferme",
39 ReminderLevel::FormalNotice => "juridique",
40 }
41 }
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
46pub enum ReminderStatus {
47 Pending, Sent, Opened, Paid, Escalated, Cancelled, }
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
57pub enum DeliveryMethod {
58 Email,
59 RegisteredLetter, Bailiff, }
62
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
65pub struct PaymentReminder {
66 pub id: Uuid,
67
68 pub acp_id: Uuid,
75
76 pub organization_id: Uuid,
78 pub expense_id: Uuid,
79 pub owner_id: Uuid,
80 pub level: ReminderLevel,
81 pub status: ReminderStatus,
82 pub amount_owed: Decimal,
84 pub penalty_amount: Decimal,
86 pub total_amount: Decimal,
89 pub due_date: DateTime<Utc>, pub days_overdue: i64, pub delivery_method: DeliveryMethod,
92 pub sent_date: Option<DateTime<Utc>>,
93 pub opened_date: Option<DateTime<Utc>>,
94 pub pdf_path: Option<String>, pub tracking_number: Option<String>, pub notes: Option<String>,
97 pub created_at: DateTime<Utc>,
98 pub updated_at: DateTime<Utc>,
99}
100
101impl PaymentReminder {
102 pub const BELGIAN_PENALTY_RATE: Decimal = dec!(0.045);
107
108 const DAYS_PER_YEAR: Decimal = dec!(365);
110
111 #[allow(clippy::too_many_arguments)]
113 pub fn new(
114 acp_id: Uuid,
115 organization_id: Uuid,
116 expense_id: Uuid,
117 owner_id: Uuid,
118 level: ReminderLevel,
119 amount_owed: Decimal,
120 due_date: DateTime<Utc>,
121 days_overdue: i64,
122 ) -> Result<Self, String> {
123 if amount_owed <= Decimal::ZERO {
125 return Err("Amount owed must be greater than 0".to_string());
126 }
127
128 if amount_owed < dec!(0.01) {
134 return Err("Amount owed must be at least 0.01".to_string());
135 }
136
137 if days_overdue < 0 {
138 return Err("Days overdue cannot be negative".to_string());
139 }
140
141 let expected_days = level.days_after_due_date();
143 if days_overdue < expected_days {
144 return Err(format!(
145 "Cannot create {} reminder before {} days overdue (currently {} days)",
146 match level {
147 ReminderLevel::FirstReminder => "first",
148 ReminderLevel::SecondReminder => "second",
149 ReminderLevel::FormalNotice => "formal notice",
150 },
151 expected_days,
152 days_overdue
153 ));
154 }
155
156 let penalty_amount = Self::calculate_penalty(amount_owed, days_overdue);
158 let total_amount = amount_owed + penalty_amount;
159
160 let delivery_method = match level {
162 ReminderLevel::FirstReminder => DeliveryMethod::Email,
163 ReminderLevel::SecondReminder => DeliveryMethod::Email,
164 ReminderLevel::FormalNotice => DeliveryMethod::RegisteredLetter,
165 };
166
167 let now = Utc::now();
168 Ok(Self {
169 id: Uuid::new_v4(),
170 acp_id,
171 organization_id,
172 expense_id,
173 owner_id,
174 level,
175 status: ReminderStatus::Pending,
176 amount_owed,
177 penalty_amount,
178 total_amount,
179 due_date,
180 days_overdue,
181 delivery_method,
182 sent_date: None,
183 opened_date: None,
184 pdf_path: None,
185 tracking_number: None,
186 notes: None,
187 created_at: now,
188 updated_at: now,
189 })
190 }
191
192 pub fn calculate_penalty(amount: Decimal, days_overdue: i64) -> Decimal {
209 if days_overdue <= 0 {
210 return Decimal::ZERO;
211 }
212 let yearly_penalty = amount * Self::BELGIAN_PENALTY_RATE;
213 let daily_penalty = yearly_penalty / Self::DAYS_PER_YEAR;
214 (daily_penalty * Decimal::from(days_overdue))
215 .round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero)
216 }
217
218 pub fn mark_as_sent(&mut self, pdf_path: Option<String>) -> Result<(), String> {
220 if self.status != ReminderStatus::Pending {
221 return Err(format!(
222 "Cannot mark reminder as sent: current status is {:?}",
223 self.status
224 ));
225 }
226
227 self.status = ReminderStatus::Sent;
228 self.sent_date = Some(Utc::now());
229 self.pdf_path = pdf_path;
230 self.updated_at = Utc::now();
231 Ok(())
232 }
233
234 pub fn mark_as_opened(&mut self) -> Result<(), String> {
236 if self.status != ReminderStatus::Sent {
237 return Err(format!(
238 "Cannot mark reminder as opened: must be sent first (current status: {:?})",
239 self.status
240 ));
241 }
242
243 self.status = ReminderStatus::Opened;
244 self.opened_date = Some(Utc::now());
245 self.updated_at = Utc::now();
246 Ok(())
247 }
248
249 pub fn mark_as_paid(&mut self) -> Result<(), String> {
251 match self.status {
252 ReminderStatus::Sent | ReminderStatus::Opened | ReminderStatus::Pending => {
253 self.status = ReminderStatus::Paid;
254 self.updated_at = Utc::now();
255 Ok(())
256 }
257 ReminderStatus::Paid => Err("Reminder is already marked as paid".to_string()),
258 ReminderStatus::Escalated => Err("Cannot mark escalated reminder as paid".to_string()),
259 ReminderStatus::Cancelled => Err("Cannot mark cancelled reminder as paid".to_string()),
260 }
261 }
262
263 pub fn can_escalate(&self) -> Result<(), String> {
272 if self.status == ReminderStatus::Paid || self.status == ReminderStatus::Cancelled {
273 return Err(format!(
274 "Cannot escalate reminder with status {:?}",
275 self.status
276 ));
277 }
278 Ok(())
279 }
280
281 pub fn escalate(&mut self) -> Result<Option<ReminderLevel>, String> {
282 self.can_escalate()?;
283
284 self.status = ReminderStatus::Escalated;
285 self.updated_at = Utc::now();
286 Ok(self.level.next_level())
287 }
288
289 pub fn cancel(&mut self, reason: String) -> Result<(), String> {
291 if self.status == ReminderStatus::Sent || self.status == ReminderStatus::Opened {
292 return Err("Cannot cancel reminder that has already been sent".to_string());
293 }
294
295 self.status = ReminderStatus::Cancelled;
296 self.notes = Some(reason);
297 self.updated_at = Utc::now();
298 Ok(())
299 }
300
301 pub fn set_tracking_number(&mut self, tracking_number: String) -> Result<(), String> {
303 if self.delivery_method != DeliveryMethod::RegisteredLetter {
304 return Err("Tracking number is only valid for registered letters".to_string());
305 }
306
307 self.tracking_number = Some(tracking_number);
308 self.updated_at = Utc::now();
309 Ok(())
310 }
311
312 pub fn needs_escalation(&self, current_date: DateTime<Utc>) -> bool {
314 if self.status != ReminderStatus::Sent && self.status != ReminderStatus::Opened {
315 return false;
316 }
317
318 if let Some(sent_date) = self.sent_date {
319 let days_since_sent = (current_date - sent_date).num_days();
320 days_since_sent >= 15 && self.level.next_level().is_some()
322 } else {
323 false
324 }
325 }
326
327 pub fn recalculate_penalties(&mut self, current_days_overdue: i64) {
329 self.days_overdue = current_days_overdue;
330 self.penalty_amount = Self::calculate_penalty(self.amount_owed, current_days_overdue);
331 self.total_amount = self.amount_owed + self.penalty_amount;
332 self.updated_at = Utc::now();
333 }
334}
335
336impl crate::domain::services::PieceDeGestion for PaymentReminder {
337 fn acp_id(&self) -> Uuid {
338 self.acp_id
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 #[test]
347 fn test_create_payment_reminder_success() {
348 let org_id = Uuid::new_v4();
349 let expense_id = Uuid::new_v4();
350 let owner_id = Uuid::new_v4();
351 let due_date = Utc::now() - chrono::Duration::days(20);
352
353 let reminder = PaymentReminder::new(
354 Uuid::new_v4(), org_id,
356 expense_id,
357 owner_id,
358 ReminderLevel::FirstReminder,
359 dec!(100),
360 due_date,
361 20,
362 );
363
364 assert!(reminder.is_ok());
365 let reminder = reminder.unwrap();
366 assert_eq!(reminder.status, ReminderStatus::Pending);
367 assert_eq!(reminder.level, ReminderLevel::FirstReminder);
368 assert_eq!(reminder.delivery_method, DeliveryMethod::Email);
369 }
370
371 #[test]
372 fn test_create_reminder_too_early() {
373 let org_id = Uuid::new_v4();
374 let expense_id = Uuid::new_v4();
375 let owner_id = Uuid::new_v4();
376 let due_date = Utc::now() - chrono::Duration::days(10);
377
378 let reminder = PaymentReminder::new(
379 Uuid::new_v4(), org_id,
381 expense_id,
382 owner_id,
383 ReminderLevel::FirstReminder,
384 dec!(100),
385 due_date,
386 10, );
388
389 assert!(reminder.is_err());
390 assert!(reminder
391 .unwrap_err()
392 .contains("Cannot create first reminder before"));
393 }
394
395 #[test]
402 fn test_calculate_penalty() {
403 assert_eq!(
405 PaymentReminder::calculate_penalty(dec!(100), 30),
406 dec!(0.37)
407 );
408
409 assert_eq!(
411 PaymentReminder::calculate_penalty(dec!(1000), 365),
412 dec!(45.00)
413 );
414 }
415
416 #[test]
418 fn test_calculate_penalty_no_overdue_days() {
419 assert_eq!(
420 PaymentReminder::calculate_penalty(dec!(100), 0),
421 Decimal::ZERO
422 );
423 assert_eq!(
424 PaymentReminder::calculate_penalty(dec!(100), -5),
425 Decimal::ZERO
426 );
427 }
428
429 #[test]
437 fn test_calculate_penalty_rounds_to_the_cent() {
438 assert_eq!(
439 PaymentReminder::calculate_penalty(dec!(1000), 81),
440 dec!(9.99)
441 );
442
443 let p = PaymentReminder::calculate_penalty(dec!(1234.56), 137);
446 assert_eq!(p.scale(), 2, "la pénalité doit être arrondie au centime");
447 }
448
449 #[test]
454 fn test_reminder_rejects_amounts_below_one_cent() {
455 let due_date = Utc::now() - chrono::Duration::days(20);
456 for amount in [dec!(0), dec!(-100), dec!(0.009)] {
457 let r = PaymentReminder::new(
458 Uuid::new_v4(), Uuid::new_v4(),
460 Uuid::new_v4(),
461 Uuid::new_v4(),
462 ReminderLevel::FirstReminder,
463 amount,
464 due_date,
465 20,
466 );
467 assert!(r.is_err(), "montant {amount} aurait dû être rejeté");
468 }
469 }
470
471 #[test]
475 fn test_total_amount_equals_owed_plus_penalty_exactly() {
476 let due_date = Utc::now() - chrono::Duration::days(90);
477 let r = PaymentReminder::new(
478 Uuid::new_v4(), Uuid::new_v4(),
480 Uuid::new_v4(),
481 Uuid::new_v4(),
482 ReminderLevel::FormalNotice,
483 dec!(1234.56),
484 due_date,
485 90,
486 )
487 .unwrap();
488
489 assert_eq!(r.total_amount, r.amount_owed + r.penalty_amount);
490 assert_eq!(r.amount_owed, dec!(1234.56));
491 }
492
493 #[test]
494 fn test_mark_as_sent() {
495 let org_id = Uuid::new_v4();
496 let expense_id = Uuid::new_v4();
497 let owner_id = Uuid::new_v4();
498 let due_date = Utc::now() - chrono::Duration::days(20);
499
500 let mut reminder = PaymentReminder::new(
501 Uuid::new_v4(), org_id,
503 expense_id,
504 owner_id,
505 ReminderLevel::FirstReminder,
506 dec!(100),
507 due_date,
508 20,
509 )
510 .unwrap();
511
512 let result = reminder.mark_as_sent(Some("/path/to/pdf".to_string()));
513 assert!(result.is_ok());
514 assert_eq!(reminder.status, ReminderStatus::Sent);
515 assert!(reminder.sent_date.is_some());
516 assert_eq!(reminder.pdf_path, Some("/path/to/pdf".to_string()));
517 }
518
519 #[test]
520 fn test_escalate() {
521 let org_id = Uuid::new_v4();
522 let expense_id = Uuid::new_v4();
523 let owner_id = Uuid::new_v4();
524 let due_date = Utc::now() - chrono::Duration::days(20);
525
526 let mut reminder = PaymentReminder::new(
527 Uuid::new_v4(), org_id,
529 expense_id,
530 owner_id,
531 ReminderLevel::FirstReminder,
532 dec!(100),
533 due_date,
534 20,
535 )
536 .unwrap();
537
538 reminder.mark_as_sent(None).unwrap();
539
540 let next_level = reminder.escalate().unwrap();
541 assert_eq!(next_level, Some(ReminderLevel::SecondReminder));
542 assert_eq!(reminder.status, ReminderStatus::Escalated);
543 }
544
545 #[test]
546 fn test_reminder_level_days() {
547 assert_eq!(ReminderLevel::FirstReminder.days_after_due_date(), 15);
548 assert_eq!(ReminderLevel::SecondReminder.days_after_due_date(), 30);
549 assert_eq!(ReminderLevel::FormalNotice.days_after_due_date(), 60);
550 }
551
552 #[test]
553 fn test_needs_escalation() {
554 let org_id = Uuid::new_v4();
555 let expense_id = Uuid::new_v4();
556 let owner_id = Uuid::new_v4();
557 let due_date = Utc::now() - chrono::Duration::days(20);
558
559 let mut reminder = PaymentReminder::new(
560 Uuid::new_v4(), org_id,
562 expense_id,
563 owner_id,
564 ReminderLevel::FirstReminder,
565 dec!(100),
566 due_date,
567 20,
568 )
569 .unwrap();
570
571 assert!(!reminder.needs_escalation(Utc::now()));
573
574 reminder.mark_as_sent(None).unwrap();
576
577 assert!(!reminder.needs_escalation(Utc::now()));
579
580 let future_date = Utc::now() + chrono::Duration::days(16);
582 assert!(reminder.needs_escalation(future_date));
583 }
584
585 #[test]
586 fn test_recalculate_penalties() {
587 let org_id = Uuid::new_v4();
588 let expense_id = Uuid::new_v4();
589 let owner_id = Uuid::new_v4();
590 let due_date = Utc::now() - chrono::Duration::days(20);
591
592 let mut reminder = PaymentReminder::new(
593 Uuid::new_v4(), org_id,
595 expense_id,
596 owner_id,
597 ReminderLevel::FirstReminder,
598 dec!(100),
599 due_date,
600 20,
601 )
602 .unwrap();
603
604 let initial_penalty = reminder.penalty_amount;
605
606 reminder.recalculate_penalties(40);
608
609 assert_eq!(reminder.days_overdue, 40);
610 assert!(reminder.penalty_amount > initial_penalty);
611 assert_eq!(
612 reminder.total_amount,
613 reminder.amount_owed + reminder.penalty_amount
614 );
615 }
616
617 #[test]
618 fn test_formal_notice_uses_registered_letter() {
619 let org_id = Uuid::new_v4();
620 let expense_id = Uuid::new_v4();
621 let owner_id = Uuid::new_v4();
622 let due_date = Utc::now() - chrono::Duration::days(70);
623
624 let reminder = PaymentReminder::new(
625 Uuid::new_v4(), org_id,
627 expense_id,
628 owner_id,
629 ReminderLevel::FormalNotice,
630 dec!(100),
631 due_date,
632 70,
633 )
634 .unwrap();
635
636 assert_eq!(reminder.delivery_method, DeliveryMethod::RegisteredLetter);
637 }
638}