Skip to main content

datasynth_core/models/
approval.rs

1//! Approval workflow models for journal entries.
2//!
3//! Provides multi-level approval chain logic based on amount thresholds,
4//! with realistic timestamps and action history.
5
6use crate::models::UserPersona;
7use chrono::{DateTime, Datelike, Duration, NaiveTime, Timelike, Utc, Weekday};
8use rand::Rng;
9use rust_decimal::Decimal;
10use serde::{Deserialize, Serialize};
11
12/// Status of an approval workflow.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
14#[serde(rename_all = "snake_case")]
15pub enum ApprovalStatus {
16    /// Entry is in draft state, not yet submitted
17    #[default]
18    Draft,
19    /// Entry is pending approval
20    Pending,
21    /// Entry has been fully approved
22    Approved,
23    /// Entry was rejected
24    Rejected,
25    /// Entry was auto-approved (below threshold)
26    AutoApproved,
27    /// Entry requires revision before resubmission
28    RequiresRevision,
29}
30
31impl ApprovalStatus {
32    /// Check if this status represents a terminal state.
33    pub fn is_terminal(&self) -> bool {
34        matches!(self, Self::Approved | Self::Rejected | Self::AutoApproved)
35    }
36
37    /// Check if approval is complete (approved or auto-approved).
38    pub fn is_approved(&self) -> bool {
39        matches!(self, Self::Approved | Self::AutoApproved)
40    }
41}
42
43/// Type of approval action taken.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum ApprovalActionType {
47    /// Entry was submitted for approval
48    Submit,
49    /// Approver approved the entry
50    Approve,
51    /// Approver rejected the entry
52    Reject,
53    /// Approver requested revision
54    RequestRevision,
55    /// Preparer revised and resubmitted
56    Resubmit,
57    /// Entry was auto-approved by system
58    AutoApprove,
59    /// Entry was escalated to higher level
60    Escalate,
61}
62
63/// Individual action in the approval workflow.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct ApprovalAction {
66    /// User ID of the person taking action
67    pub actor_id: String,
68
69    /// Display name of the actor
70    pub actor_name: String,
71
72    /// Role/persona of the actor
73    pub actor_role: UserPersona,
74
75    /// Type of action taken
76    pub action: ApprovalActionType,
77
78    /// Timestamp of the action
79    #[serde(with = "crate::serde_timestamp::utc")]
80    pub action_timestamp: DateTime<Utc>,
81
82    /// Comments/notes from the actor
83    pub comments: Option<String>,
84
85    /// Approval level this action applies to
86    pub approval_level: u8,
87}
88
89impl ApprovalAction {
90    /// Create a new approval action.
91    #[allow(clippy::too_many_arguments)]
92    pub fn new(
93        actor_id: String,
94        actor_name: String,
95        actor_role: UserPersona,
96        action: ApprovalActionType,
97        level: u8,
98    ) -> Self {
99        Self {
100            actor_id,
101            actor_name,
102            actor_role,
103            action,
104            action_timestamp: Utc::now(),
105            comments: None,
106            approval_level: level,
107        }
108    }
109
110    /// Add a comment to the action.
111    pub fn with_comment(mut self, comment: &str) -> Self {
112        self.comments = Some(comment.to_string());
113        self
114    }
115
116    /// Set the timestamp.
117    pub fn with_timestamp(mut self, timestamp: DateTime<Utc>) -> Self {
118        self.action_timestamp = timestamp;
119        self
120    }
121}
122
123/// Complete approval workflow for a journal entry.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct ApprovalWorkflow {
126    /// Current status of the workflow
127    pub status: ApprovalStatus,
128
129    /// All actions taken in this workflow
130    pub actions: Vec<ApprovalAction>,
131
132    /// Number of approval levels required
133    pub required_levels: u8,
134
135    /// Current approval level achieved
136    pub current_level: u8,
137
138    /// User ID of the preparer
139    pub preparer_id: String,
140
141    /// Display name of the preparer
142    pub preparer_name: String,
143
144    /// When the entry was submitted for approval
145    #[serde(default, with = "crate::serde_timestamp::utc::option")]
146    pub submitted_at: Option<DateTime<Utc>>,
147
148    /// When the entry was finally approved
149    #[serde(default, with = "crate::serde_timestamp::utc::option")]
150    pub approved_at: Option<DateTime<Utc>>,
151
152    /// Transaction amount (for threshold calculation)
153    pub amount: Decimal,
154}
155
156impl ApprovalWorkflow {
157    /// Create a new draft workflow.
158    pub fn new(preparer_id: String, preparer_name: String, amount: Decimal) -> Self {
159        Self {
160            status: ApprovalStatus::Draft,
161            actions: Vec::new(),
162            required_levels: 0,
163            current_level: 0,
164            preparer_id,
165            preparer_name,
166            submitted_at: None,
167            approved_at: None,
168            amount,
169        }
170    }
171
172    /// Create an auto-approved workflow.
173    pub fn auto_approved(
174        preparer_id: String,
175        preparer_name: String,
176        amount: Decimal,
177        timestamp: DateTime<Utc>,
178    ) -> Self {
179        let action = ApprovalAction {
180            actor_id: "SYSTEM".to_string(),
181            actor_name: "Automated System".to_string(),
182            actor_role: UserPersona::AutomatedSystem,
183            action: ApprovalActionType::AutoApprove,
184            action_timestamp: timestamp,
185            comments: Some("Amount below auto-approval threshold".to_string()),
186            approval_level: 0,
187        };
188
189        Self {
190            status: ApprovalStatus::AutoApproved,
191            actions: vec![action],
192            required_levels: 0,
193            current_level: 0,
194            preparer_id,
195            preparer_name,
196            submitted_at: Some(timestamp),
197            approved_at: Some(timestamp),
198            amount,
199        }
200    }
201
202    /// Submit the workflow for approval.
203    pub fn submit(&mut self, timestamp: DateTime<Utc>) {
204        self.status = ApprovalStatus::Pending;
205        self.submitted_at = Some(timestamp);
206
207        let action = ApprovalAction {
208            actor_id: self.preparer_id.clone(),
209            actor_name: self.preparer_name.clone(),
210            actor_role: UserPersona::JuniorAccountant, // Assumed
211            action: ApprovalActionType::Submit,
212            action_timestamp: timestamp,
213            comments: None,
214            approval_level: 0,
215        };
216        self.actions.push(action);
217    }
218
219    /// Add an approval action.
220    pub fn approve(
221        &mut self,
222        approver_id: String,
223        approver_name: String,
224        approver_role: UserPersona,
225        timestamp: DateTime<Utc>,
226        comment: Option<String>,
227    ) {
228        self.current_level += 1;
229
230        let mut action = ApprovalAction::new(
231            approver_id,
232            approver_name,
233            approver_role,
234            ApprovalActionType::Approve,
235            self.current_level,
236        )
237        .with_timestamp(timestamp);
238
239        if let Some(c) = comment {
240            action = action.with_comment(&c);
241        }
242
243        self.actions.push(action);
244
245        // Check if fully approved
246        if self.current_level >= self.required_levels {
247            self.status = ApprovalStatus::Approved;
248            self.approved_at = Some(timestamp);
249        }
250    }
251
252    /// Reject the workflow.
253    pub fn reject(
254        &mut self,
255        rejector_id: String,
256        rejector_name: String,
257        rejector_role: UserPersona,
258        timestamp: DateTime<Utc>,
259        reason: &str,
260    ) {
261        self.status = ApprovalStatus::Rejected;
262
263        let action = ApprovalAction::new(
264            rejector_id,
265            rejector_name,
266            rejector_role,
267            ApprovalActionType::Reject,
268            self.current_level + 1,
269        )
270        .with_timestamp(timestamp)
271        .with_comment(reason);
272
273        self.actions.push(action);
274    }
275
276    /// Request revision.
277    pub fn request_revision(
278        &mut self,
279        reviewer_id: String,
280        reviewer_name: String,
281        reviewer_role: UserPersona,
282        timestamp: DateTime<Utc>,
283        reason: &str,
284    ) {
285        self.status = ApprovalStatus::RequiresRevision;
286
287        let action = ApprovalAction::new(
288            reviewer_id,
289            reviewer_name,
290            reviewer_role,
291            ApprovalActionType::RequestRevision,
292            self.current_level + 1,
293        )
294        .with_timestamp(timestamp)
295        .with_comment(reason);
296
297        self.actions.push(action);
298    }
299
300    /// Check if workflow is complete.
301    pub fn is_complete(&self) -> bool {
302        self.status.is_terminal()
303    }
304
305    /// Get the final approver (if approved).
306    pub fn final_approver(&self) -> Option<&ApprovalAction> {
307        self.actions
308            .iter()
309            .rev()
310            .find(|a| a.action == ApprovalActionType::Approve)
311    }
312}
313
314/// Approval chain configuration with amount thresholds.
315#[derive(Debug, Clone)]
316pub struct ApprovalChain {
317    /// Thresholds in ascending order
318    pub thresholds: Vec<ApprovalThreshold>,
319    /// Auto-approve threshold (below this amount, no approval needed)
320    pub auto_approve_threshold: Decimal,
321}
322
323impl Default for ApprovalChain {
324    fn default() -> Self {
325        Self::standard()
326    }
327}
328
329impl ApprovalChain {
330    /// Create a standard approval chain.
331    pub fn standard() -> Self {
332        Self {
333            auto_approve_threshold: Decimal::from(1000),
334            thresholds: vec![
335                ApprovalThreshold {
336                    amount: Decimal::from(1000),
337                    level: 1,
338                    required_personas: vec![UserPersona::SeniorAccountant],
339                },
340                ApprovalThreshold {
341                    amount: Decimal::from(10000),
342                    level: 2,
343                    required_personas: vec![UserPersona::SeniorAccountant, UserPersona::Controller],
344                },
345                ApprovalThreshold {
346                    amount: Decimal::from(100000),
347                    level: 3,
348                    required_personas: vec![
349                        UserPersona::SeniorAccountant,
350                        UserPersona::Controller,
351                        UserPersona::Manager,
352                    ],
353                },
354                ApprovalThreshold {
355                    amount: Decimal::from(500000),
356                    level: 4,
357                    required_personas: vec![
358                        UserPersona::SeniorAccountant,
359                        UserPersona::Controller,
360                        UserPersona::Manager,
361                        UserPersona::Executive,
362                    ],
363                },
364            ],
365        }
366    }
367
368    /// Determine the required approval level for an amount.
369    pub fn required_level(&self, amount: Decimal) -> u8 {
370        let abs_amount = amount.abs();
371
372        if abs_amount < self.auto_approve_threshold {
373            return 0;
374        }
375
376        for threshold in self.thresholds.iter().rev() {
377            if abs_amount >= threshold.amount {
378                return threshold.level;
379            }
380        }
381
382        1 // Default to level 1 if above auto-approve but no threshold matched
383    }
384
385    /// Get the required personas for a given amount.
386    pub fn required_personas(&self, amount: Decimal) -> Vec<UserPersona> {
387        let level = self.required_level(amount);
388
389        if level == 0 {
390            return Vec::new();
391        }
392
393        self.thresholds
394            .iter()
395            .find(|t| t.level == level)
396            .map(|t| t.required_personas.clone())
397            .unwrap_or_default()
398    }
399
400    /// Check if an amount qualifies for auto-approval.
401    pub fn is_auto_approve(&self, amount: Decimal) -> bool {
402        amount.abs() < self.auto_approve_threshold
403    }
404}
405
406/// Single threshold in the approval chain.
407#[derive(Debug, Clone)]
408pub struct ApprovalThreshold {
409    /// Amount threshold
410    pub amount: Decimal,
411    /// Approval level required
412    pub level: u8,
413    /// Personas required to approve at this level
414    pub required_personas: Vec<UserPersona>,
415}
416
417/// Generator for realistic approval workflows.
418#[derive(Debug, Clone)]
419pub struct ApprovalWorkflowGenerator {
420    /// Approval chain configuration
421    pub chain: ApprovalChain,
422    /// Rejection rate (0.0 to 1.0)
423    pub rejection_rate: f64,
424    /// Revision request rate (0.0 to 1.0)
425    pub revision_rate: f64,
426    /// Average approval delay in hours
427    pub average_delay_hours: f64,
428}
429
430impl Default for ApprovalWorkflowGenerator {
431    fn default() -> Self {
432        Self {
433            chain: ApprovalChain::standard(),
434            rejection_rate: 0.02,
435            revision_rate: 0.05,
436            average_delay_hours: 4.0,
437        }
438    }
439}
440
441impl ApprovalWorkflowGenerator {
442    /// Generate a realistic approval timestamp during working hours.
443    pub fn generate_approval_timestamp(
444        &self,
445        base_timestamp: DateTime<Utc>,
446        rng: &mut impl Rng,
447    ) -> DateTime<Utc> {
448        // Add delay (exponential distribution around average)
449        let delay_hours = self.average_delay_hours * (-rng.random::<f64>().ln());
450        let delay_hours = delay_hours.min(48.0); // Cap at 48 hours
451
452        let mut result = base_timestamp + Duration::hours(delay_hours as i64);
453
454        // Adjust to working hours (9 AM - 6 PM)
455        let time = result.time();
456        let hour = time.hour();
457
458        if hour < 9 {
459            // Before 9 AM, move to 9 AM
460            result = result
461                .date_naive()
462                .and_time(NaiveTime::from_hms_opt(9, 0, 0).expect("valid time components"))
463                .and_utc();
464        } else if hour >= 18 {
465            // After 6 PM, move to next day 9 AM
466            result = (result.date_naive() + Duration::days(1))
467                .and_time(
468                    NaiveTime::from_hms_opt(9, rng.random_range(0..59), 0)
469                        .expect("valid time components"),
470                )
471                .and_utc();
472        }
473
474        // Skip weekends
475        let weekday = result.weekday();
476        if weekday == Weekday::Sat {
477            result += Duration::days(2);
478        } else if weekday == Weekday::Sun {
479            result += Duration::days(1);
480        }
481
482        result
483    }
484
485    /// Determine the outcome of an approval action.
486    pub fn determine_outcome(&self, rng: &mut impl Rng) -> ApprovalActionType {
487        let roll: f64 = rng.random();
488
489        if roll < self.rejection_rate {
490            ApprovalActionType::Reject
491        } else if roll < self.rejection_rate + self.revision_rate {
492            ApprovalActionType::RequestRevision
493        } else {
494            ApprovalActionType::Approve
495        }
496    }
497}
498
499/// Common rejection/revision reasons.
500pub mod rejection_reasons {
501    /// Reasons for rejection.
502    pub const REJECTION_REASONS: &[&str] = &[
503        "Missing supporting documentation",
504        "Amount exceeds budget allocation",
505        "Incorrect account coding",
506        "Duplicate entry detected",
507        "Policy violation",
508        "Vendor not approved",
509        "Missing purchase order reference",
510        "Expense not business-related",
511        "Incorrect cost center",
512        "Authorization not obtained",
513    ];
514
515    /// Reasons for revision request.
516    pub const REVISION_REASONS: &[&str] = &[
517        "Please provide additional documentation",
518        "Clarify business purpose",
519        "Split between multiple cost centers",
520        "Update account coding",
521        "Add reference number",
522        "Correct posting date",
523        "Update description",
524        "Verify amount",
525        "Add tax information",
526        "Update vendor information",
527    ];
528}
529
530/// Individual approval record for tracking approval relationships.
531#[derive(Debug, Clone, Serialize, Deserialize)]
532pub struct ApprovalRecord {
533    /// Unique approval record ID
534    pub approval_id: String,
535    /// Document being approved
536    pub document_number: String,
537    /// Document type
538    pub document_type: String,
539    /// Company code
540    pub company_code: String,
541    /// Requester's user ID
542    pub requester_id: String,
543    /// Requester's name (optional)
544    pub requester_name: Option<String>,
545    /// Approver's user ID
546    pub approver_id: String,
547    /// Approver's name
548    pub approver_name: String,
549    /// Approval date
550    pub approval_date: chrono::NaiveDate,
551    /// Approval action taken
552    pub action: String,
553    /// Amount being approved
554    pub amount: Decimal,
555    /// Approver's approval limit (if any)
556    pub approval_limit: Option<Decimal>,
557    /// Comments/notes
558    pub comments: Option<String>,
559    /// If delegated, from whom
560    pub delegation_from: Option<String>,
561    /// Whether this was auto-approved
562    pub is_auto_approved: bool,
563}
564
565impl ApprovalRecord {
566    /// Creates a new approval record.
567    #[allow(clippy::too_many_arguments)]
568    pub fn new(
569        document_number: String,
570        approver_id: String,
571        approver_name: String,
572        requester_id: String,
573        approval_date: chrono::NaiveDate,
574        amount: Decimal,
575        action: String,
576        company_code: String,
577    ) -> Self {
578        Self {
579            approval_id: uuid::Uuid::new_v4().to_string(),
580            document_number,
581            document_type: "JE".to_string(),
582            company_code,
583            requester_id,
584            requester_name: None,
585            approver_id,
586            approver_name,
587            approval_date,
588            action,
589            amount,
590            approval_limit: None,
591            comments: None,
592            delegation_from: None,
593            is_auto_approved: false,
594        }
595    }
596}
597
598#[cfg(test)]
599#[allow(clippy::unwrap_used)]
600mod tests {
601    use super::*;
602
603    #[test]
604    fn test_approval_status() {
605        assert!(ApprovalStatus::Approved.is_terminal());
606        assert!(ApprovalStatus::Rejected.is_terminal());
607        assert!(!ApprovalStatus::Pending.is_terminal());
608        assert!(ApprovalStatus::Approved.is_approved());
609        assert!(ApprovalStatus::AutoApproved.is_approved());
610    }
611
612    #[test]
613    fn test_approval_chain_levels() {
614        let chain = ApprovalChain::standard();
615
616        // Below auto-approve threshold
617        assert_eq!(chain.required_level(Decimal::from(500)), 0);
618
619        // Level 1
620        assert_eq!(chain.required_level(Decimal::from(5000)), 1);
621
622        // Level 2
623        assert_eq!(chain.required_level(Decimal::from(50000)), 2);
624
625        // Level 3
626        assert_eq!(chain.required_level(Decimal::from(200000)), 3);
627
628        // Level 4
629        assert_eq!(chain.required_level(Decimal::from(1000000)), 4);
630    }
631
632    #[test]
633    fn test_workflow_lifecycle() {
634        let mut workflow = ApprovalWorkflow::new(
635            "JSMITH001".to_string(),
636            "John Smith".to_string(),
637            Decimal::from(5000),
638        );
639
640        workflow.required_levels = 1;
641        workflow.submit(Utc::now());
642
643        assert_eq!(workflow.status, ApprovalStatus::Pending);
644
645        workflow.approve(
646            "MBROWN001".to_string(),
647            "Mary Brown".to_string(),
648            UserPersona::SeniorAccountant,
649            Utc::now(),
650            None,
651        );
652
653        assert_eq!(workflow.status, ApprovalStatus::Approved);
654        assert!(workflow.is_complete());
655    }
656
657    #[test]
658    fn test_auto_approval() {
659        let workflow = ApprovalWorkflow::auto_approved(
660            "JSMITH001".to_string(),
661            "John Smith".to_string(),
662            Decimal::from(500),
663            Utc::now(),
664        );
665
666        assert_eq!(workflow.status, ApprovalStatus::AutoApproved);
667        assert!(workflow.is_complete());
668        assert!(workflow.approved_at.is_some());
669    }
670
671    #[test]
672    fn test_rejection() {
673        let mut workflow = ApprovalWorkflow::new(
674            "JSMITH001".to_string(),
675            "John Smith".to_string(),
676            Decimal::from(5000),
677        );
678
679        workflow.required_levels = 1;
680        workflow.submit(Utc::now());
681
682        workflow.reject(
683            "MBROWN001".to_string(),
684            "Mary Brown".to_string(),
685            UserPersona::SeniorAccountant,
686            Utc::now(),
687            "Missing documentation",
688        );
689
690        assert_eq!(workflow.status, ApprovalStatus::Rejected);
691        assert!(workflow.is_complete());
692    }
693
694    #[test]
695    fn test_required_personas() {
696        let chain = ApprovalChain::standard();
697
698        let personas = chain.required_personas(Decimal::from(50000));
699        assert!(personas.contains(&UserPersona::SeniorAccountant));
700        assert!(personas.contains(&UserPersona::Controller));
701    }
702}