use chrono::NaiveDate;
use datasynth_config::schema::LeaseAccountingConfig;
use datasynth_core::utils::seeded_rng;
use datasynth_standards::accounting::leases::{
Lease, LeaseAssetClass, LeaseClassification, PaymentFrequency,
};
use datasynth_standards::framework::AccountingFramework;
use rand::prelude::*;
use rand_chacha::ChaCha8Rng;
use rand_distr::{LogNormal, Normal};
use rust_decimal::prelude::*;
use rust_decimal::Decimal;
const LESSOR_NAMES: &[&str] = &[
"Prologis Trust",
"Brookfield Property Partners",
"Digital Realty Leasing",
"CBRE Investments",
"JLL Capital Markets",
"Realty Income Corp",
"Equinix Leasing",
"Industrial Innovations Ltd",
"Global Fleet Services",
"Enterprise Rent-A-Lease",
"Dell Financial Services",
"HP Capital Leasing",
"Cisco Capital Group",
"IBM Global Financing",
"Mitsubishi HC Capital",
];
fn description_for(asset_class: LeaseAssetClass, rng: &mut ChaCha8Rng) -> String {
let options: &[&str] = match asset_class {
LeaseAssetClass::RealEstate => &[
"Office space — Class A building",
"Warehouse — distribution center",
"Retail storefront",
"Data center colocation space",
"Manufacturing plant lease",
],
LeaseAssetClass::Equipment => &[
"Industrial CNC machining center",
"Production line automation equipment",
"Packaging line equipment",
"Commercial HVAC system",
"Warehouse conveyor system",
],
LeaseAssetClass::Vehicles => &[
"Delivery truck fleet",
"Executive vehicle fleet",
"Heavy-duty forklift fleet",
"Sales-team vehicle pool",
"Cold-chain refrigerated truck",
],
LeaseAssetClass::InformationTechnology => &[
"Enterprise server cluster",
"Networking equipment rack",
"Employee laptop fleet",
"Point-of-sale terminal fleet",
"Video-conferencing equipment",
],
LeaseAssetClass::FurnitureAndFixtures => &[
"Office furniture suite",
"Retail display fixtures",
"Modular workstation package",
"Conference-room AV equipment",
],
LeaseAssetClass::Other => &[
"Specialized industrial equipment",
"Research laboratory equipment",
"Medical imaging equipment",
],
};
options
.choose(rng)
.copied()
.unwrap_or("Leased asset")
.to_string()
}
pub struct LeaseGenerator {
rng: ChaCha8Rng,
}
impl LeaseGenerator {
pub fn new(seed: u64) -> Self {
Self {
rng: seeded_rng(seed, 0),
}
}
pub fn generate(
&mut self,
company_code: &str,
commencement_anchor: NaiveDate,
config: &LeaseAccountingConfig,
framework: AccountingFramework,
) -> Vec<Lease> {
if config.lease_count == 0 {
return Vec::new();
}
let term_dist =
Normal::new(config.avg_lease_term_months as f64, 12.0).expect("positive sigma");
let fair_value_dist = LogNormal::new(11.0, 1.3).expect("valid lognormal");
let margin_dist = Normal::new(0.15, 0.08).expect("positive sigma");
let mut leases = Vec::with_capacity(config.lease_count);
for i in 0..config.lease_count {
let asset_class = self.pick_asset_class(config);
let description = description_for(asset_class, &mut self.rng);
let lessor = LESSOR_NAMES
.choose(&mut self.rng)
.copied()
.unwrap_or("Unknown Lessor")
.to_string();
let term_raw: f64 = term_dist.sample(&mut self.rng).round();
let lease_term_months = term_raw.clamp(6.0_f64, 240.0_f64) as u32;
let fair_value_sample: f64 = fair_value_dist.sample(&mut self.rng);
let fair_value_raw = fair_value_sample.max(1000.0_f64);
let fair_value = Decimal::from_f64(fair_value_raw)
.unwrap_or_else(|| Decimal::from(10_000))
.round_dp(2);
let term_months_f = lease_term_months.max(1) as f64;
let margin_raw: f64 = margin_dist.sample(&mut self.rng);
let margin = margin_raw.clamp(-0.05_f64, 0.40_f64);
let fixed_payment_f64 = (fair_value_raw / term_months_f) * (1.0 + margin);
let fixed_payment = Decimal::from_f64(fixed_payment_f64.max(10.0))
.unwrap_or(Decimal::ONE_HUNDRED)
.round_dp(2);
let discount_rate = {
let r: f64 = self.rng.random_range(0.03..0.08);
Decimal::from_f64(r)
.unwrap_or(Decimal::from_str_exact("0.05").expect("const"))
.round_dp(4)
};
let economic_life_months = match asset_class {
LeaseAssetClass::RealEstate => lease_term_months.saturating_mul(5).max(360),
_ => lease_term_months.saturating_mul(3).clamp(60, 360),
};
let offset_days: i64 = self
.rng
.random_range(0..(365.max(lease_term_months as i64 / 4)));
let commencement = commencement_anchor + chrono::Duration::days(offset_days);
let mut lease = Lease::new(
company_code.to_string(),
lessor,
description,
asset_class,
commencement,
lease_term_months,
fixed_payment,
PaymentFrequency::Monthly,
discount_rate,
fair_value,
economic_life_months,
framework,
);
if (i as f64 / config.lease_count as f64) < config.finance_lease_percent
&& lease.classification != LeaseClassification::Finance
{
lease.transfers_ownership = true;
lease.classify();
}
leases.push(lease);
}
leases
}
fn pick_asset_class(&mut self, config: &LeaseAccountingConfig) -> LeaseAssetClass {
let roll: f64 = self.rng.random();
if roll < config.real_estate_percent {
return LeaseAssetClass::RealEstate;
}
let remaining = 1.0 - config.real_estate_percent;
let per_class = remaining / 5.0;
let r = roll - config.real_estate_percent;
if r < per_class {
LeaseAssetClass::Equipment
} else if r < 2.0 * per_class {
LeaseAssetClass::Vehicles
} else if r < 3.0 * per_class {
LeaseAssetClass::InformationTechnology
} else if r < 4.0 * per_class {
LeaseAssetClass::FurnitureAndFixtures
} else {
LeaseAssetClass::Other
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn fixture_config() -> LeaseAccountingConfig {
LeaseAccountingConfig {
enabled: true,
lease_count: 20,
finance_lease_percent: 0.30,
avg_lease_term_months: 60,
generate_amortization: true,
real_estate_percent: 0.40,
}
}
#[test]
fn generates_requested_count() {
let mut gen = LeaseGenerator::new(42);
let cfg = fixture_config();
let leases = gen.generate(
"C001",
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
&cfg,
AccountingFramework::UsGaap,
);
assert_eq!(leases.len(), cfg.lease_count);
}
#[test]
fn lease_measurements_are_positive() {
let mut gen = LeaseGenerator::new(123);
let leases = gen.generate(
"C001",
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
&fixture_config(),
AccountingFramework::Ifrs,
);
for l in &leases {
assert!(l.fair_value_at_commencement > Decimal::ZERO);
assert!(l.fixed_payment > Decimal::ZERO);
assert!(l.discount_rate > Decimal::ZERO);
assert!(l.lease_term_months >= 6);
}
}
#[test]
fn finance_classification_share_is_close_to_target() {
let mut gen = LeaseGenerator::new(7);
let mut cfg = fixture_config();
cfg.lease_count = 200;
let leases = gen.generate(
"C001",
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
&cfg,
AccountingFramework::UsGaap,
);
let finance = leases
.iter()
.filter(|l| l.classification == LeaseClassification::Finance)
.count();
let operating = leases
.iter()
.filter(|l| l.classification == LeaseClassification::Operating)
.count();
assert!(finance > 0, "expected ≥ 1 Finance lease, got {finance}");
assert!(
operating > 0 || finance == leases.len(),
"expected either some Operating leases or all Finance — got 0 Operating and \
only {finance}/{} Finance",
leases.len()
);
}
#[test]
fn zero_count_returns_empty() {
let mut gen = LeaseGenerator::new(1);
let mut cfg = fixture_config();
cfg.lease_count = 0;
let leases = gen.generate(
"C001",
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
&cfg,
AccountingFramework::UsGaap,
);
assert!(leases.is_empty());
}
}