use crate::models::UserPersona;
use chrono::{DateTime, Datelike, Duration, NaiveTime, Timelike, Utc, Weekday};
use rand::{Rng, RngExt};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalStatus {
#[default]
Draft,
Pending,
Approved,
Rejected,
AutoApproved,
RequiresRevision,
}
impl ApprovalStatus {
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Approved | Self::Rejected | Self::AutoApproved)
}
pub fn is_approved(&self) -> bool {
matches!(self, Self::Approved | Self::AutoApproved)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalActionType {
Submit,
Approve,
Reject,
RequestRevision,
Resubmit,
AutoApprove,
Escalate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalAction {
pub actor_id: String,
pub actor_name: String,
pub actor_role: UserPersona,
pub action: ApprovalActionType,
#[serde(with = "crate::serde_timestamp::utc")]
pub action_timestamp: DateTime<Utc>,
pub comments: Option<String>,
pub approval_level: u8,
}
impl ApprovalAction {
#[allow(clippy::too_many_arguments)]
pub fn new(
actor_id: String,
actor_name: String,
actor_role: UserPersona,
action: ApprovalActionType,
level: u8,
) -> Self {
Self {
actor_id,
actor_name,
actor_role,
action,
action_timestamp: Utc::now(),
comments: None,
approval_level: level,
}
}
pub fn with_comment(mut self, comment: &str) -> Self {
self.comments = Some(comment.to_string());
self
}
pub fn with_timestamp(mut self, timestamp: DateTime<Utc>) -> Self {
self.action_timestamp = timestamp;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalWorkflow {
pub status: ApprovalStatus,
pub actions: Vec<ApprovalAction>,
pub required_levels: u8,
pub current_level: u8,
pub preparer_id: String,
pub preparer_name: String,
#[serde(default, with = "crate::serde_timestamp::utc::option")]
pub submitted_at: Option<DateTime<Utc>>,
#[serde(default, with = "crate::serde_timestamp::utc::option")]
pub approved_at: Option<DateTime<Utc>>,
pub amount: Decimal,
}
impl ApprovalWorkflow {
pub fn new(preparer_id: String, preparer_name: String, amount: Decimal) -> Self {
Self {
status: ApprovalStatus::Draft,
actions: Vec::new(),
required_levels: 0,
current_level: 0,
preparer_id,
preparer_name,
submitted_at: None,
approved_at: None,
amount,
}
}
pub fn auto_approved(
preparer_id: String,
preparer_name: String,
amount: Decimal,
timestamp: DateTime<Utc>,
) -> Self {
let action = ApprovalAction {
actor_id: "SYSTEM".to_string(),
actor_name: "Automated System".to_string(),
actor_role: UserPersona::AutomatedSystem,
action: ApprovalActionType::AutoApprove,
action_timestamp: timestamp,
comments: Some("Amount below auto-approval threshold".to_string()),
approval_level: 0,
};
Self {
status: ApprovalStatus::AutoApproved,
actions: vec![action],
required_levels: 0,
current_level: 0,
preparer_id,
preparer_name,
submitted_at: Some(timestamp),
approved_at: Some(timestamp),
amount,
}
}
pub fn submit(&mut self, timestamp: DateTime<Utc>) {
self.status = ApprovalStatus::Pending;
self.submitted_at = Some(timestamp);
let action = ApprovalAction {
actor_id: self.preparer_id.clone(),
actor_name: self.preparer_name.clone(),
actor_role: UserPersona::JuniorAccountant, action: ApprovalActionType::Submit,
action_timestamp: timestamp,
comments: None,
approval_level: 0,
};
self.actions.push(action);
}
pub fn approve(
&mut self,
approver_id: String,
approver_name: String,
approver_role: UserPersona,
timestamp: DateTime<Utc>,
comment: Option<String>,
) {
self.current_level += 1;
let mut action = ApprovalAction::new(
approver_id,
approver_name,
approver_role,
ApprovalActionType::Approve,
self.current_level,
)
.with_timestamp(timestamp);
if let Some(c) = comment {
action = action.with_comment(&c);
}
self.actions.push(action);
if self.current_level >= self.required_levels {
self.status = ApprovalStatus::Approved;
self.approved_at = Some(timestamp);
}
}
pub fn reject(
&mut self,
rejector_id: String,
rejector_name: String,
rejector_role: UserPersona,
timestamp: DateTime<Utc>,
reason: &str,
) {
self.status = ApprovalStatus::Rejected;
let action = ApprovalAction::new(
rejector_id,
rejector_name,
rejector_role,
ApprovalActionType::Reject,
self.current_level + 1,
)
.with_timestamp(timestamp)
.with_comment(reason);
self.actions.push(action);
}
pub fn request_revision(
&mut self,
reviewer_id: String,
reviewer_name: String,
reviewer_role: UserPersona,
timestamp: DateTime<Utc>,
reason: &str,
) {
self.status = ApprovalStatus::RequiresRevision;
let action = ApprovalAction::new(
reviewer_id,
reviewer_name,
reviewer_role,
ApprovalActionType::RequestRevision,
self.current_level + 1,
)
.with_timestamp(timestamp)
.with_comment(reason);
self.actions.push(action);
}
pub fn is_complete(&self) -> bool {
self.status.is_terminal()
}
pub fn final_approver(&self) -> Option<&ApprovalAction> {
self.actions
.iter()
.rev()
.find(|a| a.action == ApprovalActionType::Approve)
}
}
#[derive(Debug, Clone)]
pub struct ApprovalChain {
pub thresholds: Vec<ApprovalThreshold>,
pub auto_approve_threshold: Decimal,
}
impl Default for ApprovalChain {
fn default() -> Self {
Self::standard()
}
}
impl ApprovalChain {
pub fn standard() -> Self {
Self {
auto_approve_threshold: Decimal::from(1000),
thresholds: vec![
ApprovalThreshold {
amount: Decimal::from(1000),
level: 1,
required_personas: vec![UserPersona::SeniorAccountant],
},
ApprovalThreshold {
amount: Decimal::from(10000),
level: 2,
required_personas: vec![UserPersona::SeniorAccountant, UserPersona::Controller],
},
ApprovalThreshold {
amount: Decimal::from(100000),
level: 3,
required_personas: vec![
UserPersona::SeniorAccountant,
UserPersona::Controller,
UserPersona::Manager,
],
},
ApprovalThreshold {
amount: Decimal::from(500000),
level: 4,
required_personas: vec![
UserPersona::SeniorAccountant,
UserPersona::Controller,
UserPersona::Manager,
UserPersona::Executive,
],
},
],
}
}
pub fn required_level(&self, amount: Decimal) -> u8 {
let abs_amount = amount.abs();
if abs_amount < self.auto_approve_threshold {
return 0;
}
for threshold in self.thresholds.iter().rev() {
if abs_amount >= threshold.amount {
return threshold.level;
}
}
1 }
pub fn required_personas(&self, amount: Decimal) -> Vec<UserPersona> {
let level = self.required_level(amount);
if level == 0 {
return Vec::new();
}
self.thresholds
.iter()
.find(|t| t.level == level)
.map(|t| t.required_personas.clone())
.unwrap_or_default()
}
pub fn is_auto_approve(&self, amount: Decimal) -> bool {
amount.abs() < self.auto_approve_threshold
}
}
#[derive(Debug, Clone)]
pub struct ApprovalThreshold {
pub amount: Decimal,
pub level: u8,
pub required_personas: Vec<UserPersona>,
}
#[derive(Debug, Clone)]
pub struct ApprovalWorkflowGenerator {
pub chain: ApprovalChain,
pub rejection_rate: f64,
pub revision_rate: f64,
pub average_delay_hours: f64,
}
impl Default for ApprovalWorkflowGenerator {
fn default() -> Self {
Self {
chain: ApprovalChain::standard(),
rejection_rate: 0.02,
revision_rate: 0.05,
average_delay_hours: 4.0,
}
}
}
impl ApprovalWorkflowGenerator {
pub fn generate_approval_timestamp(
&self,
base_timestamp: DateTime<Utc>,
rng: &mut impl Rng,
) -> DateTime<Utc> {
let delay_hours = self.average_delay_hours * (-rng.random::<f64>().ln());
let delay_hours = delay_hours.min(48.0);
let mut result = base_timestamp + Duration::hours(delay_hours as i64);
let time = result.time();
let hour = time.hour();
if hour < 9 {
result = result
.date_naive()
.and_time(NaiveTime::from_hms_opt(9, 0, 0).expect("valid time components"))
.and_utc();
} else if hour >= 18 {
result = (result.date_naive() + Duration::days(1))
.and_time(
NaiveTime::from_hms_opt(9, rng.random_range(0..59), 0)
.expect("valid time components"),
)
.and_utc();
}
let weekday = result.weekday();
if weekday == Weekday::Sat {
result += Duration::days(2);
} else if weekday == Weekday::Sun {
result += Duration::days(1);
}
result
}
pub fn determine_outcome(&self, rng: &mut impl Rng) -> ApprovalActionType {
let roll: f64 = rng.random();
if roll < self.rejection_rate {
ApprovalActionType::Reject
} else if roll < self.rejection_rate + self.revision_rate {
ApprovalActionType::RequestRevision
} else {
ApprovalActionType::Approve
}
}
}
pub mod rejection_reasons {
pub const REJECTION_REASONS: &[&str] = &[
"Missing supporting documentation",
"Amount exceeds budget allocation",
"Incorrect account coding",
"Duplicate entry detected",
"Policy violation",
"Vendor not approved",
"Missing purchase order reference",
"Expense not business-related",
"Incorrect cost center",
"Authorization not obtained",
];
pub const REVISION_REASONS: &[&str] = &[
"Please provide additional documentation",
"Clarify business purpose",
"Split between multiple cost centers",
"Update account coding",
"Add reference number",
"Correct posting date",
"Update description",
"Verify amount",
"Add tax information",
"Update vendor information",
];
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalRecord {
pub approval_id: String,
pub document_number: String,
pub document_type: String,
pub company_code: String,
pub requester_id: String,
pub requester_name: Option<String>,
pub approver_id: String,
pub approver_name: String,
pub approval_date: chrono::NaiveDate,
pub action: String,
pub amount: Decimal,
pub approval_limit: Option<Decimal>,
pub comments: Option<String>,
pub delegation_from: Option<String>,
pub is_auto_approved: bool,
}
impl ApprovalRecord {
#[allow(clippy::too_many_arguments)]
pub fn new(
document_number: String,
approver_id: String,
approver_name: String,
requester_id: String,
approval_date: chrono::NaiveDate,
amount: Decimal,
action: String,
company_code: String,
) -> Self {
Self {
approval_id: uuid::Uuid::new_v4().to_string(),
document_number,
document_type: "JE".to_string(),
company_code,
requester_id,
requester_name: None,
approver_id,
approver_name,
approval_date,
action,
amount,
approval_limit: None,
comments: None,
delegation_from: None,
is_auto_approved: false,
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_approval_status() {
assert!(ApprovalStatus::Approved.is_terminal());
assert!(ApprovalStatus::Rejected.is_terminal());
assert!(!ApprovalStatus::Pending.is_terminal());
assert!(ApprovalStatus::Approved.is_approved());
assert!(ApprovalStatus::AutoApproved.is_approved());
}
#[test]
fn test_approval_chain_levels() {
let chain = ApprovalChain::standard();
assert_eq!(chain.required_level(Decimal::from(500)), 0);
assert_eq!(chain.required_level(Decimal::from(5000)), 1);
assert_eq!(chain.required_level(Decimal::from(50000)), 2);
assert_eq!(chain.required_level(Decimal::from(200000)), 3);
assert_eq!(chain.required_level(Decimal::from(1000000)), 4);
}
#[test]
fn test_workflow_lifecycle() {
let mut workflow = ApprovalWorkflow::new(
"JSMITH001".to_string(),
"John Smith".to_string(),
Decimal::from(5000),
);
workflow.required_levels = 1;
workflow.submit(Utc::now());
assert_eq!(workflow.status, ApprovalStatus::Pending);
workflow.approve(
"MBROWN001".to_string(),
"Mary Brown".to_string(),
UserPersona::SeniorAccountant,
Utc::now(),
None,
);
assert_eq!(workflow.status, ApprovalStatus::Approved);
assert!(workflow.is_complete());
}
#[test]
fn test_auto_approval() {
let workflow = ApprovalWorkflow::auto_approved(
"JSMITH001".to_string(),
"John Smith".to_string(),
Decimal::from(500),
Utc::now(),
);
assert_eq!(workflow.status, ApprovalStatus::AutoApproved);
assert!(workflow.is_complete());
assert!(workflow.approved_at.is_some());
}
#[test]
fn test_rejection() {
let mut workflow = ApprovalWorkflow::new(
"JSMITH001".to_string(),
"John Smith".to_string(),
Decimal::from(5000),
);
workflow.required_levels = 1;
workflow.submit(Utc::now());
workflow.reject(
"MBROWN001".to_string(),
"Mary Brown".to_string(),
UserPersona::SeniorAccountant,
Utc::now(),
"Missing documentation",
);
assert_eq!(workflow.status, ApprovalStatus::Rejected);
assert!(workflow.is_complete());
}
#[test]
fn test_required_personas() {
let chain = ApprovalChain::standard();
let personas = chain.required_personas(Decimal::from(50000));
assert!(personas.contains(&UserPersona::SeniorAccountant));
assert!(personas.contains(&UserPersona::Controller));
}
}