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