use chrono::NaiveDate;
use datasynth_core::models::journal_entry::{JournalEntry, JournalEntryLine, TransactionSource};
use datasynth_core::models::pension::{
ActuarialAssumptions, DefinedBenefitPlan, PensionDisclosure, PensionObligation,
PensionPlanType, PlanAssets,
};
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 PENSION_EXPENSE: &str = "6205";
const NET_PENSION_LIABILITY: &str = "2800";
const OCI_REMEASUREMENTS: &str = "3800";
#[derive(Debug, Default)]
pub struct PensionSnapshot {
pub plans: Vec<DefinedBenefitPlan>,
pub obligations: Vec<PensionObligation>,
pub plan_assets: Vec<PlanAssets>,
pub disclosures: Vec<PensionDisclosure>,
pub journal_entries: Vec<JournalEntry>,
}
pub struct PensionGenerator {
#[allow(dead_code)]
uuid_factory: DeterministicUuidFactory,
rng: ChaCha8Rng,
}
impl PensionGenerator {
pub fn new(seed: u64) -> Self {
Self {
uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::Pension),
rng: ChaCha8Rng::seed_from_u64(seed ^ 0x19_4500_7150_u64),
}
}
pub fn generate(
&mut self,
entity_code: &str,
entity_name: &str,
period_label: &str,
reporting_date: NaiveDate,
employee_count: usize,
currency: &str,
avg_salary: Option<Decimal>,
period_months: u32,
) -> PensionSnapshot {
let plan_id = format!("PLAN-{entity_code}-DB");
let participant_count = (employee_count.clamp(1, 500)) as u32;
let discount_rate = self.rand_rate(dec!(0.03), dec!(0.05));
let salary_growth_rate = self.rand_rate(dec!(0.02), dec!(0.04));
let pension_increase_rate = self.rand_rate(dec!(0.01), dec!(0.03));
let expected_return_on_plan_assets = self.rand_rate(dec!(0.05), dec!(0.07));
let assumptions = ActuarialAssumptions {
discount_rate,
salary_growth_rate,
pension_increase_rate,
expected_return_on_plan_assets,
};
let plan = DefinedBenefitPlan {
id: plan_id.clone(),
entity_code: entity_code.to_string(),
plan_name: format!("{entity_name} Retirement Plan"),
plan_type: PensionPlanType::DefinedBenefit,
participant_count,
assumptions: assumptions.clone(),
currency: currency.to_string(),
};
let avg_annual_salary = avg_salary.unwrap_or(dec!(50000));
let avg_service_years = dec!(15);
let dbo_opening = Decimal::from(participant_count) * avg_annual_salary * avg_service_years;
let accrual_rate = self.rand_rate(dec!(0.01), dec!(0.015));
let service_cost =
(Decimal::from(participant_count) * avg_annual_salary * accrual_rate).round_dp(2);
let interest_cost = (dbo_opening * discount_rate).round_dp(2);
let actuarial_gl_rate = self.rand_rate(dec!(-0.02), dec!(0.02));
let actuarial_gains_losses = (dbo_opening * actuarial_gl_rate).round_dp(2);
let benefits_pct = self.rand_rate(dec!(0.04), dec!(0.06));
let benefits_paid = (dbo_opening * benefits_pct).round_dp(2);
let dbo_closing = (dbo_opening + service_cost + interest_cost + actuarial_gains_losses
- benefits_paid)
.round_dp(2);
let obligation = PensionObligation {
plan_id: plan_id.clone(),
period: period_label.to_string(),
dbo_opening,
service_cost,
interest_cost,
actuarial_gains_losses,
benefits_paid,
dbo_closing,
};
let initial_funding_ratio = self.rand_rate(dec!(0.75), dec!(1.10));
let fair_value_opening = (dbo_opening * initial_funding_ratio).round_dp(2);
let expected_return = (fair_value_opening * expected_return_on_plan_assets).round_dp(2);
let asset_al_rate = self.rand_rate(dec!(-0.015), dec!(0.015));
let actuarial_gain_loss_assets = (fair_value_opening * asset_al_rate).round_dp(2);
let employer_contributions = (service_cost * dec!(0.80)).round_dp(2);
let fair_value_closing = (fair_value_opening
+ expected_return
+ actuarial_gain_loss_assets
+ employer_contributions
- benefits_paid)
.round_dp(2);
let plan_assets_rec = PlanAssets {
plan_id: plan_id.clone(),
period: period_label.to_string(),
fair_value_opening,
expected_return,
actuarial_gain_loss: actuarial_gain_loss_assets,
employer_contributions,
benefits_paid,
fair_value_closing,
};
let net_pension_liability = (dbo_closing - fair_value_closing).round_dp(2);
let effective_months = if period_months == 0 {
12
} else {
period_months.min(12)
};
let annual_pension_expense = (service_cost + interest_cost - expected_return).round_dp(2);
let pension_expense = if effective_months < 12 {
(annual_pension_expense * Decimal::from(effective_months) / Decimal::from(12u32))
.round_dp(2)
} else {
annual_pension_expense
};
let oci_remeasurements = (actuarial_gains_losses - actuarial_gain_loss_assets).round_dp(2);
let funding_ratio = if dbo_closing.is_zero() {
Decimal::ZERO
} else {
(fair_value_closing / dbo_closing).round_dp(4)
};
let disclosure = PensionDisclosure {
plan_id: plan_id.clone(),
period: period_label.to_string(),
net_pension_liability,
pension_expense,
oci_remeasurements,
funding_ratio,
};
let mut journal_entries = Vec::new();
if !pension_expense.is_zero() {
journal_entries.push(self.pension_expense_je(
entity_code,
reporting_date,
&plan_id,
period_label,
pension_expense,
));
}
if !oci_remeasurements.is_zero() {
journal_entries.push(self.oci_remeasurement_je(
entity_code,
reporting_date,
&plan_id,
period_label,
oci_remeasurements,
));
}
debug!(
"Pension generated: entity={entity_code}, participants={participant_count}, \
avg_salary={avg_annual_salary}, period_months={effective_months}/12, \
DBO closing={dbo_closing}, assets closing={fair_value_closing}, \
net_liability={net_pension_liability}, expense={pension_expense}"
);
PensionSnapshot {
plans: vec![plan],
obligations: vec![obligation],
plan_assets: vec![plan_assets_rec],
disclosures: vec![disclosure],
journal_entries,
}
}
fn pension_expense_je(
&mut self,
entity_code: &str,
posting_date: NaiveDate,
plan_id: &str,
period: &str,
pension_expense: Decimal,
) -> JournalEntry {
let doc_id = format!("JE-PENSION-EXP-{}-{}", entity_code, period.replace('-', ""));
let mut je = JournalEntry::new_simple(
doc_id,
entity_code.to_string(),
posting_date,
format!("Pension expense — {period}"),
);
je.header.source = TransactionSource::Adjustment;
if pension_expense > Decimal::ZERO {
je.add_line(JournalEntryLine {
line_number: 1,
gl_account: PENSION_EXPENSE.to_string(),
debit_amount: pension_expense,
reference: Some(plan_id.to_string()),
text: Some(format!("Pension expense {period}")),
..Default::default()
});
je.add_line(JournalEntryLine {
line_number: 2,
gl_account: NET_PENSION_LIABILITY.to_string(),
credit_amount: pension_expense,
reference: Some(plan_id.to_string()),
text: Some(format!("Net pension liability increase {period}")),
..Default::default()
});
} else {
let abs_expense = pension_expense.abs();
je.add_line(JournalEntryLine {
line_number: 1,
gl_account: NET_PENSION_LIABILITY.to_string(),
debit_amount: abs_expense,
reference: Some(plan_id.to_string()),
text: Some(format!("Net pension liability decrease {period}")),
..Default::default()
});
je.add_line(JournalEntryLine {
line_number: 2,
gl_account: PENSION_EXPENSE.to_string(),
credit_amount: abs_expense,
reference: Some(plan_id.to_string()),
text: Some(format!("Pension income credit {period}")),
..Default::default()
});
}
je
}
fn oci_remeasurement_je(
&mut self,
entity_code: &str,
posting_date: NaiveDate,
plan_id: &str,
period: &str,
oci_remeasurements: Decimal,
) -> JournalEntry {
let doc_id = format!("JE-PENSION-OCI-{}-{}", entity_code, period.replace('-', ""));
let mut je = JournalEntry::new_simple(
doc_id,
entity_code.to_string(),
posting_date,
format!("Pension OCI remeasurement — {period}"),
);
je.header.source = TransactionSource::Adjustment;
let abs_amount = oci_remeasurements.abs();
if oci_remeasurements > Decimal::ZERO {
je.add_line(JournalEntryLine {
line_number: 1,
gl_account: OCI_REMEASUREMENTS.to_string(),
debit_amount: abs_amount,
reference: Some(plan_id.to_string()),
text: Some(format!("OCI actuarial loss {period}")),
..Default::default()
});
je.add_line(JournalEntryLine {
line_number: 2,
gl_account: NET_PENSION_LIABILITY.to_string(),
credit_amount: abs_amount,
reference: Some(plan_id.to_string()),
text: Some(format!("Net pension liability — actuarial loss {period}")),
..Default::default()
});
} else {
je.add_line(JournalEntryLine {
line_number: 1,
gl_account: NET_PENSION_LIABILITY.to_string(),
debit_amount: abs_amount,
reference: Some(plan_id.to_string()),
text: Some(format!("Net pension liability — actuarial gain {period}")),
..Default::default()
});
je.add_line(JournalEntryLine {
line_number: 2,
gl_account: OCI_REMEASUREMENTS.to_string(),
credit_amount: abs_amount,
reference: Some(plan_id.to_string()),
text: Some(format!("OCI actuarial gain {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)
}
}