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 #[allow(dead_code)]
27 uuid_factory: DeterministicUuidFactory,
28 #[allow(dead_code)]
30 config: ProjectRevenueRecognitionConfig,
31 counter: u64,
32}
33
34impl RevenueGenerator {
35 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 pub fn generate(
50 &mut self,
51 projects: &[Project],
52 cost_lines: &[ProjectCostLine],
53 contract_values: &[(String, Decimal, Decimal)], 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 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 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 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
146fn 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
159fn 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 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 assert_eq!(revenues[0].completion_pct, dec!(0.1250));
283 assert_eq!(revenues[1].completion_pct, dec!(0.3125));
285 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 &[], &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}