datasynth_generators/project_accounting/
revenue_generator.rs1use 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
17pub struct RevenueGenerator {
19 rng: ChaCha8Rng,
20 uuid_factory: DeterministicUuidFactory,
21 config: ProjectRevenueRecognitionConfig,
22 counter: u64,
23}
24
25impl RevenueGenerator {
26 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 pub fn generate(
41 &mut self,
42 projects: &[Project],
43 cost_lines: &[ProjectCostLine],
44 contract_values: &[(String, Decimal, Decimal)], 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 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 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 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
137fn 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
150fn 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 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 assert_eq!(revenues[0].completion_pct, dec!(0.1250));
274 assert_eq!(revenues[1].completion_pct, dec!(0.3125));
276 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 &[], &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}