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)]
192#[allow(clippy::unwrap_used)]
193mod tests {
194    use super::*;
195    use rust_decimal_macros::dec;
196
197    fn d(s: &str) -> NaiveDate {
198        NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
199    }
200
201    #[test]
202    fn test_basic_generation() {
203        let config = NettingSchemaConfig {
204            enabled: true,
205            cycle: "monthly".to_string(),
206        };
207        let mut gen = NettingRunGenerator::new(config, 42);
208
209        let entity_ids = vec!["C001".to_string(), "C002".to_string(), "C003".to_string()];
210        let ic_amounts = vec![
211            ("C001".to_string(), "C002".to_string(), dec!(100000)),
212            ("C002".to_string(), "C003".to_string(), dec!(50000)),
213            ("C003".to_string(), "C001".to_string(), dec!(30000)),
214        ];
215
216        let runs = gen.generate(&entity_ids, "USD", d("2025-01-01"), 3, &ic_amounts);
217
218        assert!(!runs.is_empty());
219        for run in &runs {
220            assert!(run.id.starts_with("NR-"));
221            assert_eq!(run.settlement_currency, "USD");
222            assert_eq!(run.cycle, NettingCycle::Monthly);
223            assert!(!run.positions.is_empty());
224            // Gross receivables should equal gross payables (closed system)
225            assert_eq!(run.gross_receivables, run.gross_payables);
226        }
227    }
228
229    #[test]
230    fn test_deterministic() {
231        let config = NettingSchemaConfig {
232            enabled: true,
233            cycle: "monthly".to_string(),
234        };
235        let entity_ids = vec!["C001".to_string(), "C002".to_string()];
236        let ic_amounts = vec![
237            ("C001".to_string(), "C002".to_string(), dec!(100000)),
238            ("C002".to_string(), "C001".to_string(), dec!(60000)),
239        ];
240
241        let mut gen1 = NettingRunGenerator::new(config.clone(), 42);
242        let r1 = gen1.generate(&entity_ids, "USD", d("2025-01-01"), 2, &ic_amounts);
243
244        let mut gen2 = NettingRunGenerator::new(config, 42);
245        let r2 = gen2.generate(&entity_ids, "USD", d("2025-01-01"), 2, &ic_amounts);
246
247        assert_eq!(r1.len(), r2.len());
248        for (a, b) in r1.iter().zip(r2.iter()) {
249            assert_eq!(a.id, b.id);
250            assert_eq!(a.gross_receivables, b.gross_receivables);
251            assert_eq!(a.net_settlement, b.net_settlement);
252        }
253    }
254
255    #[test]
256    fn test_empty_input() {
257        let config = NettingSchemaConfig {
258            enabled: true,
259            cycle: "monthly".to_string(),
260        };
261        let mut gen = NettingRunGenerator::new(config, 42);
262
263        // Empty IC amounts
264        let entity_ids = vec!["C001".to_string(), "C002".to_string()];
265        let runs = gen.generate(&entity_ids, "USD", d("2025-01-01"), 3, &[]);
266        assert!(runs.is_empty());
267
268        // Single entity (needs 2+)
269        let single = vec!["C001".to_string()];
270        let ic_amounts = vec![("C001".to_string(), "C001".to_string(), dec!(100000))];
271        let runs = gen.generate(&single, "USD", d("2025-01-01"), 3, &ic_amounts);
272        assert!(runs.is_empty());
273    }
274
275    #[test]
276    fn test_net_positions_balance() {
277        let config = NettingSchemaConfig {
278            enabled: true,
279            cycle: "monthly".to_string(),
280        };
281        let mut gen = NettingRunGenerator::new(config, 42);
282
283        let entity_ids = vec!["C001".to_string(), "C002".to_string()];
284        let ic_amounts = vec![
285            ("C001".to_string(), "C002".to_string(), dec!(100000)),
286            ("C002".to_string(), "C001".to_string(), dec!(40000)),
287        ];
288
289        let runs = gen.generate(&entity_ids, "USD", d("2025-01-01"), 1, &ic_amounts);
290        assert_eq!(runs.len(), 1);
291
292        let run = &runs[0];
293        // Net positions should sum to zero (closed system)
294        let net_sum: Decimal = run.positions.iter().map(|p| p.net_position).sum();
295        assert_eq!(net_sum, Decimal::ZERO);
296
297        // Savings should be positive (netting reduces payment flows)
298        assert!(run.savings() >= Decimal::ZERO);
299    }
300}