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