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