datasynth_generators/treasury/
cash_forecast_generator.rs1use chrono::NaiveDate;
9use rand::prelude::*;
10use rand_chacha::ChaCha8Rng;
11use rust_decimal::Decimal;
12use rust_decimal_macros::dec;
13
14use datasynth_config::schema::CashForecastingConfig;
15use datasynth_core::models::{CashForecast, CashForecastItem, TreasuryCashFlowCategory};
16
17#[derive(Debug, Clone)]
23pub struct ArAgingItem {
24 pub expected_date: NaiveDate,
26 pub amount: Decimal,
28 pub days_past_due: u32,
30 pub document_id: String,
32}
33
34#[derive(Debug, Clone)]
36pub struct ApAgingItem {
37 pub payment_date: NaiveDate,
39 pub amount: Decimal,
41 pub document_id: String,
43}
44
45#[derive(Debug, Clone)]
47pub struct ScheduledDisbursement {
48 pub date: NaiveDate,
50 pub amount: Decimal,
52 pub category: TreasuryCashFlowCategory,
54 pub description: String,
56}
57
58pub struct CashForecastGenerator {
64 rng: ChaCha8Rng,
65 config: CashForecastingConfig,
66 id_counter: u64,
67 item_counter: u64,
68}
69
70impl CashForecastGenerator {
71 pub fn new(seed: u64, config: CashForecastingConfig) -> Self {
73 Self {
74 rng: ChaCha8Rng::seed_from_u64(seed),
75 config,
76 id_counter: 0,
77 item_counter: 0,
78 }
79 }
80
81 pub fn generate(
83 &mut self,
84 entity_id: &str,
85 currency: &str,
86 forecast_date: NaiveDate,
87 ar_items: &[ArAgingItem],
88 ap_items: &[ApAgingItem],
89 disbursements: &[ScheduledDisbursement],
90 ) -> CashForecast {
91 let horizon_end = forecast_date + chrono::Duration::days(self.config.horizon_days as i64);
92 let mut items = Vec::new();
93
94 for ar in ar_items {
96 if ar.expected_date > forecast_date && ar.expected_date <= horizon_end {
97 let prob = self.ar_collection_probability(ar.days_past_due);
98 self.item_counter += 1;
99 items.push(CashForecastItem {
100 id: format!("CFI-{:06}", self.item_counter),
101 date: ar.expected_date,
102 category: TreasuryCashFlowCategory::ArCollection,
103 amount: ar.amount,
104 probability: prob,
105 source_document_type: Some("CustomerInvoice".to_string()),
106 source_document_id: Some(ar.document_id.clone()),
107 });
108 }
109 }
110
111 for ap in ap_items {
113 if ap.payment_date > forecast_date && ap.payment_date <= horizon_end {
114 self.item_counter += 1;
115 items.push(CashForecastItem {
116 id: format!("CFI-{:06}", self.item_counter),
117 date: ap.payment_date,
118 category: TreasuryCashFlowCategory::ApPayment,
119 amount: -ap.amount,
120 probability: dec!(0.95), source_document_type: Some("VendorInvoice".to_string()),
122 source_document_id: Some(ap.document_id.clone()),
123 });
124 }
125 }
126
127 for disb in disbursements {
129 if disb.date > forecast_date && disb.date <= horizon_end {
130 self.item_counter += 1;
131 items.push(CashForecastItem {
132 id: format!("CFI-{:06}", self.item_counter),
133 date: disb.date,
134 category: disb.category,
135 amount: -disb.amount,
136 probability: dec!(1.00), source_document_type: None,
138 source_document_id: None,
139 });
140 }
141 }
142
143 self.id_counter += 1;
144 let confidence = Decimal::try_from(self.config.confidence_interval).unwrap_or(dec!(0.90));
145
146 CashForecast::new(
147 format!("CF-{:06}", self.id_counter),
148 entity_id,
149 currency,
150 forecast_date,
151 self.config.horizon_days,
152 items,
153 confidence,
154 )
155 }
156
157 fn ar_collection_probability(&mut self, days_past_due: u32) -> Decimal {
166 let base = match days_past_due {
167 0 => dec!(0.95),
168 1..=30 => dec!(0.85),
169 31..=60 => dec!(0.65),
170 61..=90 => dec!(0.40),
171 _ => dec!(0.15),
172 };
173 let jitter =
175 Decimal::try_from(self.rng.gen_range(-0.05f64..0.05f64)).unwrap_or(Decimal::ZERO);
176 (base + jitter).max(dec!(0.05)).min(dec!(1.00)).round_dp(2)
177 }
178}
179
180#[cfg(test)]
185#[allow(clippy::unwrap_used)]
186mod tests {
187 use super::*;
188
189 fn d(s: &str) -> NaiveDate {
190 NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
191 }
192
193 #[test]
194 fn test_forecast_from_ar_ap() {
195 let mut gen = CashForecastGenerator::new(42, CashForecastingConfig::default());
196 let ar = vec![ArAgingItem {
197 expected_date: d("2025-02-15"),
198 amount: dec!(50000),
199 days_past_due: 0,
200 document_id: "INV-001".to_string(),
201 }];
202 let ap = vec![ApAgingItem {
203 payment_date: d("2025-02-10"),
204 amount: dec!(30000),
205 document_id: "VI-001".to_string(),
206 }];
207 let forecast = gen.generate("C001", "USD", d("2025-01-31"), &ar, &ap, &[]);
208
209 assert_eq!(forecast.items.len(), 2);
210 let ar_item = forecast
212 .items
213 .iter()
214 .find(|i| i.category == TreasuryCashFlowCategory::ArCollection)
215 .unwrap();
216 assert!(ar_item.amount > Decimal::ZERO);
217 let ap_item = forecast
219 .items
220 .iter()
221 .find(|i| i.category == TreasuryCashFlowCategory::ApPayment)
222 .unwrap();
223 assert!(ap_item.amount < Decimal::ZERO);
224 }
225
226 #[test]
227 fn test_overdue_ar_lower_probability() {
228 let mut gen = CashForecastGenerator::new(42, CashForecastingConfig::default());
229 let ar = vec![
230 ArAgingItem {
231 expected_date: d("2025-02-15"),
232 amount: dec!(10000),
233 days_past_due: 0,
234 document_id: "INV-CURRENT".to_string(),
235 },
236 ArAgingItem {
237 expected_date: d("2025-02-20"),
238 amount: dec!(10000),
239 days_past_due: 90,
240 document_id: "INV-OVERDUE".to_string(),
241 },
242 ];
243 let forecast = gen.generate("C001", "USD", d("2025-01-31"), &ar, &[], &[]);
244
245 let current = forecast
246 .items
247 .iter()
248 .find(|i| i.source_document_id.as_deref() == Some("INV-CURRENT"))
249 .unwrap();
250 let overdue = forecast
251 .items
252 .iter()
253 .find(|i| i.source_document_id.as_deref() == Some("INV-OVERDUE"))
254 .unwrap();
255 assert!(
256 current.probability > overdue.probability,
257 "current prob {} should exceed overdue prob {}",
258 current.probability,
259 overdue.probability
260 );
261 }
262
263 #[test]
264 fn test_disbursements_included() {
265 let mut gen = CashForecastGenerator::new(42, CashForecastingConfig::default());
266 let disbursements = vec![
267 ScheduledDisbursement {
268 date: d("2025-02-28"),
269 amount: dec!(100000),
270 category: TreasuryCashFlowCategory::PayrollDisbursement,
271 description: "February payroll".to_string(),
272 },
273 ScheduledDisbursement {
274 date: d("2025-03-15"),
275 amount: dec!(50000),
276 category: TreasuryCashFlowCategory::TaxPayment,
277 description: "Q4 VAT payment".to_string(),
278 },
279 ];
280 let forecast = gen.generate("C001", "USD", d("2025-01-31"), &[], &[], &disbursements);
281
282 assert_eq!(forecast.items.len(), 2);
283 for item in &forecast.items {
284 assert!(item.amount < Decimal::ZERO); assert_eq!(item.probability, dec!(1.00)); }
287 }
288
289 #[test]
290 fn test_items_outside_horizon_excluded() {
291 let config = CashForecastingConfig {
292 horizon_days: 30,
293 ..CashForecastingConfig::default()
294 };
295 let mut gen = CashForecastGenerator::new(42, config);
296 let ar = vec![ArAgingItem {
297 expected_date: d("2025-06-15"), amount: dec!(10000),
299 days_past_due: 0,
300 document_id: "INV-FAR".to_string(),
301 }];
302 let forecast = gen.generate("C001", "USD", d("2025-01-31"), &ar, &[], &[]);
303 assert_eq!(forecast.items.len(), 0);
304 }
305
306 #[test]
307 fn test_net_position_computed() {
308 let mut gen = CashForecastGenerator::new(42, CashForecastingConfig::default());
309 let ar = vec![ArAgingItem {
310 expected_date: d("2025-02-15"),
311 amount: dec!(100000),
312 days_past_due: 0,
313 document_id: "INV-001".to_string(),
314 }];
315 let ap = vec![ApAgingItem {
316 payment_date: d("2025-02-10"),
317 amount: dec!(60000),
318 document_id: "VI-001".to_string(),
319 }];
320 let forecast = gen.generate("C001", "USD", d("2025-01-31"), &ar, &ap, &[]);
321
322 assert_eq!(forecast.net_position, forecast.computed_net_position());
324 assert!(forecast.net_position > Decimal::ZERO);
326 }
327}