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