use crate::compat::Instant;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use crate::error::CorpFinanceError;
use crate::types::{with_metadata, ComputationOutput, Money, Rate};
use crate::CorpFinanceResult;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum CostType {
Fixed,
Variable,
SemiVariable,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RevenueLine {
pub name: String,
pub budget_units: Decimal,
pub budget_price: Decimal,
pub actual_units: Decimal,
pub actual_price: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostLine {
pub name: String,
pub budget_amount: Money,
pub actual_amount: Money,
pub cost_type: CostType,
#[serde(skip_serializing_if = "Option::is_none")]
pub variable_cost_per_unit: Option<Decimal>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PriorPeriod {
pub revenue: Money,
pub costs: Money,
pub profit: Money,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VarianceInput {
pub period_name: String,
pub revenue_lines: Vec<RevenueLine>,
pub cost_lines: Vec<CostLine>,
pub budget_total_revenue: Money,
pub budget_total_costs: Money,
#[serde(skip_serializing_if = "Option::is_none")]
pub prior_period: Option<PriorPeriod>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RevenueVariance {
pub budget_revenue: Money,
pub actual_revenue: Money,
pub total_variance: Money,
pub total_variance_pct: Rate,
pub price_variance: Money,
pub volume_variance: Money,
pub mix_variance: Money,
pub favorable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostVariance {
pub budget_costs: Money,
pub actual_costs: Money,
pub total_variance: Money,
pub total_variance_pct: Rate,
pub favorable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfitVariance {
pub budget_profit: Money,
pub actual_profit: Money,
pub total_variance: Money,
pub total_variance_pct: Rate,
pub margin_budget: Rate,
pub margin_actual: Rate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LineVariance {
pub name: String,
pub line_type: String,
pub budget: Money,
pub actual: Money,
pub variance: Money,
pub variance_pct: Rate,
pub favorable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct YoyComparison {
pub prior_revenue: Money,
pub current_revenue: Money,
pub revenue_growth_pct: Rate,
pub prior_profit: Money,
pub current_profit: Money,
pub profit_growth_pct: Rate,
pub margin_expansion_bps: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VarianceOutput {
pub revenue_variance: RevenueVariance,
pub cost_variance: CostVariance,
pub profit_variance: ProfitVariance,
pub line_detail: Vec<LineVariance>,
pub yoy_comparison: Option<YoyComparison>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScenarioOverride {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub price_change_pct: Option<Rate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub variable_cost_change_pct: Option<Rate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fixed_cost_change_pct: Option<Rate>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BreakevenInput {
pub product_name: String,
pub selling_price: Money,
pub variable_cost_per_unit: Money,
pub fixed_costs: Money,
pub current_volume: Decimal,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_profit: Option<Money>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scenarios: Option<Vec<ScenarioOverride>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScenarioResult {
pub name: String,
pub breakeven_units: Decimal,
pub breakeven_revenue: Money,
pub profit_at_current_volume: Money,
pub margin_of_safety_pct: Rate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BreakevenOutput {
pub contribution_margin: Money,
pub contribution_margin_pct: Rate,
pub breakeven_units: Decimal,
pub breakeven_revenue: Money,
pub current_profit: Money,
pub margin_of_safety_units: Decimal,
pub margin_of_safety_pct: Rate,
pub operating_leverage: Decimal,
pub target_volume: Option<Decimal>,
pub scenario_results: Vec<ScenarioResult>,
}
fn safe_pct(numerator: Decimal, denominator: Decimal) -> Decimal {
if denominator == dec!(0) {
Decimal::ZERO
} else {
numerator / denominator
}
}
pub fn analyze_variance(
input: &VarianceInput,
) -> CorpFinanceResult<ComputationOutput<VarianceOutput>> {
let start = Instant::now();
let mut warnings: Vec<String> = Vec::new();
let actual_revenue: Decimal = input
.revenue_lines
.iter()
.map(|r| r.actual_units * r.actual_price)
.sum();
let budget_revenue_from_lines: Decimal = input
.revenue_lines
.iter()
.map(|r| r.budget_units * r.budget_price)
.sum();
if budget_revenue_from_lines != input.budget_total_revenue && !input.revenue_lines.is_empty() {
warnings.push(format!(
"Sum of budget revenue lines ({}) differs from stated budget_total_revenue ({})",
budget_revenue_from_lines, input.budget_total_revenue
));
}
let price_variance: Decimal = input
.revenue_lines
.iter()
.map(|r| (r.actual_price - r.budget_price) * r.actual_units)
.sum();
let volume_variance: Decimal = input
.revenue_lines
.iter()
.map(|r| (r.actual_units - r.budget_units) * r.budget_price)
.sum();
let total_revenue_variance = actual_revenue - input.budget_total_revenue;
let mix_variance = total_revenue_variance - price_variance - volume_variance;
let revenue_variance = RevenueVariance {
budget_revenue: input.budget_total_revenue,
actual_revenue,
total_variance: total_revenue_variance,
total_variance_pct: safe_pct(total_revenue_variance, input.budget_total_revenue),
price_variance,
volume_variance,
mix_variance,
favorable: actual_revenue > input.budget_total_revenue,
};
let actual_costs: Decimal = input.cost_lines.iter().map(|c| c.actual_amount).sum();
let budget_costs_from_lines: Decimal = input.cost_lines.iter().map(|c| c.budget_amount).sum();
if budget_costs_from_lines != input.budget_total_costs && !input.cost_lines.is_empty() {
warnings.push(format!(
"Sum of budget cost lines ({}) differs from stated budget_total_costs ({})",
budget_costs_from_lines, input.budget_total_costs
));
}
let total_cost_variance = actual_costs - input.budget_total_costs;
let cost_variance = CostVariance {
budget_costs: input.budget_total_costs,
actual_costs,
total_variance: total_cost_variance,
total_variance_pct: safe_pct(total_cost_variance, input.budget_total_costs),
favorable: actual_costs < input.budget_total_costs,
};
let budget_profit = input.budget_total_revenue - input.budget_total_costs;
let actual_profit = actual_revenue - actual_costs;
let total_profit_variance = actual_profit - budget_profit;
let profit_variance = ProfitVariance {
budget_profit,
actual_profit,
total_variance: total_profit_variance,
total_variance_pct: safe_pct(total_profit_variance, budget_profit),
margin_budget: safe_pct(budget_profit, input.budget_total_revenue),
margin_actual: safe_pct(actual_profit, actual_revenue),
};
let mut line_detail: Vec<LineVariance> = Vec::new();
for r in &input.revenue_lines {
let budget = r.budget_units * r.budget_price;
let actual = r.actual_units * r.actual_price;
let variance = actual - budget;
line_detail.push(LineVariance {
name: r.name.clone(),
line_type: "Revenue".to_string(),
budget,
actual,
variance,
variance_pct: safe_pct(variance, budget),
favorable: actual > budget,
});
}
for c in &input.cost_lines {
let variance = c.actual_amount - c.budget_amount;
line_detail.push(LineVariance {
name: c.name.clone(),
line_type: "Cost".to_string(),
budget: c.budget_amount,
actual: c.actual_amount,
variance,
variance_pct: safe_pct(variance, c.budget_amount),
favorable: c.actual_amount < c.budget_amount,
});
}
let yoy_comparison = input.prior_period.as_ref().map(|pp| {
let current_margin = safe_pct(actual_profit, actual_revenue);
let prior_margin = safe_pct(pp.profit, pp.revenue);
YoyComparison {
prior_revenue: pp.revenue,
current_revenue: actual_revenue,
revenue_growth_pct: safe_pct(actual_revenue - pp.revenue, pp.revenue),
prior_profit: pp.profit,
current_profit: actual_profit,
profit_growth_pct: safe_pct(actual_profit - pp.profit, pp.profit),
margin_expansion_bps: (current_margin - prior_margin) * dec!(10000),
}
});
let output = VarianceOutput {
revenue_variance,
cost_variance,
profit_variance,
line_detail,
yoy_comparison,
};
let elapsed = start.elapsed().as_micros() as u64;
Ok(with_metadata(
"Budget vs Actual Variance Analysis with Price/Volume/Mix Decomposition",
&serde_json::json!({
"period": input.period_name,
"revenue_lines": input.revenue_lines.len(),
"cost_lines": input.cost_lines.len(),
"has_prior_period": input.prior_period.is_some(),
}),
warnings,
elapsed,
output,
))
}
pub fn analyze_breakeven(
input: &BreakevenInput,
) -> CorpFinanceResult<ComputationOutput<BreakevenOutput>> {
let start = Instant::now();
let mut warnings: Vec<String> = Vec::new();
if input.selling_price <= dec!(0) {
return Err(CorpFinanceError::InvalidInput {
field: "selling_price".to_string(),
reason: "Selling price must be positive".to_string(),
});
}
if input.variable_cost_per_unit < dec!(0) {
return Err(CorpFinanceError::InvalidInput {
field: "variable_cost_per_unit".to_string(),
reason: "Variable cost per unit cannot be negative".to_string(),
});
}
if input.fixed_costs < dec!(0) {
return Err(CorpFinanceError::InvalidInput {
field: "fixed_costs".to_string(),
reason: "Fixed costs cannot be negative".to_string(),
});
}
let contribution_margin = input.selling_price - input.variable_cost_per_unit;
if contribution_margin <= dec!(0) {
return Err(CorpFinanceError::FinancialImpossibility(
"Contribution margin is zero or negative — break-even is unreachable".to_string(),
));
}
let contribution_margin_pct = contribution_margin / input.selling_price;
let breakeven_units = input.fixed_costs / contribution_margin;
let breakeven_revenue = breakeven_units * input.selling_price;
let current_profit = contribution_margin * input.current_volume - input.fixed_costs;
let margin_of_safety_units = input.current_volume - breakeven_units;
let margin_of_safety_pct = if input.current_volume == dec!(0) {
warnings.push("Current volume is zero; margin of safety is undefined".to_string());
Decimal::ZERO
} else {
margin_of_safety_units / input.current_volume
};
let total_cm = contribution_margin * input.current_volume;
let operating_leverage = if current_profit == dec!(0) {
warnings
.push("Current profit is zero; operating leverage is undefined (set to 0)".to_string());
Decimal::ZERO
} else {
total_cm / current_profit
};
let target_volume = input
.target_profit
.map(|tp| (input.fixed_costs + tp) / contribution_margin);
let scenario_results = match &input.scenarios {
Some(scenarios) => scenarios
.iter()
.map(|s| compute_scenario(input, s))
.collect::<CorpFinanceResult<Vec<ScenarioResult>>>()?,
None => Vec::new(),
};
let output = BreakevenOutput {
contribution_margin,
contribution_margin_pct,
breakeven_units,
breakeven_revenue,
current_profit,
margin_of_safety_units,
margin_of_safety_pct,
operating_leverage,
target_volume,
scenario_results,
};
let elapsed = start.elapsed().as_micros() as u64;
Ok(with_metadata(
"Break-even Analysis with Operating Leverage and Scenario Modelling",
&serde_json::json!({
"product": input.product_name,
"selling_price": input.selling_price.to_string(),
"variable_cost_per_unit": input.variable_cost_per_unit.to_string(),
"fixed_costs": input.fixed_costs.to_string(),
"current_volume": input.current_volume.to_string(),
}),
warnings,
elapsed,
output,
))
}
fn compute_scenario(
base: &BreakevenInput,
overrides: &ScenarioOverride,
) -> CorpFinanceResult<ScenarioResult> {
let price = apply_pct_change(base.selling_price, overrides.price_change_pct);
let vc = apply_pct_change(
base.variable_cost_per_unit,
overrides.variable_cost_change_pct,
);
let fc = apply_pct_change(base.fixed_costs, overrides.fixed_cost_change_pct);
let cm = price - vc;
if cm <= dec!(0) {
return Err(CorpFinanceError::FinancialImpossibility(format!(
"Scenario '{}': contribution margin is zero or negative after overrides",
overrides.name
)));
}
let be_units = fc / cm;
let be_revenue = be_units * price;
let profit = cm * base.current_volume - fc;
let mos_pct = if base.current_volume == dec!(0) {
Decimal::ZERO
} else {
(base.current_volume - be_units) / base.current_volume
};
Ok(ScenarioResult {
name: overrides.name.clone(),
breakeven_units: be_units,
breakeven_revenue: be_revenue,
profit_at_current_volume: profit,
margin_of_safety_pct: mos_pct,
})
}
fn apply_pct_change(base: Decimal, change_pct: Option<Rate>) -> Decimal {
match change_pct {
Some(pct) => base * (dec!(1) + pct),
None => base,
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn basic_variance_input() -> VarianceInput {
VarianceInput {
period_name: "Q1 2024".to_string(),
revenue_lines: vec![
RevenueLine {
name: "Product A".to_string(),
budget_units: dec!(100),
budget_price: dec!(10),
actual_units: dec!(110),
actual_price: dec!(11),
},
RevenueLine {
name: "Product B".to_string(),
budget_units: dec!(200),
budget_price: dec!(5),
actual_units: dec!(190),
actual_price: dec!(5),
},
],
cost_lines: vec![
CostLine {
name: "COGS".to_string(),
budget_amount: dec!(800),
actual_amount: dec!(750),
cost_type: CostType::Variable,
variable_cost_per_unit: Some(dec!(3)),
},
CostLine {
name: "SGA".to_string(),
budget_amount: dec!(300),
actual_amount: dec!(320),
cost_type: CostType::Fixed,
variable_cost_per_unit: None,
},
],
budget_total_revenue: dec!(2000),
budget_total_costs: dec!(1100),
prior_period: None,
}
}
fn basic_breakeven_input() -> BreakevenInput {
BreakevenInput {
product_name: "Widget".to_string(),
selling_price: dec!(50),
variable_cost_per_unit: dec!(30),
fixed_costs: dec!(10000),
current_volume: dec!(1000),
target_profit: None,
scenarios: None,
}
}
#[test]
fn test_revenue_variance_favorable() {
let input = basic_variance_input();
let result = analyze_variance(&input).unwrap();
assert_eq!(result.result.revenue_variance.actual_revenue, dec!(2160));
assert_eq!(result.result.revenue_variance.total_variance, dec!(160));
assert!(result.result.revenue_variance.favorable);
}
#[test]
fn test_revenue_variance_unfavorable() {
let mut input = basic_variance_input();
input.revenue_lines[0].actual_units = dec!(80);
input.revenue_lines[0].actual_price = dec!(9);
input.revenue_lines[1].actual_units = dec!(180);
input.revenue_lines[1].actual_price = dec!(4);
let result = analyze_variance(&input).unwrap();
assert_eq!(result.result.revenue_variance.actual_revenue, dec!(1440));
assert!(result.result.revenue_variance.total_variance < dec!(0));
assert!(!result.result.revenue_variance.favorable);
}
#[test]
fn test_price_volume_mix_decomposition_sums_to_total() {
let input = basic_variance_input();
let result = analyze_variance(&input).unwrap();
let rv = &result.result.revenue_variance;
let reconstructed = rv.price_variance + rv.volume_variance + rv.mix_variance;
assert_eq!(reconstructed, rv.total_variance);
}
#[test]
fn test_price_variance_calculation() {
let input = basic_variance_input();
let result = analyze_variance(&input).unwrap();
assert_eq!(result.result.revenue_variance.price_variance, dec!(110));
}
#[test]
fn test_volume_variance_calculation() {
let input = basic_variance_input();
let result = analyze_variance(&input).unwrap();
assert_eq!(result.result.revenue_variance.volume_variance, dec!(50));
}
#[test]
fn test_cost_variance_favorable() {
let input = basic_variance_input();
let result = analyze_variance(&input).unwrap();
assert_eq!(result.result.cost_variance.actual_costs, dec!(1070));
assert_eq!(result.result.cost_variance.total_variance, dec!(-30));
assert!(result.result.cost_variance.favorable);
}
#[test]
fn test_cost_variance_unfavorable() {
let mut input = basic_variance_input();
input.cost_lines[0].actual_amount = dec!(900);
input.cost_lines[1].actual_amount = dec!(350);
let result = analyze_variance(&input).unwrap();
assert_eq!(result.result.cost_variance.actual_costs, dec!(1250));
assert!(result.result.cost_variance.total_variance > dec!(0));
assert!(!result.result.cost_variance.favorable);
}
#[test]
fn test_profit_variance_with_margins() {
let input = basic_variance_input();
let result = analyze_variance(&input).unwrap();
let pv = &result.result.profit_variance;
assert_eq!(pv.budget_profit, dec!(900));
assert_eq!(pv.actual_profit, dec!(1090));
assert_eq!(pv.total_variance, dec!(190));
assert_eq!(pv.margin_budget, dec!(0.45));
assert!(pv.margin_actual > dec!(0.50));
}
#[test]
fn test_yoy_comparison_growth_rates() {
let mut input = basic_variance_input();
input.prior_period = Some(PriorPeriod {
revenue: dec!(1800),
costs: dec!(1000),
profit: dec!(800),
});
let result = analyze_variance(&input).unwrap();
let yoy = result.result.yoy_comparison.as_ref().unwrap();
assert_eq!(yoy.prior_revenue, dec!(1800));
assert_eq!(yoy.current_revenue, dec!(2160));
assert_eq!(yoy.revenue_growth_pct, dec!(0.2));
}
#[test]
fn test_margin_expansion_calculation() {
let mut input = basic_variance_input();
input.prior_period = Some(PriorPeriod {
revenue: dec!(1800),
costs: dec!(1000),
profit: dec!(800),
});
let result = analyze_variance(&input).unwrap();
let yoy = result.result.yoy_comparison.as_ref().unwrap();
assert!(yoy.margin_expansion_bps > dec!(500));
assert!(yoy.margin_expansion_bps < dec!(700));
}
#[test]
fn test_line_by_line_detail() {
let input = basic_variance_input();
let result = analyze_variance(&input).unwrap();
assert_eq!(result.result.line_detail.len(), 4);
assert_eq!(result.result.line_detail[0].line_type, "Revenue");
assert_eq!(result.result.line_detail[0].name, "Product A");
assert_eq!(result.result.line_detail[2].line_type, "Cost");
assert_eq!(result.result.line_detail[2].name, "COGS");
}
#[test]
fn test_breakeven_basic_calculation() {
let input = basic_breakeven_input();
let result = analyze_breakeven(&input).unwrap();
assert_eq!(result.result.breakeven_units, dec!(500));
assert_eq!(result.result.breakeven_revenue, dec!(25000));
}
#[test]
fn test_breakeven_high_fixed_costs() {
let mut input = basic_breakeven_input();
input.fixed_costs = dec!(30000);
let result = analyze_breakeven(&input).unwrap();
assert_eq!(result.result.breakeven_units, dec!(1500));
}
#[test]
fn test_contribution_margin_calculation() {
let input = basic_breakeven_input();
let result = analyze_breakeven(&input).unwrap();
assert_eq!(result.result.contribution_margin, dec!(20));
assert_eq!(result.result.contribution_margin_pct, dec!(0.4));
}
#[test]
fn test_margin_of_safety_positive() {
let input = basic_breakeven_input();
let result = analyze_breakeven(&input).unwrap();
assert_eq!(result.result.margin_of_safety_units, dec!(500));
assert_eq!(result.result.margin_of_safety_pct, dec!(0.5));
}
#[test]
fn test_margin_of_safety_negative() {
let mut input = basic_breakeven_input();
input.current_volume = dec!(300); let result = analyze_breakeven(&input).unwrap();
assert!(result.result.margin_of_safety_units < dec!(0));
assert!(result.result.margin_of_safety_pct < dec!(0));
}
#[test]
fn test_operating_leverage_dol() {
let input = basic_breakeven_input();
let result = analyze_breakeven(&input).unwrap();
assert_eq!(result.result.operating_leverage, dec!(2));
}
#[test]
fn test_target_volume_with_profit_target() {
let mut input = basic_breakeven_input();
input.target_profit = Some(dec!(20000));
let result = analyze_breakeven(&input).unwrap();
assert_eq!(result.result.target_volume, Some(dec!(1500)));
}
#[test]
fn test_scenario_price_increase_lowers_breakeven() {
let mut input = basic_breakeven_input();
input.scenarios = Some(vec![ScenarioOverride {
name: "Price +10%".to_string(),
price_change_pct: Some(dec!(0.10)),
variable_cost_change_pct: None,
fixed_cost_change_pct: None,
}]);
let result = analyze_breakeven(&input).unwrap();
let scenario = &result.result.scenario_results[0];
assert_eq!(scenario.breakeven_units, dec!(400));
assert!(scenario.breakeven_units < result.result.breakeven_units);
}
#[test]
fn test_scenario_cost_increase_raises_breakeven() {
let mut input = basic_breakeven_input();
input.scenarios = Some(vec![ScenarioOverride {
name: "VC +25%".to_string(),
price_change_pct: None,
variable_cost_change_pct: Some(dec!(0.25)),
fixed_cost_change_pct: None,
}]);
let result = analyze_breakeven(&input).unwrap();
let scenario = &result.result.scenario_results[0];
assert_eq!(scenario.breakeven_units, dec!(800));
assert!(scenario.breakeven_units > result.result.breakeven_units);
}
#[test]
fn test_edge_zero_volume_breakeven() {
let mut input = basic_breakeven_input();
input.current_volume = dec!(0);
let result = analyze_breakeven(&input).unwrap();
assert_eq!(result.result.margin_of_safety_pct, dec!(0));
assert_eq!(result.result.current_profit, dec!(-10000));
assert_eq!(result.result.operating_leverage, dec!(0));
}
#[test]
fn test_multiple_revenue_lines_different_variances() {
let input = VarianceInput {
period_name: "FY 2024".to_string(),
revenue_lines: vec![
RevenueLine {
name: "Premium".to_string(),
budget_units: dec!(50),
budget_price: dec!(100),
actual_units: dec!(60),
actual_price: dec!(105), },
RevenueLine {
name: "Standard".to_string(),
budget_units: dec!(200),
budget_price: dec!(20),
actual_units: dec!(180),
actual_price: dec!(18), },
RevenueLine {
name: "Economy".to_string(),
budget_units: dec!(500),
budget_price: dec!(5),
actual_units: dec!(520),
actual_price: dec!(5), },
],
cost_lines: vec![CostLine {
name: "Total COGS".to_string(),
budget_amount: dec!(5000),
actual_amount: dec!(4800),
cost_type: CostType::Variable,
variable_cost_per_unit: None,
}],
budget_total_revenue: dec!(11500),
budget_total_costs: dec!(5000),
prior_period: None,
};
let result = analyze_variance(&input).unwrap();
assert_eq!(result.result.revenue_variance.actual_revenue, dec!(12140));
assert_eq!(result.result.revenue_variance.total_variance, dec!(640));
assert!(result.result.revenue_variance.favorable);
let rv = &result.result.revenue_variance;
assert_eq!(
rv.price_variance + rv.volume_variance + rv.mix_variance,
rv.total_variance
);
assert_eq!(result.result.line_detail.len(), 4);
assert!(result.result.line_detail[0].favorable);
assert!(!result.result.line_detail[1].favorable);
}
#[test]
fn test_zero_contribution_margin_error() {
let mut input = basic_breakeven_input();
input.variable_cost_per_unit = dec!(50); let result = analyze_breakeven(&input);
assert!(result.is_err());
match result.unwrap_err() {
CorpFinanceError::FinancialImpossibility(msg) => {
assert!(msg.contains("Contribution margin"));
}
other => panic!("Expected FinancialImpossibility, got {:?}", other),
}
}
#[test]
fn test_variance_percentage_calculation() {
let input = basic_variance_input();
let result = analyze_variance(&input).unwrap();
assert_eq!(
result.result.revenue_variance.total_variance_pct,
dec!(0.08)
);
}
#[test]
fn test_breakeven_zero_fixed_costs() {
let mut input = basic_breakeven_input();
input.fixed_costs = dec!(0);
let result = analyze_breakeven(&input).unwrap();
assert_eq!(result.result.breakeven_units, dec!(0));
assert_eq!(result.result.breakeven_revenue, dec!(0));
assert_eq!(result.result.current_profit, dec!(20000));
}
#[test]
fn test_yoy_no_prior_period() {
let input = basic_variance_input();
let result = analyze_variance(&input).unwrap();
assert!(result.result.yoy_comparison.is_none());
}
#[test]
fn test_breakeven_scenario_fixed_cost_change() {
let mut input = basic_breakeven_input();
input.scenarios = Some(vec![ScenarioOverride {
name: "FC +50%".to_string(),
price_change_pct: None,
variable_cost_change_pct: None,
fixed_cost_change_pct: Some(dec!(0.50)),
}]);
let result = analyze_breakeven(&input).unwrap();
let scenario = &result.result.scenario_results[0];
assert_eq!(scenario.breakeven_units, dec!(750));
}
}