Skip to main content

datasynth_generators/treasury/
netting_run_generator.rs

1//! Intercompany Netting Run Generator.
2//!
3//! Creates periodic netting runs from intercompany transaction amounts,
4//! computing per-entity gross receivable/payable positions and net settlements.
5
6use std::collections::HashMap;
7
8use chrono::NaiveDate;
9use rust_decimal::Decimal;
10
11use datasynth_config::schema::NettingSchemaConfig;
12use datasynth_core::models::{NettingCycle, NettingPosition, NettingRun, PayOrReceive};
13
14// ---------------------------------------------------------------------------
15// Generator
16// ---------------------------------------------------------------------------
17
18/// Generates intercompany netting runs from IC transaction amounts.
19pub struct NettingRunGenerator {
20    config: NettingSchemaConfig,
21    counter: u64,
22}
23
24impl NettingRunGenerator {
25    /// Creates a new netting run generator.
26    pub fn new(config: NettingSchemaConfig, _seed: u64) -> Self {
27        Self { config, counter: 0 }
28    }
29
30    /// Generates netting runs from intercompany matched-pair amounts.
31    ///
32    /// `ic_amounts` contains `(seller_entity, buyer_entity, amount)` tuples.
33    /// Transactions are grouped into monthly (or configured cycle) netting runs.
34    /// Each run contains per-entity positions with gross receivables, gross
35    /// payables, and the resulting net position / settlement direction.
36    pub fn generate(
37        &mut self,
38        entity_ids: &[String],
39        currency: &str,
40        start_date: NaiveDate,
41        period_months: u32,
42        ic_amounts: &[(String, String, Decimal)],
43    ) -> Vec<NettingRun> {
44        if entity_ids.len() < 2 || ic_amounts.is_empty() {
45            return Vec::new();
46        }
47
48        let cycle = self.parse_cycle();
49        let mut runs = Vec::new();
50
51        // Generate one netting run per period according to cycle
52        let period_count = match cycle {
53            NettingCycle::Daily => period_months * 30, // approximate
54            NettingCycle::Weekly => period_months * 4,
55            NettingCycle::Monthly => period_months,
56        };
57
58        // Spread IC amounts across periods roughly evenly
59        let amounts_per_period = if period_count > 0 {
60            ic_amounts.len() / period_count as usize
61        } else {
62            ic_amounts.len()
63        }
64        .max(1);
65
66        let mut amount_idx = 0;
67
68        for period in 0..period_count {
69            if amount_idx >= ic_amounts.len() {
70                break;
71            }
72
73            // Compute netting date
74            let netting_date = match cycle {
75                NettingCycle::Daily => start_date + chrono::Duration::days(period as i64),
76                NettingCycle::Weekly => start_date + chrono::Duration::weeks(period as i64),
77                NettingCycle::Monthly => {
78                    // Last day of the month
79                    add_months_end(start_date, period)
80                }
81            };
82
83            // Collect the subset of IC amounts for this period
84            let end_idx = (amount_idx + amounts_per_period).min(ic_amounts.len());
85            let period_amounts = &ic_amounts[amount_idx..end_idx];
86            amount_idx = end_idx;
87
88            if period_amounts.is_empty() {
89                continue;
90            }
91
92            // Build positions: accumulate per-entity receivable/payable
93            let mut receivables: HashMap<&str, Decimal> = HashMap::new();
94            let mut payables: HashMap<&str, Decimal> = HashMap::new();
95
96            for (seller, buyer, amount) in period_amounts {
97                // Seller is owed money (receivable), buyer owes money (payable)
98                *receivables.entry(seller.as_str()).or_insert(Decimal::ZERO) += amount;
99                *payables.entry(buyer.as_str()).or_insert(Decimal::ZERO) += amount;
100            }
101
102            // Build NettingPosition for each participating entity
103            let mut all_entities: Vec<&str> =
104                receivables.keys().chain(payables.keys()).copied().collect();
105            all_entities.sort();
106            all_entities.dedup();
107
108            let positions: Vec<NettingPosition> = all_entities
109                .into_iter()
110                .map(|eid| {
111                    let gross_receivable = receivables
112                        .get(eid)
113                        .copied()
114                        .unwrap_or(Decimal::ZERO)
115                        .round_dp(2);
116                    let gross_payable = payables
117                        .get(eid)
118                        .copied()
119                        .unwrap_or(Decimal::ZERO)
120                        .round_dp(2);
121                    let net_position = (gross_receivable - gross_payable).round_dp(2);
122                    let settlement_direction = if net_position > Decimal::ZERO {
123                        PayOrReceive::Receive
124                    } else if net_position < Decimal::ZERO {
125                        PayOrReceive::Pay
126                    } else {
127                        PayOrReceive::Flat
128                    };
129                    NettingPosition {
130                        entity_id: eid.to_string(),
131                        gross_receivable,
132                        gross_payable,
133                        net_position,
134                        settlement_direction,
135                    }
136                })
137                .collect();
138
139            if positions.is_empty() {
140                continue;
141            }
142
143            self.counter += 1;
144            let id = format!("NR-{:06}", self.counter);
145
146            let run = NettingRun::new(id, netting_date, cycle, currency, positions);
147            runs.push(run);
148        }
149
150        runs
151    }
152
153    fn parse_cycle(&self) -> NettingCycle {
154        match self.config.cycle.as_str() {
155            "daily" => NettingCycle::Daily,
156            "weekly" => NettingCycle::Weekly,
157            _ => NettingCycle::Monthly,
158        }
159    }
160}
161
162/// Adds months to a date, returning the last day of the target month.
163fn add_months_end(date: NaiveDate, months: u32) -> NaiveDate {
164    use chrono::Datelike;
165    let total_months = date.month0() + months;
166    let year = date.year() + (total_months / 12) as i32;
167    let month = (total_months % 12) + 1;
168    let last_day = days_in_month(year, month);
169    NaiveDate::from_ymd_opt(year, month, last_day).unwrap_or(date)
170}
171
172fn days_in_month(year: i32, month: u32) -> u32 {
173    match month {
174        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
175        4 | 6 | 9 | 11 => 30,
176        2 => {
177            if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) {
178                29
179            } else {
180                28
181            }
182        }
183        _ => 30,
184    }
185}
186
187// ---------------------------------------------------------------------------
188// Tests
189// ---------------------------------------------------------------------------
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use rust_decimal_macros::dec;
195
196    fn d(s: &str) -> NaiveDate {
197        NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
198    }
199
200    #[test]
201    fn test_basic_generation() {
202        let config = NettingSchemaConfig {
203            enabled: true,
204            cycle: "monthly".to_string(),
205        };
206        let mut gen = NettingRunGenerator::new(config, 42);
207
208        let entity_ids = vec!["C001".to_string(), "C002".to_string(), "C003".to_string()];
209        let ic_amounts = vec![
210            ("C001".to_string(), "C002".to_string(), dec!(100000)),
211            ("C002".to_string(), "C003".to_string(), dec!(50000)),
212            ("C003".to_string(), "C001".to_string(), dec!(30000)),
213        ];
214
215        let runs = gen.generate(&entity_ids, "USD", d("2025-01-01"), 3, &ic_amounts);
216
217        assert!(!runs.is_empty());
218        for run in &runs {
219            assert!(run.id.starts_with("NR-"));
220            assert_eq!(run.settlement_currency, "USD");
221            assert_eq!(run.cycle, NettingCycle::Monthly);
222            assert!(!run.positions.is_empty());
223            // Gross receivables should equal gross payables (closed system)
224            assert_eq!(run.gross_receivables, run.gross_payables);
225        }
226    }
227
228    #[test]
229    fn test_deterministic() {
230        let config = NettingSchemaConfig {
231            enabled: true,
232            cycle: "monthly".to_string(),
233        };
234        let entity_ids = vec!["C001".to_string(), "C002".to_string()];
235        let ic_amounts = vec![
236            ("C001".to_string(), "C002".to_string(), dec!(100000)),
237            ("C002".to_string(), "C001".to_string(), dec!(60000)),
238        ];
239
240        let mut gen1 = NettingRunGenerator::new(config.clone(), 42);
241        let r1 = gen1.generate(&entity_ids, "USD", d("2025-01-01"), 2, &ic_amounts);
242
243        let mut gen2 = NettingRunGenerator::new(config, 42);
244        let r2 = gen2.generate(&entity_ids, "USD", d("2025-01-01"), 2, &ic_amounts);
245
246        assert_eq!(r1.len(), r2.len());
247        for (a, b) in r1.iter().zip(r2.iter()) {
248            assert_eq!(a.id, b.id);
249            assert_eq!(a.gross_receivables, b.gross_receivables);
250            assert_eq!(a.net_settlement, b.net_settlement);
251        }
252    }
253
254    #[test]
255    fn test_empty_input() {
256        let config = NettingSchemaConfig {
257            enabled: true,
258            cycle: "monthly".to_string(),
259        };
260        let mut gen = NettingRunGenerator::new(config, 42);
261
262        // Empty IC amounts
263        let entity_ids = vec!["C001".to_string(), "C002".to_string()];
264        let runs = gen.generate(&entity_ids, "USD", d("2025-01-01"), 3, &[]);
265        assert!(runs.is_empty());
266
267        // Single entity (needs 2+)
268        let single = vec!["C001".to_string()];
269        let ic_amounts = vec![("C001".to_string(), "C001".to_string(), dec!(100000))];
270        let runs = gen.generate(&single, "USD", d("2025-01-01"), 3, &ic_amounts);
271        assert!(runs.is_empty());
272    }
273
274    #[test]
275    fn test_net_positions_balance() {
276        let config = NettingSchemaConfig {
277            enabled: true,
278            cycle: "monthly".to_string(),
279        };
280        let mut gen = NettingRunGenerator::new(config, 42);
281
282        let entity_ids = vec!["C001".to_string(), "C002".to_string()];
283        let ic_amounts = vec![
284            ("C001".to_string(), "C002".to_string(), dec!(100000)),
285            ("C002".to_string(), "C001".to_string(), dec!(40000)),
286        ];
287
288        let runs = gen.generate(&entity_ids, "USD", d("2025-01-01"), 1, &ic_amounts);
289        assert_eq!(runs.len(), 1);
290
291        let run = &runs[0];
292        // Net positions should sum to zero (closed system)
293        let net_sum: Decimal = run.positions.iter().map(|p| p.net_position).sum();
294        assert_eq!(net_sum, Decimal::ZERO);
295
296        // Savings should be positive (netting reduces payment flows)
297        assert!(run.savings() >= Decimal::ZERO);
298    }
299}