Skip to main content

koprogo_api/domain/copropriete/
mandate.rs

1//! Mandate — juridical delegation tracker (Story 3.4 — FR7 INV-14).
2//!
3//! When a syndic must delegate a legal/technical act to an external
4//! professional (notaire for a unit sale, avocat for a litigation,
5//! architecte for renovation works, BET for technical studies, AMO for
6//! project management assistance, gardien for on-site duties), they issue
7//! a `Mandate` materialising:
8//!
9//! - the mandataire (`subject_user_id`),
10//! - the `kind` (mapped to a Story 3.1 `UserRole`),
11//! - the `scope` (Building or whole ACP),
12//! - mandatory temporal validity (`valid_from` / `valid_until`),
13//! - immutable audit (`issued_by`, `reason`, timestamps),
14//! - optional early revocation (`revoked_at`).
15//!
16//! # Invariants enforced at `issue()` time
17//!
18//! - `valid_until > valid_from`
19//! - `valid_until - valid_from <= 5 years` (anti-abuse: no unlimited mandates)
20//! - `reason.len() in [10, 500]`
21//! - `subject_user_id != issued_by` (a syndic may not mandate themselves)
22//!
23//! Runtime helpers `is_expired()` / `is_revoked()` / `is_currently_active()`
24//! are used by the upstream guard `assert_mandate_authorizes` to emit the
25//! correctly-typed `AppError::Mandate*` (403/404) instead of generic
26//! `Forbidden` strings.
27
28use crate::application::error::AppError;
29use crate::domain::entities::UserRole;
30use chrono::{DateTime, Duration, Utc};
31use serde::{Deserialize, Serialize};
32use uuid::Uuid;
33
34/// Kind of mandate. Mapped to a `UserRole` (Story 3.1) — issuing a mandate
35/// presumes the subject already carries the corresponding role.
36///
37/// `Lawyer`, `Notary`, `Amo`, `Architect`, `Bet`, `Warden` are the six
38/// mandataire roles introduced by Story 3.1.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40pub enum MandateKind {
41    Lawyer,
42    Notary,
43    Amo,
44    Architect,
45    Bet,
46    Warden,
47}
48
49impl MandateKind {
50    /// `UserRole` required of the subject for the mandate to be coherent.
51    /// The handler/use-case should cross-check before issuing.
52    pub fn required_user_role(&self) -> UserRole {
53        match self {
54            MandateKind::Lawyer => UserRole::Lawyer,
55            MandateKind::Notary => UserRole::Notary,
56            MandateKind::Amo => UserRole::Amo,
57            MandateKind::Architect => UserRole::Architect,
58            MandateKind::Bet => UserRole::Bet,
59            MandateKind::Warden => UserRole::Warden,
60        }
61    }
62
63    /// Whether issuance currently requires an AG (general assembly) decision.
64    ///
65    /// Story 3.4 ships the simple syndic-issued variant only; the litigation /
66    /// > 5000 EUR mandate workflow is a documented follow-up (see story body).
67    pub fn requires_ag_decision(&self) -> bool {
68        false
69    }
70}
71
72impl std::fmt::Display for MandateKind {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        match self {
75            MandateKind::Lawyer => write!(f, "lawyer"),
76            MandateKind::Notary => write!(f, "notary"),
77            MandateKind::Amo => write!(f, "amo"),
78            MandateKind::Architect => write!(f, "architect"),
79            MandateKind::Bet => write!(f, "bet"),
80            MandateKind::Warden => write!(f, "warden"),
81        }
82    }
83}
84
85impl std::str::FromStr for MandateKind {
86    type Err = AppError;
87
88    fn from_str(s: &str) -> Result<Self, Self::Err> {
89        match s.trim().to_lowercase().as_str() {
90            "lawyer" => Ok(MandateKind::Lawyer),
91            "notary" => Ok(MandateKind::Notary),
92            "amo" => Ok(MandateKind::Amo),
93            "architect" => Ok(MandateKind::Architect),
94            "bet" => Ok(MandateKind::Bet),
95            "warden" => Ok(MandateKind::Warden),
96            other => Err(AppError::Validation(format!(
97                "Invalid mandate kind: {}",
98                other
99            ))),
100        }
101    }
102}
103
104/// Scope a mandate applies to. Either a single building, or the whole ACP
105/// (e.g. a notaire mandated on all transactions of a copropriété).
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107pub enum MandateScope {
108    Building(Uuid),
109    Acp(Uuid),
110}
111
112impl MandateScope {
113    pub fn kind_str(&self) -> &'static str {
114        match self {
115            MandateScope::Building(_) => "building",
116            MandateScope::Acp(_) => "acp",
117        }
118    }
119
120    pub fn id(&self) -> Uuid {
121        match self {
122            MandateScope::Building(id) | MandateScope::Acp(id) => *id,
123        }
124    }
125
126    /// Build a scope from the (kind_str, id) tuple used at the persistence /
127    /// HTTP boundary. Validates the kind string against the whitelist.
128    pub fn from_parts(kind: &str, id: Uuid) -> Result<Self, AppError> {
129        match kind.trim().to_lowercase().as_str() {
130            "building" => Ok(MandateScope::Building(id)),
131            "acp" => Ok(MandateScope::Acp(id)),
132            other => Err(AppError::Validation(format!(
133                "Invalid mandate scope_kind: {}",
134                other
135            ))),
136        }
137    }
138}
139
140/// Anti-abuse: a single mandate cannot be active for more than 5 years.
141/// Reissue (with a fresh `reason`) keeps the audit trail granular.
142pub const MAX_MANDATE_DURATION_DAYS: i64 = 365 * 5;
143
144/// Minimal `reason` length (Belgian deontological practice: a mandate without
145/// a stated motive is unenforceable in front of a juge de paix).
146pub const MIN_REASON_LEN: usize = 10;
147
148/// Hard upper bound to keep DB rows compact and prevent free-form abuse.
149pub const MAX_REASON_LEN: usize = 500;
150
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
152pub struct Mandate {
153    pub id: Uuid,
154    pub subject_user_id: Uuid,
155    pub kind: MandateKind,
156    pub scope: MandateScope,
157    pub issued_by: Uuid,
158    pub reason: String,
159    pub valid_from: DateTime<Utc>,
160    pub valid_until: DateTime<Utc>,
161    pub revoked_at: Option<DateTime<Utc>>,
162    pub created_at: DateTime<Utc>,
163    pub updated_at: DateTime<Utc>,
164}
165
166impl Mandate {
167    /// Issue a new mandate. Invariants validated here (cf. module-level docs).
168    #[allow(clippy::too_many_arguments)]
169    pub fn issue(
170        subject_user_id: Uuid,
171        kind: MandateKind,
172        scope: MandateScope,
173        issued_by: Uuid,
174        reason: String,
175        valid_from: DateTime<Utc>,
176        valid_until: DateTime<Utc>,
177    ) -> Result<Self, AppError> {
178        if subject_user_id == issued_by {
179            return Err(AppError::Validation(
180                "Mandate subject and issuer must differ".to_string(),
181            ));
182        }
183        if valid_until <= valid_from {
184            return Err(AppError::Validation(
185                "Mandate valid_until must be strictly after valid_from".to_string(),
186            ));
187        }
188        let duration = valid_until - valid_from;
189        if duration > Duration::days(MAX_MANDATE_DURATION_DAYS) {
190            return Err(AppError::Validation(format!(
191                "Mandate duration exceeds {} days (anti-abuse)",
192                MAX_MANDATE_DURATION_DAYS
193            )));
194        }
195        let trimmed_reason = reason.trim().to_string();
196        if trimmed_reason.len() < MIN_REASON_LEN {
197            return Err(AppError::Validation(format!(
198                "Mandate reason must be at least {} chars",
199                MIN_REASON_LEN
200            )));
201        }
202        if trimmed_reason.len() > MAX_REASON_LEN {
203            return Err(AppError::Validation(format!(
204                "Mandate reason must be at most {} chars",
205                MAX_REASON_LEN
206            )));
207        }
208        if subject_user_id.is_nil() || issued_by.is_nil() || scope.id().is_nil() {
209            return Err(AppError::Validation(
210                "Mandate references must not be nil UUIDs".to_string(),
211            ));
212        }
213
214        let now = Utc::now();
215        Ok(Self {
216            id: Uuid::new_v4(),
217            subject_user_id,
218            kind,
219            scope,
220            issued_by,
221            reason: trimmed_reason,
222            valid_from,
223            valid_until,
224            revoked_at: None,
225            created_at: now,
226            updated_at: now,
227        })
228    }
229
230    pub fn is_revoked(&self) -> bool {
231        self.revoked_at.is_some()
232    }
233
234    pub fn is_expired_at(&self, t: DateTime<Utc>) -> bool {
235        t >= self.valid_until
236    }
237
238    pub fn is_expired(&self) -> bool {
239        self.is_expired_at(Utc::now())
240    }
241
242    pub fn is_active_at(&self, t: DateTime<Utc>) -> bool {
243        !self.is_revoked() && t >= self.valid_from && t < self.valid_until
244    }
245
246    pub fn is_currently_active(&self) -> bool {
247        self.is_active_at(Utc::now())
248    }
249
250    /// Mark this mandate as revoked. Idempotent — second calls keep the
251    /// original `revoked_at` (audit-faithful).
252    pub fn revoke(&mut self) {
253        let now = Utc::now();
254        if self.revoked_at.is_none() {
255            self.revoked_at = Some(now);
256        }
257        self.updated_at = now;
258    }
259}
260
261// ============================================================================
262// Tests — taxonomie 4 catégories obligatoire (CRITICAL.md #3)
263// ============================================================================
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    fn fixture_ids() -> (Uuid, Uuid, Uuid) {
270        (Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4())
271    }
272
273    fn fixture_reason() -> String {
274        "Cession unité C12 — acte authentique".to_string()
275    }
276
277    fn fixture_valid_window() -> (DateTime<Utc>, DateTime<Utc>) {
278        let now = Utc::now();
279        (now, now + Duration::days(60))
280    }
281
282    // ---- @happy -------------------------------------------------------------
283
284    #[test]
285    fn happy_issue_notary_mandate_is_currently_active() {
286        let (subject, issuer, scope_id) = fixture_ids();
287        let (from, until) = fixture_valid_window();
288        let m = Mandate::issue(
289            subject,
290            MandateKind::Notary,
291            MandateScope::Building(scope_id),
292            issuer,
293            fixture_reason(),
294            from,
295            until,
296        )
297        .expect("valid mandate should be issued");
298
299        assert_eq!(m.subject_user_id, subject);
300        assert_eq!(m.issued_by, issuer);
301        assert_eq!(m.kind, MandateKind::Notary);
302        assert!(matches!(m.scope, MandateScope::Building(_)));
303        assert!(m.is_currently_active());
304        assert!(!m.is_expired());
305        assert!(!m.is_revoked());
306    }
307
308    #[test]
309    fn happy_revoke_sets_revoked_at_and_invalidates() {
310        let (subject, issuer, scope_id) = fixture_ids();
311        let (from, until) = fixture_valid_window();
312        let mut m = Mandate::issue(
313            subject,
314            MandateKind::Lawyer,
315            MandateScope::Acp(scope_id),
316            issuer,
317            fixture_reason(),
318            from,
319            until,
320        )
321        .unwrap();
322
323        m.revoke();
324        assert!(m.is_revoked());
325        assert!(!m.is_currently_active());
326    }
327
328    #[test]
329    fn happy_kind_maps_to_corresponding_user_role() {
330        assert_eq!(MandateKind::Lawyer.required_user_role(), UserRole::Lawyer);
331        assert_eq!(MandateKind::Notary.required_user_role(), UserRole::Notary);
332        assert_eq!(MandateKind::Amo.required_user_role(), UserRole::Amo);
333        assert_eq!(
334            MandateKind::Architect.required_user_role(),
335            UserRole::Architect
336        );
337        assert_eq!(MandateKind::Bet.required_user_role(), UserRole::Bet);
338        assert_eq!(MandateKind::Warden.required_user_role(), UserRole::Warden);
339    }
340
341    #[test]
342    fn happy_kind_roundtrips_via_display_and_from_str() {
343        use std::str::FromStr;
344        for k in [
345            MandateKind::Lawyer,
346            MandateKind::Notary,
347            MandateKind::Amo,
348            MandateKind::Architect,
349            MandateKind::Bet,
350            MandateKind::Warden,
351        ] {
352            let s = k.to_string();
353            assert_eq!(MandateKind::from_str(&s).unwrap(), k);
354        }
355    }
356
357    // ---- @edge --------------------------------------------------------------
358
359    #[test]
360    fn edge_minimum_window_one_second_is_accepted() {
361        let (subject, issuer, scope_id) = fixture_ids();
362        let from = Utc::now();
363        let until = from + Duration::seconds(1);
364        let m = Mandate::issue(
365            subject,
366            MandateKind::Warden,
367            MandateScope::Building(scope_id),
368            issuer,
369            fixture_reason(),
370            from,
371            until,
372        );
373        assert!(m.is_ok());
374    }
375
376    #[test]
377    fn edge_exactly_five_years_is_accepted_boundary() {
378        let (subject, issuer, scope_id) = fixture_ids();
379        let from = Utc::now();
380        let until = from + Duration::days(MAX_MANDATE_DURATION_DAYS);
381        let m = Mandate::issue(
382            subject,
383            MandateKind::Architect,
384            MandateScope::Acp(scope_id),
385            issuer,
386            fixture_reason(),
387            from,
388            until,
389        );
390        assert!(
391            m.is_ok(),
392            "exactly 5 years must remain inside the allowed bound"
393        );
394    }
395
396    #[test]
397    fn edge_at_valid_until_is_considered_expired() {
398        let (subject, issuer, scope_id) = fixture_ids();
399        let from = Utc::now() - Duration::days(2);
400        let until = Utc::now() - Duration::seconds(1);
401        let m = Mandate::issue(
402            subject,
403            MandateKind::Notary,
404            MandateScope::Building(scope_id),
405            issuer,
406            fixture_reason(),
407            from,
408            until,
409        )
410        .unwrap();
411        assert!(m.is_expired());
412        assert!(!m.is_currently_active());
413    }
414
415    #[test]
416    fn edge_double_revoke_is_idempotent() {
417        let (subject, issuer, scope_id) = fixture_ids();
418        let (from, until) = fixture_valid_window();
419        let mut m = Mandate::issue(
420            subject,
421            MandateKind::Bet,
422            MandateScope::Building(scope_id),
423            issuer,
424            fixture_reason(),
425            from,
426            until,
427        )
428        .unwrap();
429        m.revoke();
430        let first = m.revoked_at.expect("first revoke sets timestamp");
431        m.revoke();
432        assert_eq!(m.revoked_at, Some(first));
433    }
434
435    // ---- @security ----------------------------------------------------------
436
437    #[test]
438    fn security_subject_equals_issuer_is_rejected() {
439        let (subject, _, scope_id) = fixture_ids();
440        let (from, until) = fixture_valid_window();
441        let err = Mandate::issue(
442            subject,
443            MandateKind::Lawyer,
444            MandateScope::Building(scope_id),
445            subject, // self-mandate
446            fixture_reason(),
447            from,
448            until,
449        )
450        .unwrap_err();
451        assert!(matches!(err, AppError::Validation(_)));
452    }
453
454    #[test]
455    fn security_building_and_acp_scopes_are_distinct() {
456        let id = Uuid::new_v4();
457        let b = MandateScope::Building(id);
458        let a = MandateScope::Acp(id);
459        assert_ne!(b, a, "Building and Acp scopes must not compare equal");
460        assert_eq!(b.kind_str(), "building");
461        assert_eq!(a.kind_str(), "acp");
462    }
463
464    #[test]
465    fn security_nil_uuids_are_rejected() {
466        let (from, until) = fixture_valid_window();
467        let err = Mandate::issue(
468            Uuid::nil(),
469            MandateKind::Notary,
470            MandateScope::Building(Uuid::new_v4()),
471            Uuid::new_v4(),
472            fixture_reason(),
473            from,
474            until,
475        )
476        .unwrap_err();
477        assert!(matches!(err, AppError::Validation(_)));
478    }
479
480    #[test]
481    fn security_revoked_mandate_inside_window_is_not_active() {
482        let (subject, issuer, scope_id) = fixture_ids();
483        let (from, until) = fixture_valid_window();
484        let mut m = Mandate::issue(
485            subject,
486            MandateKind::Amo,
487            MandateScope::Acp(scope_id),
488            issuer,
489            fixture_reason(),
490            from,
491            until,
492        )
493        .unwrap();
494        m.revoke();
495        // Even mid-window, a revoked mandate must NOT authorise actions.
496        assert!(!m.is_currently_active());
497    }
498
499    // ---- @negative ----------------------------------------------------------
500
501    #[test]
502    fn negative_valid_until_before_valid_from_is_rejected() {
503        let (subject, issuer, scope_id) = fixture_ids();
504        let from = Utc::now();
505        let until = from - Duration::seconds(1);
506        let err = Mandate::issue(
507            subject,
508            MandateKind::Notary,
509            MandateScope::Building(scope_id),
510            issuer,
511            fixture_reason(),
512            from,
513            until,
514        )
515        .unwrap_err();
516        assert!(matches!(err, AppError::Validation(_)));
517    }
518
519    #[test]
520    fn negative_window_equal_zero_is_rejected() {
521        let (subject, issuer, scope_id) = fixture_ids();
522        let now = Utc::now();
523        let err = Mandate::issue(
524            subject,
525            MandateKind::Warden,
526            MandateScope::Building(scope_id),
527            issuer,
528            fixture_reason(),
529            now,
530            now,
531        )
532        .unwrap_err();
533        assert!(matches!(err, AppError::Validation(_)));
534    }
535
536    #[test]
537    fn negative_duration_above_max_is_rejected() {
538        let (subject, issuer, scope_id) = fixture_ids();
539        let from = Utc::now();
540        let until = from + Duration::days(MAX_MANDATE_DURATION_DAYS + 1);
541        let err = Mandate::issue(
542            subject,
543            MandateKind::Lawyer,
544            MandateScope::Acp(scope_id),
545            issuer,
546            fixture_reason(),
547            from,
548            until,
549        )
550        .unwrap_err();
551        assert!(matches!(err, AppError::Validation(_)));
552    }
553
554    #[test]
555    fn negative_reason_too_short_is_rejected() {
556        let (subject, issuer, scope_id) = fixture_ids();
557        let (from, until) = fixture_valid_window();
558        let err = Mandate::issue(
559            subject,
560            MandateKind::Notary,
561            MandateScope::Building(scope_id),
562            issuer,
563            "court".to_string(), // 5 chars < MIN_REASON_LEN
564            from,
565            until,
566        )
567        .unwrap_err();
568        assert!(matches!(err, AppError::Validation(_)));
569    }
570
571    #[test]
572    fn negative_reason_too_long_is_rejected() {
573        let (subject, issuer, scope_id) = fixture_ids();
574        let (from, until) = fixture_valid_window();
575        let too_long = "x".repeat(MAX_REASON_LEN + 1);
576        let err = Mandate::issue(
577            subject,
578            MandateKind::Architect,
579            MandateScope::Building(scope_id),
580            issuer,
581            too_long,
582            from,
583            until,
584        )
585        .unwrap_err();
586        assert!(matches!(err, AppError::Validation(_)));
587    }
588
589    #[test]
590    fn negative_invalid_kind_string_is_rejected() {
591        use std::str::FromStr;
592        let err = MandateKind::from_str("plombier").unwrap_err();
593        assert!(matches!(err, AppError::Validation(_)));
594    }
595
596    #[test]
597    fn negative_invalid_scope_kind_string_is_rejected() {
598        let err = MandateScope::from_parts("unit", Uuid::new_v4()).unwrap_err();
599        assert!(matches!(err, AppError::Validation(_)));
600    }
601}