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