1use chrono::{DateTime, Utc};
2use f64;
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, sqlx::Type)]
8#[sqlx(type_name = "etat_date_status", rename_all = "snake_case")]
9pub enum EtatDateStatus {
10 Requested, InProgress, Generated, Delivered, Expired, }
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, sqlx::Type)]
19#[sqlx(type_name = "etat_date_language", rename_all = "snake_case")]
20pub enum EtatDateLanguage {
21 Fr, Nl, De, }
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34pub struct EtatDate {
35 pub id: Uuid,
36 pub organization_id: Uuid,
37 pub building_id: Uuid,
38 pub unit_id: Uuid,
39
40 pub reference_date: DateTime<Utc>,
42
43 pub requested_date: DateTime<Utc>,
45
46 pub generated_date: Option<DateTime<Utc>>,
48
49 pub delivered_date: Option<DateTime<Utc>>,
51
52 pub status: EtatDateStatus,
54
55 pub language: EtatDateLanguage,
57
58 pub reference_number: String,
60
61 pub notary_name: String,
63 pub notary_email: String,
64 pub notary_phone: Option<String>,
65
66 pub building_name: String,
68 pub building_address: String,
69 pub unit_number: String,
70 pub unit_floor: Option<String>,
71 pub unit_area: Option<f64>,
72
73 pub ordinary_charges_quota: f64,
76 pub extraordinary_charges_quota: f64,
78
79 pub owner_balance: f64,
82 pub arrears_amount: f64,
84
85 pub monthly_provision_amount: f64,
88
89 pub total_balance: f64,
92
93 pub approved_works_unpaid: f64,
96
97 pub additional_data: serde_json::Value,
112
113 pub pdf_file_path: Option<String>,
115
116 pub created_at: DateTime<Utc>,
117 pub updated_at: DateTime<Utc>,
118}
119
120impl EtatDate {
121 #[allow(clippy::too_many_arguments)]
122 pub fn new(
123 organization_id: Uuid,
124 building_id: Uuid,
125 unit_id: Uuid,
126 reference_date: DateTime<Utc>,
127 language: EtatDateLanguage,
128 notary_name: String,
129 notary_email: String,
130 notary_phone: Option<String>,
131 building_name: String,
132 building_address: String,
133 unit_number: String,
134 unit_floor: Option<String>,
135 unit_area: Option<f64>,
136 ordinary_charges_quota: f64,
137 extraordinary_charges_quota: f64,
138 ) -> Result<Self, String> {
139 if notary_name.trim().is_empty() {
141 return Err("Notary name cannot be empty".to_string());
142 }
143 if notary_email.trim().is_empty() {
144 return Err("Notary email cannot be empty".to_string());
145 }
146 if !notary_email.contains('@') {
147 return Err("Invalid notary email".to_string());
148 }
149 if building_name.trim().is_empty() {
150 return Err("Building name cannot be empty".to_string());
151 }
152 if building_address.trim().is_empty() {
153 return Err("Building address cannot be empty".to_string());
154 }
155 if unit_number.trim().is_empty() {
156 return Err("Unit number cannot be empty".to_string());
157 }
158
159 if ordinary_charges_quota < 0.0 || ordinary_charges_quota > 100.0 {
161 return Err("Ordinary charges quota must be between 0 and 100%".to_string());
162 }
163 if extraordinary_charges_quota < 0.0 || extraordinary_charges_quota > 100.0 {
164 return Err("Extraordinary charges quota must be between 0 and 100%".to_string());
165 }
166
167 let now = Utc::now();
168 let reference_number = Self::generate_reference_number(&building_id, &unit_id, &now);
169
170 Ok(Self {
171 id: Uuid::new_v4(),
172 organization_id,
173 building_id,
174 unit_id,
175 reference_date,
176 requested_date: now,
177 generated_date: None,
178 delivered_date: None,
179 status: EtatDateStatus::Requested,
180 language,
181 reference_number,
182 notary_name,
183 notary_email,
184 notary_phone,
185 building_name,
186 building_address,
187 unit_number,
188 unit_floor,
189 unit_area,
190 ordinary_charges_quota,
191 extraordinary_charges_quota,
192 owner_balance: 0.0,
193 arrears_amount: 0.0,
194 monthly_provision_amount: 0.0,
195 total_balance: 0.0,
196 approved_works_unpaid: 0.0,
197 additional_data: serde_json::json!({}),
198 pdf_file_path: None,
199 created_at: now,
200 updated_at: now,
201 })
202 }
203
204 fn generate_reference_number(
207 building_id: &Uuid,
208 unit_id: &Uuid,
209 date: &DateTime<Utc>,
210 ) -> String {
211 let year = date.format("%Y");
212 let building_short = &building_id.to_string()[..8];
213 let unit_short = &unit_id.to_string()[..8];
214
215 let counter = date.timestamp() % 1000;
217
218 format!(
219 "ED-{}-{:03}-BLD{}-U{}",
220 year, counter, building_short, unit_short
221 )
222 }
223
224 pub fn mark_in_progress(&mut self) -> Result<(), String> {
226 match self.status {
227 EtatDateStatus::Requested => {
228 self.status = EtatDateStatus::InProgress;
229 self.updated_at = Utc::now();
230 Ok(())
231 }
232 _ => Err(format!(
233 "Cannot mark as in progress: current status is {:?}",
234 self.status
235 )),
236 }
237 }
238
239 pub fn mark_generated(&mut self, pdf_file_path: String) -> Result<(), String> {
241 if pdf_file_path.trim().is_empty() {
242 return Err("PDF file path cannot be empty".to_string());
243 }
244
245 match self.status {
246 EtatDateStatus::InProgress => {
247 self.status = EtatDateStatus::Generated;
248 self.generated_date = Some(Utc::now());
249 self.pdf_file_path = Some(pdf_file_path);
250 self.updated_at = Utc::now();
251 Ok(())
252 }
253 _ => Err(format!(
254 "Cannot mark as generated: current status is {:?}",
255 self.status
256 )),
257 }
258 }
259
260 pub fn mark_delivered(&mut self) -> Result<(), String> {
262 match self.status {
263 EtatDateStatus::Generated => {
264 self.status = EtatDateStatus::Delivered;
265 self.delivered_date = Some(Utc::now());
266 self.updated_at = Utc::now();
267 Ok(())
268 }
269 _ => Err(format!(
270 "Cannot mark as delivered: current status is {:?}",
271 self.status
272 )),
273 }
274 }
275
276 pub fn is_expired(&self) -> bool {
278 let now = Utc::now();
279 let expiration_date = self.reference_date + chrono::Duration::days(90); now > expiration_date
281 }
282
283 pub fn is_overdue(&self) -> bool {
285 if matches!(
286 self.status,
287 EtatDateStatus::Generated | EtatDateStatus::Delivered
288 ) {
289 return false; }
291
292 let now = Utc::now();
293 let deadline = self.requested_date + chrono::Duration::days(10);
294 now > deadline
295 }
296
297 pub fn days_since_request(&self) -> i64 {
299 let now = Utc::now();
300 (now - self.requested_date).num_days()
301 }
302
303 pub fn update_financial_data(
305 &mut self,
306 owner_balance: f64,
307 arrears_amount: f64,
308 monthly_provision_amount: f64,
309 total_balance: f64,
310 approved_works_unpaid: f64,
311 ) -> Result<(), String> {
312 if arrears_amount < 0.0 {
314 return Err("Arrears amount cannot be negative".to_string());
315 }
316 if monthly_provision_amount < 0.0 {
317 return Err("Monthly provision amount cannot be negative".to_string());
318 }
319 if approved_works_unpaid < 0.0 {
320 return Err("Approved works unpaid cannot be negative".to_string());
321 }
322
323 self.owner_balance = owner_balance;
324 self.arrears_amount = arrears_amount;
325 self.monthly_provision_amount = monthly_provision_amount;
326 self.total_balance = total_balance;
327 self.approved_works_unpaid = approved_works_unpaid;
328 self.updated_at = Utc::now();
329
330 Ok(())
331 }
332
333 pub fn update_additional_data(&mut self, data: serde_json::Value) -> Result<(), String> {
335 if !data.is_object() {
336 return Err("Additional data must be a JSON object".to_string());
337 }
338
339 self.additional_data = data;
340 self.updated_at = Utc::now();
341 Ok(())
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn test_create_etat_date_success() {
351 let org_id = Uuid::new_v4();
352 let building_id = Uuid::new_v4();
353 let unit_id = Uuid::new_v4();
354 let ref_date = Utc::now();
355
356 let etat_date = EtatDate::new(
357 org_id,
358 building_id,
359 unit_id,
360 ref_date,
361 EtatDateLanguage::Fr,
362 "Maître Dupont".to_string(),
363 "dupont@notaire.be".to_string(),
364 Some("+32 2 123 4567".to_string()),
365 "Résidence Les Jardins".to_string(),
366 "Rue de la Loi 123, 1000 Bruxelles".to_string(),
367 "101".to_string(),
368 Some("1".to_string()),
369 Some(100.0),
370 100.0, 100.0, );
373
374 assert!(etat_date.is_ok());
375 let ed = etat_date.unwrap();
376 assert_eq!(ed.status, EtatDateStatus::Requested);
377 assert_eq!(ed.notary_name, "Maître Dupont");
378 assert!(ed.reference_number.starts_with("ED-"));
379 }
380
381 #[test]
382 fn test_create_etat_date_invalid_email() {
383 let org_id = Uuid::new_v4();
384 let building_id = Uuid::new_v4();
385 let unit_id = Uuid::new_v4();
386 let ref_date = Utc::now();
387
388 let result = EtatDate::new(
389 org_id,
390 building_id,
391 unit_id,
392 ref_date,
393 EtatDateLanguage::Fr,
394 "Maître Dupont".to_string(),
395 "invalid-email".to_string(), None,
397 "Résidence Les Jardins".to_string(),
398 "Rue de la Loi 123".to_string(),
399 "101".to_string(),
400 None,
401 None,
402 100.0,
403 100.0,
404 );
405
406 assert!(result.is_err());
407 assert_eq!(result.unwrap_err(), "Invalid notary email");
408 }
409
410 #[test]
411 fn test_create_etat_date_invalid_quota() {
412 let org_id = Uuid::new_v4();
413 let building_id = Uuid::new_v4();
414 let unit_id = Uuid::new_v4();
415 let ref_date = Utc::now();
416
417 let result = EtatDate::new(
418 org_id,
419 building_id,
420 unit_id,
421 ref_date,
422 EtatDateLanguage::Fr,
423 "Maître Dupont".to_string(),
424 "dupont@notaire.be".to_string(),
425 None,
426 "Résidence Les Jardins".to_string(),
427 "Rue de la Loi 123".to_string(),
428 "101".to_string(),
429 None,
430 None,
431 150.0, 100.0,
433 );
434
435 assert!(result.is_err());
436 assert!(result.unwrap_err().contains("between 0 and 100%"));
437 }
438
439 #[test]
440 fn test_workflow_transitions() {
441 let org_id = Uuid::new_v4();
442 let building_id = Uuid::new_v4();
443 let unit_id = Uuid::new_v4();
444 let ref_date = Utc::now();
445
446 let mut ed = EtatDate::new(
447 org_id,
448 building_id,
449 unit_id,
450 ref_date,
451 EtatDateLanguage::Fr,
452 "Maître Dupont".to_string(),
453 "dupont@notaire.be".to_string(),
454 None,
455 "Résidence Les Jardins".to_string(),
456 "Rue de la Loi 123".to_string(),
457 "101".to_string(),
458 None,
459 None,
460 100.0,
461 100.0,
462 )
463 .unwrap();
464
465 assert!(ed.mark_in_progress().is_ok());
467 assert_eq!(ed.status, EtatDateStatus::InProgress);
468
469 assert!(ed
471 .mark_generated("/path/to/etat_date_001.pdf".to_string())
472 .is_ok());
473 assert_eq!(ed.status, EtatDateStatus::Generated);
474 assert!(ed.generated_date.is_some());
475 assert!(ed.pdf_file_path.is_some());
476
477 assert!(ed.mark_delivered().is_ok());
479 assert_eq!(ed.status, EtatDateStatus::Delivered);
480 assert!(ed.delivered_date.is_some());
481 }
482
483 #[test]
484 fn test_invalid_workflow_transition() {
485 let org_id = Uuid::new_v4();
486 let building_id = Uuid::new_v4();
487 let unit_id = Uuid::new_v4();
488 let ref_date = Utc::now();
489
490 let mut ed = EtatDate::new(
491 org_id,
492 building_id,
493 unit_id,
494 ref_date,
495 EtatDateLanguage::Fr,
496 "Maître Dupont".to_string(),
497 "dupont@notaire.be".to_string(),
498 None,
499 "Résidence Les Jardins".to_string(),
500 "Rue de la Loi 123".to_string(),
501 "101".to_string(),
502 None,
503 None,
504 100.0,
505 100.0,
506 )
507 .unwrap();
508
509 let result = ed.mark_delivered();
511 assert!(result.is_err());
512 }
513
514 #[test]
515 fn test_update_financial_data() {
516 let org_id = Uuid::new_v4();
517 let building_id = Uuid::new_v4();
518 let unit_id = Uuid::new_v4();
519 let ref_date = Utc::now();
520
521 let mut ed = EtatDate::new(
522 org_id,
523 building_id,
524 unit_id,
525 ref_date,
526 EtatDateLanguage::Fr,
527 "Maître Dupont".to_string(),
528 "dupont@notaire.be".to_string(),
529 None,
530 "Résidence Les Jardins".to_string(),
531 "Rue de la Loi 123".to_string(),
532 "101".to_string(),
533 None,
534 None,
535 100.0,
536 100.0,
537 )
538 .unwrap();
539
540 let result = ed.update_financial_data(
541 -500.00, 100.0, 100.0, -500.00, 100.0, );
547
548 assert!(result.is_ok());
549 assert_eq!(ed.owner_balance, -500.00);
550 assert_eq!(ed.arrears_amount, 100.0);
551 }
552
553 #[test]
554 fn test_is_overdue() {
555 let org_id = Uuid::new_v4();
556 let building_id = Uuid::new_v4();
557 let unit_id = Uuid::new_v4();
558 let ref_date = Utc::now();
559
560 let mut ed = EtatDate::new(
561 org_id,
562 building_id,
563 unit_id,
564 ref_date,
565 EtatDateLanguage::Fr,
566 "Maître Dupont".to_string(),
567 "dupont@notaire.be".to_string(),
568 None,
569 "Résidence Les Jardins".to_string(),
570 "Rue de la Loi 123".to_string(),
571 "101".to_string(),
572 None,
573 None,
574 100.0,
575 100.0,
576 )
577 .unwrap();
578
579 ed.requested_date = Utc::now() - chrono::Duration::days(11);
581
582 assert!(ed.is_overdue());
583 }
584
585 #[test]
586 fn test_days_since_request() {
587 let org_id = Uuid::new_v4();
588 let building_id = Uuid::new_v4();
589 let unit_id = Uuid::new_v4();
590 let ref_date = Utc::now();
591
592 let mut ed = EtatDate::new(
593 org_id,
594 building_id,
595 unit_id,
596 ref_date,
597 EtatDateLanguage::Fr,
598 "Maître Dupont".to_string(),
599 "dupont@notaire.be".to_string(),
600 None,
601 "Résidence Les Jardins".to_string(),
602 "Rue de la Loi 123".to_string(),
603 "101".to_string(),
604 None,
605 None,
606 100.0,
607 100.0,
608 )
609 .unwrap();
610
611 ed.requested_date = Utc::now() - chrono::Duration::days(5);
613
614 assert_eq!(ed.days_since_request(), 5);
615 }
616}