1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum CashFlowDirection {
23 Inflow,
25 Outflow,
27}
28
29#[derive(Debug, Clone)]
34pub struct CashFlow {
35 pub date: NaiveDate,
37 pub account_id: String,
39 pub amount: Decimal,
41 pub direction: CashFlowDirection,
43}
44
45pub struct CashPositionGenerator {
51 rng: ChaCha8Rng,
52 config: CashPositioningConfig,
53 id_counter: u64,
54}
55
56impl CashPositionGenerator {
57 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 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 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 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 pub fn generate_multi_account(
135 &mut self,
136 entity_id: &str,
137 accounts: &[(String, String, Decimal)], 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 pub fn minimum_balance_policy(&self) -> Decimal {
169 Decimal::try_from(self.config.minimum_balance_policy).unwrap_or(dec!(100000))
170 }
171
172 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#[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 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 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 assert_eq!(positions[0].closing_balance, dec!(30000));
302 assert_eq!(positions[1].opening_balance, dec!(30000));
304 assert_eq!(positions[1].closing_balance, dec!(27000));
305 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 assert_eq!(positions.len(), 4);
360
361 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 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}