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};
use crate::CorpFinanceResult;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConversionTrigger {
QualifiedFinancing,
Maturity,
ChangeOfControl,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConvertibleNoteInput {
pub principal: Decimal,
pub interest_rate: Decimal,
pub term_months: u32,
pub elapsed_months: u32,
pub discount_rate: Decimal,
pub valuation_cap: Option<Decimal>,
pub qualified_financing_amount: Decimal,
pub qualified_financing_pre_money: Decimal,
pub pre_money_shares: u64,
pub conversion_trigger: ConversionTrigger,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConvertibleNoteOutput {
pub accrued_interest: Decimal,
pub total_conversion_amount: Decimal,
pub effective_price_discount: Decimal,
pub effective_price_cap: Option<Decimal>,
pub conversion_price: Decimal,
pub shares_issued: u64,
pub effective_valuation: Decimal,
pub discount_savings: Decimal,
pub ownership_pct: Decimal,
}
pub fn convert_note(
input: &ConvertibleNoteInput,
) -> CorpFinanceResult<ComputationOutput<ConvertibleNoteOutput>> {
let start = Instant::now();
let mut warnings: Vec<String> = Vec::new();
if input.principal.is_zero() || input.principal.is_sign_negative() {
return Err(CorpFinanceError::InvalidInput {
field: "principal".into(),
reason: "Principal must be positive".into(),
});
}
if input.pre_money_shares == 0 {
return Err(CorpFinanceError::DivisionByZero {
context: "pre_money_shares cannot be zero".into(),
});
}
if input.qualified_financing_pre_money.is_zero()
|| input.qualified_financing_pre_money.is_sign_negative()
{
return Err(CorpFinanceError::InvalidInput {
field: "qualified_financing_pre_money".into(),
reason: "Pre-money valuation of qualifying round must be positive".into(),
});
}
if input.discount_rate < Decimal::ZERO || input.discount_rate >= dec!(1) {
return Err(CorpFinanceError::InvalidInput {
field: "discount_rate".into(),
reason: "Discount rate must be in [0, 1)".into(),
});
}
if input.elapsed_months > input.term_months {
warnings.push(format!(
"elapsed_months ({}) exceeds term_months ({}); note may be past maturity",
input.elapsed_months, input.term_months
));
}
let shares_dec = Decimal::from(input.pre_money_shares);
let accrued_interest =
input.principal * input.interest_rate * Decimal::from(input.elapsed_months) / dec!(12);
let total_conversion_amount = input.principal + accrued_interest;
let price_at_round = input.qualified_financing_pre_money / shares_dec;
let effective_price_discount = price_at_round * (dec!(1) - input.discount_rate);
let effective_price_cap = input.valuation_cap.map(|cap| cap / shares_dec);
let conversion_price = match effective_price_cap {
Some(cap_price) => {
if cap_price < effective_price_discount {
cap_price
} else {
effective_price_discount
}
}
None => {
if input.discount_rate > Decimal::ZERO {
effective_price_discount
} else {
warnings
.push("No valuation cap and zero discount; converting at round price".into());
price_at_round
}
}
};
if conversion_price.is_zero() || conversion_price.is_sign_negative() {
return Err(CorpFinanceError::DivisionByZero {
context: "conversion_price must be positive".into(),
});
}
let shares_issued_dec = (total_conversion_amount / conversion_price).floor();
let shares_issued = decimal_to_u64(shares_issued_dec)?;
let effective_valuation = conversion_price * shares_dec;
let shares_at_round_price = if price_at_round.is_zero() {
Decimal::ZERO
} else {
(total_conversion_amount / price_at_round).floor()
};
let discount_savings = (shares_issued_dec - shares_at_round_price) * price_at_round;
let new_round_shares = if price_at_round.is_zero() {
Decimal::ZERO
} else {
(input.qualified_financing_amount / price_at_round).floor()
};
let total_post_shares = shares_dec + new_round_shares + shares_issued_dec;
let ownership_pct = if total_post_shares.is_zero() {
Decimal::ZERO
} else {
shares_issued_dec / total_post_shares
};
let output = ConvertibleNoteOutput {
accrued_interest,
total_conversion_amount,
effective_price_discount,
effective_price_cap,
conversion_price,
shares_issued,
effective_valuation,
discount_savings,
ownership_pct,
};
let elapsed = start.elapsed().as_micros() as u64;
Ok(with_metadata(
"Convertible Note Conversion",
&serde_json::json!({
"principal": input.principal.to_string(),
"interest_rate": input.interest_rate.to_string(),
"discount_rate": input.discount_rate.to_string(),
"valuation_cap": input.valuation_cap.map(|c| c.to_string()),
"conversion_trigger": format!("{:?}", input.conversion_trigger),
}),
warnings,
elapsed,
output,
))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SafeType {
PreMoney,
PostMoney,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SafeInput {
pub investment_amount: Decimal,
pub valuation_cap: Option<Decimal>,
pub discount_rate: Option<Decimal>,
pub safe_type: SafeType,
pub qualified_financing_pre_money: Decimal,
pub qualified_financing_amount: Decimal,
pub pre_money_shares: u64,
pub mfn: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SafeOutput {
pub conversion_price: Decimal,
pub shares_issued: u64,
pub effective_valuation: Decimal,
pub ownership_pct: Decimal,
pub price_via_cap: Option<Decimal>,
pub price_via_discount: Option<Decimal>,
pub method_used: String,
}
pub fn convert_safe(input: &SafeInput) -> CorpFinanceResult<ComputationOutput<SafeOutput>> {
let start = Instant::now();
let mut warnings: Vec<String> = Vec::new();
if input.investment_amount.is_zero() || input.investment_amount.is_sign_negative() {
return Err(CorpFinanceError::InvalidInput {
field: "investment_amount".into(),
reason: "Investment amount must be positive".into(),
});
}
if input.pre_money_shares == 0 {
return Err(CorpFinanceError::DivisionByZero {
context: "pre_money_shares cannot be zero".into(),
});
}
if input.qualified_financing_pre_money.is_zero()
|| input.qualified_financing_pre_money.is_sign_negative()
{
return Err(CorpFinanceError::InvalidInput {
field: "qualified_financing_pre_money".into(),
reason: "Pre-money valuation of qualifying round must be positive".into(),
});
}
if let Some(dr) = input.discount_rate {
if dr < Decimal::ZERO || dr >= dec!(1) {
return Err(CorpFinanceError::InvalidInput {
field: "discount_rate".into(),
reason: "Discount rate must be in [0, 1)".into(),
});
}
}
let shares_dec = Decimal::from(input.pre_money_shares);
match input.safe_type {
SafeType::PreMoney => convert_safe_pre_money(input, shares_dec, &mut warnings, start),
SafeType::PostMoney => convert_safe_post_money(input, shares_dec, &mut warnings, start),
}
}
fn convert_safe_pre_money(
input: &SafeInput,
shares_dec: Decimal,
warnings: &mut Vec<String>,
start: Instant,
) -> CorpFinanceResult<ComputationOutput<SafeOutput>> {
let price_at_round = input.qualified_financing_pre_money / shares_dec;
let price_via_cap = input.valuation_cap.map(|cap| cap / shares_dec);
let price_via_discount = input
.discount_rate
.map(|dr| price_at_round * (dec!(1) - dr));
let (conversion_price, method_used) = match (price_via_cap, price_via_discount) {
(Some(cap_p), Some(disc_p)) => {
if cap_p <= disc_p {
(cap_p, "cap")
} else {
(disc_p, "discount")
}
}
(Some(cap_p), None) => (cap_p, "cap"),
(None, Some(disc_p)) => (disc_p, "discount"),
(None, None) => {
warnings.push("No valuation cap or discount; converting at round price".into());
(price_at_round, "round_price")
}
};
if conversion_price.is_zero() || conversion_price.is_sign_negative() {
return Err(CorpFinanceError::DivisionByZero {
context: "conversion_price must be positive".into(),
});
}
let shares_issued_dec = (input.investment_amount / conversion_price).floor();
let shares_issued = decimal_to_u64(shares_issued_dec)?;
let effective_valuation = conversion_price * shares_dec;
let new_round_shares = if price_at_round.is_zero() {
Decimal::ZERO
} else {
(input.qualified_financing_amount / price_at_round).floor()
};
let total_post = shares_dec + new_round_shares + shares_issued_dec;
let ownership_pct = if total_post.is_zero() {
Decimal::ZERO
} else {
shares_issued_dec / total_post
};
if input.mfn {
warnings
.push("MFN provision active: holder may elect better terms from later SAFEs".into());
}
let output = SafeOutput {
conversion_price,
shares_issued,
effective_valuation,
ownership_pct,
price_via_cap,
price_via_discount,
method_used: method_used.to_string(),
};
let elapsed = start.elapsed().as_micros() as u64;
Ok(with_metadata(
"SAFE Conversion (Pre-Money)",
&serde_json::json!({
"investment_amount": input.investment_amount.to_string(),
"valuation_cap": input.valuation_cap.map(|c| c.to_string()),
"discount_rate": input.discount_rate.map(|d| d.to_string()),
"mfn": input.mfn,
}),
warnings.clone(),
elapsed,
output,
))
}
fn convert_safe_post_money(
input: &SafeInput,
shares_dec: Decimal,
warnings: &mut Vec<String>,
start: Instant,
) -> CorpFinanceResult<ComputationOutput<SafeOutput>> {
let price_at_round = input.qualified_financing_pre_money / shares_dec;
let (conversion_price, method_used, price_via_cap, price_via_discount) = if let Some(cap) =
input.valuation_cap
{
if cap.is_zero() || cap.is_sign_negative() {
return Err(CorpFinanceError::InvalidInput {
field: "valuation_cap".into(),
reason: "Post-money cap must be positive".into(),
});
}
let implied_pre_money = cap - input.investment_amount;
let pvc = if shares_dec.is_zero() {
Decimal::ZERO
} else {
implied_pre_money / shares_dec
};
let pvd = input
.discount_rate
.map(|dr| price_at_round * (dec!(1) - dr));
let (cp, method) = match pvd {
Some(disc_p) if disc_p < pvc => (disc_p, "discount"),
_ => (pvc, "cap"),
};
(cp, method, Some(pvc), pvd)
} else if let Some(dr) = input.discount_rate {
let disc_p = price_at_round * (dec!(1) - dr);
(disc_p, "discount", None, Some(disc_p))
} else {
warnings.push("Post-money SAFE without cap or discount; converting at round price".into());
(price_at_round, "round_price", None, None)
};
if conversion_price.is_zero() || conversion_price.is_sign_negative() {
return Err(CorpFinanceError::DivisionByZero {
context: "conversion_price must be positive".into(),
});
}
let (shares_issued, ownership_pct) =
if let (Some(cap), true) = (input.valuation_cap, method_used == "cap") {
let ownership = input.investment_amount / cap;
let denom = dec!(1) - ownership;
if denom.is_zero() || denom.is_sign_negative() {
return Err(CorpFinanceError::FinancialImpossibility(
"Investment equals or exceeds post-money cap".into(),
));
}
let shares_dec_issued = (shares_dec * ownership / denom).floor();
let si = decimal_to_u64(shares_dec_issued)?;
let new_round_shares = if price_at_round.is_zero() {
Decimal::ZERO
} else {
(input.qualified_financing_amount / price_at_round).floor()
};
let total_post = shares_dec + new_round_shares + shares_dec_issued;
let own = if total_post.is_zero() {
Decimal::ZERO
} else {
shares_dec_issued / total_post
};
(si, own)
} else {
let shares_issued_dec = (input.investment_amount / conversion_price).floor();
let si = decimal_to_u64(shares_issued_dec)?;
let new_round_shares = if price_at_round.is_zero() {
Decimal::ZERO
} else {
(input.qualified_financing_amount / price_at_round).floor()
};
let total_post = shares_dec + new_round_shares + shares_issued_dec;
let own = if total_post.is_zero() {
Decimal::ZERO
} else {
shares_issued_dec / total_post
};
(si, own)
};
let effective_valuation = conversion_price * shares_dec;
if input.mfn {
warnings
.push("MFN provision active: holder may elect better terms from later SAFEs".into());
}
let output = SafeOutput {
conversion_price,
shares_issued,
effective_valuation,
ownership_pct,
price_via_cap,
price_via_discount,
method_used: method_used.to_string(),
};
let elapsed = start.elapsed().as_micros() as u64;
Ok(with_metadata(
"SAFE Conversion (Post-Money / YC Standard)",
&serde_json::json!({
"investment_amount": input.investment_amount.to_string(),
"valuation_cap": input.valuation_cap.map(|c| c.to_string()),
"discount_rate": input.discount_rate.map(|d| d.to_string()),
"safe_type": "PostMoney",
"mfn": input.mfn,
}),
warnings.clone(),
elapsed,
output,
))
}
fn decimal_to_u64(val: Decimal) -> CorpFinanceResult<u64> {
if val.is_sign_negative() {
return Err(CorpFinanceError::FinancialImpossibility(
"Cannot convert negative value to share count".into(),
));
}
let truncated = val.floor();
truncated
.to_string()
.parse::<u64>()
.map_err(|e| CorpFinanceError::FinancialImpossibility(format!("Share count overflow: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn base_note_input() -> ConvertibleNoteInput {
ConvertibleNoteInput {
principal: dec!(100_000),
interest_rate: dec!(0.05),
term_months: 24,
elapsed_months: 12,
discount_rate: dec!(0.20),
valuation_cap: Some(dec!(5_000_000)),
qualified_financing_amount: dec!(2_000_000),
qualified_financing_pre_money: dec!(8_000_000),
pre_money_shares: 10_000_000,
conversion_trigger: ConversionTrigger::QualifiedFinancing,
}
}
fn base_safe_pre_money() -> SafeInput {
SafeInput {
investment_amount: dec!(500_000),
valuation_cap: Some(dec!(5_000_000)),
discount_rate: Some(dec!(0.20)),
safe_type: SafeType::PreMoney,
qualified_financing_pre_money: dec!(10_000_000),
qualified_financing_amount: dec!(3_000_000),
pre_money_shares: 10_000_000,
mfn: false,
}
}
fn base_safe_post_money() -> SafeInput {
SafeInput {
investment_amount: dec!(500_000),
valuation_cap: Some(dec!(10_000_000)),
discount_rate: None,
safe_type: SafeType::PostMoney,
qualified_financing_pre_money: dec!(15_000_000),
qualified_financing_amount: dec!(5_000_000),
pre_money_shares: 10_000_000,
mfn: false,
}
}
#[test]
fn test_note_discount_only() {
let mut input = base_note_input();
input.valuation_cap = None; input.discount_rate = dec!(0.20);
input.elapsed_months = 0;
let result = convert_note(&input).unwrap();
let out = &result.result;
assert_eq!(out.conversion_price, dec!(0.64));
assert_eq!(out.effective_price_cap, None);
assert_eq!(out.accrued_interest, Decimal::ZERO);
assert_eq!(out.shares_issued, 156_250);
}
#[test]
fn test_note_cap_only() {
let mut input = base_note_input();
input.discount_rate = dec!(0.0); input.valuation_cap = Some(dec!(4_000_000));
input.elapsed_months = 0;
let result = convert_note(&input).unwrap();
let out = &result.result;
assert_eq!(out.conversion_price, dec!(0.40));
assert_eq!(out.effective_price_cap, Some(dec!(0.40)));
assert_eq!(out.shares_issued, 250_000);
}
#[test]
fn test_note_cap_and_discount_cap_wins() {
let mut input = base_note_input();
input.discount_rate = dec!(0.20);
input.valuation_cap = Some(dec!(4_000_000));
input.elapsed_months = 0;
let result = convert_note(&input).unwrap();
let out = &result.result;
assert_eq!(out.conversion_price, dec!(0.40));
}
#[test]
fn test_note_cap_and_discount_discount_wins() {
let mut input = base_note_input();
input.discount_rate = dec!(0.20);
input.valuation_cap = Some(dec!(7_000_000));
input.elapsed_months = 0;
let result = convert_note(&input).unwrap();
let out = &result.result;
assert_eq!(out.conversion_price, dec!(0.64));
}
#[test]
fn test_note_accrued_interest() {
let mut input = base_note_input();
input.elapsed_months = 18;
let result = convert_note(&input).unwrap();
let out = &result.result;
assert_eq!(out.accrued_interest, dec!(7500));
assert_eq!(out.total_conversion_amount, dec!(107500));
}
#[test]
fn test_note_maturity_conversion() {
let mut input = base_note_input();
input.conversion_trigger = ConversionTrigger::Maturity;
input.elapsed_months = 24;
let result = convert_note(&input).unwrap();
let out = &result.result;
assert_eq!(out.accrued_interest, dec!(10000));
assert_eq!(out.total_conversion_amount, dec!(110000));
}
#[test]
fn test_note_change_of_control() {
let mut input = base_note_input();
input.conversion_trigger = ConversionTrigger::ChangeOfControl;
let result = convert_note(&input).unwrap();
assert!(result.result.shares_issued > 0);
}
#[test]
fn test_note_zero_principal_error() {
let mut input = base_note_input();
input.principal = Decimal::ZERO;
assert!(convert_note(&input).is_err());
}
#[test]
fn test_note_zero_shares_error() {
let mut input = base_note_input();
input.pre_money_shares = 0;
assert!(convert_note(&input).is_err());
}
#[test]
fn test_note_discount_savings() {
let mut input = base_note_input();
input.valuation_cap = None;
input.discount_rate = dec!(0.20);
input.elapsed_months = 0;
let result = convert_note(&input).unwrap();
let out = &result.result;
assert_eq!(out.discount_savings, dec!(25000.00));
}
#[test]
fn test_safe_pre_money_cap_only() {
let mut input = base_safe_pre_money();
input.discount_rate = None;
let result = convert_safe(&input).unwrap();
let out = &result.result;
assert_eq!(out.price_via_cap, Some(dec!(0.50)));
assert_eq!(out.price_via_discount, None);
assert_eq!(out.conversion_price, dec!(0.50));
assert_eq!(out.method_used, "cap");
assert_eq!(out.shares_issued, 1_000_000);
}
#[test]
fn test_safe_pre_money_discount_only() {
let mut input = base_safe_pre_money();
input.valuation_cap = None;
let result = convert_safe(&input).unwrap();
let out = &result.result;
assert_eq!(out.price_via_discount, Some(dec!(0.80)));
assert_eq!(out.price_via_cap, None);
assert_eq!(out.conversion_price, dec!(0.80));
assert_eq!(out.method_used, "discount");
assert_eq!(out.shares_issued, 625_000);
}
#[test]
fn test_safe_pre_money_cap_and_discount() {
let input = base_safe_pre_money();
let result = convert_safe(&input).unwrap();
let out = &result.result;
assert_eq!(out.conversion_price, dec!(0.50));
assert_eq!(out.method_used, "cap");
}
#[test]
fn test_safe_pre_money_discount_wins_over_cap() {
let mut input = base_safe_pre_money();
input.valuation_cap = Some(dec!(9_000_000)); input.discount_rate = Some(dec!(0.20));
let result = convert_safe(&input).unwrap();
let out = &result.result;
assert_eq!(out.conversion_price, dec!(0.80));
assert_eq!(out.method_used, "discount");
}
#[test]
fn test_safe_post_money_standard_yc() {
let input = base_safe_post_money();
let result = convert_safe(&input).unwrap();
let out = &result.result;
assert_eq!(out.method_used, "cap");
assert_eq!(out.shares_issued, 526_315);
}
#[test]
fn test_safe_post_money_ownership_equals_investment_over_cap() {
let mut input = base_safe_post_money();
input.investment_amount = dec!(1_000_000);
input.valuation_cap = Some(dec!(10_000_000));
let result = convert_safe(&input).unwrap();
let out = &result.result;
assert_eq!(out.shares_issued, 1_111_111);
}
#[test]
fn test_safe_post_money_small_investment() {
let mut input = base_safe_post_money();
input.investment_amount = dec!(100_000);
input.valuation_cap = Some(dec!(10_000_000));
let result = convert_safe(&input).unwrap();
let out = &result.result;
assert_eq!(out.shares_issued, 101_010);
}
#[test]
fn test_cap_exactly_equals_round_valuation() {
let mut input = base_note_input();
input.valuation_cap = Some(dec!(8_000_000)); input.discount_rate = dec!(0.20);
input.elapsed_months = 0;
let result = convert_note(&input).unwrap();
let out = &result.result;
assert_eq!(out.conversion_price, dec!(0.64));
}
#[test]
fn test_no_cap_no_discount_converts_at_round_price() {
let mut input = base_safe_pre_money();
input.valuation_cap = None;
input.discount_rate = None;
let result = convert_safe(&input).unwrap();
let out = &result.result;
assert_eq!(out.conversion_price, dec!(1.00));
assert_eq!(out.method_used, "round_price");
assert_eq!(out.shares_issued, 500_000);
}
#[test]
fn test_note_no_cap_no_discount_at_round_price() {
let mut input = base_note_input();
input.valuation_cap = None;
input.discount_rate = dec!(0.0);
input.elapsed_months = 0;
let result = convert_note(&input).unwrap();
let out = &result.result;
assert_eq!(out.conversion_price, dec!(0.80));
assert_eq!(out.shares_issued, 125_000);
}
#[test]
fn test_ownership_percentage_accuracy() {
let mut input = base_note_input();
input.valuation_cap = None;
input.discount_rate = dec!(0.20);
input.elapsed_months = 0;
let result = convert_note(&input).unwrap();
let out = &result.result;
let expected_ownership = dec!(156250) / dec!(12656250);
let diff = (out.ownership_pct - expected_ownership).abs();
assert!(diff < dec!(0.000001), "ownership diff: {diff}");
}
#[test]
fn test_multiple_safes_independent() {
let input = base_safe_pre_money();
let result1 = convert_safe(&input).unwrap();
let result2 = convert_safe(&input).unwrap();
assert_eq!(result1.result.shares_issued, result2.result.shares_issued);
assert_eq!(
result1.result.conversion_price,
result2.result.conversion_price
);
assert_eq!(result1.result.ownership_pct, result2.result.ownership_pct);
}
#[test]
fn test_safe_mfn_flag_warning() {
let mut input = base_safe_pre_money();
input.mfn = true;
let result = convert_safe(&input).unwrap();
assert!(result.warnings.iter().any(|w| w.contains("MFN provision")));
}
#[test]
fn test_note_elapsed_exceeds_term_warning() {
let mut input = base_note_input();
input.elapsed_months = 30;
input.term_months = 24;
let result = convert_note(&input).unwrap();
assert!(result.warnings.iter().any(|w| w.contains("past maturity")));
}
#[test]
fn test_safe_invalid_investment_amount() {
let mut input = base_safe_pre_money();
input.investment_amount = dec!(-100);
assert!(convert_safe(&input).is_err());
}
#[test]
fn test_note_invalid_discount_rate() {
let mut input = base_note_input();
input.discount_rate = dec!(1.5);
assert!(convert_note(&input).is_err());
}
#[test]
fn test_safe_post_money_effective_valuation() {
let input = base_safe_post_money();
let result = convert_safe(&input).unwrap();
let out = &result.result;
assert_eq!(out.effective_valuation, dec!(9_500_000));
assert_eq!(out.price_via_cap, Some(dec!(0.95)));
}
#[test]
fn test_note_large_accrued_interest() {
let mut input = base_note_input();
input.interest_rate = dec!(0.12);
input.elapsed_months = 24;
let result = convert_note(&input).unwrap();
let out = &result.result;
assert_eq!(out.accrued_interest, dec!(24000));
assert_eq!(out.total_conversion_amount, dec!(124000));
}
}