Skip to main content

datasynth_generators/treasury/
cash_forecast_generator.rs

1//! Cash Forecast Generator.
2//!
3//! Produces forward-looking [`CashForecast`] records from AR aging, AP aging,
4//! payroll schedules, and tax deadlines. Each forecast item is probability-weighted
5//! based on its source: scheduled AP payments are near-certain while overdue AR
6//! collections receive lower probability.
7
8use 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// ---------------------------------------------------------------------------
18// Input abstractions
19// ---------------------------------------------------------------------------
20
21/// A receivable item from AR aging for forecast input.
22#[derive(Debug, Clone)]
23pub struct ArAgingItem {
24    /// Expected collection date
25    pub expected_date: NaiveDate,
26    /// Invoice amount
27    pub amount: Decimal,
28    /// Days past due (0 = current)
29    pub days_past_due: u32,
30    /// Source document ID
31    pub document_id: String,
32}
33
34/// A payable item from AP aging for forecast input.
35#[derive(Debug, Clone)]
36pub struct ApAgingItem {
37    /// Scheduled payment date
38    pub payment_date: NaiveDate,
39    /// Payment amount
40    pub amount: Decimal,
41    /// Source document ID
42    pub document_id: String,
43}
44
45/// A scheduled disbursement (payroll, tax, debt service).
46#[derive(Debug, Clone)]
47pub struct ScheduledDisbursement {
48    /// Scheduled date
49    pub date: NaiveDate,
50    /// Disbursement amount
51    pub amount: Decimal,
52    /// Category of the disbursement
53    pub category: TreasuryCashFlowCategory,
54    /// Description or source reference
55    pub description: String,
56}
57
58// ---------------------------------------------------------------------------
59// Generator
60// ---------------------------------------------------------------------------
61
62/// Generates cash forecasts with probability-weighted items.
63pub struct CashForecastGenerator {
64    rng: ChaCha8Rng,
65    config: CashForecastingConfig,
66    id_counter: u64,
67    item_counter: u64,
68}
69
70impl CashForecastGenerator {
71    /// Creates a new cash forecast generator.
72    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    /// Generates a cash forecast from various input sources.
82    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        // AR collections (inflows)
95        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        // AP payments (outflows, negative amounts)
112        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), // scheduled payments are near-certain
121                    source_document_type: Some("VendorInvoice".to_string()),
122                    source_document_id: Some(ap.document_id.clone()),
123                });
124            }
125        }
126
127        // Scheduled disbursements (outflows)
128        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), // scheduled = certain
137                    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    /// Computes AR collection probability based on aging.
158    ///
159    /// Overdue invoices get progressively lower probability:
160    /// - Current (0 days): 95%
161    /// - 1-30 days past due: 85%
162    /// - 31-60 days: 65%
163    /// - 61-90 days: 40%
164    /// - 90+ days: 15%
165    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        // Add small random jitter (±5%)
174        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// ---------------------------------------------------------------------------
181// Tests
182// ---------------------------------------------------------------------------
183
184#[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        // AR item is positive
211        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        // AP item is negative
218        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); // outflows are negative
285            assert_eq!(item.probability, dec!(1.00)); // scheduled = certain
286        }
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"), // way beyond 30-day horizon
298            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        // Net should be computed from probability-weighted amounts
323        assert_eq!(forecast.net_position, forecast.computed_net_position());
324        // Net should be positive (AR inflow > AP outflow after weighting)
325        assert!(forecast.net_position > Decimal::ZERO);
326    }
327}