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).expect("valid time components"))
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(
465                    NaiveTime::from_hms_opt(9, rng.gen_range(0..59), 0)
466                        .expect("valid time components"),
467                )
468                .and_utc();
469        }
470
471        // Skip weekends
472        let weekday = result.weekday();
473        if weekday == Weekday::Sat {
474            result += Duration::days(2);
475        } else if weekday == Weekday::Sun {
476            result += Duration::days(1);
477        }
478
479        result
480    }
481
482    /// Determine the outcome of an approval action.
483    pub fn determine_outcome(&self, rng: &mut impl Rng) -> ApprovalActionType {
484        let roll: f64 = rng.gen();
485
486        if roll < self.rejection_rate {
487            ApprovalActionType::Reject
488        } else if roll < self.rejection_rate + self.revision_rate {
489            ApprovalActionType::RequestRevision
490        } else {
491            ApprovalActionType::Approve
492        }
493    }
494}
495
496/// Common rejection/revision reasons.
497pub mod rejection_reasons {
498    /// Reasons for rejection.
499    pub const REJECTION_REASONS: &[&str] = &[
500        "Missing supporting documentation",
501        "Amount exceeds budget allocation",
502        "Incorrect account coding",
503        "Duplicate entry detected",
504        "Policy violation",
505        "Vendor not approved",
506        "Missing purchase order reference",
507        "Expense not business-related",
508        "Incorrect cost center",
509        "Authorization not obtained",
510    ];
511
512    /// Reasons for revision request.
513    pub const REVISION_REASONS: &[&str] = &[
514        "Please provide additional documentation",
515        "Clarify business purpose",
516        "Split between multiple cost centers",
517        "Update account coding",
518        "Add reference number",
519        "Correct posting date",
520        "Update description",
521        "Verify amount",
522        "Add tax information",
523        "Update vendor information",
524    ];
525}
526
527/// Individual approval record for tracking approval relationships.
528#[derive(Debug, Clone, Serialize, Deserialize)]
529pub struct ApprovalRecord {
530    /// Unique approval record ID
531    pub approval_id: String,
532    /// Document being approved
533    pub document_number: String,
534    /// Document type
535    pub document_type: String,
536    /// Company code
537    pub company_code: String,
538    /// Requester's user ID
539    pub requester_id: String,
540    /// Requester's name (optional)
541    pub requester_name: Option<String>,
542    /// Approver's user ID
543    pub approver_id: String,
544    /// Approver's name
545    pub approver_name: String,
546    /// Approval date
547    pub approval_date: chrono::NaiveDate,
548    /// Approval action taken
549    pub action: String,
550    /// Amount being approved
551    pub amount: Decimal,
552    /// Approver's approval limit (if any)
553    pub approval_limit: Option<Decimal>,
554    /// Comments/notes
555    pub comments: Option<String>,
556    /// If delegated, from whom
557    pub delegation_from: Option<String>,
558    /// Whether this was auto-approved
559    pub is_auto_approved: bool,
560}
561
562impl ApprovalRecord {
563    /// Creates a new approval record.
564    #[allow(clippy::too_many_arguments)]
565    pub fn new(
566        document_number: String,
567        approver_id: String,
568        approver_name: String,
569        requester_id: String,
570        approval_date: chrono::NaiveDate,
571        amount: Decimal,
572        action: String,
573        company_code: String,
574    ) -> Self {
575        Self {
576            approval_id: uuid::Uuid::new_v4().to_string(),
577            document_number,
578            document_type: "JE".to_string(),
579            company_code,
580            requester_id,
581            requester_name: None,
582            approver_id,
583            approver_name,
584            approval_date,
585            action,
586            amount,
587            approval_limit: None,
588            comments: None,
589            delegation_from: None,
590            is_auto_approved: false,
591        }
592    }
593}
594
595#[cfg(test)]
596#[allow(clippy::unwrap_used)]
597mod tests {
598    use super::*;
599
600    #[test]
601    fn test_approval_status() {
602        assert!(ApprovalStatus::Approved.is_terminal());
603        assert!(ApprovalStatus::Rejected.is_terminal());
604        assert!(!ApprovalStatus::Pending.is_terminal());
605        assert!(ApprovalStatus::Approved.is_approved());
606        assert!(ApprovalStatus::AutoApproved.is_approved());
607    }
608
609    #[test]
610    fn test_approval_chain_levels() {
611        let chain = ApprovalChain::standard();
612
613        // Below auto-approve threshold
614        assert_eq!(chain.required_level(Decimal::from(500)), 0);
615
616        // Level 1
617        assert_eq!(chain.required_level(Decimal::from(5000)), 1);
618
619        // Level 2
620        assert_eq!(chain.required_level(Decimal::from(50000)), 2);
621
622        // Level 3
623        assert_eq!(chain.required_level(Decimal::from(200000)), 3);
624
625        // Level 4
626        assert_eq!(chain.required_level(Decimal::from(1000000)), 4);
627    }
628
629    #[test]
630    fn test_workflow_lifecycle() {
631        let mut workflow = ApprovalWorkflow::new(
632            "JSMITH001".to_string(),
633            "John Smith".to_string(),
634            Decimal::from(5000),
635        );
636
637        workflow.required_levels = 1;
638        workflow.submit(Utc::now());
639
640        assert_eq!(workflow.status, ApprovalStatus::Pending);
641
642        workflow.approve(
643            "MBROWN001".to_string(),
644            "Mary Brown".to_string(),
645            UserPersona::SeniorAccountant,
646            Utc::now(),
647            None,
648        );
649
650        assert_eq!(workflow.status, ApprovalStatus::Approved);
651        assert!(workflow.is_complete());
652    }
653
654    #[test]
655    fn test_auto_approval() {
656        let workflow = ApprovalWorkflow::auto_approved(
657            "JSMITH001".to_string(),
658            "John Smith".to_string(),
659            Decimal::from(500),
660            Utc::now(),
661        );
662
663        assert_eq!(workflow.status, ApprovalStatus::AutoApproved);
664        assert!(workflow.is_complete());
665        assert!(workflow.approved_at.is_some());
666    }
667
668    #[test]
669    fn test_rejection() {
670        let mut workflow = ApprovalWorkflow::new(
671            "JSMITH001".to_string(),
672            "John Smith".to_string(),
673            Decimal::from(5000),
674        );
675
676        workflow.required_levels = 1;
677        workflow.submit(Utc::now());
678
679        workflow.reject(
680            "MBROWN001".to_string(),
681            "Mary Brown".to_string(),
682            UserPersona::SeniorAccountant,
683            Utc::now(),
684            "Missing documentation",
685        );
686
687        assert_eq!(workflow.status, ApprovalStatus::Rejected);
688        assert!(workflow.is_complete());
689    }
690
691    #[test]
692    fn test_required_personas() {
693        let chain = ApprovalChain::standard();
694
695        let personas = chain.required_personas(Decimal::from(50000));
696        assert!(personas.contains(&UserPersona::SeniorAccountant));
697        assert!(personas.contains(&UserPersona::Controller));
698    }
699}