use chrono::{Datelike, NaiveDate};
use datasynth_core::models::journal_entry::{JournalEntry, JournalEntryLine, TransactionSource};
use datasynth_core::models::stock_compensation::{
InstrumentType, StockCompExpense, StockGrant, VestingEntry, VestingSchedule, VestingType,
};
use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
use rand::prelude::*;
use rand_chacha::ChaCha8Rng;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use tracing::debug;
const COMP_EXPENSE: &str = "7200";
const APIC_STOCK_COMP: &str = "3150";
#[derive(Debug, Clone)]
pub struct StockCompConfig {
pub share_price: Decimal,
pub min_grant_quantity: u32,
pub max_grant_quantity: u32,
pub vesting_years: u32,
pub forfeiture_min: Decimal,
pub forfeiture_max: Decimal,
}
impl Default for StockCompConfig {
fn default() -> Self {
Self {
share_price: dec!(50.00),
min_grant_quantity: 500,
max_grant_quantity: 5000,
vesting_years: 4,
forfeiture_min: dec!(0.05),
forfeiture_max: dec!(0.15),
}
}
}
#[derive(Debug, Default)]
pub struct StockCompSnapshot {
pub grants: Vec<StockGrant>,
pub expenses: Vec<StockCompExpense>,
pub journal_entries: Vec<JournalEntry>,
}
pub struct StockCompGenerator {
#[allow(dead_code)]
uuid_factory: DeterministicUuidFactory,
rng: ChaCha8Rng,
config: StockCompConfig,
}
impl StockCompGenerator {
pub fn new(seed: u64) -> Self {
Self {
uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::StockCompensation),
rng: ChaCha8Rng::seed_from_u64(seed ^ 0x7182_0018_u64),
config: StockCompConfig::default(),
}
}
pub fn with_config(mut self, config: StockCompConfig) -> Self {
self.config = config;
self
}
pub fn generate(
&mut self,
entity_code: &str,
employee_ids: &[String],
grant_date: NaiveDate,
period_label: &str,
reporting_date: NaiveDate,
currency: &str,
) -> StockCompSnapshot {
let mut snapshot = StockCompSnapshot::default();
if employee_ids.is_empty() {
return snapshot;
}
let exec_count = ((employee_ids.len() as f64 * 0.10).ceil() as usize).clamp(1, 50);
let grantees = &employee_ids[..exec_count];
debug!(
"StockComp: entity={entity_code}, employees={}, grantees={exec_count}",
employee_ids.len()
);
let rsu_count = ((exec_count as f64 * 0.50).round() as usize).min(exec_count);
let opt_count = ((exec_count as f64 * 0.30).round() as usize).min(exec_count - rsu_count);
let _psu_count = exec_count - rsu_count - opt_count;
for (idx, employee_id) in grantees.iter().enumerate() {
let instrument_type = if idx < rsu_count {
InstrumentType::RSUs
} else if idx < rsu_count + opt_count {
InstrumentType::Options
} else {
InstrumentType::PSUs
};
let grant = self.build_grant(
entity_code,
employee_id,
grant_date,
instrument_type,
currency,
);
let expenses = self.build_expenses(&grant, period_label, reporting_date);
let period_expense: Decimal = expenses.iter().map(|e| e.expense_amount).sum();
if !period_expense.is_zero() {
let je = self.build_je(
entity_code,
reporting_date,
&grant.id,
period_label,
period_expense,
);
snapshot.journal_entries.push(je);
}
snapshot.expenses.extend(expenses);
snapshot.grants.push(grant);
}
debug!(
"StockComp generated: entity={entity_code}, grants={}, expenses={}, jes={}",
snapshot.grants.len(),
snapshot.expenses.len(),
snapshot.journal_entries.len()
);
snapshot
}
fn build_grant(
&mut self,
entity_code: &str,
employee_id: &str,
grant_date: NaiveDate,
instrument_type: InstrumentType,
currency: &str,
) -> StockGrant {
let grant_id = format!(
"GRANT-{}-{}-{}",
entity_code,
employee_id,
grant_date.year()
);
let qty_range = (self.config.max_grant_quantity - self.config.min_grant_quantity) as f64;
let quantity =
self.config.min_grant_quantity + (self.rng.random::<f64>() * qty_range) as u32;
let (fair_value_at_grant, exercise_price) = match instrument_type {
InstrumentType::RSUs => (self.config.share_price, None),
InstrumentType::Options => {
let factor = self.rand_rate(dec!(0.30), dec!(0.50));
let fv = (self.config.share_price * factor).round_dp(2);
(fv, Some(self.config.share_price))
}
InstrumentType::PSUs => {
let factor = self.rand_rate(dec!(0.80), dec!(1.20));
((self.config.share_price * factor).round_dp(2), None)
}
};
let total_grant_value = (fair_value_at_grant * Decimal::from(quantity)).round_dp(2);
let forfeiture_rate =
self.rand_rate(self.config.forfeiture_min, self.config.forfeiture_max);
let vesting_schedule = self.build_vesting_schedule(grant_date, self.config.vesting_years);
let expiration_date = if instrument_type == InstrumentType::Options {
grant_date.checked_add_signed(chrono::Duration::days(365 * 10))
} else {
None
};
StockGrant {
id: grant_id,
entity_code: entity_code.to_string(),
employee_id: employee_id.to_string(),
grant_date,
instrument_type,
quantity,
exercise_price,
fair_value_at_grant,
total_grant_value,
vesting_schedule,
expiration_date,
forfeiture_rate,
currency: currency.to_string(),
}
}
fn build_vesting_schedule(&self, grant_date: NaiveDate, years: u32) -> VestingSchedule {
let pct_per_period = (Decimal::ONE / Decimal::from(years)).round_dp(4);
let mut cumulative = Decimal::ZERO;
let mut entries = Vec::with_capacity(years as usize);
for period in 1..=years {
let pct = if period == years {
(Decimal::ONE - cumulative).round_dp(4)
} else {
pct_per_period
};
cumulative = (cumulative + pct).round_dp(4);
let vesting_date = add_years(grant_date, period);
entries.push(VestingEntry {
period,
vesting_date,
percentage: pct,
cumulative_percentage: cumulative,
});
}
VestingSchedule {
vesting_type: VestingType::Graded,
total_periods: years,
cliff_periods: None,
vesting_entries: entries,
}
}
fn build_expenses(
&self,
grant: &StockGrant,
period_label: &str,
reporting_date: NaiveDate,
) -> Vec<StockCompExpense> {
if reporting_date < grant.grant_date {
return vec![];
}
let total_expense =
(grant.total_grant_value * (Decimal::ONE - grant.forfeiture_rate)).round_dp(2);
let n = grant.vesting_schedule.total_periods;
if n == 0 || total_expense.is_zero() {
return vec![];
}
let per_period_base = (total_expense / Decimal::from(n)).round_dp(2);
let mut cumulative = Decimal::ZERO;
for (tranche_idx, entry) in grant.vesting_schedule.vesting_entries.iter().enumerate() {
let service_start = if tranche_idx == 0 {
grant.grant_date
} else {
grant
.vesting_schedule
.vesting_entries
.get(tranche_idx - 1)
.map(|prev| prev.vesting_date)
.unwrap_or(grant.grant_date)
};
let service_end = entry.vesting_date;
if service_start > reporting_date {
break;
}
let expense_amount = if service_end <= reporting_date {
if tranche_idx + 1 == n as usize {
(total_expense - cumulative).max(Decimal::ZERO)
} else {
per_period_base
}
} else {
let total_days = (service_end - service_start).num_days().max(1) as f64;
let elapsed_days = (reporting_date - service_start).num_days().max(0) as f64;
let tranche_max = if tranche_idx + 1 == n as usize {
(total_expense - cumulative).max(Decimal::ZERO)
} else {
per_period_base
};
let fraction = elapsed_days / total_days;
let frac_dec = Decimal::try_from(fraction).unwrap_or(Decimal::ZERO);
(tranche_max * frac_dec).round_dp(2)
};
cumulative = (cumulative + expense_amount).round_dp(2);
}
if cumulative.is_zero() {
return vec![];
}
let remaining = (total_expense - cumulative).max(Decimal::ZERO);
vec![StockCompExpense {
grant_id: grant.id.clone(),
entity_code: grant.entity_code.clone(),
period: period_label.to_string(),
expense_amount: cumulative,
cumulative_recognized: cumulative,
remaining_unrecognized: remaining,
forfeiture_rate: grant.forfeiture_rate,
}]
}
fn build_je(
&mut self,
entity_code: &str,
posting_date: NaiveDate,
grant_id: &str,
period: &str,
amount: Decimal,
) -> JournalEntry {
let doc_id = format!("JE-STOCKCOMP-{}-{}", entity_code, period.replace('-', ""));
let mut je = JournalEntry::new_simple(
doc_id,
entity_code.to_string(),
posting_date,
format!("Stock-based compensation expense — {period}"),
);
je.header.source = TransactionSource::Adjustment;
je.add_line(JournalEntryLine {
line_number: 1,
gl_account: COMP_EXPENSE.to_string(),
debit_amount: amount,
reference: Some(grant_id.to_string()),
text: Some(format!("SBC expense {period}")),
..Default::default()
});
je.add_line(JournalEntryLine {
line_number: 2,
gl_account: APIC_STOCK_COMP.to_string(),
credit_amount: amount,
reference: Some(grant_id.to_string()),
text: Some(format!("APIC stock comp {period}")),
..Default::default()
});
je
}
fn rand_rate(&mut self, lo: Decimal, hi: Decimal) -> Decimal {
let range_f = (hi - lo).to_string().parse::<f64>().unwrap_or(0.0);
let sample: f64 = self.rng.random::<f64>() * range_f;
let sample_d = Decimal::try_from(sample).unwrap_or(Decimal::ZERO);
(lo + sample_d).round_dp(4)
}
}
fn add_years(date: NaiveDate, years: u32) -> NaiveDate {
let target_year = date.year() + years as i32;
let day = date.day();
NaiveDate::from_ymd_opt(target_year, date.month(), day)
.or_else(|| NaiveDate::from_ymd_opt(target_year, date.month(), 28))
.unwrap_or(date)
}