Skip to main content

datasynth_generators/
budget_generator.rs

1//! Budget generator.
2//!
3//! Generates realistic budgets with line items for each GL account,
4//! budget-vs-actual variance analysis, and approval workflows.
5
6use chrono::NaiveDate;
7use datasynth_config::schema::BudgetConfig;
8use datasynth_core::models::{Budget, BudgetLineItem, BudgetStatus};
9use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
10use rand::prelude::*;
11use rand_chacha::ChaCha8Rng;
12use rust_decimal::Decimal;
13
14/// Departments cycled through for budget line items.
15const DEPARTMENTS: &[&str] = &["Finance", "Sales", "Engineering", "Operations", "HR"];
16
17/// Cost center codes corresponding to departments.
18const COST_CENTERS: &[&str] = &["CC-100", "CC-200", "CC-300", "CC-400", "CC-500"];
19
20/// Generates [`Budget`] instances with line items, variance analysis,
21/// and realistic approval workflows.
22pub struct BudgetGenerator {
23    rng: ChaCha8Rng,
24    uuid_factory: DeterministicUuidFactory,
25    line_uuid_factory: DeterministicUuidFactory,
26}
27
28impl BudgetGenerator {
29    /// Create a new generator with the given seed.
30    pub fn new(seed: u64) -> Self {
31        Self {
32            rng: ChaCha8Rng::seed_from_u64(seed),
33            uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::BudgetLine),
34            line_uuid_factory: DeterministicUuidFactory::with_sub_discriminator(
35                seed,
36                GeneratorType::BudgetLine,
37                1,
38            ),
39        }
40    }
41
42    /// Generate a budget for the given fiscal year and accounts.
43    ///
44    /// # Arguments
45    ///
46    /// * `company_code` - The company code this budget belongs to.
47    /// * `fiscal_year` - The fiscal year the budget covers.
48    /// * `account_codes` - Slice of (account_code, account_name) tuples.
49    /// * `config` - Budget configuration knobs.
50    pub fn generate(
51        &mut self,
52        company_code: &str,
53        fiscal_year: u32,
54        account_codes: &[(String, String)],
55        config: &BudgetConfig,
56    ) -> Budget {
57        let budget_id = self.uuid_factory.next().to_string();
58
59        let mut line_items = Vec::new();
60        let mut total_budget = Decimal::ZERO;
61        let mut total_actual = Decimal::ZERO;
62
63        for (idx, (account_code, account_name)) in account_codes.iter().enumerate() {
64            // Cycle through departments
65            let dept_idx = idx % DEPARTMENTS.len();
66            let department = DEPARTMENTS[dept_idx];
67            let cost_center = COST_CENTERS[dept_idx];
68
69            // Generate monthly line items for the fiscal year (Jan-Dec)
70            for month in 1..=12u32 {
71                let line = self.generate_line_item(
72                    &budget_id,
73                    account_code,
74                    account_name,
75                    department,
76                    cost_center,
77                    fiscal_year,
78                    month,
79                    config,
80                );
81                total_budget += line.budget_amount;
82                total_actual += line.actual_amount;
83                line_items.push(line);
84            }
85        }
86
87        let total_variance = total_actual - total_budget;
88
89        // Status: 60% Approved, 20% Closed, 15% Revised, 5% Submitted
90        let status_roll: f64 = self.rng.gen();
91        let status = if status_roll < 0.60 {
92            BudgetStatus::Approved
93        } else if status_roll < 0.80 {
94            BudgetStatus::Closed
95        } else if status_roll < 0.95 {
96            BudgetStatus::Revised
97        } else {
98            BudgetStatus::Submitted
99        };
100
101        // Approved/Closed budgets get an approver
102        let (approved_by, approved_date) =
103            if matches!(status, BudgetStatus::Approved | BudgetStatus::Closed) {
104                let approver = if self.rng.gen_bool(0.5) {
105                    "CFO-001".to_string()
106                } else {
107                    "VP-FIN-001".to_string()
108                };
109                // Approved before the fiscal year starts
110                let approve_date =
111                    NaiveDate::from_ymd_opt(fiscal_year.saturating_sub(1) as i32, 12, 15)
112                        .or_else(|| NaiveDate::from_ymd_opt(fiscal_year as i32, 1, 1));
113                (Some(approver), approve_date)
114            } else {
115                (None, None)
116            };
117
118        Budget {
119            budget_id,
120            company_code: company_code.to_string(),
121            fiscal_year,
122            name: format!("FY{} Operating Budget", fiscal_year),
123            status,
124            total_budget: total_budget.round_dp(2),
125            total_actual: total_actual.round_dp(2),
126            total_variance: total_variance.round_dp(2),
127            line_items,
128            approved_by,
129            approved_date,
130        }
131    }
132
133    /// Generate a single budget line item for an account/month.
134    #[allow(clippy::too_many_arguments)]
135    fn generate_line_item(
136        &mut self,
137        budget_id: &str,
138        account_code: &str,
139        account_name: &str,
140        department: &str,
141        cost_center: &str,
142        fiscal_year: u32,
143        month: u32,
144        config: &BudgetConfig,
145    ) -> BudgetLineItem {
146        let line_id = self.line_uuid_factory.next().to_string();
147
148        // Budget amount: random 10000 - 500000, applying revenue growth rate
149        let base_amount: f64 = self.rng.gen_range(10_000.0..500_000.0);
150        let growth_adjusted = base_amount * (1.0 + config.revenue_growth_rate);
151        let budget_amount = Decimal::from_f64_retain(growth_adjusted)
152            .unwrap_or(Decimal::ZERO)
153            .round_dp(2);
154
155        // Actual amount: budget * (1 + random(-variance_noise, +variance_noise))
156        let variance_factor: f64 = self
157            .rng
158            .gen_range(-config.variance_noise..config.variance_noise);
159        let actual_raw = growth_adjusted * (1.0 + variance_factor);
160        let actual_amount = Decimal::from_f64_retain(actual_raw)
161            .unwrap_or(Decimal::ZERO)
162            .round_dp(2);
163
164        // Variance = actual - budget
165        let variance = actual_amount - budget_amount;
166
167        // Variance percent = variance / budget * 100
168        let variance_percent = if budget_amount != Decimal::ZERO {
169            let pct = variance.to_string().parse::<f64>().unwrap_or(0.0) / growth_adjusted * 100.0;
170            (pct * 100.0).round() / 100.0
171        } else {
172            0.0
173        };
174
175        // Period dates for this month
176        let period_start =
177            NaiveDate::from_ymd_opt(fiscal_year as i32, month, 1).unwrap_or_else(|| {
178                NaiveDate::from_ymd_opt(fiscal_year as i32, 1, 1)
179                    .unwrap_or(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap_or_default())
180            });
181
182        let period_end = {
183            let (next_year, next_month) = if month == 12 {
184                (fiscal_year as i32 + 1, 1)
185            } else {
186                (fiscal_year as i32, month + 1)
187            };
188            NaiveDate::from_ymd_opt(next_year, next_month, 1)
189                .and_then(|d| d.pred_opt())
190                .unwrap_or(period_start)
191        };
192
193        // Add a note for large variances
194        let notes = if variance_percent.abs() > 5.0 {
195            Some(format!(
196                "Variance of {:.1}% requires management review",
197                variance_percent
198            ))
199        } else {
200            None
201        };
202
203        BudgetLineItem {
204            line_id,
205            budget_id: budget_id.to_string(),
206            account_code: account_code.to_string(),
207            account_name: account_name.to_string(),
208            department: Some(department.to_string()),
209            cost_center: Some(cost_center.to_string()),
210            budget_amount,
211            actual_amount,
212            variance,
213            variance_percent,
214            period_start,
215            period_end,
216            notes,
217        }
218    }
219}
220
221// ---------------------------------------------------------------------------
222// Tests
223// ---------------------------------------------------------------------------
224
225#[cfg(test)]
226#[allow(clippy::unwrap_used)]
227mod tests {
228    use super::*;
229
230    fn sample_accounts() -> Vec<(String, String)> {
231        vec![
232            ("4000".to_string(), "Revenue".to_string()),
233            ("5000".to_string(), "Cost of Goods Sold".to_string()),
234            ("6100".to_string(), "Salaries Expense".to_string()),
235            ("6200".to_string(), "Rent Expense".to_string()),
236            ("6300".to_string(), "Utilities Expense".to_string()),
237        ]
238    }
239
240    fn default_config() -> BudgetConfig {
241        BudgetConfig {
242            enabled: true,
243            revenue_growth_rate: 0.05,
244            expense_inflation_rate: 0.03,
245            variance_noise: 0.10,
246        }
247    }
248
249    #[test]
250    fn test_basic_generation_produces_expected_structure() {
251        let mut gen = BudgetGenerator::new(42);
252        let accounts = sample_accounts();
253        let config = default_config();
254
255        let budget = gen.generate("C001", 2024, &accounts, &config);
256
257        // Basic field checks
258        assert!(!budget.budget_id.is_empty());
259        assert_eq!(budget.company_code, "C001");
260        assert_eq!(budget.fiscal_year, 2024);
261        assert_eq!(budget.name, "FY2024 Operating Budget");
262
263        // 5 accounts * 12 months = 60 line items
264        assert_eq!(budget.line_items.len(), 60);
265
266        // Totals should be consistent with line items
267        let sum_budget: Decimal = budget.line_items.iter().map(|l| l.budget_amount).sum();
268        let sum_actual: Decimal = budget.line_items.iter().map(|l| l.actual_amount).sum();
269        assert_eq!(budget.total_budget, sum_budget.round_dp(2));
270        assert_eq!(budget.total_actual, sum_actual.round_dp(2));
271
272        // Variance = actual - budget
273        let expected_variance = budget.total_actual - budget.total_budget;
274        assert_eq!(budget.total_variance, expected_variance);
275
276        // All line items should have departments and cost centers
277        for line in &budget.line_items {
278            assert!(line.department.is_some());
279            assert!(line.cost_center.is_some());
280            assert!(line.budget_amount > Decimal::ZERO);
281            assert!(line.actual_amount > Decimal::ZERO);
282        }
283
284        // Approved/Closed should have approver, Submitted should not
285        if matches!(budget.status, BudgetStatus::Approved | BudgetStatus::Closed) {
286            assert!(budget.approved_by.is_some());
287            assert!(budget.approved_date.is_some());
288        }
289    }
290
291    #[test]
292    fn test_deterministic_output_with_same_seed() {
293        let accounts = sample_accounts();
294        let config = default_config();
295
296        let mut gen1 = BudgetGenerator::new(12345);
297        let budget1 = gen1.generate("C001", 2025, &accounts, &config);
298
299        let mut gen2 = BudgetGenerator::new(12345);
300        let budget2 = gen2.generate("C001", 2025, &accounts, &config);
301
302        assert_eq!(budget1.budget_id, budget2.budget_id);
303        assert_eq!(budget1.total_budget, budget2.total_budget);
304        assert_eq!(budget1.total_actual, budget2.total_actual);
305        assert_eq!(budget1.line_items.len(), budget2.line_items.len());
306
307        for (l1, l2) in budget1.line_items.iter().zip(budget2.line_items.iter()) {
308            assert_eq!(l1.line_id, l2.line_id);
309            assert_eq!(l1.budget_amount, l2.budget_amount);
310            assert_eq!(l1.actual_amount, l2.actual_amount);
311            assert_eq!(l1.variance, l2.variance);
312        }
313    }
314
315    #[test]
316    fn test_variance_within_noise_bounds() {
317        let mut gen = BudgetGenerator::new(777);
318        let accounts = sample_accounts();
319        let config = BudgetConfig {
320            enabled: true,
321            revenue_growth_rate: 0.0,
322            expense_inflation_rate: 0.0,
323            variance_noise: 0.10,
324        };
325
326        let budget = gen.generate("C002", 2024, &accounts, &config);
327
328        // Each line item's variance should be within +-10% of budget
329        for line in &budget.line_items {
330            let ratio = if line.budget_amount != Decimal::ZERO {
331                (line.actual_amount - line.budget_amount).abs() / line.budget_amount
332            } else {
333                Decimal::ZERO
334            };
335            // Allow small rounding slack
336            assert!(
337                ratio <= Decimal::from_f64_retain(0.11).unwrap_or(Decimal::ONE),
338                "Variance ratio {} exceeds noise bound for account {}",
339                ratio,
340                line.account_code
341            );
342        }
343    }
344}