1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7pub enum ReminderLevel {
8 FirstReminder, SecondReminder, FormalNotice, }
12
13impl ReminderLevel {
14 pub fn days_after_due_date(&self) -> i64 {
16 match self {
17 ReminderLevel::FirstReminder => 15,
18 ReminderLevel::SecondReminder => 30,
19 ReminderLevel::FormalNotice => 60,
20 }
21 }
22
23 pub fn next_level(&self) -> Option<ReminderLevel> {
25 match self {
26 ReminderLevel::FirstReminder => Some(ReminderLevel::SecondReminder),
27 ReminderLevel::SecondReminder => Some(ReminderLevel::FormalNotice),
28 ReminderLevel::FormalNotice => None, }
30 }
31
32 pub fn tone(&self) -> &'static str {
34 match self {
35 ReminderLevel::FirstReminder => "aimable",
36 ReminderLevel::SecondReminder => "ferme",
37 ReminderLevel::FormalNotice => "juridique",
38 }
39 }
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
44pub enum ReminderStatus {
45 Pending, Sent, Opened, Paid, Escalated, Cancelled, }
52
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55pub enum DeliveryMethod {
56 Email,
57 RegisteredLetter, Bailiff, }
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
63pub struct PaymentReminder {
64 pub id: Uuid,
65 pub organization_id: Uuid,
66 pub expense_id: Uuid,
67 pub owner_id: Uuid,
68 pub level: ReminderLevel,
69 pub status: ReminderStatus,
70 pub amount_owed: f64, pub penalty_amount: f64, pub total_amount: f64, pub due_date: DateTime<Utc>, pub days_overdue: i64, pub delivery_method: DeliveryMethod,
76 pub sent_date: Option<DateTime<Utc>>,
77 pub opened_date: Option<DateTime<Utc>>,
78 pub pdf_path: Option<String>, pub tracking_number: Option<String>, pub notes: Option<String>,
81 pub created_at: DateTime<Utc>,
82 pub updated_at: DateTime<Utc>,
83}
84
85impl PaymentReminder {
86 pub const BELGIAN_PENALTY_RATE: f64 = 0.045;
91
92 #[allow(clippy::too_many_arguments)]
94 pub fn new(
95 organization_id: Uuid,
96 expense_id: Uuid,
97 owner_id: Uuid,
98 level: ReminderLevel,
99 amount_owed: f64,
100 due_date: DateTime<Utc>,
101 days_overdue: i64,
102 ) -> Result<Self, String> {
103 if amount_owed <= 0.0 {
105 return Err("Amount owed must be greater than 0".to_string());
106 }
107
108 if days_overdue < 0 {
109 return Err("Days overdue cannot be negative".to_string());
110 }
111
112 let expected_days = level.days_after_due_date();
114 if days_overdue < expected_days {
115 return Err(format!(
116 "Cannot create {} reminder before {} days overdue (currently {} days)",
117 match level {
118 ReminderLevel::FirstReminder => "first",
119 ReminderLevel::SecondReminder => "second",
120 ReminderLevel::FormalNotice => "formal notice",
121 },
122 expected_days,
123 days_overdue
124 ));
125 }
126
127 let penalty_amount = Self::calculate_penalty(amount_owed, days_overdue);
129 let total_amount = amount_owed + penalty_amount;
130
131 let delivery_method = match level {
133 ReminderLevel::FirstReminder => DeliveryMethod::Email,
134 ReminderLevel::SecondReminder => DeliveryMethod::Email,
135 ReminderLevel::FormalNotice => DeliveryMethod::RegisteredLetter,
136 };
137
138 let now = Utc::now();
139 Ok(Self {
140 id: Uuid::new_v4(),
141 organization_id,
142 expense_id,
143 owner_id,
144 level,
145 status: ReminderStatus::Pending,
146 amount_owed,
147 penalty_amount,
148 total_amount,
149 due_date,
150 days_overdue,
151 delivery_method,
152 sent_date: None,
153 opened_date: None,
154 pdf_path: None,
155 tracking_number: None,
156 notes: None,
157 created_at: now,
158 updated_at: now,
159 })
160 }
161
162 pub fn calculate_penalty(amount: f64, days_overdue: i64) -> f64 {
165 if days_overdue <= 0 {
166 return 0.0;
167 }
168 let yearly_penalty = amount * Self::BELGIAN_PENALTY_RATE;
169 let daily_penalty = yearly_penalty / 365.0;
170 (daily_penalty * days_overdue as f64 * 100.0).round() / 100.0 }
172
173 pub fn mark_as_sent(&mut self, pdf_path: Option<String>) -> Result<(), String> {
175 if self.status != ReminderStatus::Pending {
176 return Err(format!(
177 "Cannot mark reminder as sent: current status is {:?}",
178 self.status
179 ));
180 }
181
182 self.status = ReminderStatus::Sent;
183 self.sent_date = Some(Utc::now());
184 self.pdf_path = pdf_path;
185 self.updated_at = Utc::now();
186 Ok(())
187 }
188
189 pub fn mark_as_opened(&mut self) -> Result<(), String> {
191 if self.status != ReminderStatus::Sent {
192 return Err(format!(
193 "Cannot mark reminder as opened: must be sent first (current status: {:?})",
194 self.status
195 ));
196 }
197
198 self.status = ReminderStatus::Opened;
199 self.opened_date = Some(Utc::now());
200 self.updated_at = Utc::now();
201 Ok(())
202 }
203
204 pub fn mark_as_paid(&mut self) -> Result<(), String> {
206 match self.status {
207 ReminderStatus::Sent | ReminderStatus::Opened | ReminderStatus::Pending => {
208 self.status = ReminderStatus::Paid;
209 self.updated_at = Utc::now();
210 Ok(())
211 }
212 ReminderStatus::Paid => Err("Reminder is already marked as paid".to_string()),
213 ReminderStatus::Escalated => Err("Cannot mark escalated reminder as paid".to_string()),
214 ReminderStatus::Cancelled => Err("Cannot mark cancelled reminder as paid".to_string()),
215 }
216 }
217
218 pub fn escalate(&mut self) -> Result<Option<ReminderLevel>, String> {
220 if self.status == ReminderStatus::Paid || self.status == ReminderStatus::Cancelled {
221 return Err(format!(
222 "Cannot escalate reminder with status {:?}",
223 self.status
224 ));
225 }
226
227 self.status = ReminderStatus::Escalated;
228 self.updated_at = Utc::now();
229 Ok(self.level.next_level())
230 }
231
232 pub fn cancel(&mut self, reason: String) -> Result<(), String> {
234 if self.status == ReminderStatus::Sent || self.status == ReminderStatus::Opened {
235 return Err("Cannot cancel reminder that has already been sent".to_string());
236 }
237
238 self.status = ReminderStatus::Cancelled;
239 self.notes = Some(reason);
240 self.updated_at = Utc::now();
241 Ok(())
242 }
243
244 pub fn set_tracking_number(&mut self, tracking_number: String) -> Result<(), String> {
246 if self.delivery_method != DeliveryMethod::RegisteredLetter {
247 return Err("Tracking number is only valid for registered letters".to_string());
248 }
249
250 self.tracking_number = Some(tracking_number);
251 self.updated_at = Utc::now();
252 Ok(())
253 }
254
255 pub fn needs_escalation(&self, current_date: DateTime<Utc>) -> bool {
257 if self.status != ReminderStatus::Sent && self.status != ReminderStatus::Opened {
258 return false;
259 }
260
261 if let Some(sent_date) = self.sent_date {
262 let days_since_sent = (current_date - sent_date).num_days();
263 days_since_sent >= 15 && self.level.next_level().is_some()
265 } else {
266 false
267 }
268 }
269
270 pub fn recalculate_penalties(&mut self, current_days_overdue: i64) {
272 self.days_overdue = current_days_overdue;
273 self.penalty_amount = Self::calculate_penalty(self.amount_owed, current_days_overdue);
274 self.total_amount = self.amount_owed + self.penalty_amount;
275 self.updated_at = Utc::now();
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn test_create_payment_reminder_success() {
285 let org_id = Uuid::new_v4();
286 let expense_id = Uuid::new_v4();
287 let owner_id = Uuid::new_v4();
288 let due_date = Utc::now() - chrono::Duration::days(20);
289
290 let reminder = PaymentReminder::new(
291 org_id,
292 expense_id,
293 owner_id,
294 ReminderLevel::FirstReminder,
295 100.0,
296 due_date,
297 20,
298 );
299
300 assert!(reminder.is_ok());
301 let reminder = reminder.unwrap();
302 assert_eq!(reminder.status, ReminderStatus::Pending);
303 assert_eq!(reminder.level, ReminderLevel::FirstReminder);
304 assert_eq!(reminder.delivery_method, DeliveryMethod::Email);
305 }
306
307 #[test]
308 fn test_create_reminder_too_early() {
309 let org_id = Uuid::new_v4();
310 let expense_id = Uuid::new_v4();
311 let owner_id = Uuid::new_v4();
312 let due_date = Utc::now() - chrono::Duration::days(10);
313
314 let reminder = PaymentReminder::new(
315 org_id,
316 expense_id,
317 owner_id,
318 ReminderLevel::FirstReminder,
319 100.0,
320 due_date,
321 10, );
323
324 assert!(reminder.is_err());
325 assert!(reminder
326 .unwrap_err()
327 .contains("Cannot create first reminder before"));
328 }
329
330 #[test]
331 fn test_calculate_penalty() {
332 let penalty = PaymentReminder::calculate_penalty(100.0, 30);
335 assert!((penalty - 0.37).abs() < 0.01);
336
337 let penalty = PaymentReminder::calculate_penalty(1000.0, 365);
340 assert!((penalty - 45.0).abs() < 0.01);
341 }
342
343 #[test]
344 fn test_mark_as_sent() {
345 let org_id = Uuid::new_v4();
346 let expense_id = Uuid::new_v4();
347 let owner_id = Uuid::new_v4();
348 let due_date = Utc::now() - chrono::Duration::days(20);
349
350 let mut reminder = PaymentReminder::new(
351 org_id,
352 expense_id,
353 owner_id,
354 ReminderLevel::FirstReminder,
355 100.0,
356 due_date,
357 20,
358 )
359 .unwrap();
360
361 let result = reminder.mark_as_sent(Some("/path/to/pdf".to_string()));
362 assert!(result.is_ok());
363 assert_eq!(reminder.status, ReminderStatus::Sent);
364 assert!(reminder.sent_date.is_some());
365 assert_eq!(reminder.pdf_path, Some("/path/to/pdf".to_string()));
366 }
367
368 #[test]
369 fn test_escalate() {
370 let org_id = Uuid::new_v4();
371 let expense_id = Uuid::new_v4();
372 let owner_id = Uuid::new_v4();
373 let due_date = Utc::now() - chrono::Duration::days(20);
374
375 let mut reminder = PaymentReminder::new(
376 org_id,
377 expense_id,
378 owner_id,
379 ReminderLevel::FirstReminder,
380 100.0,
381 due_date,
382 20,
383 )
384 .unwrap();
385
386 reminder.mark_as_sent(None).unwrap();
387
388 let next_level = reminder.escalate().unwrap();
389 assert_eq!(next_level, Some(ReminderLevel::SecondReminder));
390 assert_eq!(reminder.status, ReminderStatus::Escalated);
391 }
392
393 #[test]
394 fn test_reminder_level_days() {
395 assert_eq!(ReminderLevel::FirstReminder.days_after_due_date(), 15);
396 assert_eq!(ReminderLevel::SecondReminder.days_after_due_date(), 30);
397 assert_eq!(ReminderLevel::FormalNotice.days_after_due_date(), 60);
398 }
399
400 #[test]
401 fn test_needs_escalation() {
402 let org_id = Uuid::new_v4();
403 let expense_id = Uuid::new_v4();
404 let owner_id = Uuid::new_v4();
405 let due_date = Utc::now() - chrono::Duration::days(20);
406
407 let mut reminder = PaymentReminder::new(
408 org_id,
409 expense_id,
410 owner_id,
411 ReminderLevel::FirstReminder,
412 100.0,
413 due_date,
414 20,
415 )
416 .unwrap();
417
418 assert!(!reminder.needs_escalation(Utc::now()));
420
421 reminder.mark_as_sent(None).unwrap();
423
424 assert!(!reminder.needs_escalation(Utc::now()));
426
427 let future_date = Utc::now() + chrono::Duration::days(16);
429 assert!(reminder.needs_escalation(future_date));
430 }
431
432 #[test]
433 fn test_recalculate_penalties() {
434 let org_id = Uuid::new_v4();
435 let expense_id = Uuid::new_v4();
436 let owner_id = Uuid::new_v4();
437 let due_date = Utc::now() - chrono::Duration::days(20);
438
439 let mut reminder = PaymentReminder::new(
440 org_id,
441 expense_id,
442 owner_id,
443 ReminderLevel::FirstReminder,
444 100.0,
445 due_date,
446 20,
447 )
448 .unwrap();
449
450 let initial_penalty = reminder.penalty_amount;
451
452 reminder.recalculate_penalties(40);
454
455 assert_eq!(reminder.days_overdue, 40);
456 assert!(reminder.penalty_amount > initial_penalty);
457 assert_eq!(
458 reminder.total_amount,
459 reminder.amount_owed + reminder.penalty_amount
460 );
461 }
462
463 #[test]
464 fn test_formal_notice_uses_registered_letter() {
465 let org_id = Uuid::new_v4();
466 let expense_id = Uuid::new_v4();
467 let owner_id = Uuid::new_v4();
468 let due_date = Utc::now() - chrono::Duration::days(70);
469
470 let reminder = PaymentReminder::new(
471 org_id,
472 expense_id,
473 owner_id,
474 ReminderLevel::FormalNotice,
475 100.0,
476 due_date,
477 70,
478 )
479 .unwrap();
480
481 assert_eq!(reminder.delivery_method, DeliveryMethod::RegisteredLetter);
482 }
483}