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