datasynth_generators/project_accounting/
revenue_generator.rs1use 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
17pub struct RevenueGenerator {
23 rng: ChaCha8Rng,
24 uuid_factory: DeterministicUuidFactory,
26 config: ProjectRevenueRecognitionConfig,
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 }
38 }
39
40 pub fn generate(
45 &mut self,
46 projects: &[Project],
47 cost_lines: &[ProjectCostLine],
48 contract_values: &[(String, Decimal, Decimal)], 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 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 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 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
149fn 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
162fn 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)]
173#[allow(clippy::unwrap_used)]
174mod tests {
175 use super::*;
176 use datasynth_core::models::{CostCategory, CostSourceType, ProjectType};
177
178 fn d(s: &str) -> NaiveDate {
179 NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
180 }
181
182 fn test_project() -> Project {
183 Project::new("PRJ-001", "Customer Build", ProjectType::Customer)
184 .with_budget(dec!(800000))
185 .with_company("TEST")
186 }
187
188 fn test_cost_lines() -> Vec<ProjectCostLine> {
189 let months = [
191 (d("2024-01-15"), dec!(100000)),
192 (d("2024-02-15"), dec!(150000)),
193 (d("2024-03-15"), dec!(200000)),
194 ];
195 let mut lines = Vec::new();
196 for (i, (date, amount)) in months.iter().enumerate() {
197 lines.push(ProjectCostLine::new(
198 format!("PCL-{:03}", i + 1),
199 "PRJ-001",
200 "PRJ-001.01",
201 "TEST",
202 *date,
203 CostCategory::Labor,
204 CostSourceType::TimeEntry,
205 format!("TE-{:03}", i + 1),
206 *amount,
207 "USD",
208 ));
209 }
210 lines
211 }
212
213 #[test]
214 fn test_revenue_increases_monotonically() {
215 let project = test_project();
216 let cost_lines = test_cost_lines();
217 let contracts = vec![("PRJ-001".to_string(), dec!(1000000), dec!(800000))];
218
219 let config = ProjectRevenueRecognitionConfig::default();
220 let mut gen = RevenueGenerator::new(config, 42);
221 let revenues = gen.generate(
222 &[project],
223 &cost_lines,
224 &contracts,
225 d("2024-01-01"),
226 d("2024-03-31"),
227 );
228
229 assert!(!revenues.is_empty(), "Should generate revenue records");
230
231 let mut prev_cumulative = dec!(0);
232 for rev in &revenues {
233 assert!(
234 rev.cumulative_revenue >= prev_cumulative,
235 "Revenue should increase monotonically: {} >= {}",
236 rev.cumulative_revenue,
237 prev_cumulative
238 );
239 prev_cumulative = rev.cumulative_revenue;
240 }
241 }
242
243 #[test]
244 fn test_unbilled_revenue_calculation() {
245 let project = test_project();
246 let cost_lines = test_cost_lines();
247 let contracts = vec![("PRJ-001".to_string(), dec!(1000000), dec!(800000))];
248
249 let config = ProjectRevenueRecognitionConfig::default();
250 let mut gen = RevenueGenerator::new(config, 42);
251 let revenues = gen.generate(
252 &[project],
253 &cost_lines,
254 &contracts,
255 d("2024-01-01"),
256 d("2024-03-31"),
257 );
258
259 for rev in &revenues {
260 let expected_unbilled = (rev.cumulative_revenue - rev.billed_to_date).round_dp(2);
261 assert_eq!(
262 rev.unbilled_revenue, expected_unbilled,
263 "Unbilled revenue = recognized - billed"
264 );
265 }
266 }
267
268 #[test]
269 fn test_poc_completion_calculation() {
270 let project = test_project();
271 let cost_lines = test_cost_lines();
272 let contracts = vec![("PRJ-001".to_string(), dec!(1000000), dec!(800000))];
273
274 let config = ProjectRevenueRecognitionConfig::default();
275 let mut gen = RevenueGenerator::new(config, 42);
276 let revenues = gen.generate(
277 &[project],
278 &cost_lines,
279 &contracts,
280 d("2024-01-01"),
281 d("2024-03-31"),
282 );
283
284 assert_eq!(revenues[0].completion_pct, dec!(0.1250));
286 assert_eq!(revenues[1].completion_pct, dec!(0.3125));
288 assert_eq!(revenues[2].completion_pct, dec!(0.5625));
290 }
291
292 #[test]
293 fn test_no_revenue_without_costs() {
294 let project = test_project();
295 let contracts = vec![("PRJ-001".to_string(), dec!(1000000), dec!(800000))];
296
297 let config = ProjectRevenueRecognitionConfig::default();
298 let mut gen = RevenueGenerator::new(config, 42);
299 let revenues = gen.generate(
300 &[project],
301 &[], &contracts,
303 d("2024-01-01"),
304 d("2024-03-31"),
305 );
306
307 assert!(revenues.is_empty(), "No costs should produce no revenue");
308 }
309
310 #[test]
311 fn test_deterministic_revenue() {
312 let project = test_project();
313 let cost_lines = test_cost_lines();
314 let contracts = vec![("PRJ-001".to_string(), dec!(1000000), dec!(800000))];
315
316 let config = ProjectRevenueRecognitionConfig::default();
317 let mut gen1 = RevenueGenerator::new(config.clone(), 42);
318 let rev1 = gen1.generate(
319 std::slice::from_ref(&project),
320 &cost_lines,
321 &contracts,
322 d("2024-01-01"),
323 d("2024-03-31"),
324 );
325
326 let mut gen2 = RevenueGenerator::new(config, 42);
327 let rev2 = gen2.generate(
328 &[project],
329 &cost_lines,
330 &contracts,
331 d("2024-01-01"),
332 d("2024-03-31"),
333 );
334
335 assert_eq!(rev1.len(), rev2.len());
336 for (r1, r2) in rev1.iter().zip(rev2.iter()) {
337 assert_eq!(r1.cumulative_revenue, r2.cumulative_revenue);
338 assert_eq!(r1.billed_to_date, r2.billed_to_date);
339 }
340 }
341}