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