datasynth-generators 4.0.1

50+ data generators covering GL, P2P, O2C, S2C, HR, manufacturing, audit, tax, treasury, and ESG
Documentation
//! Lease Accounting Generator (IFRS 16 / ASC 842).
//!
//! Basic v1 scope (per v3.3.1 roadmap decision):
//!   - Straight-line amortization of the right-of-use asset.
//!   - Simple discount-rate model: IBR drawn from a configured band.
//!   - Per-period liability rollforward (interest accrual + principal
//!     reduction from fixed payments).
//!   - Lease classification honors framework rules (ASC 842 bright-line,
//!     IFRS 16 principles-based) via [`Lease::classify`] in
//!     `datasynth_standards`.
//!
//! Deferred to v3.4.x+:
//!   - Lease modifications / reassessments (indices, termination
//!     options exercised mid-term).
//!   - Subleases / head-lease pairs.
//!   - Variable lease payment rebasing beyond CPI-indexed examples.
//!   - Impairment of ROU assets (ASC 360 / IAS 36 overlay).
//!
//! The generator reuses the rich `Lease::new` constructor in the
//! `datasynth-standards` crate (which handles classification,
//! present-value measurement, and amortization schedule seeding).
//! This generator's job is to draw realistic inputs: lessor names,
//! asset classes, terms, payment amounts, discount rates.

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;

/// Realistic lessor names for generated leases.
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",
];

/// Short asset descriptions per asset class.
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()
}

/// Generator for lease accounting records (IFRS 16 / ASC 842).
pub struct LeaseGenerator {
    rng: ChaCha8Rng,
}

impl LeaseGenerator {
    /// Create a new generator with the given seed.
    pub fn new(seed: u64) -> Self {
        Self {
            rng: seeded_rng(seed, 0),
        }
    }

    /// Generate `config.lease_count` leases for one entity.
    ///
    /// Lease parameters are drawn from:
    /// - **Term**: Normal(mean=`config.avg_lease_term_months`, σ=~12) clamped to \[6, 240\].
    /// - **Fair value**: LogNormal(μ=11, σ=1.3) — centered around $60k,
    ///   heavy right tail for large real-estate leases.
    /// - **Fixed payment**: ~fair_value / term_months × (1 + margin),
    ///   margin drawn from Normal(0.15, 0.08).
    /// - **Discount rate**: Uniform \[0.03, 0.08\] (typical IBR band).
    /// - **Classification proportion**: driven by
    ///   `config.finance_lease_percent` via setting the
    ///   `transfers_ownership` flag on that share (ASC 842 Test 1) so
    ///   the standards library classifies them Finance.
    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)
            };

            // Economic life is typically much longer than lease term —
            // we aim for term/life ≈ 0.3–0.5 so ASC 842 Test 3 (term
            // ≥ 75 % of economic life = Finance) doesn't sweep every
            // lease into Finance classification.
            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),
            };

            // Staggered commencement across the period so amortization
            // schedules look natural in downstream analytics.
            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,
            );

            // Push a fraction of leases into Finance classification by
            // flipping the bright-line Test 1 flag (transfer of
            // ownership). The `classify` method will re-evaluate.
            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
    }

    /// Draw an asset class respecting `config.real_estate_percent` and
    /// a fixed distribution among the remaining classes.
    fn pick_asset_class(&mut self, config: &LeaseAccountingConfig) -> LeaseAssetClass {
        let roll: f64 = self.rng.random();
        if roll < config.real_estate_percent {
            return LeaseAssetClass::RealEstate;
        }
        // Split the non-real-estate share roughly equally.
        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();
        // Sanity check — the generator must produce BOTH classifications
        // in the same batch. Exact ratios depend on ASC 842 bright-line
        // tests (ownership transfer / bargain purchase / term ratio /
        // PV ratio / specialized asset) which the standards library
        // evaluates independently. We don't pin a target ratio here —
        // regression guard catches the "all one classification"
        // pathological cases instead.
        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());
    }
}