use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use super::chart_of_accounts::AccountSubType;
use super::journal_entry::BusinessProcess;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ThresholdComparison {
GreaterThan,
GreaterThanOrEqual,
LessThan,
LessThanOrEqual,
Between,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ControlAccountMapping {
pub control_id: String,
pub account_numbers: Vec<String>,
pub account_sub_types: Vec<AccountSubType>,
}
impl ControlAccountMapping {
pub fn new(control_id: impl Into<String>) -> Self {
Self {
control_id: control_id.into(),
account_numbers: Vec::new(),
account_sub_types: Vec::new(),
}
}
pub fn with_accounts(mut self, accounts: Vec<String>) -> Self {
self.account_numbers = accounts;
self
}
pub fn with_sub_types(mut self, sub_types: Vec<AccountSubType>) -> Self {
self.account_sub_types = sub_types;
self
}
pub fn applies_to_account(
&self,
account_number: &str,
sub_type: Option<&AccountSubType>,
) -> bool {
if !self.account_numbers.is_empty()
&& self.account_numbers.iter().any(|a| a == account_number)
{
return true;
}
if let Some(st) = sub_type {
if self.account_sub_types.contains(st) {
return true;
}
}
false
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ControlProcessMapping {
pub control_id: String,
pub business_processes: Vec<BusinessProcess>,
}
impl ControlProcessMapping {
pub fn new(control_id: impl Into<String>, processes: Vec<BusinessProcess>) -> Self {
Self {
control_id: control_id.into(),
business_processes: processes,
}
}
pub fn applies_to_process(&self, process: &BusinessProcess) -> bool {
self.business_processes.contains(process)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ControlThresholdMapping {
pub control_id: String,
pub amount_threshold: Decimal,
pub upper_threshold: Option<Decimal>,
pub comparison: ThresholdComparison,
}
impl ControlThresholdMapping {
pub fn new(
control_id: impl Into<String>,
threshold: Decimal,
comparison: ThresholdComparison,
) -> Self {
Self {
control_id: control_id.into(),
amount_threshold: threshold,
upper_threshold: None,
comparison,
}
}
pub fn between(control_id: impl Into<String>, lower: Decimal, upper: Decimal) -> Self {
Self {
control_id: control_id.into(),
amount_threshold: lower,
upper_threshold: Some(upper),
comparison: ThresholdComparison::Between,
}
}
pub fn applies_to_amount(&self, amount: Decimal) -> bool {
match self.comparison {
ThresholdComparison::GreaterThan => amount > self.amount_threshold,
ThresholdComparison::GreaterThanOrEqual => amount >= self.amount_threshold,
ThresholdComparison::LessThan => amount < self.amount_threshold,
ThresholdComparison::LessThanOrEqual => amount <= self.amount_threshold,
ThresholdComparison::Between => {
if let Some(upper) = self.upper_threshold {
amount >= self.amount_threshold && amount <= upper
} else {
amount >= self.amount_threshold
}
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ControlDocTypeMapping {
pub control_id: String,
pub document_types: Vec<String>,
}
impl ControlDocTypeMapping {
pub fn new(control_id: impl Into<String>, doc_types: Vec<String>) -> Self {
Self {
control_id: control_id.into(),
document_types: doc_types,
}
}
pub fn applies_to_doc_type(&self, doc_type: &str) -> bool {
self.document_types.iter().any(|dt| dt == doc_type)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ControlMappingRegistry {
pub account_mappings: Vec<ControlAccountMapping>,
pub process_mappings: Vec<ControlProcessMapping>,
pub threshold_mappings: Vec<ControlThresholdMapping>,
pub doc_type_mappings: Vec<ControlDocTypeMapping>,
}
impl ControlMappingRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn standard() -> Self {
let mut registry = Self::new();
registry
.account_mappings
.push(ControlAccountMapping::new("C001").with_sub_types(vec![AccountSubType::Cash]));
registry
.threshold_mappings
.push(ControlThresholdMapping::new(
"C002",
Decimal::from(10000),
ThresholdComparison::GreaterThanOrEqual,
));
registry.process_mappings.push(ControlProcessMapping::new(
"C010",
vec![BusinessProcess::P2P],
));
registry.process_mappings.push(ControlProcessMapping::new(
"C011",
vec![BusinessProcess::P2P],
));
registry.account_mappings.push(
ControlAccountMapping::new("C010")
.with_sub_types(vec![AccountSubType::AccountsPayable]),
);
registry.process_mappings.push(ControlProcessMapping::new(
"C020",
vec![BusinessProcess::O2C],
));
registry.process_mappings.push(ControlProcessMapping::new(
"C021",
vec![BusinessProcess::O2C],
));
registry
.account_mappings
.push(ControlAccountMapping::new("C020").with_sub_types(vec![
AccountSubType::ProductRevenue,
AccountSubType::ServiceRevenue,
]));
registry.account_mappings.push(
ControlAccountMapping::new("C021")
.with_sub_types(vec![AccountSubType::AccountsReceivable]),
);
registry.process_mappings.push(ControlProcessMapping::new(
"C030",
vec![BusinessProcess::R2R],
));
registry.process_mappings.push(ControlProcessMapping::new(
"C031",
vec![BusinessProcess::R2R],
));
registry.process_mappings.push(ControlProcessMapping::new(
"C032",
vec![BusinessProcess::R2R],
));
registry
.doc_type_mappings
.push(ControlDocTypeMapping::new("C031", vec!["SA".to_string()]));
registry.process_mappings.push(ControlProcessMapping::new(
"C040",
vec![BusinessProcess::H2R],
));
registry.process_mappings.push(ControlProcessMapping::new(
"C050",
vec![BusinessProcess::A2R],
));
registry
.account_mappings
.push(ControlAccountMapping::new("C050").with_sub_types(vec![
AccountSubType::FixedAssets,
AccountSubType::AccumulatedDepreciation,
]));
registry.process_mappings.push(ControlProcessMapping::new(
"C060",
vec![BusinessProcess::Intercompany],
));
registry
}
pub fn get_applicable_controls(
&self,
account_number: &str,
account_sub_type: Option<&AccountSubType>,
process: Option<&BusinessProcess>,
amount: Decimal,
doc_type: Option<&str>,
) -> Vec<String> {
let mut control_ids = HashSet::new();
for mapping in &self.account_mappings {
if mapping.applies_to_account(account_number, account_sub_type) {
control_ids.insert(mapping.control_id.clone());
}
}
if let Some(bp) = process {
for mapping in &self.process_mappings {
if mapping.applies_to_process(bp) {
control_ids.insert(mapping.control_id.clone());
}
}
}
for mapping in &self.threshold_mappings {
if mapping.applies_to_amount(amount) {
control_ids.insert(mapping.control_id.clone());
}
}
if let Some(dt) = doc_type {
for mapping in &self.doc_type_mappings {
if mapping.applies_to_doc_type(dt) {
control_ids.insert(mapping.control_id.clone());
}
}
}
let mut result: Vec<_> = control_ids.into_iter().collect();
result.sort();
result
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_threshold_mapping() {
let mapping = ControlThresholdMapping::new(
"C002",
Decimal::from(10000),
ThresholdComparison::GreaterThanOrEqual,
);
assert!(mapping.applies_to_amount(Decimal::from(10000)));
assert!(mapping.applies_to_amount(Decimal::from(50000)));
assert!(!mapping.applies_to_amount(Decimal::from(9999)));
}
#[test]
fn test_between_threshold() {
let mapping =
ControlThresholdMapping::between("TEST", Decimal::from(1000), Decimal::from(10000));
assert!(mapping.applies_to_amount(Decimal::from(5000)));
assert!(mapping.applies_to_amount(Decimal::from(1000)));
assert!(mapping.applies_to_amount(Decimal::from(10000)));
assert!(!mapping.applies_to_amount(Decimal::from(999)));
assert!(!mapping.applies_to_amount(Decimal::from(10001)));
}
#[test]
fn test_account_mapping() {
let mapping = ControlAccountMapping::new("C001").with_sub_types(vec![AccountSubType::Cash]);
assert!(mapping.applies_to_account("100000", Some(&AccountSubType::Cash)));
assert!(!mapping.applies_to_account("200000", Some(&AccountSubType::AccountsPayable)));
}
#[test]
fn test_standard_registry() {
let registry = ControlMappingRegistry::standard();
assert!(!registry.account_mappings.is_empty());
assert!(!registry.process_mappings.is_empty());
assert!(!registry.threshold_mappings.is_empty());
let controls = registry.get_applicable_controls(
"100000",
Some(&AccountSubType::Cash),
Some(&BusinessProcess::R2R),
Decimal::from(50000),
Some("SA"),
);
assert!(controls.contains(&"C001".to_string()));
assert!(controls.contains(&"C002".to_string()));
}
}