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 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// ---------------------------------------------------------------------------
19// Input abstractions
20// ---------------------------------------------------------------------------
21
22/// A receivable item from AR aging for forecast input.
23#[derive(Debug, Clone)]
24pub struct ArAgingItem {
25    /// Expected collection date
26    pub expected_date: NaiveDate,
27    /// Invoice amount
28    pub amount: Decimal,
29    /// Days past due (0 = current)
30    pub days_past_due: u32,
31    /// Source document ID
32    pub document_id: String,
33}
34
35/// A payable item from AP aging for forecast input.
36#[derive(Debug, Clone)]
37pub struct ApAgingItem {
38    /// Scheduled payment date
39    pub payment_date: NaiveDate,
40    /// Payment amount
41    pub amount: Decimal,
42    /// Source document ID
43    pub document_id: String,
44}
45
46/// A scheduled disbursement (payroll, tax, debt service).
47#[derive(Debug, Clone)]
48pub struct ScheduledDisbursement {
49    /// Scheduled date
50    pub date: NaiveDate,
51    /// Disbursement amount
52    pub amount: Decimal,
53    /// Category of the disbursement
54    pub category: TreasuryCashFlowCategory,
55    /// Description or source reference
56    pub description: String,
57}
58
59// ---------------------------------------------------------------------------
60// Generator
61// ---------------------------------------------------------------------------
62
63/// Generates cash forecasts with probability-weighted items.
64pub struct CashForecastGenerator {
65    rng: ChaCha8Rng,
66    config: CashForecastingConfig,
67    id_counter: u64,
68    item_counter: u64,
69}
70
71impl CashForecastGenerator {
72    /// Creates a new cash forecast generator.
73    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    /// Generates a cash forecast from various input sources.
83    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        // AR collections (inflows)
96        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        // AP payments (outflows, negative amounts)
113        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), // scheduled payments are near-certain
122                    source_document_type: Some("VendorInvoice".to_string()),
123                    source_document_id: Some(ap.document_id.clone()),
124                });
125            }
126        }
127
128        // Scheduled disbursements (outflows)
129        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), // scheduled = certain
138                    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    /// Computes AR collection probability based on aging.
159    ///
160    /// Overdue invoices get progressively lower probability:
161    /// - Current (0 days): 95%
162    /// - 1-30 days past due: 85%
163    /// - 31-60 days: 65%
164    /// - 61-90 days: 40%
165    /// - 90+ days: 15%
166    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        // Add small random jitter (±5%)
175        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// ---------------------------------------------------------------------------
182// Tests
183// ---------------------------------------------------------------------------
184
185#[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        // 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(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); // 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(config, 42);
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(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        // 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}