Skip to main content

datasynth_generators/treasury/
cash_position_generator.rs

1//! Cash Position Generator.
2//!
3//! Aggregates cash inflows and outflows into daily [`CashPosition`] records
4//! per bank account. Runs after AP/AR/payroll/banking generators have produced
5//! payment records.
6
7use chrono::NaiveDate;
8use datasynth_core::utils::seeded_rng;
9use rand::prelude::*;
10use rand_chacha::ChaCha8Rng;
11use rust_decimal::Decimal;
12use rust_decimal_macros::dec;
13
14use datasynth_config::schema::CashPositioningConfig;
15use datasynth_core::models::CashPosition;
16
17// ---------------------------------------------------------------------------
18// Cash flow input abstraction
19// ---------------------------------------------------------------------------
20
21/// Direction of a cash flow.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum CashFlowDirection {
24    /// Money coming in (AR collection, refund, etc.)
25    Inflow,
26    /// Money going out (AP payment, payroll, tax, etc.)
27    Outflow,
28}
29
30/// An individual cash flow event to be aggregated into positions.
31///
32/// This is a simplified representation that the cash position generator uses
33/// to aggregate from various sources (AP payments, AR receipts, payroll, tax).
34#[derive(Debug, Clone)]
35pub struct CashFlow {
36    /// Date the cash flow occurs
37    pub date: NaiveDate,
38    /// Bank account the flow affects
39    pub account_id: String,
40    /// Absolute amount of the flow
41    pub amount: Decimal,
42    /// Whether this is an inflow or outflow
43    pub direction: CashFlowDirection,
44}
45
46// ---------------------------------------------------------------------------
47// Generator
48// ---------------------------------------------------------------------------
49
50/// Generates daily cash positions by aggregating cash flows per bank account.
51pub struct CashPositionGenerator {
52    rng: ChaCha8Rng,
53    config: CashPositioningConfig,
54    id_counter: u64,
55}
56
57impl CashPositionGenerator {
58    /// Creates a new cash position generator.
59    pub fn new(config: CashPositioningConfig, seed: u64) -> Self {
60        Self {
61            rng: seeded_rng(seed, 0),
62            config,
63            id_counter: 0,
64        }
65    }
66
67    /// Generates daily cash positions for a single bank account over a date range.
68    ///
69    /// # Arguments
70    /// * `entity_id` — Legal entity identifier
71    /// * `account_id` — Bank account identifier
72    /// * `currency` — Account currency
73    /// * `flows` — Cash flows for this account (filtered by caller)
74    /// * `start_date` — First day of the position series
75    /// * `end_date` — Last day (inclusive) of the position series
76    /// * `opening_balance` — Opening balance on the start date
77    pub fn generate(
78        &mut self,
79        entity_id: &str,
80        account_id: &str,
81        currency: &str,
82        flows: &[CashFlow],
83        start_date: NaiveDate,
84        end_date: NaiveDate,
85        opening_balance: Decimal,
86    ) -> Vec<CashPosition> {
87        let mut positions = Vec::new();
88        let mut current_date = start_date;
89        let mut running_balance = opening_balance;
90
91        while current_date <= end_date {
92            // Aggregate flows for this date
93            let mut inflows = Decimal::ZERO;
94            let mut outflows = Decimal::ZERO;
95
96            for flow in flows {
97                if flow.date == current_date {
98                    match flow.direction {
99                        CashFlowDirection::Inflow => inflows += flow.amount,
100                        CashFlowDirection::Outflow => outflows += flow.amount,
101                    }
102                }
103            }
104
105            self.id_counter += 1;
106            let id = format!("CP-{:06}", self.id_counter);
107
108            let mut pos = CashPosition::new(
109                id,
110                entity_id,
111                account_id,
112                currency,
113                current_date,
114                running_balance,
115                inflows,
116                outflows,
117            );
118
119            // Simulate available balance as slightly less than closing
120            // (pending transactions reduce available balance)
121            let closing = pos.closing_balance;
122            let pending_hold = self.random_hold_amount(closing);
123            pos = pos.with_available_balance((closing - pending_hold).max(Decimal::ZERO));
124
125            running_balance = pos.closing_balance;
126            positions.push(pos);
127
128            current_date = current_date.succ_opt().unwrap_or(current_date);
129        }
130
131        positions
132    }
133
134    /// Generates positions for multiple bank accounts in a single entity.
135    pub fn generate_multi_account(
136        &mut self,
137        entity_id: &str,
138        accounts: &[(String, String, Decimal)], // (account_id, currency, opening_balance)
139        flows: &[CashFlow],
140        start_date: NaiveDate,
141        end_date: NaiveDate,
142    ) -> Vec<CashPosition> {
143        let mut all_positions = Vec::new();
144
145        for (account_id, currency, opening_balance) in accounts {
146            let account_flows: Vec<CashFlow> = flows
147                .iter()
148                .filter(|f| f.account_id == *account_id)
149                .cloned()
150                .collect();
151
152            let positions = self.generate(
153                entity_id,
154                account_id,
155                currency,
156                &account_flows,
157                start_date,
158                end_date,
159                *opening_balance,
160            );
161
162            all_positions.extend(positions);
163        }
164
165        all_positions
166    }
167
168    /// Returns the minimum balance policy from config.
169    pub fn minimum_balance_policy(&self) -> Decimal {
170        Decimal::try_from(self.config.minimum_balance_policy).unwrap_or(dec!(100000))
171    }
172
173    /// Generates a small random hold amount (0-2% of balance) to differentiate
174    /// available from closing balance.
175    fn random_hold_amount(&mut self, closing_balance: Decimal) -> Decimal {
176        if closing_balance <= Decimal::ZERO {
177            return Decimal::ZERO;
178        }
179        let pct = self.rng.gen_range(0.0f64..0.02);
180        let hold = closing_balance * Decimal::try_from(pct).unwrap_or(Decimal::ZERO);
181        hold.round_dp(2)
182    }
183}
184
185// ---------------------------------------------------------------------------
186// Tests
187// ---------------------------------------------------------------------------
188
189#[cfg(test)]
190#[allow(clippy::unwrap_used)]
191mod tests {
192    use super::*;
193
194    fn d(s: &str) -> NaiveDate {
195        NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
196    }
197
198    #[test]
199    fn test_cash_positions_from_payment_flows() {
200        let mut gen = CashPositionGenerator::new(CashPositioningConfig::default(), 42);
201        let flows = vec![
202            CashFlow {
203                date: d("2025-01-15"),
204                account_id: "BA-001".into(),
205                amount: dec!(5000),
206                direction: CashFlowDirection::Inflow,
207            },
208            CashFlow {
209                date: d("2025-01-15"),
210                account_id: "BA-001".into(),
211                amount: dec!(2000),
212                direction: CashFlowDirection::Outflow,
213            },
214            CashFlow {
215                date: d("2025-01-16"),
216                account_id: "BA-001".into(),
217                amount: dec!(1000),
218                direction: CashFlowDirection::Outflow,
219            },
220        ];
221        let positions = gen.generate(
222            "C001",
223            "BA-001",
224            "USD",
225            &flows,
226            d("2025-01-15"),
227            d("2025-01-16"),
228            dec!(10000),
229        );
230
231        assert_eq!(positions.len(), 2);
232        // Day 1: opening=10000, in=5000, out=2000, closing=13000
233        assert_eq!(positions[0].opening_balance, dec!(10000));
234        assert_eq!(positions[0].inflows, dec!(5000));
235        assert_eq!(positions[0].outflows, dec!(2000));
236        assert_eq!(positions[0].closing_balance, dec!(13000));
237        // Day 2: opening=13000, in=0, out=1000, closing=12000
238        assert_eq!(positions[1].opening_balance, dec!(13000));
239        assert_eq!(positions[1].inflows, dec!(0));
240        assert_eq!(positions[1].outflows, dec!(1000));
241        assert_eq!(positions[1].closing_balance, dec!(12000));
242    }
243
244    #[test]
245    fn test_no_flows_produces_flat_positions() {
246        let mut gen = CashPositionGenerator::new(CashPositioningConfig::default(), 42);
247        let positions = gen.generate(
248            "C001",
249            "BA-001",
250            "EUR",
251            &[],
252            d("2025-01-01"),
253            d("2025-01-03"),
254            dec!(50000),
255        );
256
257        assert_eq!(positions.len(), 3);
258        for pos in &positions {
259            assert_eq!(pos.opening_balance, dec!(50000));
260            assert_eq!(pos.inflows, dec!(0));
261            assert_eq!(pos.outflows, dec!(0));
262            assert_eq!(pos.closing_balance, dec!(50000));
263        }
264    }
265
266    #[test]
267    fn test_balance_carries_forward() {
268        let mut gen = CashPositionGenerator::new(CashPositioningConfig::default(), 42);
269        let flows = vec![
270            CashFlow {
271                date: d("2025-01-01"),
272                account_id: "BA-001".into(),
273                amount: dec!(10000),
274                direction: CashFlowDirection::Inflow,
275            },
276            CashFlow {
277                date: d("2025-01-02"),
278                account_id: "BA-001".into(),
279                amount: dec!(3000),
280                direction: CashFlowDirection::Outflow,
281            },
282            CashFlow {
283                date: d("2025-01-03"),
284                account_id: "BA-001".into(),
285                amount: dec!(5000),
286                direction: CashFlowDirection::Inflow,
287            },
288        ];
289
290        let positions = gen.generate(
291            "C001",
292            "BA-001",
293            "USD",
294            &flows,
295            d("2025-01-01"),
296            d("2025-01-03"),
297            dec!(20000),
298        );
299
300        assert_eq!(positions.len(), 3);
301        // Day 1: 20000 + 10000 = 30000
302        assert_eq!(positions[0].closing_balance, dec!(30000));
303        // Day 2: 30000 - 3000 = 27000
304        assert_eq!(positions[1].opening_balance, dec!(30000));
305        assert_eq!(positions[1].closing_balance, dec!(27000));
306        // Day 3: 27000 + 5000 = 32000
307        assert_eq!(positions[2].opening_balance, dec!(27000));
308        assert_eq!(positions[2].closing_balance, dec!(32000));
309    }
310
311    #[test]
312    fn test_available_balance_less_than_or_equal_to_closing() {
313        let mut gen = CashPositionGenerator::new(CashPositioningConfig::default(), 42);
314        let positions = gen.generate(
315            "C001",
316            "BA-001",
317            "USD",
318            &[],
319            d("2025-01-01"),
320            d("2025-01-05"),
321            dec!(100000),
322        );
323
324        for pos in &positions {
325            assert!(
326                pos.available_balance <= pos.closing_balance,
327                "available {} should be <= closing {}",
328                pos.available_balance,
329                pos.closing_balance
330            );
331        }
332    }
333
334    #[test]
335    fn test_multi_account_generation() {
336        let mut gen = CashPositionGenerator::new(CashPositioningConfig::default(), 42);
337        let accounts = vec![
338            ("BA-001".to_string(), "USD".to_string(), dec!(10000)),
339            ("BA-002".to_string(), "EUR".to_string(), dec!(20000)),
340        ];
341        let flows = vec![
342            CashFlow {
343                date: d("2025-01-01"),
344                account_id: "BA-001".into(),
345                amount: dec!(5000),
346                direction: CashFlowDirection::Inflow,
347            },
348            CashFlow {
349                date: d("2025-01-01"),
350                account_id: "BA-002".into(),
351                amount: dec!(3000),
352                direction: CashFlowDirection::Outflow,
353            },
354        ];
355
356        let positions =
357            gen.generate_multi_account("C001", &accounts, &flows, d("2025-01-01"), d("2025-01-02"));
358
359        // 2 accounts * 2 days = 4 positions
360        assert_eq!(positions.len(), 4);
361
362        // BA-001 day 1: 10000 + 5000 = 15000
363        let ba001_day1 = positions
364            .iter()
365            .find(|p| p.bank_account_id == "BA-001" && p.date == d("2025-01-01"))
366            .unwrap();
367        assert_eq!(ba001_day1.closing_balance, dec!(15000));
368
369        // BA-002 day 1: 20000 - 3000 = 17000
370        let ba002_day1 = positions
371            .iter()
372            .find(|p| p.bank_account_id == "BA-002" && p.date == d("2025-01-01"))
373            .unwrap();
374        assert_eq!(ba002_day1.closing_balance, dec!(17000));
375    }
376
377    #[test]
378    fn test_minimum_balance_policy() {
379        let config = CashPositioningConfig {
380            minimum_balance_policy: 250_000.0,
381            ..CashPositioningConfig::default()
382        };
383        let gen = CashPositionGenerator::new(config, 42);
384        assert_eq!(gen.minimum_balance_policy(), dec!(250000));
385    }
386
387    #[test]
388    fn test_deterministic_generation() {
389        let flows = vec![CashFlow {
390            date: d("2025-01-01"),
391            account_id: "BA-001".into(),
392            amount: dec!(5000),
393            direction: CashFlowDirection::Inflow,
394        }];
395
396        let mut gen1 = CashPositionGenerator::new(CashPositioningConfig::default(), 42);
397        let pos1 = gen1.generate(
398            "C001",
399            "BA-001",
400            "USD",
401            &flows,
402            d("2025-01-01"),
403            d("2025-01-01"),
404            dec!(10000),
405        );
406
407        let mut gen2 = CashPositionGenerator::new(CashPositioningConfig::default(), 42);
408        let pos2 = gen2.generate(
409            "C001",
410            "BA-001",
411            "USD",
412            &flows,
413            d("2025-01-01"),
414            d("2025-01-01"),
415            dec!(10000),
416        );
417
418        assert_eq!(pos1[0].closing_balance, pos2[0].closing_balance);
419        assert_eq!(pos1[0].available_balance, pos2[0].available_balance);
420    }
421}