Skip to main content

datasynth_core/models/
project_accounting.rs

1//! Project Accounting Models.
2//!
3//! Extends the base [`Project`]/[`WbsElement`] models with accounting-specific types:
4//! - Cost tracking by category and source document
5//! - Revenue recognition (Percentage-of-Completion / ASC 606)
6//! - Project milestones
7//! - Change orders and retainage
8//! - Earned Value Management (EVM) metrics (BCWS/BCWP/ACWP/SPI/CPI/EAC/ETC/TCPI)
9
10use chrono::NaiveDate;
11use rust_decimal::Decimal;
12use rust_decimal_macros::dec;
13use serde::{Deserialize, Serialize};
14
15// ---------------------------------------------------------------------------
16// Cost Category and Source
17// ---------------------------------------------------------------------------
18
19/// Category of project cost.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
21#[serde(rename_all = "snake_case")]
22pub enum CostCategory {
23    /// Labor costs (employee time)
24    #[default]
25    Labor,
26    /// Material / parts costs
27    Material,
28    /// External subcontractor costs
29    Subcontractor,
30    /// Overhead allocation
31    Overhead,
32    /// Equipment / machinery usage
33    Equipment,
34    /// Travel & expense costs
35    Travel,
36}
37
38/// Type of source document that originated the cost.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
40#[serde(rename_all = "snake_case")]
41pub enum CostSourceType {
42    /// From a time entry (employee hours)
43    #[default]
44    TimeEntry,
45    /// From an expense report
46    ExpenseReport,
47    /// From a purchase order
48    PurchaseOrder,
49    /// From a vendor invoice
50    VendorInvoice,
51    /// From a manual journal entry
52    JournalEntry,
53}
54
55// ---------------------------------------------------------------------------
56// Project Cost Line
57// ---------------------------------------------------------------------------
58
59/// A single cost posting against a project WBS element.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ProjectCostLine {
62    /// Unique cost line ID
63    pub id: String,
64    /// Project ID this cost belongs to
65    pub project_id: String,
66    /// WBS element ID for cost assignment
67    pub wbs_id: String,
68    /// Entity that incurred the cost
69    pub entity_id: String,
70    /// Date the cost was incurred
71    pub posting_date: NaiveDate,
72    /// Category of the cost
73    pub cost_category: CostCategory,
74    /// Type of source document
75    pub source_type: CostSourceType,
76    /// Reference to the source document
77    pub source_document_id: String,
78    /// Cost amount (always positive)
79    #[serde(with = "rust_decimal::serde::str")]
80    pub amount: Decimal,
81    /// Currency
82    pub currency: String,
83    /// Hours (for labor costs)
84    pub hours: Option<Decimal>,
85    /// Description
86    pub description: String,
87}
88
89impl ProjectCostLine {
90    /// Creates a new project cost line.
91    #[allow(clippy::too_many_arguments)]
92    pub fn new(
93        id: impl Into<String>,
94        project_id: impl Into<String>,
95        wbs_id: impl Into<String>,
96        entity_id: impl Into<String>,
97        posting_date: NaiveDate,
98        cost_category: CostCategory,
99        source_type: CostSourceType,
100        source_document_id: impl Into<String>,
101        amount: Decimal,
102        currency: impl Into<String>,
103    ) -> Self {
104        Self {
105            id: id.into(),
106            project_id: project_id.into(),
107            wbs_id: wbs_id.into(),
108            entity_id: entity_id.into(),
109            posting_date,
110            cost_category,
111            source_type,
112            source_document_id: source_document_id.into(),
113            amount,
114            currency: currency.into(),
115            hours: None,
116            description: String::new(),
117        }
118    }
119
120    /// Sets the hours (for labor costs).
121    pub fn with_hours(mut self, hours: Decimal) -> Self {
122        self.hours = Some(hours);
123        self
124    }
125
126    /// Sets the description.
127    pub fn with_description(mut self, description: impl Into<String>) -> Self {
128        self.description = description.into();
129        self
130    }
131
132    /// Computes an effective hourly rate (if hours are available).
133    pub fn hourly_rate(&self) -> Option<Decimal> {
134        self.hours
135            .filter(|h| !h.is_zero())
136            .map(|h| (self.amount / h).round_dp(2))
137    }
138}
139
140// ---------------------------------------------------------------------------
141// Revenue Recognition
142// ---------------------------------------------------------------------------
143
144/// Method used for revenue recognition on long-term projects.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
146#[serde(rename_all = "snake_case")]
147pub enum RevenueMethod {
148    /// Percentage of Completion (ASC 606 input method)
149    #[default]
150    PercentageOfCompletion,
151    /// Completed Contract
152    CompletedContract,
153    /// Milestone-based recognition (ASC 606 output method)
154    MilestoneBased,
155}
156
157/// How completion percentage is measured.
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
159#[serde(rename_all = "snake_case")]
160pub enum CompletionMeasure {
161    /// Cost-to-cost (incurred / estimated total)
162    #[default]
163    CostToCost,
164    /// Labor hours (hours worked / estimated total hours)
165    LaborHours,
166    /// Physical completion (engineering estimate)
167    PhysicalCompletion,
168}
169
170/// Revenue recognition for a project period.
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct ProjectRevenue {
173    /// Unique revenue record ID
174    pub id: String,
175    /// Project ID
176    pub project_id: String,
177    /// Entity ID (billing entity)
178    pub entity_id: String,
179    /// Period start
180    pub period_start: NaiveDate,
181    /// Period end
182    pub period_end: NaiveDate,
183    /// Total contract value
184    #[serde(with = "rust_decimal::serde::str")]
185    pub contract_value: Decimal,
186    /// Total estimated cost at completion
187    #[serde(with = "rust_decimal::serde::str")]
188    pub estimated_total_cost: Decimal,
189    /// Costs incurred to date
190    #[serde(with = "rust_decimal::serde::str")]
191    pub costs_to_date: Decimal,
192    /// Completion percentage (0.00 to 1.00)
193    #[serde(with = "rust_decimal::serde::str")]
194    pub completion_pct: Decimal,
195    /// Revenue method
196    pub method: RevenueMethod,
197    /// Completion measure
198    pub measure: CompletionMeasure,
199    /// Cumulative revenue recognized to date
200    #[serde(with = "rust_decimal::serde::str")]
201    pub cumulative_revenue: Decimal,
202    /// Revenue recognized in this period
203    #[serde(with = "rust_decimal::serde::str")]
204    pub period_revenue: Decimal,
205    /// Cumulative amount billed to customer
206    #[serde(with = "rust_decimal::serde::str")]
207    pub billed_to_date: Decimal,
208    /// Unbilled revenue (cumulative_revenue - billed_to_date)
209    #[serde(with = "rust_decimal::serde::str")]
210    pub unbilled_revenue: Decimal,
211    /// Estimated gross margin percentage
212    #[serde(with = "rust_decimal::serde::str")]
213    pub gross_margin_pct: Decimal,
214}
215
216impl ProjectRevenue {
217    /// Computes the PoC completion percentage (cost-to-cost method).
218    pub fn computed_completion_pct(&self) -> Decimal {
219        if self.estimated_total_cost.is_zero() {
220            return Decimal::ZERO;
221        }
222        (self.costs_to_date / self.estimated_total_cost).round_dp(4)
223    }
224
225    /// Computes the cumulative revenue based on PoC.
226    pub fn computed_cumulative_revenue(&self) -> Decimal {
227        (self.contract_value * self.completion_pct).round_dp(2)
228    }
229
230    /// Computes the unbilled revenue.
231    pub fn computed_unbilled_revenue(&self) -> Decimal {
232        (self.cumulative_revenue - self.billed_to_date).round_dp(2)
233    }
234
235    /// Computes estimated gross margin percentage.
236    pub fn computed_gross_margin_pct(&self) -> Decimal {
237        if self.contract_value.is_zero() {
238            return Decimal::ZERO;
239        }
240        ((self.contract_value - self.estimated_total_cost) / self.contract_value).round_dp(4)
241    }
242}
243
244// ---------------------------------------------------------------------------
245// Milestones
246// ---------------------------------------------------------------------------
247
248/// Status of a project milestone.
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
250#[serde(rename_all = "snake_case")]
251pub enum MilestoneStatus {
252    /// Not yet started
253    #[default]
254    Pending,
255    /// In progress
256    InProgress,
257    /// Completed
258    Completed,
259    /// Overdue (past planned date, not completed)
260    Overdue,
261    /// Cancelled
262    Cancelled,
263}
264
265/// A project milestone.
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct ProjectMilestone {
268    /// Unique milestone ID
269    pub id: String,
270    /// Project ID
271    pub project_id: String,
272    /// WBS element ID (optional)
273    pub wbs_id: Option<String>,
274    /// Milestone name
275    pub name: String,
276    /// Planned completion date
277    pub planned_date: NaiveDate,
278    /// Actual completion date (if completed)
279    pub actual_date: Option<NaiveDate>,
280    /// Current status
281    pub status: MilestoneStatus,
282    /// Payment amount tied to milestone (if any)
283    #[serde(with = "rust_decimal::serde::str")]
284    pub payment_amount: Decimal,
285    /// Completion weight for EVM (0.0 to 1.0)
286    #[serde(with = "rust_decimal::serde::str")]
287    pub weight: Decimal,
288    /// Sequence order
289    pub sequence: u32,
290}
291
292impl ProjectMilestone {
293    /// Creates a new milestone.
294    pub fn new(
295        id: impl Into<String>,
296        project_id: impl Into<String>,
297        name: impl Into<String>,
298        planned_date: NaiveDate,
299        sequence: u32,
300    ) -> Self {
301        Self {
302            id: id.into(),
303            project_id: project_id.into(),
304            wbs_id: None,
305            name: name.into(),
306            planned_date,
307            actual_date: None,
308            status: MilestoneStatus::Pending,
309            payment_amount: Decimal::ZERO,
310            weight: Decimal::ZERO,
311            sequence,
312        }
313    }
314
315    /// Sets the WBS element.
316    pub fn with_wbs(mut self, wbs_id: impl Into<String>) -> Self {
317        self.wbs_id = Some(wbs_id.into());
318        self
319    }
320
321    /// Sets the payment amount tied to this milestone.
322    pub fn with_payment(mut self, amount: Decimal) -> Self {
323        self.payment_amount = amount;
324        self
325    }
326
327    /// Sets the EVM weight.
328    pub fn with_weight(mut self, weight: Decimal) -> Self {
329        self.weight = weight;
330        self
331    }
332
333    /// Returns true if the milestone is overdue on the given date.
334    pub fn is_overdue_on(&self, date: NaiveDate) -> bool {
335        self.actual_date.is_none() && date > self.planned_date
336    }
337
338    /// Returns the number of days late (negative if early or not yet complete).
339    pub fn days_variance(&self) -> Option<i64> {
340        self.actual_date
341            .map(|actual| (actual - self.planned_date).num_days())
342    }
343}
344
345// ---------------------------------------------------------------------------
346// Change Orders
347// ---------------------------------------------------------------------------
348
349/// Status of a change order.
350#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
351#[serde(rename_all = "snake_case")]
352pub enum ChangeOrderStatus {
353    /// Change order submitted
354    #[default]
355    Submitted,
356    /// Under review
357    UnderReview,
358    /// Approved
359    Approved,
360    /// Rejected
361    Rejected,
362    /// Withdrawn
363    Withdrawn,
364}
365
366/// Reason for the change order.
367#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
368#[serde(rename_all = "snake_case")]
369pub enum ChangeReason {
370    /// Client-requested scope change
371    #[default]
372    ScopeChange,
373    /// Unforeseen site conditions
374    UnforeseenConditions,
375    /// Design error or omission
376    DesignError,
377    /// Regulatory requirement change
378    RegulatoryChange,
379    /// Value engineering (cost reduction)
380    ValueEngineering,
381    /// Schedule acceleration
382    ScheduleAcceleration,
383}
384
385/// A change order that modifies a project's scope, cost, or schedule.
386#[derive(Debug, Clone, Serialize, Deserialize)]
387pub struct ChangeOrder {
388    /// Unique change order ID
389    pub id: String,
390    /// Project ID
391    pub project_id: String,
392    /// Change order number (sequential within project)
393    pub number: u32,
394    /// Date submitted
395    pub submitted_date: NaiveDate,
396    /// Date approved (if approved)
397    pub approved_date: Option<NaiveDate>,
398    /// Status
399    pub status: ChangeOrderStatus,
400    /// Reason for the change
401    pub reason: ChangeReason,
402    /// Description of the change
403    pub description: String,
404    /// Impact on contract value (positive = increase, negative = decrease)
405    #[serde(with = "rust_decimal::serde::str")]
406    pub cost_impact: Decimal,
407    /// Impact on estimated total cost
408    #[serde(with = "rust_decimal::serde::str")]
409    pub estimated_cost_impact: Decimal,
410    /// Schedule impact in calendar days (positive = delay)
411    pub schedule_impact_days: i32,
412}
413
414impl ChangeOrder {
415    /// Creates a new change order.
416    pub fn new(
417        id: impl Into<String>,
418        project_id: impl Into<String>,
419        number: u32,
420        submitted_date: NaiveDate,
421        reason: ChangeReason,
422        description: impl Into<String>,
423    ) -> Self {
424        Self {
425            id: id.into(),
426            project_id: project_id.into(),
427            number,
428            submitted_date,
429            approved_date: None,
430            status: ChangeOrderStatus::Submitted,
431            reason,
432            description: description.into(),
433            cost_impact: Decimal::ZERO,
434            estimated_cost_impact: Decimal::ZERO,
435            schedule_impact_days: 0,
436        }
437    }
438
439    /// Sets the cost impact.
440    pub fn with_cost_impact(mut self, contract_impact: Decimal, estimated_impact: Decimal) -> Self {
441        self.cost_impact = contract_impact;
442        self.estimated_cost_impact = estimated_impact;
443        self
444    }
445
446    /// Sets the schedule impact.
447    pub fn with_schedule_impact(mut self, days: i32) -> Self {
448        self.schedule_impact_days = days;
449        self
450    }
451
452    /// Approves the change order.
453    pub fn approve(mut self, date: NaiveDate) -> Self {
454        self.status = ChangeOrderStatus::Approved;
455        self.approved_date = Some(date);
456        self
457    }
458
459    /// Returns true if the change order is approved.
460    pub fn is_approved(&self) -> bool {
461        self.status == ChangeOrderStatus::Approved
462    }
463
464    /// Returns the net cost impact (only if approved).
465    pub fn net_cost_impact(&self) -> Decimal {
466        if self.is_approved() {
467            self.cost_impact
468        } else {
469            Decimal::ZERO
470        }
471    }
472}
473
474// ---------------------------------------------------------------------------
475// Retainage
476// ---------------------------------------------------------------------------
477
478/// Status of a retainage hold.
479#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
480#[serde(rename_all = "snake_case")]
481pub enum RetainageStatus {
482    /// Retainage is being held
483    #[default]
484    Held,
485    /// Partial release
486    PartiallyReleased,
487    /// Fully released
488    Released,
489    /// Forfeited (e.g., for defective work)
490    Forfeited,
491}
492
493/// Retainage record — a portion of each payment withheld until project completion.
494#[derive(Debug, Clone, Serialize, Deserialize)]
495pub struct Retainage {
496    /// Unique retainage record ID
497    pub id: String,
498    /// Project ID
499    pub project_id: String,
500    /// Entity ID
501    pub entity_id: String,
502    /// Vendor/subcontractor ID this retainage relates to
503    pub vendor_id: String,
504    /// Retainage percentage (e.g., 0.10 for 10%)
505    #[serde(with = "rust_decimal::serde::str")]
506    pub retainage_pct: Decimal,
507    /// Total retainage held
508    #[serde(with = "rust_decimal::serde::str")]
509    pub total_held: Decimal,
510    /// Amount released to date
511    #[serde(with = "rust_decimal::serde::str")]
512    pub released_amount: Decimal,
513    /// Current status
514    pub status: RetainageStatus,
515    /// Date retainage was first held
516    pub inception_date: NaiveDate,
517    /// Date of last release (if any)
518    pub last_release_date: Option<NaiveDate>,
519}
520
521impl Retainage {
522    /// Creates a new retainage record.
523    pub fn new(
524        id: impl Into<String>,
525        project_id: impl Into<String>,
526        entity_id: impl Into<String>,
527        vendor_id: impl Into<String>,
528        retainage_pct: Decimal,
529        inception_date: NaiveDate,
530    ) -> Self {
531        Self {
532            id: id.into(),
533            project_id: project_id.into(),
534            entity_id: entity_id.into(),
535            vendor_id: vendor_id.into(),
536            retainage_pct,
537            total_held: Decimal::ZERO,
538            released_amount: Decimal::ZERO,
539            status: RetainageStatus::Held,
540            inception_date,
541            last_release_date: None,
542        }
543    }
544
545    /// Adds retainage from a payment.
546    pub fn add_from_payment(&mut self, payment_amount: Decimal) {
547        let held = (payment_amount * self.retainage_pct).round_dp(2);
548        self.total_held += held;
549    }
550
551    /// Returns the balance still held.
552    pub fn balance_held(&self) -> Decimal {
553        (self.total_held - self.released_amount).round_dp(2)
554    }
555
556    /// Releases a specified amount.
557    pub fn release(&mut self, amount: Decimal, date: NaiveDate) {
558        let release = amount.min(self.balance_held());
559        self.released_amount += release;
560        self.last_release_date = Some(date);
561        if self.balance_held().is_zero() {
562            self.status = RetainageStatus::Released;
563        } else {
564            self.status = RetainageStatus::PartiallyReleased;
565        }
566    }
567}
568
569// ---------------------------------------------------------------------------
570// Earned Value Management (EVM)
571// ---------------------------------------------------------------------------
572
573/// Earned Value Management metrics for a project at a point in time.
574///
575/// EVM formulas:
576/// - SV (Schedule Variance) = EV - PV
577/// - CV (Cost Variance) = EV - AC
578/// - SPI (Schedule Performance Index) = EV / PV
579/// - CPI (Cost Performance Index) = EV / AC
580/// - EAC (Estimate at Completion) = BAC / CPI
581/// - ETC (Estimate to Complete) = EAC - AC
582/// - TCPI (To-Complete Performance Index) = (BAC - EV) / (BAC - AC)
583#[derive(Debug, Clone, Serialize, Deserialize)]
584pub struct EarnedValueMetric {
585    /// Unique metric ID
586    pub id: String,
587    /// Project ID
588    pub project_id: String,
589    /// Measurement date
590    pub measurement_date: NaiveDate,
591    /// Budget at Completion (total baseline budget)
592    #[serde(with = "rust_decimal::serde::str")]
593    pub bac: Decimal,
594    /// Planned Value (BCWS — Budgeted Cost of Work Scheduled)
595    #[serde(with = "rust_decimal::serde::str")]
596    pub planned_value: Decimal,
597    /// Earned Value (BCWP — Budgeted Cost of Work Performed)
598    #[serde(with = "rust_decimal::serde::str")]
599    pub earned_value: Decimal,
600    /// Actual Cost (ACWP — Actual Cost of Work Performed)
601    #[serde(with = "rust_decimal::serde::str")]
602    pub actual_cost: Decimal,
603    /// Schedule Variance (EV - PV)
604    #[serde(with = "rust_decimal::serde::str")]
605    pub schedule_variance: Decimal,
606    /// Cost Variance (EV - AC)
607    #[serde(with = "rust_decimal::serde::str")]
608    pub cost_variance: Decimal,
609    /// Schedule Performance Index (EV / PV)
610    #[serde(with = "rust_decimal::serde::str")]
611    pub spi: Decimal,
612    /// Cost Performance Index (EV / AC)
613    #[serde(with = "rust_decimal::serde::str")]
614    pub cpi: Decimal,
615    /// Estimate at Completion (BAC / CPI)
616    #[serde(with = "rust_decimal::serde::str")]
617    pub eac: Decimal,
618    /// Estimate to Complete (EAC - AC)
619    #[serde(with = "rust_decimal::serde::str")]
620    pub etc: Decimal,
621    /// To-Complete Performance Index ((BAC - EV) / (BAC - AC))
622    #[serde(with = "rust_decimal::serde::str")]
623    pub tcpi: Decimal,
624}
625
626impl EarnedValueMetric {
627    /// Creates a new EVM metric by computing all derived values from
628    /// the three fundamental inputs: PV, EV, AC, and BAC.
629    pub fn compute(
630        id: impl Into<String>,
631        project_id: impl Into<String>,
632        measurement_date: NaiveDate,
633        bac: Decimal,
634        planned_value: Decimal,
635        earned_value: Decimal,
636        actual_cost: Decimal,
637    ) -> Self {
638        let sv = (earned_value - planned_value).round_dp(2);
639        let cv = (earned_value - actual_cost).round_dp(2);
640        let spi = if planned_value.is_zero() {
641            dec!(1.00)
642        } else {
643            (earned_value / planned_value).round_dp(4)
644        };
645        let cpi = if actual_cost.is_zero() {
646            dec!(1.00)
647        } else {
648            (earned_value / actual_cost).round_dp(4)
649        };
650        let eac = if cpi.is_zero() {
651            bac
652        } else {
653            (bac / cpi).round_dp(2)
654        };
655        let etc = (eac - actual_cost).round_dp(2);
656        let remaining_budget = bac - actual_cost;
657        let remaining_work = bac - earned_value;
658        let tcpi = if remaining_budget.is_zero() {
659            dec!(1.00)
660        } else {
661            (remaining_work / remaining_budget).round_dp(4)
662        };
663
664        Self {
665            id: id.into(),
666            project_id: project_id.into(),
667            measurement_date,
668            bac,
669            planned_value,
670            earned_value,
671            actual_cost,
672            schedule_variance: sv,
673            cost_variance: cv,
674            spi,
675            cpi,
676            eac,
677            etc,
678            tcpi,
679        }
680    }
681
682    /// Returns true if the project is ahead of schedule (SPI > 1.0).
683    pub fn is_ahead_of_schedule(&self) -> bool {
684        self.spi > dec!(1.00)
685    }
686
687    /// Returns true if the project is under budget (CPI > 1.0).
688    pub fn is_under_budget(&self) -> bool {
689        self.cpi > dec!(1.00)
690    }
691
692    /// Returns true if the project is both on schedule and on budget.
693    pub fn is_healthy(&self) -> bool {
694        self.spi >= dec!(0.90) && self.cpi >= dec!(0.90)
695    }
696
697    /// Returns the variance at completion (BAC - EAC).
698    pub fn variance_at_completion(&self) -> Decimal {
699        (self.bac - self.eac).round_dp(2)
700    }
701}
702
703// ---------------------------------------------------------------------------
704// Tests
705// ---------------------------------------------------------------------------
706
707#[cfg(test)]
708#[allow(clippy::unwrap_used)]
709mod tests {
710    use super::*;
711
712    fn d(s: &str) -> NaiveDate {
713        NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
714    }
715
716    // -- Cost Lines --
717
718    #[test]
719    fn test_cost_line_creation() {
720        let line = ProjectCostLine::new(
721            "PCL-001",
722            "PRJ-001",
723            "PRJ-001.01",
724            "C001",
725            d("2025-03-15"),
726            CostCategory::Labor,
727            CostSourceType::TimeEntry,
728            "TE-001",
729            dec!(1500),
730            "USD",
731        )
732        .with_hours(dec!(20))
733        .with_description("Developer time - sprint 5");
734
735        assert_eq!(line.cost_category, CostCategory::Labor);
736        assert_eq!(line.source_type, CostSourceType::TimeEntry);
737        assert_eq!(line.amount, dec!(1500));
738        assert_eq!(line.hours, Some(dec!(20)));
739        assert_eq!(line.hourly_rate(), Some(dec!(75.00)));
740    }
741
742    #[test]
743    fn test_cost_line_hourly_rate_no_hours() {
744        let line = ProjectCostLine::new(
745            "PCL-002",
746            "PRJ-001",
747            "PRJ-001.02",
748            "C001",
749            d("2025-03-15"),
750            CostCategory::Material,
751            CostSourceType::PurchaseOrder,
752            "PO-001",
753            dec!(5000),
754            "USD",
755        );
756        assert_eq!(line.hourly_rate(), None);
757    }
758
759    // -- Revenue Recognition --
760
761    #[test]
762    fn test_revenue_poc_completion() {
763        let rev = ProjectRevenue {
764            id: "REV-001".to_string(),
765            project_id: "PRJ-001".to_string(),
766            entity_id: "C001".to_string(),
767            period_start: d("2025-01-01"),
768            period_end: d("2025-03-31"),
769            contract_value: dec!(1000000),
770            estimated_total_cost: dec!(800000),
771            costs_to_date: dec!(400000),
772            completion_pct: dec!(0.50),
773            method: RevenueMethod::PercentageOfCompletion,
774            measure: CompletionMeasure::CostToCost,
775            cumulative_revenue: dec!(500000),
776            period_revenue: dec!(200000),
777            billed_to_date: dec!(400000),
778            unbilled_revenue: dec!(100000),
779            gross_margin_pct: dec!(0.20),
780        };
781
782        // PoC: 400000 / 800000 = 0.50
783        assert_eq!(rev.computed_completion_pct(), dec!(0.5000));
784        // Cumulative revenue: 1000000 * 0.50 = 500000
785        assert_eq!(rev.computed_cumulative_revenue(), dec!(500000.00));
786        // Unbilled: 500000 - 400000 = 100000
787        assert_eq!(rev.computed_unbilled_revenue(), dec!(100000.00));
788        // Margin: (1000000 - 800000) / 1000000 = 0.20
789        assert_eq!(rev.computed_gross_margin_pct(), dec!(0.2000));
790    }
791
792    #[test]
793    fn test_revenue_zero_estimated_cost() {
794        let rev = ProjectRevenue {
795            id: "REV-002".to_string(),
796            project_id: "PRJ-002".to_string(),
797            entity_id: "C001".to_string(),
798            period_start: d("2025-01-01"),
799            period_end: d("2025-03-31"),
800            contract_value: dec!(100000),
801            estimated_total_cost: Decimal::ZERO,
802            costs_to_date: Decimal::ZERO,
803            completion_pct: Decimal::ZERO,
804            method: RevenueMethod::PercentageOfCompletion,
805            measure: CompletionMeasure::CostToCost,
806            cumulative_revenue: Decimal::ZERO,
807            period_revenue: Decimal::ZERO,
808            billed_to_date: Decimal::ZERO,
809            unbilled_revenue: Decimal::ZERO,
810            gross_margin_pct: Decimal::ZERO,
811        };
812        assert_eq!(rev.computed_completion_pct(), Decimal::ZERO);
813    }
814
815    // -- Milestones --
816
817    #[test]
818    fn test_milestone_creation() {
819        let ms = ProjectMilestone::new(
820            "MS-001",
821            "PRJ-001",
822            "Foundation Complete",
823            d("2025-06-30"),
824            1,
825        )
826        .with_wbs("PRJ-001.02")
827        .with_payment(dec!(50000))
828        .with_weight(dec!(0.25));
829
830        assert_eq!(ms.status, MilestoneStatus::Pending);
831        assert_eq!(ms.payment_amount, dec!(50000));
832        assert_eq!(ms.weight, dec!(0.25));
833        assert!(ms.is_overdue_on(d("2025-07-01")));
834        assert!(!ms.is_overdue_on(d("2025-06-15")));
835    }
836
837    #[test]
838    fn test_milestone_variance() {
839        let mut ms = ProjectMilestone::new("MS-002", "PRJ-001", "Testing", d("2025-09-30"), 3);
840        assert_eq!(ms.days_variance(), None);
841
842        // Complete 5 days late
843        ms.actual_date = Some(d("2025-10-05"));
844        ms.status = MilestoneStatus::Completed;
845        assert_eq!(ms.days_variance(), Some(5));
846
847        // Complete 3 days early
848        let mut ms2 = ProjectMilestone::new("MS-003", "PRJ-001", "Delivery", d("2025-12-31"), 4);
849        ms2.actual_date = Some(d("2025-12-28"));
850        ms2.status = MilestoneStatus::Completed;
851        assert_eq!(ms2.days_variance(), Some(-3));
852    }
853
854    // -- Change Orders --
855
856    #[test]
857    fn test_change_order_approval() {
858        let co = ChangeOrder::new(
859            "CO-001",
860            "PRJ-001",
861            1,
862            d("2025-04-15"),
863            ChangeReason::ScopeChange,
864            "Add additional floor to building",
865        )
866        .with_cost_impact(dec!(200000), dec!(180000))
867        .with_schedule_impact(30);
868
869        assert!(!co.is_approved());
870        assert_eq!(co.net_cost_impact(), Decimal::ZERO);
871
872        let co_approved = co.approve(d("2025-04-25"));
873        assert!(co_approved.is_approved());
874        assert_eq!(co_approved.net_cost_impact(), dec!(200000));
875        assert_eq!(co_approved.schedule_impact_days, 30);
876    }
877
878    // -- Retainage --
879
880    #[test]
881    fn test_retainage_hold_and_release() {
882        let mut ret = Retainage::new(
883            "RET-001",
884            "PRJ-001",
885            "C001",
886            "V-001",
887            dec!(0.10), // 10%
888            d("2025-01-15"),
889        );
890
891        // Add retainage from three payments
892        ret.add_from_payment(dec!(100000));
893        ret.add_from_payment(dec!(150000));
894        ret.add_from_payment(dec!(75000));
895
896        // Total held: (100000 + 150000 + 75000) * 0.10 = 32500
897        assert_eq!(ret.total_held, dec!(32500.00));
898        assert_eq!(ret.balance_held(), dec!(32500.00));
899        assert_eq!(ret.status, RetainageStatus::Held);
900
901        // Partial release
902        ret.release(dec!(15000), d("2025-06-30"));
903        assert_eq!(ret.balance_held(), dec!(17500.00));
904        assert_eq!(ret.status, RetainageStatus::PartiallyReleased);
905
906        // Full release
907        ret.release(dec!(17500), d("2025-12-31"));
908        assert_eq!(ret.balance_held(), dec!(0.00));
909        assert_eq!(ret.status, RetainageStatus::Released);
910    }
911
912    #[test]
913    fn test_retainage_release_capped() {
914        let mut ret = Retainage::new(
915            "RET-002",
916            "PRJ-001",
917            "C001",
918            "V-001",
919            dec!(0.10),
920            d("2025-01-15"),
921        );
922        ret.add_from_payment(dec!(100000)); // held = 10000
923
924        // Try to release more than held
925        ret.release(dec!(50000), d("2025-12-31"));
926        assert_eq!(ret.released_amount, dec!(10000.00)); // capped
927        assert_eq!(ret.balance_held(), dec!(0.00));
928        assert_eq!(ret.status, RetainageStatus::Released);
929    }
930
931    // -- Earned Value Management --
932
933    #[test]
934    fn test_evm_formulas() {
935        // Project: BAC=1,000,000, 50% scheduled, 40% earned, 450,000 spent
936        let evm = EarnedValueMetric::compute(
937            "EVM-001",
938            "PRJ-001",
939            d("2025-06-30"),
940            dec!(1000000), // BAC
941            dec!(500000),  // PV (50% scheduled)
942            dec!(400000),  // EV (40% earned)
943            dec!(450000),  // AC (450k spent)
944        );
945
946        // SV = EV - PV = 400000 - 500000 = -100000 (behind schedule)
947        assert_eq!(evm.schedule_variance, dec!(-100000.00));
948        // CV = EV - AC = 400000 - 450000 = -50000 (over budget)
949        assert_eq!(evm.cost_variance, dec!(-50000.00));
950        // SPI = EV / PV = 400000 / 500000 = 0.80
951        assert_eq!(evm.spi, dec!(0.8000));
952        // CPI = EV / AC = 400000 / 450000 = 0.8889
953        assert_eq!(evm.cpi, dec!(0.8889));
954        // EAC = BAC / CPI = 1000000 / 0.8889 ≈ 1124972.44
955        let expected_eac = (dec!(1000000) / dec!(0.8889)).round_dp(2);
956        assert_eq!(evm.eac, expected_eac);
957        // ETC = EAC - AC
958        assert_eq!(evm.etc, (evm.eac - dec!(450000)).round_dp(2));
959        // TCPI = (BAC - EV) / (BAC - AC) = 600000 / 550000 ≈ 1.0909
960        assert_eq!(evm.tcpi, dec!(1.0909));
961
962        assert!(!evm.is_ahead_of_schedule());
963        assert!(!evm.is_under_budget());
964        assert!(!evm.is_healthy());
965    }
966
967    #[test]
968    fn test_evm_healthy_project() {
969        let evm = EarnedValueMetric::compute(
970            "EVM-002",
971            "PRJ-002",
972            d("2025-06-30"),
973            dec!(500000), // BAC
974            dec!(250000), // PV (50% scheduled)
975            dec!(275000), // EV (55% earned — ahead)
976            dec!(240000), // AC (240k — under budget)
977        );
978
979        // SPI = 275000 / 250000 = 1.10
980        assert_eq!(evm.spi, dec!(1.1000));
981        // CPI = 275000 / 240000 ≈ 1.1458
982        assert_eq!(evm.cpi, dec!(1.1458));
983
984        assert!(evm.is_ahead_of_schedule());
985        assert!(evm.is_under_budget());
986        assert!(evm.is_healthy());
987
988        // VAC = BAC - EAC = 500000 - (500000 / 1.1458)
989        assert!(evm.variance_at_completion() > Decimal::ZERO);
990    }
991
992    #[test]
993    fn test_evm_zero_inputs() {
994        // Edge case: all zeros (project just started)
995        let evm = EarnedValueMetric::compute(
996            "EVM-003",
997            "PRJ-003",
998            d("2025-01-01"),
999            dec!(1000000), // BAC
1000            Decimal::ZERO, // PV
1001            Decimal::ZERO, // EV
1002            Decimal::ZERO, // AC
1003        );
1004
1005        // With PV=0 and AC=0, SPI and CPI default to 1.0
1006        assert_eq!(evm.spi, dec!(1.00));
1007        assert_eq!(evm.cpi, dec!(1.00));
1008        assert_eq!(evm.eac, dec!(1000000));
1009    }
1010
1011    // -- Serde roundtrip --
1012
1013    #[test]
1014    fn test_cost_line_serde_roundtrip() {
1015        let line = ProjectCostLine::new(
1016            "PCL-100",
1017            "PRJ-001",
1018            "PRJ-001.01",
1019            "C001",
1020            d("2025-03-15"),
1021            CostCategory::Subcontractor,
1022            CostSourceType::VendorInvoice,
1023            "VI-099",
1024            dec!(25000),
1025            "EUR",
1026        );
1027        let json = serde_json::to_string(&line).unwrap();
1028        let deserialized: ProjectCostLine = serde_json::from_str(&json).unwrap();
1029        assert_eq!(deserialized.id, "PCL-100");
1030        assert_eq!(deserialized.cost_category, CostCategory::Subcontractor);
1031        assert_eq!(deserialized.amount, dec!(25000));
1032    }
1033
1034    #[test]
1035    fn test_evm_serde_roundtrip() {
1036        let evm = EarnedValueMetric::compute(
1037            "EVM-100",
1038            "PRJ-001",
1039            d("2025-06-30"),
1040            dec!(1000000),
1041            dec!(500000),
1042            dec!(400000),
1043            dec!(450000),
1044        );
1045        let json = serde_json::to_string(&evm).unwrap();
1046        let deserialized: EarnedValueMetric = serde_json::from_str(&json).unwrap();
1047        assert_eq!(deserialized.spi, evm.spi);
1048        assert_eq!(deserialized.cpi, evm.cpi);
1049        assert_eq!(deserialized.eac, evm.eac);
1050    }
1051}