use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use crate::error::CorpFinanceError;
use crate::types::{Money, Rate};
use crate::CorpFinanceResult;
const NEWTON_ITERATIONS: u32 = 30;
const FINANCE_LEASE_TERM_THRESHOLD: Decimal = dec!(0.75);
const FINANCE_LEASE_PV_THRESHOLD: Decimal = dec!(0.90);
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum LeaseStandard {
Asc842,
Ifrs16,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeaseInput {
pub lease_description: String,
pub standard: LeaseStandard,
pub lease_term_months: u32,
pub monthly_payment: Money,
#[serde(skip_serializing_if = "Option::is_none")]
pub annual_escalation: Option<Rate>,
pub incremental_borrowing_rate: Rate,
#[serde(skip_serializing_if = "Option::is_none")]
pub implicit_rate: Option<Rate>,
pub fair_value_of_asset: Money,
pub useful_life_months: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub residual_value_guaranteed: Option<Money>,
#[serde(skip_serializing_if = "Option::is_none")]
pub residual_value_unguaranteed: Option<Money>,
#[serde(skip_serializing_if = "Option::is_none")]
pub purchase_option_price: Option<Money>,
#[serde(skip_serializing_if = "Option::is_none")]
pub purchase_option_reasonably_certain: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub termination_penalty: Option<Money>,
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_direct_costs: Option<Money>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lease_incentives_received: Option<Money>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prepaid_lease_payments: Option<Money>,
pub transfer_of_ownership: bool,
pub specialized_asset: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeaseOutput {
pub lease_description: String,
pub standard: String,
pub classification: String,
pub classification_criteria: Vec<ClassificationCriterion>,
pub initial_rou_asset: Money,
pub initial_lease_liability: Money,
pub total_lease_payments: Money,
pub total_interest_expense: Money,
pub total_depreciation: Money,
pub amortization_schedule: Vec<LeaseAmortizationRow>,
pub weighted_average_lease_term: Decimal,
pub present_value_to_fair_value_pct: Rate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClassificationCriterion {
pub test_name: String,
pub test_result: bool,
pub detail: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeaseAmortizationRow {
pub month: u32,
pub beginning_liability: Money,
pub payment: Money,
pub interest_expense: Money,
pub principal_reduction: Money,
pub ending_liability: Money,
pub rou_asset: Money,
pub depreciation: Money,
}
pub fn classify_lease(input: &LeaseInput) -> CorpFinanceResult<LeaseOutput> {
validate_input(input)?;
let annual_rate = input
.implicit_rate
.unwrap_or(input.incremental_borrowing_rate);
let monthly_rate = annual_to_monthly_rate(annual_rate);
let payments = build_payment_schedule(input);
let total_lease_payments: Money = payments.iter().copied().sum();
let mut pv_of_payments = Decimal::ZERO;
let mut discount_factor = Decimal::ONE;
let one_plus_r = Decimal::ONE + monthly_rate;
for payment in &payments {
discount_factor *= one_plus_r;
if !discount_factor.is_zero() {
pv_of_payments += *payment / discount_factor;
}
}
let purchase_option_rc = input.purchase_option_reasonably_certain.unwrap_or(false);
if purchase_option_rc {
if let Some(pop) = input.purchase_option_price {
discount_factor *= one_plus_r;
if !discount_factor.is_zero() {
pv_of_payments += pop / discount_factor;
}
}
}
if let Some(grv) = input.residual_value_guaranteed {
let mut df_grv = Decimal::ONE;
for _ in 0..input.lease_term_months {
df_grv *= one_plus_r;
}
if !df_grv.is_zero() {
pv_of_payments += grv / df_grv;
}
}
let lease_liability = pv_of_payments;
let idc = input.initial_direct_costs.unwrap_or(Decimal::ZERO);
let incentives = input.lease_incentives_received.unwrap_or(Decimal::ZERO);
let prepaid = input.prepaid_lease_payments.unwrap_or(Decimal::ZERO);
let initial_rou_asset = lease_liability + idc + prepaid - incentives;
let criteria = run_classification_tests(input, lease_liability);
let is_finance = criteria.iter().any(|c| c.test_result);
let classification = match input.standard {
LeaseStandard::Ifrs16 => "Finance",
LeaseStandard::Asc842 => {
if is_finance {
"Finance"
} else {
"Operating"
}
}
};
let pv_to_fv_pct = if input.fair_value_of_asset.is_zero() {
Decimal::ZERO
} else {
lease_liability / input.fair_value_of_asset
};
let dep_months = if input.transfer_of_ownership || purchase_option_rc {
input.useful_life_months
} else {
input.lease_term_months.min(input.useful_life_months)
};
let is_operating_asc842 =
input.standard == LeaseStandard::Asc842 && classification == "Operating";
let schedule = build_amortization_schedule(
&payments,
lease_liability,
initial_rou_asset,
monthly_rate,
dep_months,
is_operating_asc842,
);
let total_interest_expense: Money = schedule.iter().map(|r| r.interest_expense).sum();
let total_depreciation: Money = schedule.iter().map(|r| r.depreciation).sum();
let weighted_average_lease_term = Decimal::from(input.lease_term_months) / dec!(12);
Ok(LeaseOutput {
lease_description: input.lease_description.clone(),
standard: match input.standard {
LeaseStandard::Asc842 => "ASC 842".to_string(),
LeaseStandard::Ifrs16 => "IFRS 16".to_string(),
},
classification: classification.to_string(),
classification_criteria: criteria,
initial_rou_asset,
initial_lease_liability: lease_liability,
total_lease_payments,
total_interest_expense,
total_depreciation,
amortization_schedule: schedule,
weighted_average_lease_term,
present_value_to_fair_value_pct: pv_to_fv_pct,
})
}
fn validate_input(input: &LeaseInput) -> CorpFinanceResult<()> {
if input.lease_term_months == 0 {
return Err(CorpFinanceError::InvalidInput {
field: "lease_term_months".into(),
reason: "Lease term must be greater than zero".into(),
});
}
if input.monthly_payment <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "monthly_payment".into(),
reason: "Monthly payment must be positive".into(),
});
}
if input.incremental_borrowing_rate <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "incremental_borrowing_rate".into(),
reason: "Incremental borrowing rate must be positive".into(),
});
}
if input.fair_value_of_asset <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "fair_value_of_asset".into(),
reason: "Fair value of asset must be positive".into(),
});
}
if input.useful_life_months == 0 {
return Err(CorpFinanceError::InvalidInput {
field: "useful_life_months".into(),
reason: "Useful life must be greater than zero".into(),
});
}
Ok(())
}
fn run_classification_tests(
input: &LeaseInput,
pv_of_payments: Money,
) -> Vec<ClassificationCriterion> {
let mut criteria = Vec::with_capacity(5);
criteria.push(ClassificationCriterion {
test_name: "Transfer of Ownership".to_string(),
test_result: input.transfer_of_ownership,
detail: if input.transfer_of_ownership {
"Ownership transfers to lessee at end of lease term".to_string()
} else {
"No transfer of ownership at lease end".to_string()
},
});
let po_rc = input.purchase_option_reasonably_certain.unwrap_or(false)
&& input.purchase_option_price.is_some();
criteria.push(ClassificationCriterion {
test_name: "Purchase Option Reasonably Certain".to_string(),
test_result: po_rc,
detail: if po_rc {
format!(
"Purchase option of {} is reasonably certain to be exercised",
input.purchase_option_price.unwrap_or(Decimal::ZERO)
)
} else {
"No purchase option reasonably certain to be exercised".to_string()
},
});
let term_ratio = if input.useful_life_months == 0 {
Decimal::ZERO
} else {
Decimal::from(input.lease_term_months) / Decimal::from(input.useful_life_months)
};
let term_test = term_ratio >= FINANCE_LEASE_TERM_THRESHOLD;
criteria.push(ClassificationCriterion {
test_name: "Lease Term >= 75% of Useful Life".to_string(),
test_result: term_test,
detail: format!(
"Lease term {}/{} months = {:.1}% of useful life (threshold: 75%)",
input.lease_term_months,
input.useful_life_months,
term_ratio * dec!(100)
),
});
let pv_ratio = if input.fair_value_of_asset.is_zero() {
Decimal::ZERO
} else {
pv_of_payments / input.fair_value_of_asset
};
let pv_test = pv_ratio >= FINANCE_LEASE_PV_THRESHOLD;
criteria.push(ClassificationCriterion {
test_name: "PV of Payments >= 90% of Fair Value".to_string(),
test_result: pv_test,
detail: format!(
"PV of payments {} / FMV {} = {:.1}% (threshold: 90%)",
pv_of_payments,
input.fair_value_of_asset,
pv_ratio * dec!(100)
),
});
criteria.push(ClassificationCriterion {
test_name: "Specialized Asset with No Alternative Use".to_string(),
test_result: input.specialized_asset,
detail: if input.specialized_asset {
"Asset is specialized with no alternative use to the lessor".to_string()
} else {
"Asset is not specialized; has alternative uses".to_string()
},
});
criteria
}
fn build_payment_schedule(input: &LeaseInput) -> Vec<Money> {
let escalation = input.annual_escalation.unwrap_or(Decimal::ZERO);
let mut payments = Vec::with_capacity(input.lease_term_months as usize);
for m in 0..input.lease_term_months {
let year = m / 12; let mut escalation_factor = Decimal::ONE;
for _ in 0..year {
escalation_factor *= Decimal::ONE + escalation;
}
payments.push(input.monthly_payment * escalation_factor);
}
payments
}
fn build_amortization_schedule(
payments: &[Money],
initial_liability: Money,
initial_rou: Money,
monthly_rate: Rate,
depreciation_months: u32,
is_operating_asc842: bool,
) -> Vec<LeaseAmortizationRow> {
let n = payments.len();
let mut schedule = Vec::with_capacity(n);
let monthly_depreciation = if depreciation_months == 0 {
Decimal::ZERO
} else {
initial_rou / Decimal::from(depreciation_months)
};
let mut liability = initial_liability;
let mut rou = initial_rou;
if is_operating_asc842 {
let total_payments: Money = payments.iter().copied().sum();
let straight_line_cost = if n == 0 {
Decimal::ZERO
} else {
total_payments / Decimal::from(n as u32)
};
for (i, &payment) in payments.iter().enumerate() {
let month = (i + 1) as u32;
let beg_liability = liability;
let interest = beg_liability * monthly_rate;
let principal = payment - interest;
liability = beg_liability + interest - payment;
let dep = if month <= depreciation_months {
straight_line_cost - interest
} else {
Decimal::ZERO
};
rou -= dep;
if rou < Decimal::ZERO {
rou = Decimal::ZERO;
}
if liability < Decimal::ZERO && liability > dec!(-0.01) {
liability = Decimal::ZERO;
}
schedule.push(LeaseAmortizationRow {
month,
beginning_liability: beg_liability,
payment,
interest_expense: interest,
principal_reduction: principal,
ending_liability: liability,
rou_asset: rou,
depreciation: dep,
});
}
} else {
for (i, &payment) in payments.iter().enumerate() {
let month = (i + 1) as u32;
let beg_liability = liability;
let interest = beg_liability * monthly_rate;
let principal = payment - interest;
liability = beg_liability + interest - payment;
let dep = if month <= depreciation_months {
monthly_depreciation
} else {
Decimal::ZERO
};
rou -= dep;
if rou < Decimal::ZERO {
rou = Decimal::ZERO;
}
if liability < Decimal::ZERO && liability > dec!(-0.01) {
liability = Decimal::ZERO;
}
schedule.push(LeaseAmortizationRow {
month,
beginning_liability: beg_liability,
payment,
interest_expense: interest,
principal_reduction: principal,
ending_liability: liability,
rou_asset: rou,
depreciation: dep,
});
}
}
schedule
}
fn annual_to_monthly_rate(annual_rate: Rate) -> Rate {
let a = Decimal::ONE + annual_rate;
nth_root(a, 12) - Decimal::ONE
}
fn nth_root(a: Decimal, n: u32) -> Decimal {
if a <= Decimal::ZERO {
return Decimal::ZERO;
}
if a == Decimal::ONE {
return Decimal::ONE;
}
let n_dec = Decimal::from(n);
let n_minus_1 = n_dec - Decimal::ONE;
let mut x = a;
if a > dec!(0.5) && a < dec!(2.0) {
x = Decimal::ONE + (a - Decimal::ONE) / n_dec;
}
for _ in 0..NEWTON_ITERATIONS {
let mut x_pow = Decimal::ONE;
for _ in 0..(n - 1) {
x_pow *= x;
}
if x_pow.is_zero() {
break;
}
let x_new = (n_minus_1 * x + a / x_pow) / n_dec;
if (x_new - x).abs() < dec!(0.0000000000001) {
return x_new;
}
x = x_new;
}
x
}
pub(crate) fn pv_of_payment_stream(payments: &[Money], monthly_rate: Rate) -> Money {
let mut pv = Decimal::ZERO;
let mut discount_factor = Decimal::ONE;
let one_plus_r = Decimal::ONE + monthly_rate;
for payment in payments {
discount_factor *= one_plus_r;
if !discount_factor.is_zero() {
pv += *payment / discount_factor;
}
}
pv
}
pub(crate) fn build_payment_schedule_from_params(
lease_term_months: u32,
monthly_payment: Money,
annual_escalation: Option<Rate>,
) -> Vec<Money> {
let escalation = annual_escalation.unwrap_or(Decimal::ZERO);
let mut payments = Vec::with_capacity(lease_term_months as usize);
for m in 0..lease_term_months {
let year = m / 12;
let mut escalation_factor = Decimal::ONE;
for _ in 0..year {
escalation_factor *= Decimal::ONE + escalation;
}
payments.push(monthly_payment * escalation_factor);
}
payments
}
pub(crate) fn annual_to_monthly(annual_rate: Rate) -> Rate {
annual_to_monthly_rate(annual_rate)
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn standard_office_lease() -> LeaseInput {
LeaseInput {
lease_description: "Office Lease - 123 Main St".to_string(),
standard: LeaseStandard::Asc842,
lease_term_months: 60,
monthly_payment: dec!(10000),
annual_escalation: None,
incremental_borrowing_rate: dec!(0.06),
implicit_rate: None,
fair_value_of_asset: dec!(1000000),
useful_life_months: 240,
residual_value_guaranteed: None,
residual_value_unguaranteed: None,
purchase_option_price: None,
purchase_option_reasonably_certain: None,
termination_penalty: None,
initial_direct_costs: None,
lease_incentives_received: None,
prepaid_lease_payments: None,
transfer_of_ownership: false,
specialized_asset: false,
}
}
fn finance_lease_long_term() -> LeaseInput {
LeaseInput {
lease_description: "Equipment Lease - Long Term".to_string(),
standard: LeaseStandard::Asc842,
lease_term_months: 96, monthly_payment: dec!(5000),
annual_escalation: None,
incremental_borrowing_rate: dec!(0.05),
implicit_rate: None,
fair_value_of_asset: dec!(400000),
useful_life_months: 120, residual_value_guaranteed: None,
residual_value_unguaranteed: None,
purchase_option_price: None,
purchase_option_reasonably_certain: None,
termination_penalty: None,
initial_direct_costs: None,
lease_incentives_received: None,
prepaid_lease_payments: None,
transfer_of_ownership: false,
specialized_asset: false,
}
}
#[test]
fn test_operating_lease_classification() {
let input = standard_office_lease();
let result = classify_lease(&input).unwrap();
assert_eq!(result.classification, "Operating");
assert_eq!(result.standard, "ASC 842");
assert!(
!result.classification_criteria.iter().any(|c| c.test_result),
"No finance criteria should trigger for a short-term office lease"
);
}
#[test]
fn test_finance_lease_transfer_of_ownership() {
let mut input = standard_office_lease();
input.transfer_of_ownership = true;
let result = classify_lease(&input).unwrap();
assert_eq!(result.classification, "Finance");
let ownership_test = result
.classification_criteria
.iter()
.find(|c| c.test_name == "Transfer of Ownership")
.unwrap();
assert!(ownership_test.test_result);
}
#[test]
fn test_finance_lease_purchase_option() {
let mut input = standard_office_lease();
input.purchase_option_price = Some(dec!(1));
input.purchase_option_reasonably_certain = Some(true);
let result = classify_lease(&input).unwrap();
assert_eq!(result.classification, "Finance");
let po_test = result
.classification_criteria
.iter()
.find(|c| c.test_name == "Purchase Option Reasonably Certain")
.unwrap();
assert!(po_test.test_result);
}
#[test]
fn test_finance_lease_75_percent_useful_life() {
let input = finance_lease_long_term();
let result = classify_lease(&input).unwrap();
assert_eq!(result.classification, "Finance");
let term_test = result
.classification_criteria
.iter()
.find(|c| c.test_name == "Lease Term >= 75% of Useful Life")
.unwrap();
assert!(term_test.test_result);
}
#[test]
fn test_finance_lease_90_percent_pv() {
let mut input = standard_office_lease();
input.fair_value_of_asset = dec!(500000);
let result = classify_lease(&input).unwrap();
assert_eq!(result.classification, "Finance");
let pv_test = result
.classification_criteria
.iter()
.find(|c| c.test_name.contains("90%"))
.unwrap();
assert!(pv_test.test_result);
assert!(result.present_value_to_fair_value_pct >= dec!(0.90));
}
#[test]
fn test_finance_lease_specialized_asset() {
let mut input = standard_office_lease();
input.specialized_asset = true;
let result = classify_lease(&input).unwrap();
assert_eq!(result.classification, "Finance");
let spec_test = result
.classification_criteria
.iter()
.find(|c| c.test_name.contains("Specialized"))
.unwrap();
assert!(spec_test.test_result);
}
#[test]
fn test_ifrs16_always_finance() {
let mut input = standard_office_lease();
input.standard = LeaseStandard::Ifrs16;
let result = classify_lease(&input).unwrap();
assert_eq!(result.classification, "Finance");
assert_eq!(result.standard, "IFRS 16");
}
#[test]
fn test_ifrs16_operating_scenario_still_finance() {
let mut input = standard_office_lease();
input.standard = LeaseStandard::Ifrs16;
let result = classify_lease(&input).unwrap();
assert_eq!(result.classification, "Finance");
}
#[test]
fn test_escalating_payments() {
let mut input = standard_office_lease();
input.annual_escalation = Some(dec!(0.03));
let result = classify_lease(&input).unwrap();
let flat_total = dec!(10000) * dec!(60);
assert!(
result.total_lease_payments > flat_total,
"Escalating payments total {} should exceed flat total {}",
result.total_lease_payments,
flat_total
);
let sched = &result.amortization_schedule;
let first_payment = sched[0].payment;
let last_payment = sched[sched.len() - 1].payment;
assert!(
last_payment > first_payment,
"Last payment {} should exceed first payment {} with escalation",
last_payment,
first_payment
);
}
#[test]
fn test_guaranteed_residual_value() {
let input_no_grv = standard_office_lease();
let result_no_grv = classify_lease(&input_no_grv).unwrap();
let mut input_grv = standard_office_lease();
input_grv.residual_value_guaranteed = Some(dec!(50000));
let result_grv = classify_lease(&input_grv).unwrap();
assert!(
result_grv.initial_lease_liability > result_no_grv.initial_lease_liability,
"Lease liability with GRV {} should exceed without GRV {}",
result_grv.initial_lease_liability,
result_no_grv.initial_lease_liability
);
}
#[test]
fn test_initial_direct_costs() {
let input_no_idc = standard_office_lease();
let result_no_idc = classify_lease(&input_no_idc).unwrap();
let mut input_idc = standard_office_lease();
input_idc.initial_direct_costs = Some(dec!(15000));
let result_idc = classify_lease(&input_idc).unwrap();
let diff = result_idc.initial_rou_asset - result_no_idc.initial_rou_asset;
assert_eq!(
diff,
dec!(15000),
"ROU should increase by IDC amount, got diff {}",
diff
);
}
#[test]
fn test_lease_incentives() {
let input_no_inc = standard_office_lease();
let result_no_inc = classify_lease(&input_no_inc).unwrap();
let mut input_inc = standard_office_lease();
input_inc.lease_incentives_received = Some(dec!(20000));
let result_inc = classify_lease(&input_inc).unwrap();
let diff = result_no_inc.initial_rou_asset - result_inc.initial_rou_asset;
assert_eq!(
diff,
dec!(20000),
"ROU should decrease by incentive amount, got diff {}",
diff
);
}
#[test]
fn test_prepaid_lease_payments() {
let input_no_pp = standard_office_lease();
let result_no_pp = classify_lease(&input_no_pp).unwrap();
let mut input_pp = standard_office_lease();
input_pp.prepaid_lease_payments = Some(dec!(10000));
let result_pp = classify_lease(&input_pp).unwrap();
let diff = result_pp.initial_rou_asset - result_no_pp.initial_rou_asset;
assert_eq!(
diff,
dec!(10000),
"ROU should increase by prepaid amount, got diff {}",
diff
);
}
#[test]
fn test_amortization_schedule_length() {
let input = standard_office_lease();
let result = classify_lease(&input).unwrap();
assert_eq!(
result.amortization_schedule.len(),
60,
"Schedule should have 60 monthly rows"
);
}
#[test]
fn test_amortization_first_row() {
let input = standard_office_lease();
let result = classify_lease(&input).unwrap();
let first = &result.amortization_schedule[0];
assert_eq!(first.month, 1);
let diff = (first.beginning_liability - result.initial_lease_liability).abs();
assert!(
diff < dec!(0.01),
"First row liability {} should match initial {}",
first.beginning_liability,
result.initial_lease_liability
);
}
#[test]
fn test_amortization_ending_liability_near_zero() {
let input = finance_lease_long_term();
let result = classify_lease(&input).unwrap();
let last = result.amortization_schedule.last().unwrap();
assert!(
last.ending_liability.abs() < dec!(1.0),
"Ending liability should be near zero, got {}",
last.ending_liability
);
}
#[test]
fn test_implicit_rate_used() {
let mut input_ibr = standard_office_lease();
input_ibr.implicit_rate = None;
let result_ibr = classify_lease(&input_ibr).unwrap();
let mut input_imp = standard_office_lease();
input_imp.implicit_rate = Some(dec!(0.04)); let result_imp = classify_lease(&input_imp).unwrap();
assert!(
result_imp.initial_lease_liability > result_ibr.initial_lease_liability,
"Lower implicit rate should produce higher PV: {} vs {}",
result_imp.initial_lease_liability,
result_ibr.initial_lease_liability
);
}
#[test]
fn test_weighted_average_lease_term() {
let input = standard_office_lease();
let result = classify_lease(&input).unwrap();
assert_eq!(
result.weighted_average_lease_term,
dec!(5),
"60 months = 5 years, got {}",
result.weighted_average_lease_term
);
}
#[test]
fn test_invalid_zero_lease_term() {
let mut input = standard_office_lease();
input.lease_term_months = 0;
let result = classify_lease(&input);
assert!(result.is_err());
match result.unwrap_err() {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "lease_term_months");
}
other => panic!("Expected InvalidInput, got {:?}", other),
}
}
#[test]
fn test_invalid_zero_monthly_payment() {
let mut input = standard_office_lease();
input.monthly_payment = Decimal::ZERO;
let result = classify_lease(&input);
assert!(result.is_err());
match result.unwrap_err() {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "monthly_payment");
}
other => panic!("Expected InvalidInput, got {:?}", other),
}
}
#[test]
fn test_invalid_zero_ibr() {
let mut input = standard_office_lease();
input.incremental_borrowing_rate = Decimal::ZERO;
let result = classify_lease(&input);
assert!(result.is_err());
match result.unwrap_err() {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "incremental_borrowing_rate");
}
other => panic!("Expected InvalidInput, got {:?}", other),
}
}
#[test]
fn test_invalid_zero_fair_value() {
let mut input = standard_office_lease();
input.fair_value_of_asset = Decimal::ZERO;
let result = classify_lease(&input);
assert!(result.is_err());
match result.unwrap_err() {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "fair_value_of_asset");
}
other => panic!("Expected InvalidInput, got {:?}", other),
}
}
#[test]
fn test_invalid_zero_useful_life() {
let mut input = standard_office_lease();
input.useful_life_months = 0;
let result = classify_lease(&input);
assert!(result.is_err());
match result.unwrap_err() {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "useful_life_months");
}
other => panic!("Expected InvalidInput, got {:?}", other),
}
}
#[test]
fn test_depreciation_shorter_period() {
let mut input = standard_office_lease();
input.transfer_of_ownership = true; let result = classify_lease(&input).unwrap();
let expected_monthly_dep = result.initial_rou_asset / dec!(240);
let first_dep = result.amortization_schedule[0].depreciation;
let diff = (first_dep - expected_monthly_dep).abs();
assert!(
diff < dec!(0.01),
"Monthly depreciation should be ~{}, got {}",
expected_monthly_dep,
first_dep
);
}
#[test]
fn test_operating_lease_straight_line() {
let input = standard_office_lease();
let result = classify_lease(&input).unwrap();
assert_eq!(result.classification, "Operating");
let sched = &result.amortization_schedule;
let first_total = sched[0].interest_expense + sched[0].depreciation;
let mid_total = sched[30].interest_expense + sched[30].depreciation;
let diff = (first_total - mid_total).abs();
assert!(
diff < dec!(1.0),
"Operating lease cost should be approximately straight-line: first={}, mid={}",
first_total,
mid_total
);
}
#[test]
fn test_pv_to_fair_value_pct() {
let input = standard_office_lease();
let result = classify_lease(&input).unwrap();
assert!(
result.present_value_to_fair_value_pct > Decimal::ZERO,
"PV/FMV should be positive"
);
assert!(
result.present_value_to_fair_value_pct < Decimal::ONE,
"PV/FMV should be less than 1 for this operating lease, got {}",
result.present_value_to_fair_value_pct
);
}
#[test]
fn test_interest_plus_principal_equals_total_payments() {
let input = finance_lease_long_term();
let result = classify_lease(&input).unwrap();
let total_interest: Decimal = result
.amortization_schedule
.iter()
.map(|r| r.interest_expense)
.sum();
let total_principal: Decimal = result
.amortization_schedule
.iter()
.map(|r| r.principal_reduction)
.sum();
let sum = total_interest + total_principal;
let diff = (sum - result.total_lease_payments).abs();
assert!(
diff < dec!(1.0),
"Interest ({}) + principal ({}) = {} should equal total payments ({})",
total_interest,
total_principal,
sum,
result.total_lease_payments
);
}
#[test]
fn test_multiple_criteria_triggered() {
let mut input = standard_office_lease();
input.transfer_of_ownership = true;
input.specialized_asset = true;
let result = classify_lease(&input).unwrap();
assert_eq!(result.classification, "Finance");
let triggered: Vec<_> = result
.classification_criteria
.iter()
.filter(|c| c.test_result)
.collect();
assert!(
triggered.len() >= 2,
"At least 2 criteria should trigger, got {}",
triggered.len()
);
}
#[test]
fn test_purchase_option_not_reasonably_certain() {
let mut input = standard_office_lease();
input.purchase_option_price = Some(dec!(50000));
input.purchase_option_reasonably_certain = Some(false);
let result = classify_lease(&input).unwrap();
let po_test = result
.classification_criteria
.iter()
.find(|c| c.test_name.contains("Purchase Option"))
.unwrap();
assert!(
!po_test.test_result,
"Purchase option not reasonably certain should not trigger"
);
}
#[test]
fn test_nth_root_precision() {
let annual = dec!(0.06);
let monthly = annual_to_monthly_rate(annual);
assert!(
monthly > dec!(0.00486) && monthly < dec!(0.00488),
"Monthly rate for 6% annual should be ~0.00487, got {}",
monthly
);
let mut compounded = Decimal::ONE;
for _ in 0..12 {
compounded *= Decimal::ONE + monthly;
}
let diff = (compounded - dec!(1.06)).abs();
assert!(
diff < dec!(0.000001),
"Round-trip (1+m)^12 should be ~1.06, got {}",
compounded
);
}
}