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)]
186#[allow(clippy::unwrap_used)]
187mod tests {
188 use super::*;
189
190 fn d(s: &str) -> NaiveDate {
191 NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
192 }
193
194 #[test]
195 fn test_forecast_from_ar_ap() {
196 let mut gen = CashForecastGenerator::new(CashForecastingConfig::default(), 42);
197 let ar = vec![ArAgingItem {
198 expected_date: d("2025-02-15"),
199 amount: dec!(50000),
200 days_past_due: 0,
201 document_id: "INV-001".to_string(),
202 }];
203 let ap = vec![ApAgingItem {
204 payment_date: d("2025-02-10"),
205 amount: dec!(30000),
206 document_id: "VI-001".to_string(),
207 }];
208 let forecast = gen.generate("C001", "USD", d("2025-01-31"), &ar, &ap, &[]);
209
210 assert_eq!(forecast.items.len(), 2);
211 let ar_item = forecast
213 .items
214 .iter()
215 .find(|i| i.category == TreasuryCashFlowCategory::ArCollection)
216 .unwrap();
217 assert!(ar_item.amount > Decimal::ZERO);
218 let ap_item = forecast
220 .items
221 .iter()
222 .find(|i| i.category == TreasuryCashFlowCategory::ApPayment)
223 .unwrap();
224 assert!(ap_item.amount < Decimal::ZERO);
225 }
226
227 #[test]
228 fn test_overdue_ar_lower_probability() {
229 let mut gen = CashForecastGenerator::new(CashForecastingConfig::default(), 42);
230 let ar = vec![
231 ArAgingItem {
232 expected_date: d("2025-02-15"),
233 amount: dec!(10000),
234 days_past_due: 0,
235 document_id: "INV-CURRENT".to_string(),
236 },
237 ArAgingItem {
238 expected_date: d("2025-02-20"),
239 amount: dec!(10000),
240 days_past_due: 90,
241 document_id: "INV-OVERDUE".to_string(),
242 },
243 ];
244 let forecast = gen.generate("C001", "USD", d("2025-01-31"), &ar, &[], &[]);
245
246 let current = forecast
247 .items
248 .iter()
249 .find(|i| i.source_document_id.as_deref() == Some("INV-CURRENT"))
250 .unwrap();
251 let overdue = forecast
252 .items
253 .iter()
254 .find(|i| i.source_document_id.as_deref() == Some("INV-OVERDUE"))
255 .unwrap();
256 assert!(
257 current.probability > overdue.probability,
258 "current prob {} should exceed overdue prob {}",
259 current.probability,
260 overdue.probability
261 );
262 }
263
264 #[test]
265 fn test_disbursements_included() {
266 let mut gen = CashForecastGenerator::new(CashForecastingConfig::default(), 42);
267 let disbursements = vec![
268 ScheduledDisbursement {
269 date: d("2025-02-28"),
270 amount: dec!(100000),
271 category: TreasuryCashFlowCategory::PayrollDisbursement,
272 description: "February payroll".to_string(),
273 },
274 ScheduledDisbursement {
275 date: d("2025-03-15"),
276 amount: dec!(50000),
277 category: TreasuryCashFlowCategory::TaxPayment,
278 description: "Q4 VAT payment".to_string(),
279 },
280 ];
281 let forecast = gen.generate("C001", "USD", d("2025-01-31"), &[], &[], &disbursements);
282
283 assert_eq!(forecast.items.len(), 2);
284 for item in &forecast.items {
285 assert!(item.amount < Decimal::ZERO); assert_eq!(item.probability, dec!(1.00)); }
288 }
289
290 #[test]
291 fn test_items_outside_horizon_excluded() {
292 let config = CashForecastingConfig {
293 horizon_days: 30,
294 ..CashForecastingConfig::default()
295 };
296 let mut gen = CashForecastGenerator::new(config, 42);
297 let ar = vec![ArAgingItem {
298 expected_date: d("2025-06-15"), amount: dec!(10000),
300 days_past_due: 0,
301 document_id: "INV-FAR".to_string(),
302 }];
303 let forecast = gen.generate("C001", "USD", d("2025-01-31"), &ar, &[], &[]);
304 assert_eq!(forecast.items.len(), 0);
305 }
306
307 #[test]
308 fn test_net_position_computed() {
309 let mut gen = CashForecastGenerator::new(CashForecastingConfig::default(), 42);
310 let ar = vec![ArAgingItem {
311 expected_date: d("2025-02-15"),
312 amount: dec!(100000),
313 days_past_due: 0,
314 document_id: "INV-001".to_string(),
315 }];
316 let ap = vec![ApAgingItem {
317 payment_date: d("2025-02-10"),
318 amount: dec!(60000),
319 document_id: "VI-001".to_string(),
320 }];
321 let forecast = gen.generate("C001", "USD", d("2025-01-31"), &ar, &ap, &[]);
322
323 assert_eq!(forecast.net_position, forecast.computed_net_position());
325 assert!(forecast.net_position > Decimal::ZERO);
327 }
328}