1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum ThresholdComparison {
17 GreaterThan,
19 GreaterThanOrEqual,
21 LessThan,
23 LessThanOrEqual,
25 Between,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ControlAccountMapping {
32 pub control_id: String,
34 pub account_numbers: Vec<String>,
36 pub account_sub_types: Vec<AccountSubType>,
38}
39
40impl ControlAccountMapping {
41 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 pub fn with_accounts(mut self, accounts: Vec<String>) -> Self {
52 self.account_numbers = accounts;
53 self
54 }
55
56 pub fn with_sub_types(mut self, sub_types: Vec<AccountSubType>) -> Self {
58 self.account_sub_types = sub_types;
59 self
60 }
61
62 pub fn applies_to_account(
64 &self,
65 account_number: &str,
66 sub_type: Option<&AccountSubType>,
67 ) -> bool {
68 if !self.account_numbers.is_empty()
70 && self.account_numbers.iter().any(|a| a == account_number)
71 {
72 return true;
73 }
74
75 if let Some(st) = sub_type {
77 if self.account_sub_types.contains(st) {
78 return true;
79 }
80 }
81
82 false
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ControlProcessMapping {
90 pub control_id: String,
92 pub business_processes: Vec<BusinessProcess>,
94}
95
96impl ControlProcessMapping {
97 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 pub fn applies_to_process(&self, process: &BusinessProcess) -> bool {
107 self.business_processes.contains(process)
108 }
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct ControlThresholdMapping {
114 pub control_id: String,
116 pub amount_threshold: Decimal,
118 pub upper_threshold: Option<Decimal>,
120 pub comparison: ThresholdComparison,
122}
123
124impl ControlThresholdMapping {
125 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct ControlDocTypeMapping {
170 pub control_id: String,
172 pub document_types: Vec<String>,
174}
175
176impl ControlDocTypeMapping {
177 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 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
193pub struct ControlMappingRegistry {
194 pub account_mappings: Vec<ControlAccountMapping>,
196 pub process_mappings: Vec<ControlProcessMapping>,
198 pub threshold_mappings: Vec<ControlThresholdMapping>,
200 pub doc_type_mappings: Vec<ControlDocTypeMapping>,
202}
203
204impl ControlMappingRegistry {
205 pub fn new() -> Self {
207 Self::default()
208 }
209
210 pub fn standard() -> Self {
212 let mut registry = Self::new();
213
214 registry
216 .account_mappings
217 .push(ControlAccountMapping::new("C001").with_sub_types(vec![AccountSubType::Cash]));
218
219 registry
221 .threshold_mappings
222 .push(ControlThresholdMapping::new(
223 "C002",
224 Decimal::from(10000),
225 ThresholdComparison::GreaterThanOrEqual,
226 ));
227
228 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 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 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 registry
277 .doc_type_mappings
278 .push(ControlDocTypeMapping::new("C031", vec!["SA".to_string()]));
279
280 registry.process_mappings.push(ControlProcessMapping::new(
282 "C040",
283 vec![BusinessProcess::H2R],
284 ));
285
286 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 registry.process_mappings.push(ControlProcessMapping::new(
300 "C060",
301 vec![BusinessProcess::Intercompany],
302 ));
303
304 registry
305 }
306
307 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 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 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 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 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 assert!(!registry.account_mappings.is_empty());
399 assert!(!registry.process_mappings.is_empty());
400 assert!(!registry.threshold_mappings.is_empty());
401
402 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 assert!(controls.contains(&"C001".to_string()));
413 assert!(controls.contains(&"C002".to_string()));
414 }
415}