datasynth_generators/project_accounting/
revenue_generator.rs1#![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
22pub struct RevenueGenerator {
24 rng: ChaCha8Rng,
25 uuid_factory: DeterministicUuidFactory,
26 config: ProjectRevenueRecognitionConfig,
27 counter: u64,
28}
29
30impl RevenueGenerator {
31 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 pub fn generate(
46 &mut self,
47 projects: &[Project],
48 cost_lines: &[ProjectCostLine],
49 contract_values: &[(String, Decimal, Decimal)], 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 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 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 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
142fn 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
155fn 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 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 assert_eq!(revenues[0].completion_pct, dec!(0.1250));
279 assert_eq!(revenues[1].completion_pct, dec!(0.3125));
281 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 &[], &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}