use crate::compat::Instant;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use crate::error::CorpFinanceError;
use crate::types::*;
use crate::CorpFinanceResult;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ValuationType {
GoingConcern,
Liquidation,
Both,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ClaimPriority {
SuperPriority,
Administrative,
Priority,
SecuredFirst,
SecuredSecond,
Senior,
SeniorSubordinated,
Subordinated,
Mezzanine,
Equity,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claim {
pub name: String,
pub amount: Money,
pub priority: ClaimPriority,
pub is_secured: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub collateral_value: Option<Money>,
#[serde(skip_serializing_if = "Option::is_none")]
pub interest_rate: Option<Rate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub accrued_months: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DipFacility {
pub amount: Money,
pub priming: bool,
pub roll_up_amount: Money,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecoveryAnalysisInput {
pub enterprise_value: Money,
pub liquidation_value: Money,
pub valuation_type: ValuationType,
pub claims: Vec<Claim>,
pub administrative_costs: Money,
#[serde(skip_serializing_if = "Option::is_none")]
pub dip_facility: Option<DipFacility>,
pub cash_on_hand: Money,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaimRecovery {
pub name: String,
pub claim_amount: Money,
pub accrued_interest: Money,
pub total_claim: Money,
pub recovery_amount: Money,
pub recovery_rate: Decimal,
pub recovery_cents_on_dollar: Decimal,
pub is_impaired: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LiquidationDetail {
pub liquidation_distributable: Money,
pub claim_recoveries: Vec<ClaimRecovery>,
pub shortfall: Money,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecoveryAnalysisOutput {
pub total_distributable: Money,
pub claim_recoveries: Vec<ClaimRecovery>,
pub fulcrum_security: Option<String>,
pub total_claims: Money,
pub shortfall: Money,
pub going_concern_premium: Option<Decimal>,
pub liquidation_analysis: Option<LiquidationDetail>,
}
pub fn analyze_recovery(
input: &RecoveryAnalysisInput,
) -> CorpFinanceResult<ComputationOutput<RecoveryAnalysisOutput>> {
let start = Instant::now();
let mut warnings: Vec<String> = Vec::new();
validate_input(input)?;
let enriched_claims: Vec<EnrichedClaim> = input
.claims
.iter()
.map(|c| {
let accrued = compute_accrued_interest(c);
EnrichedClaim {
claim: c.clone(),
accrued_interest: accrued,
total_claim: c.amount + accrued,
}
})
.collect();
let total_claims: Money = enriched_claims.iter().map(|ec| ec.total_claim).sum();
let gc_distributable = compute_distributable(input.enterprise_value, input);
let gc_recoveries = run_waterfall(gc_distributable, &enriched_claims, input);
let gc_fulcrum = find_fulcrum_security(&gc_recoveries);
let gc_shortfall = (total_claims - gc_distributable).max(Decimal::ZERO);
let liquidation_analysis = match input.valuation_type {
ValuationType::GoingConcern => None,
ValuationType::Liquidation | ValuationType::Both => {
let liq_distributable = compute_distributable(input.liquidation_value, input);
let liq_recoveries = run_waterfall(liq_distributable, &enriched_claims, input);
let liq_shortfall = (total_claims - liq_distributable).max(Decimal::ZERO);
Some(LiquidationDetail {
liquidation_distributable: liq_distributable,
claim_recoveries: liq_recoveries,
shortfall: liq_shortfall,
})
}
};
let (primary_distributable, primary_recoveries, primary_shortfall, primary_fulcrum) =
match input.valuation_type {
ValuationType::GoingConcern | ValuationType::Both => {
(gc_distributable, gc_recoveries, gc_shortfall, gc_fulcrum)
}
ValuationType::Liquidation => {
let liq_dist = compute_distributable(input.liquidation_value, input);
let liq_rec = run_waterfall(liq_dist, &enriched_claims, input);
let liq_short = (total_claims - liq_dist).max(Decimal::ZERO);
let liq_ful = find_fulcrum_security(&liq_rec);
(liq_dist, liq_rec, liq_short, liq_ful)
}
};
let going_concern_premium = if input.liquidation_value > Decimal::ZERO {
Some((input.enterprise_value - input.liquidation_value) / input.liquidation_value)
} else {
None
};
if primary_fulcrum.is_some() {
warnings.push("Fulcrum security identified: not all classes are made whole.".into());
}
for rec in &primary_recoveries {
if input
.claims
.iter()
.any(|c| c.name == rec.name && c.priority == ClaimPriority::Equity)
&& rec.recovery_amount > Decimal::ZERO
&& primary_fulcrum.is_some()
{
warnings.push(format!(
"Equity class '{}' receives recovery despite senior impairment.",
rec.name
));
}
}
if input.liquidation_value > input.enterprise_value {
warnings.push(
"Liquidation value exceeds going-concern value; going-concern premium is negative."
.into(),
);
}
let admin_pct = if input.enterprise_value > Decimal::ZERO {
input.administrative_costs / input.enterprise_value * dec!(100)
} else {
Decimal::ZERO
};
if admin_pct > dec!(10) {
warnings.push(format!(
"Administrative costs are {admin_pct:.1}% of enterprise value (>10%)."
));
}
let output = RecoveryAnalysisOutput {
total_distributable: primary_distributable,
claim_recoveries: primary_recoveries,
fulcrum_security: primary_fulcrum,
total_claims,
shortfall: primary_shortfall,
going_concern_premium,
liquidation_analysis,
};
let elapsed = start.elapsed().as_micros() as u64;
Ok(with_metadata(
"Restructuring Recovery Analysis (Absolute Priority Rule)",
&serde_json::json!({
"enterprise_value": input.enterprise_value.to_string(),
"liquidation_value": input.liquidation_value.to_string(),
"valuation_type": format!("{:?}", input.valuation_type),
"num_claims": input.claims.len(),
"administrative_costs": input.administrative_costs.to_string(),
"dip_facility": input.dip_facility.is_some(),
"cash_on_hand": input.cash_on_hand.to_string(),
}),
warnings,
elapsed,
output,
))
}
#[derive(Debug, Clone)]
struct EnrichedClaim {
claim: Claim,
accrued_interest: Money,
total_claim: Money,
}
fn compute_accrued_interest(claim: &Claim) -> Money {
match (claim.interest_rate, claim.accrued_months) {
(Some(rate), Some(months)) => {
claim.amount * rate * Decimal::from(months) / dec!(12)
}
_ => Decimal::ZERO,
}
}
fn compute_distributable(base_value: Money, input: &RecoveryAnalysisInput) -> Money {
let gross = base_value + input.cash_on_hand;
(gross - input.administrative_costs).max(Decimal::ZERO)
}
fn run_waterfall(
total_distributable: Money,
enriched_claims: &[EnrichedClaim],
input: &RecoveryAnalysisInput,
) -> Vec<ClaimRecovery> {
let mut remaining = total_distributable;
let mut recoveries: Vec<ClaimRecovery> = Vec::with_capacity(enriched_claims.len());
let mut deficiency_claims: Vec<(String, Money)> = Vec::new();
if let Some(dip) = &input.dip_facility {
if dip.amount > Decimal::ZERO {
let dip_total = dip.amount;
let paid = remaining.min(dip_total);
remaining -= paid;
let rate = safe_divide(paid, dip_total);
recoveries.push(ClaimRecovery {
name: "DIP Facility".into(),
claim_amount: dip.amount,
accrued_interest: Decimal::ZERO,
total_claim: dip_total,
recovery_amount: paid,
recovery_rate: rate,
recovery_cents_on_dollar: rate * dec!(100),
is_impaired: rate < Decimal::ONE,
});
}
}
let mut priority_classes: Vec<ClaimPriority> =
enriched_claims.iter().map(|ec| ec.claim.priority).collect();
priority_classes.sort();
priority_classes.dedup();
for priority in &priority_classes {
let class_claims: Vec<&EnrichedClaim> = enriched_claims
.iter()
.filter(|ec| ec.claim.priority == *priority)
.collect();
if class_claims.is_empty() {
continue;
}
if is_secured_priority(*priority) {
for ec in &class_claims {
if ec.claim.is_secured {
let collateral = ec.claim.collateral_value.unwrap_or(ec.total_claim);
let secured_amount = ec.total_claim.min(collateral);
let paid = remaining.min(secured_amount);
remaining -= paid;
let deficiency = ec.total_claim - secured_amount;
if deficiency > Decimal::ZERO {
deficiency_claims
.push((format!("{} (deficiency)", ec.claim.name), deficiency));
}
let rate = safe_divide(paid, ec.total_claim);
recoveries.push(ClaimRecovery {
name: ec.claim.name.clone(),
claim_amount: ec.claim.amount,
accrued_interest: ec.accrued_interest,
total_claim: ec.total_claim,
recovery_amount: paid,
recovery_rate: rate,
recovery_cents_on_dollar: rate * dec!(100),
is_impaired: rate < Decimal::ONE,
});
} else {
let paid = remaining.min(ec.total_claim);
remaining -= paid;
let rate = safe_divide(paid, ec.total_claim);
recoveries.push(ClaimRecovery {
name: ec.claim.name.clone(),
claim_amount: ec.claim.amount,
accrued_interest: ec.accrued_interest,
total_claim: ec.total_claim,
recovery_amount: paid,
recovery_rate: rate,
recovery_cents_on_dollar: rate * dec!(100),
is_impaired: rate < Decimal::ONE,
});
}
}
} else {
let total_class_claims: Money = class_claims.iter().map(|ec| ec.total_claim).sum();
let deficiency_total: Money = if *priority == ClaimPriority::Senior {
deficiency_claims.iter().map(|(_, amt)| *amt).sum()
} else {
Decimal::ZERO
};
let combined_class = total_class_claims + deficiency_total;
let available_for_class = remaining.min(combined_class);
let pro_rata = safe_divide(available_for_class, combined_class);
for ec in &class_claims {
let paid = ec.total_claim * pro_rata;
let rate = safe_divide(paid, ec.total_claim);
recoveries.push(ClaimRecovery {
name: ec.claim.name.clone(),
claim_amount: ec.claim.amount,
accrued_interest: ec.accrued_interest,
total_claim: ec.total_claim,
recovery_amount: paid,
recovery_rate: rate,
recovery_cents_on_dollar: rate * dec!(100),
is_impaired: rate < Decimal::ONE,
});
}
if *priority == ClaimPriority::Senior && deficiency_total > Decimal::ZERO {
for (def_name, def_amount) in &deficiency_claims {
let paid = *def_amount * pro_rata;
let rate = safe_divide(paid, *def_amount);
recoveries.push(ClaimRecovery {
name: def_name.clone(),
claim_amount: *def_amount,
accrued_interest: Decimal::ZERO,
total_claim: *def_amount,
recovery_amount: paid,
recovery_rate: rate,
recovery_cents_on_dollar: rate * dec!(100),
is_impaired: rate < Decimal::ONE,
});
}
}
remaining -= available_for_class;
}
}
recoveries
}
fn is_secured_priority(p: ClaimPriority) -> bool {
matches!(
p,
ClaimPriority::SecuredFirst | ClaimPriority::SecuredSecond
)
}
fn find_fulcrum_security(recoveries: &[ClaimRecovery]) -> Option<String> {
recoveries
.iter()
.find(|r| r.is_impaired)
.map(|r| r.name.clone())
}
fn safe_divide(numerator: Decimal, denominator: Decimal) -> Decimal {
if denominator.is_zero() {
Decimal::ZERO
} else {
numerator / denominator
}
}
fn validate_input(input: &RecoveryAnalysisInput) -> CorpFinanceResult<()> {
if input.enterprise_value < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "enterprise_value".into(),
reason: "Enterprise value cannot be negative.".into(),
});
}
if input.liquidation_value < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "liquidation_value".into(),
reason: "Liquidation value cannot be negative.".into(),
});
}
if input.claims.is_empty() {
return Err(CorpFinanceError::InvalidInput {
field: "claims".into(),
reason: "At least one claim is required.".into(),
});
}
if input.administrative_costs < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "administrative_costs".into(),
reason: "Administrative costs cannot be negative.".into(),
});
}
if input.cash_on_hand < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "cash_on_hand".into(),
reason: "Cash on hand cannot be negative.".into(),
});
}
for claim in &input.claims {
if claim.amount < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: format!("claim[{}].amount", claim.name),
reason: "Claim amount cannot be negative.".into(),
});
}
if let Some(cv) = claim.collateral_value {
if cv < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: format!("claim[{}].collateral_value", claim.name),
reason: "Collateral value cannot be negative.".into(),
});
}
}
}
if let Some(dip) = &input.dip_facility {
if dip.amount < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "dip_facility.amount".into(),
reason: "DIP facility amount cannot be negative.".into(),
});
}
if dip.roll_up_amount < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "dip_facility.roll_up_amount".into(),
reason: "DIP roll-up amount cannot be negative.".into(),
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn simple_claim(name: &str, amount: Money, priority: ClaimPriority) -> Claim {
Claim {
name: name.into(),
amount,
priority,
is_secured: false,
collateral_value: None,
interest_rate: None,
accrued_months: None,
}
}
fn secured_claim(
name: &str,
amount: Money,
priority: ClaimPriority,
collateral: Money,
) -> Claim {
Claim {
name: name.into(),
amount,
priority,
is_secured: true,
collateral_value: Some(collateral),
interest_rate: None,
accrued_months: None,
}
}
fn base_input(claims: Vec<Claim>) -> RecoveryAnalysisInput {
RecoveryAnalysisInput {
enterprise_value: dec!(500),
liquidation_value: dec!(300),
valuation_type: ValuationType::GoingConcern,
claims,
administrative_costs: dec!(20),
dip_facility: None,
cash_on_hand: dec!(30),
}
}
#[test]
fn test_simple_two_claim_waterfall() {
let input = base_input(vec![
simple_claim("Senior Notes", dec!(400), ClaimPriority::Senior),
simple_claim("Equity", dec!(200), ClaimPriority::Equity),
]);
let result = analyze_recovery(&input).unwrap();
let out = &result.result;
assert_eq!(out.total_distributable, dec!(510));
assert_eq!(out.claim_recoveries[0].name, "Senior Notes");
assert_eq!(out.claim_recoveries[0].recovery_amount, dec!(400));
assert_eq!(out.claim_recoveries[0].recovery_rate, Decimal::ONE);
assert!(!out.claim_recoveries[0].is_impaired);
assert_eq!(out.claim_recoveries[1].name, "Equity");
assert_eq!(out.claim_recoveries[1].recovery_amount, dec!(110));
assert!(out.claim_recoveries[1].is_impaired);
assert_eq!(out.fulcrum_security, Some("Equity".into()));
assert_eq!(out.total_claims, dec!(600));
assert_eq!(out.shortfall, dec!(90)); }
#[test]
fn test_full_capital_structure() {
let claims = vec![
secured_claim(
"1st Lien TL",
dec!(200),
ClaimPriority::SecuredFirst,
dec!(250),
),
secured_claim(
"2nd Lien TL",
dec!(100),
ClaimPriority::SecuredSecond,
dec!(80),
),
simple_claim("Senior Notes", dec!(150), ClaimPriority::Senior),
simple_claim("Mezz Notes", dec!(100), ClaimPriority::Mezzanine),
simple_claim("Equity", dec!(300), ClaimPriority::Equity),
];
let mut input = base_input(claims);
input.enterprise_value = dec!(600);
input.cash_on_hand = dec!(50);
input.administrative_costs = dec!(30);
input.dip_facility = Some(DipFacility {
amount: dec!(50),
priming: true,
roll_up_amount: dec!(10),
});
let result = analyze_recovery(&input).unwrap();
let out = &result.result;
assert_eq!(out.total_distributable, dec!(620));
let dip = out
.claim_recoveries
.iter()
.find(|r| r.name == "DIP Facility")
.unwrap();
assert_eq!(dip.recovery_rate, Decimal::ONE);
let first_lien = out
.claim_recoveries
.iter()
.find(|r| r.name == "1st Lien TL")
.unwrap();
assert_eq!(first_lien.recovery_amount, dec!(200));
assert!(!first_lien.is_impaired);
let second_lien = out
.claim_recoveries
.iter()
.find(|r| r.name == "2nd Lien TL")
.unwrap();
assert_eq!(second_lien.recovery_amount, dec!(80));
assert!(second_lien.is_impaired);
let senior = out
.claim_recoveries
.iter()
.find(|r| r.name == "Senior Notes")
.unwrap();
assert_eq!(senior.recovery_amount, dec!(150));
assert!(!senior.is_impaired);
let mezz = out
.claim_recoveries
.iter()
.find(|r| r.name == "Mezz Notes")
.unwrap();
assert_eq!(mezz.recovery_amount, dec!(100));
assert!(!mezz.is_impaired);
let equity = out
.claim_recoveries
.iter()
.find(|r| r.name == "Equity")
.unwrap();
assert!(equity.is_impaired);
assert!(equity.recovery_amount < dec!(300));
}
#[test]
fn test_fulcrum_security_identification() {
let claims = vec![
simple_claim("Senior A", dec!(200), ClaimPriority::Senior),
simple_claim("Senior B", dec!(200), ClaimPriority::Senior),
simple_claim("Equity", dec!(100), ClaimPriority::Equity),
];
let mut input = base_input(claims);
input.enterprise_value = dec!(350);
input.cash_on_hand = dec!(0);
input.administrative_costs = dec!(0);
let result = analyze_recovery(&input).unwrap();
let out = &result.result;
assert!(out.claim_recoveries[0].is_impaired);
assert!(out.claim_recoveries[1].is_impaired);
assert!(
out.fulcrum_security == Some("Senior A".into())
|| out.fulcrum_security == Some("Senior B".into())
);
let equity = out
.claim_recoveries
.iter()
.find(|r| r.name == "Equity")
.unwrap();
assert_eq!(equity.recovery_amount, Decimal::ZERO);
}
#[test]
fn test_all_secured_scenario() {
let claims = vec![
secured_claim(
"1st Lien",
dec!(300),
ClaimPriority::SecuredFirst,
dec!(400),
),
secured_claim(
"2nd Lien",
dec!(200),
ClaimPriority::SecuredSecond,
dec!(250),
),
];
let mut input = base_input(claims);
input.enterprise_value = dec!(600);
input.cash_on_hand = dec!(0);
input.administrative_costs = dec!(0);
let result = analyze_recovery(&input).unwrap();
let out = &result.result;
let first = &out.claim_recoveries[0];
assert_eq!(first.recovery_amount, dec!(300));
assert!(!first.is_impaired);
let second = &out.claim_recoveries[1];
assert_eq!(second.recovery_amount, dec!(200));
assert!(!second.is_impaired);
assert!(out.fulcrum_security.is_none());
assert_eq!(out.shortfall, Decimal::ZERO);
}
#[test]
fn test_total_impairment_ev_zero() {
let claims = vec![
simple_claim("Senior", dec!(200), ClaimPriority::Senior),
simple_claim("Equity", dec!(100), ClaimPriority::Equity),
];
let mut input = base_input(claims);
input.enterprise_value = Decimal::ZERO;
input.cash_on_hand = Decimal::ZERO;
input.administrative_costs = Decimal::ZERO;
let result = analyze_recovery(&input).unwrap();
let out = &result.result;
assert_eq!(out.total_distributable, Decimal::ZERO);
for rec in &out.claim_recoveries {
assert_eq!(rec.recovery_amount, Decimal::ZERO);
assert!(rec.is_impaired);
}
assert_eq!(out.shortfall, dec!(300));
assert_eq!(out.fulcrum_security, Some("Senior".into()));
}
#[test]
fn test_dip_priming() {
let claims = vec![
secured_claim(
"1st Lien",
dec!(200),
ClaimPriority::SecuredFirst,
dec!(200),
),
simple_claim("Equity", dec!(100), ClaimPriority::Equity),
];
let mut input = base_input(claims);
input.enterprise_value = dec!(180);
input.cash_on_hand = dec!(0);
input.administrative_costs = dec!(0);
input.dip_facility = Some(DipFacility {
amount: dec!(50),
priming: true,
roll_up_amount: Decimal::ZERO,
});
let result = analyze_recovery(&input).unwrap();
let out = &result.result;
let dip = out
.claim_recoveries
.iter()
.find(|r| r.name == "DIP Facility")
.unwrap();
assert_eq!(dip.recovery_amount, dec!(50));
assert!(!dip.is_impaired);
let first_lien = out
.claim_recoveries
.iter()
.find(|r| r.name == "1st Lien")
.unwrap();
assert_eq!(first_lien.recovery_amount, dec!(130));
assert!(first_lien.is_impaired);
let equity = out
.claim_recoveries
.iter()
.find(|r| r.name == "Equity")
.unwrap();
assert_eq!(equity.recovery_amount, Decimal::ZERO);
}
#[test]
fn test_collateral_deficiency() {
let claims = vec![
secured_claim(
"1st Lien",
dec!(300),
ClaimPriority::SecuredFirst,
dec!(200),
),
simple_claim("Senior Notes", dec!(100), ClaimPriority::Senior),
simple_claim("Equity", dec!(50), ClaimPriority::Equity),
];
let mut input = base_input(claims);
input.enterprise_value = dec!(350);
input.cash_on_hand = dec!(0);
input.administrative_costs = dec!(0);
let result = analyze_recovery(&input).unwrap();
let out = &result.result;
let first_lien = out
.claim_recoveries
.iter()
.find(|r| r.name == "1st Lien")
.unwrap();
assert_eq!(first_lien.recovery_amount, dec!(200));
assert!(first_lien.is_impaired);
let senior = out
.claim_recoveries
.iter()
.find(|r| r.name == "Senior Notes")
.unwrap();
assert_eq!(senior.recovery_amount, dec!(75)); assert!(senior.is_impaired);
let deficiency = out
.claim_recoveries
.iter()
.find(|r| r.name == "1st Lien (deficiency)")
.unwrap();
assert_eq!(deficiency.recovery_amount, dec!(75));
let equity = out
.claim_recoveries
.iter()
.find(|r| r.name == "Equity")
.unwrap();
assert_eq!(equity.recovery_amount, Decimal::ZERO);
}
#[test]
fn test_accrued_interest() {
let claims = vec![
Claim {
name: "Senior Notes".into(),
amount: dec!(1000),
priority: ClaimPriority::Senior,
is_secured: false,
collateral_value: None,
interest_rate: Some(dec!(0.10)),
accrued_months: Some(6),
},
simple_claim("Equity", dec!(200), ClaimPriority::Equity),
];
let mut input = base_input(claims);
input.enterprise_value = dec!(1100);
input.cash_on_hand = dec!(0);
input.administrative_costs = dec!(0);
let result = analyze_recovery(&input).unwrap();
let out = &result.result;
let senior = &out.claim_recoveries[0];
assert_eq!(senior.accrued_interest, dec!(50));
assert_eq!(senior.total_claim, dec!(1050));
assert_eq!(senior.recovery_amount, dec!(1050));
assert!(!senior.is_impaired);
let equity = &out.claim_recoveries[1];
assert_eq!(equity.recovery_amount, dec!(50));
assert!(equity.is_impaired);
}
#[test]
fn test_going_concern_vs_liquidation_both() {
let claims = vec![
simple_claim("Senior", dec!(400), ClaimPriority::Senior),
simple_claim("Equity", dec!(200), ClaimPriority::Equity),
];
let mut input = base_input(claims);
input.enterprise_value = dec!(600);
input.liquidation_value = dec!(300);
input.cash_on_hand = dec!(0);
input.administrative_costs = dec!(0);
input.valuation_type = ValuationType::Both;
let result = analyze_recovery(&input).unwrap();
let out = &result.result;
assert_eq!(out.total_distributable, dec!(600));
let senior = &out.claim_recoveries[0];
assert_eq!(senior.recovery_amount, dec!(400));
let equity = &out.claim_recoveries[1];
assert_eq!(equity.recovery_amount, dec!(200));
assert!(!equity.is_impaired);
assert_eq!(out.going_concern_premium, Some(Decimal::ONE));
let liq = out.liquidation_analysis.as_ref().unwrap();
assert_eq!(liq.liquidation_distributable, dec!(300));
let liq_senior = &liq.claim_recoveries[0];
assert_eq!(liq_senior.recovery_amount, dec!(300));
assert!(liq_senior.is_impaired); let liq_equity = &liq.claim_recoveries[1];
assert_eq!(liq_equity.recovery_amount, Decimal::ZERO);
assert_eq!(liq.shortfall, dec!(300)); }
#[test]
fn test_pro_rata_within_class() {
let claims = vec![
simple_claim("Senior A", dec!(200), ClaimPriority::Senior),
simple_claim("Senior B", dec!(300), ClaimPriority::Senior),
];
let mut input = base_input(claims);
input.enterprise_value = dec!(250);
input.cash_on_hand = dec!(0);
input.administrative_costs = dec!(0);
let result = analyze_recovery(&input).unwrap();
let out = &result.result;
let a = out
.claim_recoveries
.iter()
.find(|r| r.name == "Senior A")
.unwrap();
assert_eq!(a.recovery_amount, dec!(100)); assert_eq!(a.recovery_rate, dec!(0.5));
assert_eq!(a.recovery_cents_on_dollar, dec!(50));
assert!(a.is_impaired);
let b = out
.claim_recoveries
.iter()
.find(|r| r.name == "Senior B")
.unwrap();
assert_eq!(b.recovery_amount, dec!(150)); assert_eq!(b.recovery_rate, dec!(0.5));
assert!(b.is_impaired);
}
#[test]
fn test_full_recovery_all_claims() {
let claims = vec![
simple_claim("Senior", dec!(100), ClaimPriority::Senior),
simple_claim("Sub", dec!(50), ClaimPriority::Subordinated),
simple_claim("Equity", dec!(50), ClaimPriority::Equity),
];
let mut input = base_input(claims);
input.enterprise_value = dec!(500);
input.cash_on_hand = dec!(0);
input.administrative_costs = dec!(0);
let result = analyze_recovery(&input).unwrap();
let out = &result.result;
for rec in &out.claim_recoveries {
assert_eq!(rec.recovery_rate, Decimal::ONE);
assert!(!rec.is_impaired);
}
assert!(out.fulcrum_security.is_none());
assert_eq!(out.shortfall, Decimal::ZERO);
}
#[test]
fn test_administrative_costs_deducted() {
let claims = vec![simple_claim("Senior", dec!(100), ClaimPriority::Senior)];
let mut input = base_input(claims);
input.enterprise_value = dec!(150);
input.cash_on_hand = dec!(0);
input.administrative_costs = dec!(60);
let result = analyze_recovery(&input).unwrap();
let out = &result.result;
assert_eq!(out.total_distributable, dec!(90));
assert_eq!(out.claim_recoveries[0].recovery_amount, dec!(90));
assert!(out.claim_recoveries[0].is_impaired);
}
#[test]
fn test_admin_costs_exceed_ev_warning() {
let claims = vec![simple_claim("Senior", dec!(100), ClaimPriority::Senior)];
let mut input = base_input(claims);
input.enterprise_value = dec!(100);
input.cash_on_hand = dec!(0);
input.administrative_costs = dec!(15);
let result = analyze_recovery(&input).unwrap();
assert!(result
.warnings
.iter()
.any(|w| w.contains("Administrative costs")));
}
#[test]
fn test_cash_on_hand_increases_distributable() {
let claims = vec![simple_claim("Senior", dec!(200), ClaimPriority::Senior)];
let mut input = base_input(claims);
input.enterprise_value = dec!(100);
input.cash_on_hand = dec!(80);
input.administrative_costs = dec!(0);
let result = analyze_recovery(&input).unwrap();
let out = &result.result;
assert_eq!(out.total_distributable, dec!(180));
assert_eq!(out.claim_recoveries[0].recovery_amount, dec!(180));
}
#[test]
fn test_liquidation_only_valuation_type() {
let claims = vec![
simple_claim("Senior", dec!(200), ClaimPriority::Senior),
simple_claim("Equity", dec!(100), ClaimPriority::Equity),
];
let mut input = base_input(claims);
input.enterprise_value = dec!(500);
input.liquidation_value = dec!(250);
input.cash_on_hand = dec!(0);
input.administrative_costs = dec!(0);
input.valuation_type = ValuationType::Liquidation;
let result = analyze_recovery(&input).unwrap();
let out = &result.result;
assert_eq!(out.total_distributable, dec!(250));
let senior = &out.claim_recoveries[0];
assert_eq!(senior.recovery_amount, dec!(200));
assert!(!senior.is_impaired);
let equity = &out.claim_recoveries[1];
assert_eq!(equity.recovery_amount, dec!(50));
assert!(equity.is_impaired);
}
#[test]
fn test_negative_going_concern_premium_warning() {
let claims = vec![simple_claim("Senior", dec!(100), ClaimPriority::Senior)];
let mut input = base_input(claims);
input.enterprise_value = dec!(200);
input.liquidation_value = dec!(300);
let result = analyze_recovery(&input).unwrap();
assert!(result
.warnings
.iter()
.any(|w| w.contains("going-concern premium is negative")));
}
#[test]
fn test_going_concern_premium_calculation() {
let claims = vec![simple_claim("Senior", dec!(100), ClaimPriority::Senior)];
let mut input = base_input(claims);
input.enterprise_value = dec!(400);
input.liquidation_value = dec!(200);
let result = analyze_recovery(&input).unwrap();
assert_eq!(result.result.going_concern_premium, Some(Decimal::ONE));
}
#[test]
fn test_going_concern_premium_zero_liquidation() {
let claims = vec![simple_claim("Senior", dec!(100), ClaimPriority::Senior)];
let mut input = base_input(claims);
input.enterprise_value = dec!(400);
input.liquidation_value = Decimal::ZERO;
let result = analyze_recovery(&input).unwrap();
assert_eq!(result.result.going_concern_premium, None);
}
#[test]
fn test_priority_ordering() {
let claims = vec![
simple_claim("Equity", dec!(100), ClaimPriority::Equity),
simple_claim("Senior", dec!(200), ClaimPriority::Senior),
simple_claim("Priority", dec!(50), ClaimPriority::Priority),
];
let mut input = base_input(claims);
input.enterprise_value = dec!(300);
input.cash_on_hand = dec!(0);
input.administrative_costs = dec!(0);
let result = analyze_recovery(&input).unwrap();
let out = &result.result;
let priority_claim = out
.claim_recoveries
.iter()
.find(|r| r.name == "Priority")
.unwrap();
assert_eq!(priority_claim.recovery_amount, dec!(50));
assert!(!priority_claim.is_impaired);
let senior = out
.claim_recoveries
.iter()
.find(|r| r.name == "Senior")
.unwrap();
assert_eq!(senior.recovery_amount, dec!(200));
assert!(!senior.is_impaired);
let equity = out
.claim_recoveries
.iter()
.find(|r| r.name == "Equity")
.unwrap();
assert_eq!(equity.recovery_amount, dec!(50));
assert!(equity.is_impaired);
}
#[test]
fn test_dip_with_no_remaining_for_others() {
let claims = vec![
simple_claim("Senior", dec!(200), ClaimPriority::Senior),
simple_claim("Equity", dec!(100), ClaimPriority::Equity),
];
let mut input = base_input(claims);
input.enterprise_value = dec!(40);
input.cash_on_hand = dec!(10);
input.administrative_costs = dec!(0);
input.dip_facility = Some(DipFacility {
amount: dec!(50),
priming: true,
roll_up_amount: Decimal::ZERO,
});
let result = analyze_recovery(&input).unwrap();
let out = &result.result;
let dip = out
.claim_recoveries
.iter()
.find(|r| r.name == "DIP Facility")
.unwrap();
assert_eq!(dip.recovery_amount, dec!(50));
assert!(!dip.is_impaired);
for rec in out
.claim_recoveries
.iter()
.filter(|r| r.name != "DIP Facility")
{
assert_eq!(rec.recovery_amount, Decimal::ZERO);
assert!(rec.is_impaired);
}
}
#[test]
fn test_recovery_cents_on_dollar() {
let claims = vec![simple_claim("Senior", dec!(200), ClaimPriority::Senior)];
let mut input = base_input(claims);
input.enterprise_value = dec!(100);
input.cash_on_hand = dec!(0);
input.administrative_costs = dec!(0);
let result = analyze_recovery(&input).unwrap();
let rec = &result.result.claim_recoveries[0];
assert_eq!(rec.recovery_rate, dec!(0.5));
assert_eq!(rec.recovery_cents_on_dollar, dec!(50));
}
#[test]
fn test_validation_negative_ev() {
let mut input = base_input(vec![simple_claim("S", dec!(100), ClaimPriority::Senior)]);
input.enterprise_value = dec!(-10);
let err = analyze_recovery(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "enterprise_value");
}
other => panic!("Expected InvalidInput, got: {other:?}"),
}
}
#[test]
fn test_validation_negative_liquidation() {
let mut input = base_input(vec![simple_claim("S", dec!(100), ClaimPriority::Senior)]);
input.liquidation_value = dec!(-5);
let err = analyze_recovery(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "liquidation_value");
}
other => panic!("Expected InvalidInput, got: {other:?}"),
}
}
#[test]
fn test_validation_empty_claims() {
let mut input = base_input(vec![]);
input.claims = vec![];
let err = analyze_recovery(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "claims");
}
other => panic!("Expected InvalidInput, got: {other:?}"),
}
}
#[test]
fn test_validation_negative_admin_costs() {
let mut input = base_input(vec![simple_claim("S", dec!(100), ClaimPriority::Senior)]);
input.administrative_costs = dec!(-1);
let err = analyze_recovery(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "administrative_costs");
}
other => panic!("Expected InvalidInput, got: {other:?}"),
}
}
#[test]
fn test_validation_negative_claim_amount() {
let input = base_input(vec![simple_claim("Bad", dec!(-50), ClaimPriority::Senior)]);
let err = analyze_recovery(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert!(field.contains("Bad"));
}
other => panic!("Expected InvalidInput, got: {other:?}"),
}
}
#[test]
fn test_validation_negative_cash() {
let mut input = base_input(vec![simple_claim("S", dec!(100), ClaimPriority::Senior)]);
input.cash_on_hand = dec!(-10);
let err = analyze_recovery(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "cash_on_hand");
}
other => panic!("Expected InvalidInput, got: {other:?}"),
}
}
#[test]
fn test_metadata_populated() {
let input = base_input(vec![simple_claim("S", dec!(100), ClaimPriority::Senior)]);
let result = analyze_recovery(&input).unwrap();
assert!(!result.methodology.is_empty());
assert!(result.methodology.contains("Absolute Priority Rule"));
assert_eq!(result.metadata.precision, "rust_decimal_128bit");
}
#[test]
fn test_multiple_priority_classes_sequential() {
let claims = vec![
simple_claim("Wages", dec!(50), ClaimPriority::Priority),
simple_claim("Senior", dec!(200), ClaimPriority::Senior),
simple_claim("Sub", dec!(100), ClaimPriority::Subordinated),
simple_claim("Mezz", dec!(80), ClaimPriority::Mezzanine),
simple_claim("Equity", dec!(150), ClaimPriority::Equity),
];
let mut input = base_input(claims);
input.enterprise_value = dec!(400);
input.cash_on_hand = dec!(0);
input.administrative_costs = dec!(0);
let result = analyze_recovery(&input).unwrap();
let out = &result.result;
let wages = out
.claim_recoveries
.iter()
.find(|r| r.name == "Wages")
.unwrap();
assert!(!wages.is_impaired);
let senior = out
.claim_recoveries
.iter()
.find(|r| r.name == "Senior")
.unwrap();
assert!(!senior.is_impaired);
let sub = out
.claim_recoveries
.iter()
.find(|r| r.name == "Sub")
.unwrap();
assert!(!sub.is_impaired);
let mezz = out
.claim_recoveries
.iter()
.find(|r| r.name == "Mezz")
.unwrap();
assert_eq!(mezz.recovery_amount, dec!(50));
assert!(mezz.is_impaired);
let equity = out
.claim_recoveries
.iter()
.find(|r| r.name == "Equity")
.unwrap();
assert_eq!(equity.recovery_amount, Decimal::ZERO);
assert!(equity.is_impaired);
assert_eq!(out.fulcrum_security, Some("Mezz".into()));
}
#[test]
fn test_senior_subordinated_class() {
let claims = vec![
simple_claim("Senior", dec!(100), ClaimPriority::Senior),
simple_claim("Senior Sub", dec!(100), ClaimPriority::SeniorSubordinated),
simple_claim("Equity", dec!(50), ClaimPriority::Equity),
];
let mut input = base_input(claims);
input.enterprise_value = dec!(180);
input.cash_on_hand = dec!(0);
input.administrative_costs = dec!(0);
let result = analyze_recovery(&input).unwrap();
let out = &result.result;
let senior = out
.claim_recoveries
.iter()
.find(|r| r.name == "Senior")
.unwrap();
assert_eq!(senior.recovery_amount, dec!(100));
assert!(!senior.is_impaired);
let sub = out
.claim_recoveries
.iter()
.find(|r| r.name == "Senior Sub")
.unwrap();
assert_eq!(sub.recovery_amount, dec!(80));
assert!(sub.is_impaired);
assert_eq!(out.fulcrum_security, Some("Senior Sub".into()));
}
}