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)]
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        // AR item is positive
212        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        // AP item is negative
219        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); // outflows are negative
286            assert_eq!(item.probability, dec!(1.00)); // scheduled = certain
287        }
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"), // way beyond 30-day horizon
299            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        // Net should be computed from probability-weighted amounts
324        assert_eq!(forecast.net_position, forecast.computed_net_position());
325        // Net should be positive (AR inflow > AP outflow after weighting)
326        assert!(forecast.net_position > Decimal::ZERO);
327    }
328}