Skip to main content

datasynth_generators/standards/
lease_generator.rs

1//! Lease Accounting Generator (IFRS 16 / ASC 842).
2//!
3//! Basic v1 scope (per v3.3.1 roadmap decision):
4//!   - Straight-line amortization of the right-of-use asset.
5//!   - Simple discount-rate model: IBR drawn from a configured band.
6//!   - Per-period liability rollforward (interest accrual + principal
7//!     reduction from fixed payments).
8//!   - Lease classification honors framework rules (ASC 842 bright-line,
9//!     IFRS 16 principles-based) via [`Lease::classify`] in
10//!     `datasynth_standards`.
11//!
12//! Deferred to v3.4.x+:
13//!   - Lease modifications / reassessments (indices, termination
14//!     options exercised mid-term).
15//!   - Subleases / head-lease pairs.
16//!   - Variable lease payment rebasing beyond CPI-indexed examples.
17//!   - Impairment of ROU assets (ASC 360 / IAS 36 overlay).
18//!
19//! The generator reuses the rich `Lease::new` constructor in the
20//! `datasynth-standards` crate (which handles classification,
21//! present-value measurement, and amortization schedule seeding).
22//! This generator's job is to draw realistic inputs: lessor names,
23//! asset classes, terms, payment amounts, discount rates.
24
25use chrono::NaiveDate;
26use datasynth_config::schema::LeaseAccountingConfig;
27use datasynth_core::utils::seeded_rng;
28use datasynth_standards::accounting::leases::{
29    Lease, LeaseAssetClass, LeaseClassification, PaymentFrequency,
30};
31use datasynth_standards::framework::AccountingFramework;
32use rand::prelude::*;
33use rand_chacha::ChaCha8Rng;
34use rand_distr::{LogNormal, Normal};
35use rust_decimal::prelude::*;
36use rust_decimal::Decimal;
37
38/// Realistic lessor names for generated leases.
39const LESSOR_NAMES: &[&str] = &[
40    "Prologis Trust",
41    "Brookfield Property Partners",
42    "Digital Realty Leasing",
43    "CBRE Investments",
44    "JLL Capital Markets",
45    "Realty Income Corp",
46    "Equinix Leasing",
47    "Industrial Innovations Ltd",
48    "Global Fleet Services",
49    "Enterprise Rent-A-Lease",
50    "Dell Financial Services",
51    "HP Capital Leasing",
52    "Cisco Capital Group",
53    "IBM Global Financing",
54    "Mitsubishi HC Capital",
55];
56
57/// Short asset descriptions per asset class.
58fn description_for(asset_class: LeaseAssetClass, rng: &mut ChaCha8Rng) -> String {
59    let options: &[&str] = match asset_class {
60        LeaseAssetClass::RealEstate => &[
61            "Office space — Class A building",
62            "Warehouse — distribution center",
63            "Retail storefront",
64            "Data center colocation space",
65            "Manufacturing plant lease",
66        ],
67        LeaseAssetClass::Equipment => &[
68            "Industrial CNC machining center",
69            "Production line automation equipment",
70            "Packaging line equipment",
71            "Commercial HVAC system",
72            "Warehouse conveyor system",
73        ],
74        LeaseAssetClass::Vehicles => &[
75            "Delivery truck fleet",
76            "Executive vehicle fleet",
77            "Heavy-duty forklift fleet",
78            "Sales-team vehicle pool",
79            "Cold-chain refrigerated truck",
80        ],
81        LeaseAssetClass::InformationTechnology => &[
82            "Enterprise server cluster",
83            "Networking equipment rack",
84            "Employee laptop fleet",
85            "Point-of-sale terminal fleet",
86            "Video-conferencing equipment",
87        ],
88        LeaseAssetClass::FurnitureAndFixtures => &[
89            "Office furniture suite",
90            "Retail display fixtures",
91            "Modular workstation package",
92            "Conference-room AV equipment",
93        ],
94        LeaseAssetClass::Other => &[
95            "Specialized industrial equipment",
96            "Research laboratory equipment",
97            "Medical imaging equipment",
98        ],
99    };
100    options
101        .choose(rng)
102        .copied()
103        .unwrap_or("Leased asset")
104        .to_string()
105}
106
107/// Generator for lease accounting records (IFRS 16 / ASC 842).
108pub struct LeaseGenerator {
109    rng: ChaCha8Rng,
110}
111
112impl LeaseGenerator {
113    /// Create a new generator with the given seed.
114    pub fn new(seed: u64) -> Self {
115        Self {
116            rng: seeded_rng(seed, 0),
117        }
118    }
119
120    /// Generate `config.lease_count` leases for one entity.
121    ///
122    /// Lease parameters are drawn from:
123    /// - **Term**: Normal(mean=`config.avg_lease_term_months`, σ=~12) clamped to \[6, 240\].
124    /// - **Fair value**: LogNormal(μ=11, σ=1.3) — centered around $60k,
125    ///   heavy right tail for large real-estate leases.
126    /// - **Fixed payment**: ~fair_value / term_months × (1 + margin),
127    ///   margin drawn from Normal(0.15, 0.08).
128    /// - **Discount rate**: Uniform \[0.03, 0.08\] (typical IBR band).
129    /// - **Classification proportion**: driven by
130    ///   `config.finance_lease_percent` via setting the
131    ///   `transfers_ownership` flag on that share (ASC 842 Test 1) so
132    ///   the standards library classifies them Finance.
133    pub fn generate(
134        &mut self,
135        company_code: &str,
136        commencement_anchor: NaiveDate,
137        config: &LeaseAccountingConfig,
138        framework: AccountingFramework,
139    ) -> Vec<Lease> {
140        if config.lease_count == 0 {
141            return Vec::new();
142        }
143
144        let term_dist =
145            Normal::new(config.avg_lease_term_months as f64, 12.0).expect("positive sigma");
146        let fair_value_dist = LogNormal::new(11.0, 1.3).expect("valid lognormal");
147        let margin_dist = Normal::new(0.15, 0.08).expect("positive sigma");
148
149        let mut leases = Vec::with_capacity(config.lease_count);
150        for i in 0..config.lease_count {
151            let asset_class = self.pick_asset_class(config);
152            let description = description_for(asset_class, &mut self.rng);
153            let lessor = LESSOR_NAMES
154                .choose(&mut self.rng)
155                .copied()
156                .unwrap_or("Unknown Lessor")
157                .to_string();
158
159            let term_raw: f64 = term_dist.sample(&mut self.rng).round();
160            let lease_term_months = term_raw.clamp(6.0_f64, 240.0_f64) as u32;
161
162            let fair_value_sample: f64 = fair_value_dist.sample(&mut self.rng);
163            let fair_value_raw = fair_value_sample.max(1000.0_f64);
164            let fair_value = Decimal::from_f64(fair_value_raw)
165                .unwrap_or_else(|| Decimal::from(10_000))
166                .round_dp(2);
167
168            let term_months_f = lease_term_months.max(1) as f64;
169            let margin_raw: f64 = margin_dist.sample(&mut self.rng);
170            let margin = margin_raw.clamp(-0.05_f64, 0.40_f64);
171            let fixed_payment_f64 = (fair_value_raw / term_months_f) * (1.0 + margin);
172            let fixed_payment = Decimal::from_f64(fixed_payment_f64.max(10.0))
173                .unwrap_or(Decimal::ONE_HUNDRED)
174                .round_dp(2);
175
176            let discount_rate = {
177                let r: f64 = self.rng.random_range(0.03..0.08);
178                Decimal::from_f64(r)
179                    .unwrap_or(Decimal::from_str_exact("0.05").expect("const"))
180                    .round_dp(4)
181            };
182
183            // Economic life is typically much longer than lease term —
184            // we aim for term/life ≈ 0.3–0.5 so ASC 842 Test 3 (term
185            // ≥ 75 % of economic life = Finance) doesn't sweep every
186            // lease into Finance classification.
187            let economic_life_months = match asset_class {
188                LeaseAssetClass::RealEstate => lease_term_months.saturating_mul(5).max(360),
189                _ => lease_term_months.saturating_mul(3).clamp(60, 360),
190            };
191
192            // Staggered commencement across the period so amortization
193            // schedules look natural in downstream analytics.
194            let offset_days: i64 = self
195                .rng
196                .random_range(0..(365.max(lease_term_months as i64 / 4)));
197            let commencement = commencement_anchor + chrono::Duration::days(offset_days);
198
199            let mut lease = Lease::new(
200                company_code.to_string(),
201                lessor,
202                description,
203                asset_class,
204                commencement,
205                lease_term_months,
206                fixed_payment,
207                PaymentFrequency::Monthly,
208                discount_rate,
209                fair_value,
210                economic_life_months,
211                framework,
212            );
213
214            // Push a fraction of leases into Finance classification by
215            // flipping the bright-line Test 1 flag (transfer of
216            // ownership). The `classify` method will re-evaluate.
217            if (i as f64 / config.lease_count as f64) < config.finance_lease_percent
218                && lease.classification != LeaseClassification::Finance
219            {
220                lease.transfers_ownership = true;
221                lease.classify();
222            }
223
224            leases.push(lease);
225        }
226        leases
227    }
228
229    /// Draw an asset class respecting `config.real_estate_percent` and
230    /// a fixed distribution among the remaining classes.
231    fn pick_asset_class(&mut self, config: &LeaseAccountingConfig) -> LeaseAssetClass {
232        let roll: f64 = self.rng.random();
233        if roll < config.real_estate_percent {
234            return LeaseAssetClass::RealEstate;
235        }
236        // Split the non-real-estate share roughly equally.
237        let remaining = 1.0 - config.real_estate_percent;
238        let per_class = remaining / 5.0;
239        let r = roll - config.real_estate_percent;
240        if r < per_class {
241            LeaseAssetClass::Equipment
242        } else if r < 2.0 * per_class {
243            LeaseAssetClass::Vehicles
244        } else if r < 3.0 * per_class {
245            LeaseAssetClass::InformationTechnology
246        } else if r < 4.0 * per_class {
247            LeaseAssetClass::FurnitureAndFixtures
248        } else {
249            LeaseAssetClass::Other
250        }
251    }
252}
253
254#[cfg(test)]
255#[allow(clippy::unwrap_used)]
256mod tests {
257    use super::*;
258
259    fn fixture_config() -> LeaseAccountingConfig {
260        LeaseAccountingConfig {
261            enabled: true,
262            lease_count: 20,
263            finance_lease_percent: 0.30,
264            avg_lease_term_months: 60,
265            generate_amortization: true,
266            real_estate_percent: 0.40,
267        }
268    }
269
270    #[test]
271    fn generates_requested_count() {
272        let mut gen = LeaseGenerator::new(42);
273        let cfg = fixture_config();
274        let leases = gen.generate(
275            "C001",
276            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
277            &cfg,
278            AccountingFramework::UsGaap,
279        );
280        assert_eq!(leases.len(), cfg.lease_count);
281    }
282
283    #[test]
284    fn lease_measurements_are_positive() {
285        let mut gen = LeaseGenerator::new(123);
286        let leases = gen.generate(
287            "C001",
288            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
289            &fixture_config(),
290            AccountingFramework::Ifrs,
291        );
292        for l in &leases {
293            assert!(l.fair_value_at_commencement > Decimal::ZERO);
294            assert!(l.fixed_payment > Decimal::ZERO);
295            assert!(l.discount_rate > Decimal::ZERO);
296            assert!(l.lease_term_months >= 6);
297        }
298    }
299
300    #[test]
301    fn finance_classification_share_is_close_to_target() {
302        let mut gen = LeaseGenerator::new(7);
303        let mut cfg = fixture_config();
304        cfg.lease_count = 200;
305        let leases = gen.generate(
306            "C001",
307            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
308            &cfg,
309            AccountingFramework::UsGaap,
310        );
311        let finance = leases
312            .iter()
313            .filter(|l| l.classification == LeaseClassification::Finance)
314            .count();
315        let operating = leases
316            .iter()
317            .filter(|l| l.classification == LeaseClassification::Operating)
318            .count();
319        // Sanity check — the generator must produce BOTH classifications
320        // in the same batch. Exact ratios depend on ASC 842 bright-line
321        // tests (ownership transfer / bargain purchase / term ratio /
322        // PV ratio / specialized asset) which the standards library
323        // evaluates independently. We don't pin a target ratio here —
324        // regression guard catches the "all one classification"
325        // pathological cases instead.
326        assert!(finance > 0, "expected ≥ 1 Finance lease, got {finance}");
327        assert!(
328            operating > 0 || finance == leases.len(),
329            "expected either some Operating leases or all Finance — got 0 Operating and \
330             only {finance}/{} Finance",
331            leases.len()
332        );
333    }
334
335    #[test]
336    fn zero_count_returns_empty() {
337        let mut gen = LeaseGenerator::new(1);
338        let mut cfg = fixture_config();
339        cfg.lease_count = 0;
340        let leases = gen.generate(
341            "C001",
342            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
343            &cfg,
344            AccountingFramework::UsGaap,
345        );
346        assert!(leases.is_empty());
347    }
348}