datasynth_generators/treasury/
cash_forecast_generator.rs1use chrono::NaiveDate;
9use datasynth_core::utils::seeded_rng;
10use rand::prelude::*;
11use rand_chacha::ChaCha8Rng;
12use rust_decimal::Decimal;
13use rust_decimal_macros::dec;
14
15use datasynth_config::schema::CashForecastingConfig;
16use datasynth_core::models::{CashForecast, CashForecastItem, TreasuryCashFlowCategory};
17
18#[derive(Debug, Clone)]
24pub struct ArAgingItem {
25 pub expected_date: NaiveDate,
27 pub amount: Decimal,
29 pub days_past_due: u32,
31 pub document_id: String,
33}
34
35#[derive(Debug, Clone)]
37pub struct ApAgingItem {
38 pub payment_date: NaiveDate,
40 pub amount: Decimal,
42 pub document_id: String,
44}
45
46#[derive(Debug, Clone)]
48pub struct ScheduledDisbursement {
49 pub date: NaiveDate,
51 pub amount: Decimal,
53 pub category: TreasuryCashFlowCategory,
55 pub description: String,
57}
58
59pub struct CashForecastGenerator {
65 rng: ChaCha8Rng,
66 config: CashForecastingConfig,
67 id_counter: u64,
68 item_counter: u64,
69}
70
71impl CashForecastGenerator {
72 pub fn new(config: CashForecastingConfig, seed: u64) -> Self {
74 Self {
75 rng: seeded_rng(seed, 0),
76 config,
77 id_counter: 0,
78 item_counter: 0,
79 }
80 }
81
82 pub fn generate(
84 &mut self,
85 entity_id: &str,
86 currency: &str,
87 forecast_date: NaiveDate,
88 ar_items: &[ArAgingItem],
89 ap_items: &[ApAgingItem],
90 disbursements: &[ScheduledDisbursement],
91 ) -> CashForecast {
92 let horizon_end = forecast_date + chrono::Duration::days(self.config.horizon_days as i64);
93 let mut items = Vec::new();
94
95 for ar in ar_items {
97 if ar.expected_date > forecast_date && ar.expected_date <= horizon_end {
98 let prob = self.ar_collection_probability(ar.days_past_due);
99 self.item_counter += 1;
100 items.push(CashForecastItem {
101 id: format!("CFI-{:06}", self.item_counter),
102 date: ar.expected_date,
103 category: TreasuryCashFlowCategory::ArCollection,
104 amount: ar.amount,
105 probability: prob,
106 source_document_type: Some("CustomerInvoice".to_string()),
107 source_document_id: Some(ar.document_id.clone()),
108 });
109 }
110 }
111
112 for ap in ap_items {
114 if ap.payment_date > forecast_date && ap.payment_date <= horizon_end {
115 self.item_counter += 1;
116 items.push(CashForecastItem {
117 id: format!("CFI-{:06}", self.item_counter),
118 date: ap.payment_date,
119 category: TreasuryCashFlowCategory::ApPayment,
120 amount: -ap.amount,
121 probability: dec!(0.95), source_document_type: Some("VendorInvoice".to_string()),
123 source_document_id: Some(ap.document_id.clone()),
124 });
125 }
126 }
127
128 for disb in disbursements {
130 if disb.date > forecast_date && disb.date <= horizon_end {
131 self.item_counter += 1;
132 items.push(CashForecastItem {
133 id: format!("CFI-{:06}", self.item_counter),
134 date: disb.date,
135 category: disb.category,
136 amount: -disb.amount,
137 probability: dec!(1.00), source_document_type: None,
139 source_document_id: None,
140 });
141 }
142 }
143
144 self.id_counter += 1;
145 let confidence = Decimal::try_from(self.config.confidence_interval).unwrap_or(dec!(0.90));
146
147 CashForecast::new(
148 format!("CF-{:06}", self.id_counter),
149 entity_id,
150 currency,
151 forecast_date,
152 self.config.horizon_days,
153 items,
154 confidence,
155 )
156 }
157
158 fn ar_collection_probability(&mut self, days_past_due: u32) -> Decimal {
167 let base = match days_past_due {
168 0 => dec!(0.95),
169 1..=30 => dec!(0.85),
170 31..=60 => dec!(0.65),
171 61..=90 => dec!(0.40),
172 _ => dec!(0.15),
173 };
174 let jitter =
176 Decimal::try_from(self.rng.random_range(-0.05f64..0.05f64)).unwrap_or(Decimal::ZERO);
177 (base + jitter).max(dec!(0.05)).min(dec!(1.00)).round_dp(2)
178 }
179}
180
181#[cfg(test)]
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(CashForecastingConfig::default(), 42);
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(CashForecastingConfig::default(), 42);
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(CashForecastingConfig::default(), 42);
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(config, 42);
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(CashForecastingConfig::default(), 42);
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}