1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum CashFlowDirection {
24 Inflow,
26 Outflow,
28}
29
30#[derive(Debug, Clone)]
35pub struct CashFlow {
36 pub date: NaiveDate,
38 pub account_id: String,
40 pub amount: Decimal,
42 pub direction: CashFlowDirection,
44}
45
46pub struct CashPositionGenerator {
52 rng: ChaCha8Rng,
53 config: CashPositioningConfig,
54 id_counter: u64,
55}
56
57impl CashPositionGenerator {
58 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 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 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 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 pub fn generate_multi_account(
136 &mut self,
137 entity_id: &str,
138 accounts: &[(String, String, Decimal)], 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 pub fn minimum_balance_policy(&self) -> Decimal {
170 Decimal::try_from(self.config.minimum_balance_policy).unwrap_or(dec!(100000))
171 }
172
173 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.random_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#[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 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 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 assert_eq!(positions[0].closing_balance, dec!(30000));
303 assert_eq!(positions[1].opening_balance, dec!(30000));
305 assert_eq!(positions[1].closing_balance, dec!(27000));
306 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 assert_eq!(positions.len(), 4);
361
362 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 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}