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