Skip to main content

datasynth_generators/project_accounting/
revenue_generator.rs

1//! Project revenue recognition generator (Percentage of Completion).
2//!
3//! Takes project cost lines and project contract values to compute revenue
4//! recognition using the cost-to-cost PoC method (ASC 606 input method).
5use chrono::{Datelike, NaiveDate};
6use datasynth_config::schema::ProjectRevenueRecognitionConfig;
7use datasynth_core::models::{
8    CompletionMeasure, Project, ProjectCostLine, ProjectRevenue, RevenueMethod,
9};
10use datasynth_core::utils::seeded_rng;
11use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
12use rand::prelude::*;
13use rand_chacha::ChaCha8Rng;
14use rust_decimal::Decimal;
15use rust_decimal_macros::dec;
16
17/// Generates [`ProjectRevenue`] records using Percentage of Completion.
18///
19/// Wired into `EnhancedOrchestrator` at
20/// `crates/datasynth-runtime/src/enhanced_orchestrator.rs:8580` under
21/// `config.project_accounting.revenue_recognition.enabled`.
22pub struct RevenueGenerator {
23    rng: ChaCha8Rng,
24    /// Produces deterministic revenue-record IDs.
25    uuid_factory: DeterministicUuidFactory,
26    /// Controls PoC method selection and completion measure.
27    config: ProjectRevenueRecognitionConfig,
28}
29
30impl RevenueGenerator {
31    /// Create a new revenue generator.
32    pub fn new(config: ProjectRevenueRecognitionConfig, seed: u64) -> Self {
33        Self {
34            rng: seeded_rng(seed, 0),
35            uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::ProjectAccounting),
36            config,
37        }
38    }
39
40    /// Generate revenue recognition records for customer projects.
41    ///
42    /// Only generates revenue for projects that have contract values (customer projects).
43    /// Revenue is computed per month using the cost-to-cost PoC method.
44    pub fn generate(
45        &mut self,
46        projects: &[Project],
47        cost_lines: &[ProjectCostLine],
48        contract_values: &[(String, Decimal, Decimal)], // (project_id, contract_value, estimated_total_cost)
49        start_date: NaiveDate,
50        end_date: NaiveDate,
51    ) -> Vec<ProjectRevenue> {
52        let mut revenues = Vec::new();
53
54        for (project_id, contract_value, estimated_total_cost) in contract_values {
55            let project = match projects.iter().find(|p| &p.project_id == project_id) {
56                Some(p) => p,
57                None => continue,
58            };
59
60            // Collect cost lines for this project, sorted by date
61            let mut project_costs: Vec<&ProjectCostLine> = cost_lines
62                .iter()
63                .filter(|cl| &cl.project_id == project_id)
64                .collect();
65            project_costs.sort_by_key(|cl| cl.posting_date);
66
67            // Generate monthly revenue records
68            let mut current = start_date;
69            let mut prev_cumulative_revenue = dec!(0);
70            let mut billed_to_date = dec!(0);
71
72            while current <= end_date {
73                let period_end = end_of_month(current);
74                let costs_to_date: Decimal = project_costs
75                    .iter()
76                    .filter(|cl| cl.posting_date <= period_end)
77                    .map(|cl| cl.amount)
78                    .sum();
79
80                if costs_to_date.is_zero() {
81                    current = next_month_start(current);
82                    continue;
83                }
84
85                let completion_pct = if estimated_total_cost.is_zero() {
86                    dec!(0)
87                } else {
88                    (costs_to_date / estimated_total_cost)
89                        .min(dec!(1.0))
90                        .round_dp(4)
91                };
92
93                let cumulative_revenue = (*contract_value * completion_pct).round_dp(2);
94                let period_revenue = (cumulative_revenue - prev_cumulative_revenue).max(dec!(0));
95
96                // Billing lags behind recognition by a random factor
97                let billing_pct: f64 = self.rng.random_range(0.70..0.95);
98                let target_billed = cumulative_revenue
99                    * Decimal::from_f64_retain(billing_pct).unwrap_or(dec!(0.85));
100                if target_billed > billed_to_date {
101                    billed_to_date = target_billed.round_dp(2);
102                }
103
104                let unbilled_revenue = (cumulative_revenue - billed_to_date).round_dp(2);
105                let gross_margin_pct = if contract_value.is_zero() {
106                    dec!(0)
107                } else {
108                    ((*contract_value - *estimated_total_cost) / *contract_value).round_dp(4)
109                };
110
111                let method = match self.config.method.as_str() {
112                    "completed_contract" => RevenueMethod::CompletedContract,
113                    _ => RevenueMethod::PercentageOfCompletion,
114                };
115                let measure = match self.config.completion_measure.as_str() {
116                    "labor_hours" => CompletionMeasure::LaborHours,
117                    _ => CompletionMeasure::CostToCost,
118                };
119
120                let rev = ProjectRevenue {
121                    id: self.uuid_factory.next().to_string(),
122                    project_id: project_id.clone(),
123                    entity_id: project.company_code.clone(),
124                    period_start: current,
125                    period_end,
126                    contract_value: *contract_value,
127                    estimated_total_cost: *estimated_total_cost,
128                    costs_to_date,
129                    completion_pct,
130                    method,
131                    measure,
132                    cumulative_revenue,
133                    period_revenue,
134                    billed_to_date,
135                    unbilled_revenue,
136                    gross_margin_pct,
137                };
138
139                prev_cumulative_revenue = cumulative_revenue;
140                revenues.push(rev);
141                current = next_month_start(current);
142            }
143        }
144
145        revenues
146    }
147}
148
149/// Get the last day of a month.
150fn end_of_month(date: NaiveDate) -> NaiveDate {
151    let (year, month) = if date.month() == 12 {
152        (date.year() + 1, 1)
153    } else {
154        (date.year(), date.month() + 1)
155    };
156    NaiveDate::from_ymd_opt(year, month, 1)
157        .expect("valid date")
158        .pred_opt()
159        .expect("valid date")
160}
161
162/// Get the first day of the next month.
163fn next_month_start(date: NaiveDate) -> NaiveDate {
164    let (year, month) = if date.month() == 12 {
165        (date.year() + 1, 1)
166    } else {
167        (date.year(), date.month() + 1)
168    };
169    NaiveDate::from_ymd_opt(year, month, 1).expect("valid date")
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use datasynth_core::models::{CostCategory, CostSourceType, ProjectType};
176
177    fn d(s: &str) -> NaiveDate {
178        NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
179    }
180
181    fn test_project() -> Project {
182        Project::new("PRJ-001", "Customer Build", ProjectType::Customer)
183            .with_budget(dec!(800000))
184            .with_company("TEST")
185    }
186
187    fn test_cost_lines() -> Vec<ProjectCostLine> {
188        // Create cost lines spread across 3 months
189        let months = [
190            (d("2024-01-15"), dec!(100000)),
191            (d("2024-02-15"), dec!(150000)),
192            (d("2024-03-15"), dec!(200000)),
193        ];
194        let mut lines = Vec::new();
195        for (i, (date, amount)) in months.iter().enumerate() {
196            lines.push(ProjectCostLine::new(
197                format!("PCL-{:03}", i + 1),
198                "PRJ-001",
199                "PRJ-001.01",
200                "TEST",
201                *date,
202                CostCategory::Labor,
203                CostSourceType::TimeEntry,
204                format!("TE-{:03}", i + 1),
205                *amount,
206                "USD",
207            ));
208        }
209        lines
210    }
211
212    #[test]
213    fn test_revenue_increases_monotonically() {
214        let project = test_project();
215        let cost_lines = test_cost_lines();
216        let contracts = vec![("PRJ-001".to_string(), dec!(1000000), dec!(800000))];
217
218        let config = ProjectRevenueRecognitionConfig::default();
219        let mut gen = RevenueGenerator::new(config, 42);
220        let revenues = gen.generate(
221            &[project],
222            &cost_lines,
223            &contracts,
224            d("2024-01-01"),
225            d("2024-03-31"),
226        );
227
228        assert!(!revenues.is_empty(), "Should generate revenue records");
229
230        let mut prev_cumulative = dec!(0);
231        for rev in &revenues {
232            assert!(
233                rev.cumulative_revenue >= prev_cumulative,
234                "Revenue should increase monotonically: {} >= {}",
235                rev.cumulative_revenue,
236                prev_cumulative
237            );
238            prev_cumulative = rev.cumulative_revenue;
239        }
240    }
241
242    #[test]
243    fn test_unbilled_revenue_calculation() {
244        let project = test_project();
245        let cost_lines = test_cost_lines();
246        let contracts = vec![("PRJ-001".to_string(), dec!(1000000), dec!(800000))];
247
248        let config = ProjectRevenueRecognitionConfig::default();
249        let mut gen = RevenueGenerator::new(config, 42);
250        let revenues = gen.generate(
251            &[project],
252            &cost_lines,
253            &contracts,
254            d("2024-01-01"),
255            d("2024-03-31"),
256        );
257
258        for rev in &revenues {
259            let expected_unbilled = (rev.cumulative_revenue - rev.billed_to_date).round_dp(2);
260            assert_eq!(
261                rev.unbilled_revenue, expected_unbilled,
262                "Unbilled revenue = recognized - billed"
263            );
264        }
265    }
266
267    #[test]
268    fn test_poc_completion_calculation() {
269        let project = test_project();
270        let cost_lines = test_cost_lines();
271        let contracts = vec![("PRJ-001".to_string(), dec!(1000000), dec!(800000))];
272
273        let config = ProjectRevenueRecognitionConfig::default();
274        let mut gen = RevenueGenerator::new(config, 42);
275        let revenues = gen.generate(
276            &[project],
277            &cost_lines,
278            &contracts,
279            d("2024-01-01"),
280            d("2024-03-31"),
281        );
282
283        // After month 1: costs 100000 / 800000 = 0.125
284        assert_eq!(revenues[0].completion_pct, dec!(0.1250));
285        // After month 2: costs 250000 / 800000 = 0.3125
286        assert_eq!(revenues[1].completion_pct, dec!(0.3125));
287        // After month 3: costs 450000 / 800000 = 0.5625
288        assert_eq!(revenues[2].completion_pct, dec!(0.5625));
289    }
290
291    #[test]
292    fn test_no_revenue_without_costs() {
293        let project = test_project();
294        let contracts = vec![("PRJ-001".to_string(), dec!(1000000), dec!(800000))];
295
296        let config = ProjectRevenueRecognitionConfig::default();
297        let mut gen = RevenueGenerator::new(config, 42);
298        let revenues = gen.generate(
299            &[project],
300            &[], // No cost lines
301            &contracts,
302            d("2024-01-01"),
303            d("2024-03-31"),
304        );
305
306        assert!(revenues.is_empty(), "No costs should produce no revenue");
307    }
308
309    #[test]
310    fn test_deterministic_revenue() {
311        let project = test_project();
312        let cost_lines = test_cost_lines();
313        let contracts = vec![("PRJ-001".to_string(), dec!(1000000), dec!(800000))];
314
315        let config = ProjectRevenueRecognitionConfig::default();
316        let mut gen1 = RevenueGenerator::new(config.clone(), 42);
317        let rev1 = gen1.generate(
318            std::slice::from_ref(&project),
319            &cost_lines,
320            &contracts,
321            d("2024-01-01"),
322            d("2024-03-31"),
323        );
324
325        let mut gen2 = RevenueGenerator::new(config, 42);
326        let rev2 = gen2.generate(
327            &[project],
328            &cost_lines,
329            &contracts,
330            d("2024-01-01"),
331            d("2024-03-31"),
332        );
333
334        assert_eq!(rev1.len(), rev2.len());
335        for (r1, r2) in rev1.iter().zip(rev2.iter()) {
336            assert_eq!(r1.cumulative_revenue, r2.cumulative_revenue);
337            assert_eq!(r1.billed_to_date, r2.billed_to_date);
338        }
339    }
340}