Skip to main content

datasynth_core/models/
control_mapping.rs

1//! Control-to-entity mappings for Internal Controls System.
2//!
3//! Defines how controls map to GL accounts, business processes,
4//! amount thresholds, and document types.
5
6use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8use std::collections::HashSet;
9
10use super::chart_of_accounts::AccountSubType;
11use super::journal_entry::BusinessProcess;
12
13/// Comparison operator for threshold mappings.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum ThresholdComparison {
17    /// Amount must be greater than threshold
18    GreaterThan,
19    /// Amount must be greater than or equal to threshold
20    GreaterThanOrEqual,
21    /// Amount must be less than threshold
22    LessThan,
23    /// Amount must be less than or equal to threshold
24    LessThanOrEqual,
25    /// Amount must be between two thresholds
26    Between,
27}
28
29/// Mapping between a control and GL accounts.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ControlAccountMapping {
32    /// Control ID
33    pub control_id: String,
34    /// Specific GL account numbers (if any)
35    pub account_numbers: Vec<String>,
36    /// Account sub-types this control applies to
37    pub account_sub_types: Vec<AccountSubType>,
38}
39
40impl ControlAccountMapping {
41    /// Create a new control-to-account mapping.
42    pub fn new(control_id: impl Into<String>) -> Self {
43        Self {
44            control_id: control_id.into(),
45            account_numbers: Vec::new(),
46            account_sub_types: Vec::new(),
47        }
48    }
49
50    /// Add specific account numbers.
51    pub fn with_accounts(mut self, accounts: Vec<String>) -> Self {
52        self.account_numbers = accounts;
53        self
54    }
55
56    /// Add account sub-types.
57    pub fn with_sub_types(mut self, sub_types: Vec<AccountSubType>) -> Self {
58        self.account_sub_types = sub_types;
59        self
60    }
61
62    /// Check if this mapping applies to a given account.
63    pub fn applies_to_account(
64        &self,
65        account_number: &str,
66        sub_type: Option<&AccountSubType>,
67    ) -> bool {
68        // Check specific account numbers first
69        if !self.account_numbers.is_empty()
70            && self.account_numbers.iter().any(|a| a == account_number)
71        {
72            return true;
73        }
74
75        // Then check sub-types
76        if let Some(st) = sub_type {
77            if self.account_sub_types.contains(st) {
78                return true;
79            }
80        }
81
82        // If no specific accounts or sub-types defined, mapping doesn't apply
83        false
84    }
85}
86
87/// Mapping between a control and business processes.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ControlProcessMapping {
90    /// Control ID
91    pub control_id: String,
92    /// Business processes this control applies to
93    pub business_processes: Vec<BusinessProcess>,
94}
95
96impl ControlProcessMapping {
97    /// Create a new control-to-process mapping.
98    pub fn new(control_id: impl Into<String>, processes: Vec<BusinessProcess>) -> Self {
99        Self {
100            control_id: control_id.into(),
101            business_processes: processes,
102        }
103    }
104
105    /// Check if this mapping applies to a given process.
106    pub fn applies_to_process(&self, process: &BusinessProcess) -> bool {
107        self.business_processes.contains(process)
108    }
109}
110
111/// Mapping between a control and amount thresholds.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct ControlThresholdMapping {
114    /// Control ID
115    pub control_id: String,
116    /// Primary threshold amount
117    pub amount_threshold: Decimal,
118    /// Optional upper bound for 'between' comparison
119    pub upper_threshold: Option<Decimal>,
120    /// Comparison type
121    pub comparison: ThresholdComparison,
122}
123
124impl ControlThresholdMapping {
125    /// Create a new control-to-threshold mapping.
126    pub fn new(
127        control_id: impl Into<String>,
128        threshold: Decimal,
129        comparison: ThresholdComparison,
130    ) -> Self {
131        Self {
132            control_id: control_id.into(),
133            amount_threshold: threshold,
134            upper_threshold: None,
135            comparison,
136        }
137    }
138
139    /// Create a 'between' threshold mapping.
140    pub fn between(control_id: impl Into<String>, lower: Decimal, upper: Decimal) -> Self {
141        Self {
142            control_id: control_id.into(),
143            amount_threshold: lower,
144            upper_threshold: Some(upper),
145            comparison: ThresholdComparison::Between,
146        }
147    }
148
149    /// Check if this mapping applies to a given amount.
150    pub fn applies_to_amount(&self, amount: Decimal) -> bool {
151        match self.comparison {
152            ThresholdComparison::GreaterThan => amount > self.amount_threshold,
153            ThresholdComparison::GreaterThanOrEqual => amount >= self.amount_threshold,
154            ThresholdComparison::LessThan => amount < self.amount_threshold,
155            ThresholdComparison::LessThanOrEqual => amount <= self.amount_threshold,
156            ThresholdComparison::Between => {
157                if let Some(upper) = self.upper_threshold {
158                    amount >= self.amount_threshold && amount <= upper
159                } else {
160                    amount >= self.amount_threshold
161                }
162            }
163        }
164    }
165}
166
167/// Mapping between a control and document types.
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct ControlDocTypeMapping {
170    /// Control ID
171    pub control_id: String,
172    /// Document types this control applies to (SAP document types)
173    pub document_types: Vec<String>,
174}
175
176impl ControlDocTypeMapping {
177    /// Create a new control-to-document type mapping.
178    pub fn new(control_id: impl Into<String>, doc_types: Vec<String>) -> Self {
179        Self {
180            control_id: control_id.into(),
181            document_types: doc_types,
182        }
183    }
184
185    /// Check if this mapping applies to a given document type.
186    pub fn applies_to_doc_type(&self, doc_type: &str) -> bool {
187        self.document_types.iter().any(|dt| dt == doc_type)
188    }
189}
190
191/// Master registry of all control mappings.
192#[derive(Debug, Clone, Default, Serialize, Deserialize)]
193pub struct ControlMappingRegistry {
194    /// Control-to-account mappings
195    pub account_mappings: Vec<ControlAccountMapping>,
196    /// Control-to-process mappings
197    pub process_mappings: Vec<ControlProcessMapping>,
198    /// Control-to-threshold mappings
199    pub threshold_mappings: Vec<ControlThresholdMapping>,
200    /// Control-to-document type mappings
201    pub doc_type_mappings: Vec<ControlDocTypeMapping>,
202}
203
204impl ControlMappingRegistry {
205    /// Create a new empty registry.
206    pub fn new() -> Self {
207        Self::default()
208    }
209
210    /// Create a registry with standard mappings.
211    pub fn standard() -> Self {
212        let mut registry = Self::new();
213
214        // Cash controls (C001) - apply to cash accounts
215        registry
216            .account_mappings
217            .push(ControlAccountMapping::new("C001").with_sub_types(vec![AccountSubType::Cash]));
218
219        // Large transaction approval (C002) - threshold based
220        registry
221            .threshold_mappings
222            .push(ControlThresholdMapping::new(
223                "C002",
224                Decimal::from(10000),
225                ThresholdComparison::GreaterThanOrEqual,
226            ));
227
228        // P2P controls (C010, C011) - apply to P2P process
229        registry.process_mappings.push(ControlProcessMapping::new(
230            "C010",
231            vec![BusinessProcess::P2P],
232        ));
233        registry.process_mappings.push(ControlProcessMapping::new(
234            "C011",
235            vec![BusinessProcess::P2P],
236        ));
237        registry.account_mappings.push(
238            ControlAccountMapping::new("C010")
239                .with_sub_types(vec![AccountSubType::AccountsPayable]),
240        );
241
242        // O2C controls (C020, C021) - apply to O2C process
243        registry.process_mappings.push(ControlProcessMapping::new(
244            "C020",
245            vec![BusinessProcess::O2C],
246        ));
247        registry.process_mappings.push(ControlProcessMapping::new(
248            "C021",
249            vec![BusinessProcess::O2C],
250        ));
251        registry
252            .account_mappings
253            .push(ControlAccountMapping::new("C020").with_sub_types(vec![
254                AccountSubType::ProductRevenue,
255                AccountSubType::ServiceRevenue,
256            ]));
257        registry.account_mappings.push(
258            ControlAccountMapping::new("C021")
259                .with_sub_types(vec![AccountSubType::AccountsReceivable]),
260        );
261
262        // GL controls (C030, C031, C032) - apply to R2R process
263        registry.process_mappings.push(ControlProcessMapping::new(
264            "C030",
265            vec![BusinessProcess::R2R],
266        ));
267        registry.process_mappings.push(ControlProcessMapping::new(
268            "C031",
269            vec![BusinessProcess::R2R],
270        ));
271        registry.process_mappings.push(ControlProcessMapping::new(
272            "C032",
273            vec![BusinessProcess::R2R],
274        ));
275        // Manual JE review applies to document type SA
276        registry
277            .doc_type_mappings
278            .push(ControlDocTypeMapping::new("C031", vec!["SA".to_string()]));
279
280        // Payroll controls (C040) - apply to H2R process
281        registry.process_mappings.push(ControlProcessMapping::new(
282            "C040",
283            vec![BusinessProcess::H2R],
284        ));
285
286        // Fixed asset controls (C050) - apply to A2R process
287        registry.process_mappings.push(ControlProcessMapping::new(
288            "C050",
289            vec![BusinessProcess::A2R],
290        ));
291        registry
292            .account_mappings
293            .push(ControlAccountMapping::new("C050").with_sub_types(vec![
294                AccountSubType::FixedAssets,
295                AccountSubType::AccumulatedDepreciation,
296            ]));
297
298        // Intercompany controls (C060) - apply to Intercompany process
299        registry.process_mappings.push(ControlProcessMapping::new(
300            "C060",
301            vec![BusinessProcess::Intercompany],
302        ));
303
304        registry
305    }
306
307    /// Get all control IDs that apply to a transaction.
308    pub fn get_applicable_controls(
309        &self,
310        account_number: &str,
311        account_sub_type: Option<&AccountSubType>,
312        process: Option<&BusinessProcess>,
313        amount: Decimal,
314        doc_type: Option<&str>,
315    ) -> Vec<String> {
316        let mut control_ids = HashSet::new();
317
318        // Check account mappings
319        for mapping in &self.account_mappings {
320            if mapping.applies_to_account(account_number, account_sub_type) {
321                control_ids.insert(mapping.control_id.clone());
322            }
323        }
324
325        // Check process mappings
326        if let Some(bp) = process {
327            for mapping in &self.process_mappings {
328                if mapping.applies_to_process(bp) {
329                    control_ids.insert(mapping.control_id.clone());
330                }
331            }
332        }
333
334        // Check threshold mappings
335        for mapping in &self.threshold_mappings {
336            if mapping.applies_to_amount(amount) {
337                control_ids.insert(mapping.control_id.clone());
338            }
339        }
340
341        // Check document type mappings
342        if let Some(dt) = doc_type {
343            for mapping in &self.doc_type_mappings {
344                if mapping.applies_to_doc_type(dt) {
345                    control_ids.insert(mapping.control_id.clone());
346                }
347            }
348        }
349
350        let mut result: Vec<_> = control_ids.into_iter().collect();
351        result.sort();
352        result
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn test_threshold_mapping() {
362        let mapping = ControlThresholdMapping::new(
363            "C002",
364            Decimal::from(10000),
365            ThresholdComparison::GreaterThanOrEqual,
366        );
367
368        assert!(mapping.applies_to_amount(Decimal::from(10000)));
369        assert!(mapping.applies_to_amount(Decimal::from(50000)));
370        assert!(!mapping.applies_to_amount(Decimal::from(9999)));
371    }
372
373    #[test]
374    fn test_between_threshold() {
375        let mapping =
376            ControlThresholdMapping::between("TEST", Decimal::from(1000), Decimal::from(10000));
377
378        assert!(mapping.applies_to_amount(Decimal::from(5000)));
379        assert!(mapping.applies_to_amount(Decimal::from(1000)));
380        assert!(mapping.applies_to_amount(Decimal::from(10000)));
381        assert!(!mapping.applies_to_amount(Decimal::from(999)));
382        assert!(!mapping.applies_to_amount(Decimal::from(10001)));
383    }
384
385    #[test]
386    fn test_account_mapping() {
387        let mapping = ControlAccountMapping::new("C001").with_sub_types(vec![AccountSubType::Cash]);
388
389        assert!(mapping.applies_to_account("100000", Some(&AccountSubType::Cash)));
390        assert!(!mapping.applies_to_account("200000", Some(&AccountSubType::AccountsPayable)));
391    }
392
393    #[test]
394    fn test_standard_registry() {
395        let registry = ControlMappingRegistry::standard();
396
397        // Test that standard mappings exist
398        assert!(!registry.account_mappings.is_empty());
399        assert!(!registry.process_mappings.is_empty());
400        assert!(!registry.threshold_mappings.is_empty());
401
402        // Test getting applicable controls for a large cash transaction
403        let controls = registry.get_applicable_controls(
404            "100000",
405            Some(&AccountSubType::Cash),
406            Some(&BusinessProcess::R2R),
407            Decimal::from(50000),
408            Some("SA"),
409        );
410
411        // Should include cash control and large transaction control
412        assert!(controls.contains(&"C001".to_string()));
413        assert!(controls.contains(&"C002".to_string()));
414    }
415}