Skip to main content

datasynth_generators/subledger/
fa_generator.rs

1//! Fixed Assets (FA) generator.
2
3use chrono::NaiveDate;
4use datasynth_core::accounts::cash_accounts;
5use datasynth_core::utils::seeded_rng;
6use rand::RngExt;
7use rand_chacha::ChaCha8Rng;
8use rust_decimal::Decimal;
9use rust_decimal_macros::dec;
10
11use tracing::debug;
12
13use datasynth_core::models::subledger::fa::{
14    AssetClass, AssetDisposal, AssetStatus, DepreciationArea, DepreciationAreaType,
15    DepreciationEntry, DepreciationMethod, DepreciationRun, DisposalReason, DisposalType,
16    FixedAssetRecord,
17};
18use datasynth_core::models::{JournalEntry, JournalEntryLine};
19
20/// Configuration for FA generation.
21#[derive(Debug, Clone)]
22pub struct FAGeneratorConfig {
23    /// Default depreciation method.
24    pub default_depreciation_method: DepreciationMethod,
25    /// Default useful life in months.
26    pub default_useful_life_months: u32,
27    /// Salvage value percentage.
28    pub salvage_value_percent: Decimal,
29    /// Average acquisition cost.
30    pub avg_acquisition_cost: Decimal,
31    /// Cost variation factor.
32    pub cost_variation: Decimal,
33    /// Disposal rate per year.
34    pub annual_disposal_rate: Decimal,
35}
36
37impl Default for FAGeneratorConfig {
38    fn default() -> Self {
39        Self {
40            default_depreciation_method: DepreciationMethod::StraightLine,
41            default_useful_life_months: 60,
42            salvage_value_percent: dec!(0.10),
43            avg_acquisition_cost: dec!(50000),
44            cost_variation: dec!(0.7),
45            annual_disposal_rate: dec!(0.05),
46        }
47    }
48}
49
50/// Generator for Fixed Assets transactions.
51pub struct FAGenerator {
52    config: FAGeneratorConfig,
53    rng: ChaCha8Rng,
54    asset_counter: u64,
55    depreciation_run_counter: u64,
56    disposal_counter: u64,
57}
58
59impl FAGenerator {
60    /// Creates a new FA generator.
61    pub fn new(config: FAGeneratorConfig, rng: ChaCha8Rng) -> Self {
62        Self {
63            config,
64            rng,
65            asset_counter: 0,
66            depreciation_run_counter: 0,
67            disposal_counter: 0,
68        }
69    }
70
71    /// Creates a new FA generator from a seed, constructing the RNG internally.
72    pub fn with_seed(config: FAGeneratorConfig, seed: u64) -> Self {
73        Self::new(config, seeded_rng(seed, 0))
74    }
75
76    /// Maps a string asset class to the enum.
77    fn parse_asset_class(class_str: &str) -> AssetClass {
78        match class_str.to_uppercase().as_str() {
79            "LAND" => AssetClass::Land,
80            "BUILDINGS" | "BUILDING" => AssetClass::Buildings,
81            "MACHINERY" | "EQUIPMENT" | "MACHINERY_EQUIPMENT" => AssetClass::MachineryEquipment,
82            "VEHICLES" | "VEHICLE" => AssetClass::Vehicles,
83            "FURNITURE" | "FIXTURES" => AssetClass::FurnitureFixtures,
84            "COMPUTER" | "IT" | "IT_EQUIPMENT" => AssetClass::ComputerEquipment,
85            "SOFTWARE" => AssetClass::Software,
86            "LEASEHOLD" | "LEASEHOLD_IMPROVEMENTS" => AssetClass::LeaseholdImprovements,
87            _ => AssetClass::Other,
88        }
89    }
90
91    /// Generates a new fixed asset acquisition.
92    pub fn generate_asset_acquisition(
93        &mut self,
94        company_code: &str,
95        asset_class_str: &str,
96        description: &str,
97        acquisition_date: NaiveDate,
98        currency: &str,
99        cost_center: Option<&str>,
100    ) -> (FixedAssetRecord, JournalEntry) {
101        debug!(company_code, asset_class_str, %acquisition_date, "Generating FA asset acquisition");
102        self.asset_counter += 1;
103        let asset_number = format!("FA{:08}", self.asset_counter);
104        let asset_class = Self::parse_asset_class(asset_class_str);
105
106        let acquisition_cost = self.generate_acquisition_cost();
107        let salvage_value = (acquisition_cost * self.config.salvage_value_percent).round_dp(2);
108
109        // Create the asset using the constructor
110        let mut asset = FixedAssetRecord::new(
111            asset_number,
112            company_code.to_string(),
113            asset_class,
114            description.to_string(),
115            acquisition_date,
116            acquisition_cost,
117            currency.to_string(),
118        );
119
120        // Add serial number and inventory number
121        asset.serial_number = Some(format!("SN-{:010}", self.rng.random::<u32>()));
122        asset.inventory_number = Some(format!("INV-{:08}", self.asset_counter));
123        asset.cost_center = cost_center.map(std::string::ToString::to_string);
124
125        // Add a depreciation area
126        let mut depreciation_area = DepreciationArea::new(
127            DepreciationAreaType::Book,
128            self.config.default_depreciation_method,
129            self.config.default_useful_life_months,
130            acquisition_cost,
131        );
132        depreciation_area.salvage_value = salvage_value;
133        asset.add_depreciation_area(depreciation_area);
134
135        let je = self.generate_acquisition_je(&asset);
136        (asset, je)
137    }
138
139    /// Runs depreciation for a period.
140    pub fn run_depreciation(
141        &mut self,
142        company_code: &str,
143        assets: &[&FixedAssetRecord],
144        period_date: NaiveDate,
145        fiscal_year: i32,
146        fiscal_period: u32,
147    ) -> (DepreciationRun, Vec<JournalEntry>) {
148        self.depreciation_run_counter += 1;
149        let run_id = format!("DEPR{:08}", self.depreciation_run_counter);
150
151        let mut run = DepreciationRun::new(
152            run_id,
153            company_code.to_string(),
154            fiscal_year,
155            fiscal_period,
156            DepreciationAreaType::Book,
157            period_date,
158            "FAGenerator".to_string(),
159        );
160
161        run.start();
162        let mut journal_entries = Vec::new();
163
164        for asset in assets {
165            if asset.status != AssetStatus::Active {
166                continue;
167            }
168
169            // Create entry from asset using the from_asset method
170            if let Some(entry) = DepreciationEntry::from_asset(asset, DepreciationAreaType::Book) {
171                if entry.depreciation_amount <= Decimal::ZERO {
172                    continue;
173                }
174
175                let je = self.generate_depreciation_je(asset, &entry, period_date);
176                run.add_entry(entry);
177                journal_entries.push(je);
178            }
179        }
180
181        run.complete();
182        (run, journal_entries)
183    }
184
185    /// Generates an asset disposal.
186    pub fn generate_disposal(
187        &mut self,
188        asset: &FixedAssetRecord,
189        disposal_date: NaiveDate,
190        disposal_type: DisposalType,
191        proceeds: Decimal,
192    ) -> (AssetDisposal, JournalEntry) {
193        self.disposal_counter += 1;
194        let disposal_id = format!("DISP{:08}", self.disposal_counter);
195
196        let disposal_reason = self.random_disposal_reason();
197
198        // Use the appropriate constructor based on disposal type
199        let mut disposal = if disposal_type == DisposalType::Sale && proceeds > Decimal::ZERO {
200            AssetDisposal::sale(
201                disposal_id,
202                asset,
203                disposal_date,
204                proceeds,
205                format!("CUST-{}", self.disposal_counter),
206                "FAGenerator".to_string(),
207            )
208        } else {
209            let mut d = AssetDisposal::new(
210                disposal_id,
211                asset,
212                disposal_date,
213                disposal_type,
214                disposal_reason,
215                "FAGenerator".to_string(),
216            );
217            if proceeds > Decimal::ZERO {
218                d = d.with_sale_proceeds(proceeds);
219            } else {
220                d.calculate_gain_loss();
221            }
222            d
223        };
224
225        // Approve the disposal
226        disposal.approve("SYSTEM".to_string(), disposal_date);
227
228        let je = self.generate_disposal_je(asset, &disposal);
229        (disposal, je)
230    }
231
232    fn generate_acquisition_cost(&mut self) -> Decimal {
233        let base = self.config.avg_acquisition_cost;
234        let variation = base * self.config.cost_variation;
235        let random: f64 = self.rng.random_range(-1.0..1.0);
236        (base + variation * Decimal::try_from(random).unwrap_or_default())
237            .max(dec!(1000))
238            .round_dp(2)
239    }
240
241    fn random_disposal_reason(&mut self) -> DisposalReason {
242        match self.rng.random_range(0..5) {
243            0 => DisposalReason::Sale,
244            1 => DisposalReason::EndOfLife,
245            2 => DisposalReason::Obsolescence,
246            3 => DisposalReason::Donated,
247            _ => DisposalReason::Replacement,
248        }
249    }
250
251    fn generate_acquisition_je(&self, asset: &FixedAssetRecord) -> JournalEntry {
252        let header_text = format!("Asset Acquisition {}", asset.asset_number);
253        let mut je = JournalEntry::new_simple(
254            format!("JE-ACQ-{}", asset.asset_number),
255            asset.company_code.clone(),
256            asset.acquisition_date,
257            header_text.clone(),
258        );
259
260        // Debit Fixed Asset
261        je.add_line(JournalEntryLine {
262            line_number: 1,
263            gl_account: asset.account_determination.acquisition_account.clone(),
264            debit_amount: asset.acquisition_cost,
265            cost_center: asset.cost_center.clone(),
266            profit_center: asset.profit_center.clone(),
267            reference: Some(asset.asset_number.clone()),
268            text: Some(asset.description.clone()),
269            line_text: Some(header_text.clone()),
270            quantity: Some(dec!(1)),
271            unit: Some("EA".to_string()),
272            ..Default::default()
273        });
274
275        // Credit Cash/AP (assuming cash purchase)
276        je.add_line(JournalEntryLine {
277            line_number: 2,
278            gl_account: asset.account_determination.clearing_account.clone(),
279            credit_amount: asset.acquisition_cost,
280            reference: Some(asset.asset_number.clone()),
281            line_text: Some(header_text),
282            ..Default::default()
283        });
284
285        je
286    }
287
288    fn generate_depreciation_je(
289        &self,
290        asset: &FixedAssetRecord,
291        entry: &DepreciationEntry,
292        posting_date: NaiveDate,
293    ) -> JournalEntry {
294        let header_text = format!("Depreciation {}", asset.asset_number);
295        let mut je = JournalEntry::new_simple(
296            format!("JE-DEP-{}", asset.asset_number),
297            asset.company_code.clone(),
298            posting_date,
299            header_text.clone(),
300        );
301
302        // Debit Depreciation Expense
303        je.add_line(JournalEntryLine {
304            line_number: 1,
305            gl_account: entry.expense_account.clone(),
306            debit_amount: entry.depreciation_amount,
307            cost_center: asset.cost_center.clone(),
308            profit_center: asset.profit_center.clone(),
309            reference: Some(asset.asset_number.clone()),
310            line_text: Some(header_text.clone()),
311            ..Default::default()
312        });
313
314        // Credit Accumulated Depreciation
315        je.add_line(JournalEntryLine {
316            line_number: 2,
317            gl_account: entry.accum_depr_account.clone(),
318            credit_amount: entry.depreciation_amount,
319            reference: Some(asset.asset_number.clone()),
320            line_text: Some(header_text),
321            ..Default::default()
322        });
323
324        je
325    }
326
327    fn generate_disposal_je(
328        &self,
329        asset: &FixedAssetRecord,
330        disposal: &AssetDisposal,
331    ) -> JournalEntry {
332        let header_text = format!("Asset Disposal {}", asset.asset_number);
333        let mut je = JournalEntry::new_simple(
334            format!("JE-{}", disposal.disposal_id),
335            asset.company_code.clone(),
336            disposal.disposal_date,
337            header_text.clone(),
338        );
339
340        let mut line_num = 1;
341
342        // Debit Cash (if proceeds > 0)
343        if disposal.sale_proceeds > Decimal::ZERO {
344            je.add_line(JournalEntryLine {
345                line_number: line_num,
346                gl_account: cash_accounts::OPERATING_CASH.to_string(),
347                debit_amount: disposal.sale_proceeds,
348                reference: Some(disposal.disposal_id.clone()),
349                line_text: Some(header_text.clone()),
350                ..Default::default()
351            });
352            line_num += 1;
353        }
354
355        // Debit Accumulated Depreciation
356        je.add_line(JournalEntryLine {
357            line_number: line_num,
358            gl_account: asset
359                .account_determination
360                .accumulated_depreciation_account
361                .clone(),
362            debit_amount: disposal.accumulated_depreciation,
363            reference: Some(disposal.disposal_id.clone()),
364            line_text: Some(header_text.clone()),
365            ..Default::default()
366        });
367        line_num += 1;
368
369        // Debit Loss on Disposal (if loss)
370        if !disposal.is_gain {
371            je.add_line(JournalEntryLine {
372                line_number: line_num,
373                gl_account: asset.account_determination.loss_on_disposal_account.clone(),
374                debit_amount: disposal.loss(),
375                cost_center: asset.cost_center.clone(),
376                profit_center: asset.profit_center.clone(),
377                reference: Some(disposal.disposal_id.clone()),
378                line_text: Some(header_text.clone()),
379                ..Default::default()
380            });
381            line_num += 1;
382        }
383
384        // Credit Fixed Asset
385        je.add_line(JournalEntryLine {
386            line_number: line_num,
387            gl_account: asset.account_determination.acquisition_account.clone(),
388            credit_amount: asset.acquisition_cost,
389            reference: Some(disposal.disposal_id.clone()),
390            line_text: Some(header_text.clone()),
391            ..Default::default()
392        });
393        line_num += 1;
394
395        // Credit Gain on Disposal (if gain)
396        if disposal.is_gain && disposal.gain() > Decimal::ZERO {
397            je.add_line(JournalEntryLine {
398                line_number: line_num,
399                gl_account: asset.account_determination.gain_on_disposal_account.clone(),
400                credit_amount: disposal.gain(),
401                cost_center: asset.cost_center.clone(),
402                profit_center: asset.profit_center.clone(),
403                reference: Some(disposal.disposal_id.clone()),
404                line_text: Some(header_text),
405                ..Default::default()
406            });
407        }
408
409        je
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use datasynth_core::models::subledger::fa::DepreciationRunStatus;
417    use rand::SeedableRng;
418
419    #[test]
420    fn test_generate_asset_acquisition() {
421        let rng = ChaCha8Rng::seed_from_u64(12345);
422        let mut generator = FAGenerator::new(FAGeneratorConfig::default(), rng);
423
424        let (asset, je) = generator.generate_asset_acquisition(
425            "1000",
426            "MACHINERY",
427            "CNC Machine",
428            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
429            "USD",
430            Some("CC100"),
431        );
432
433        assert_eq!(asset.status, AssetStatus::Active);
434        assert!(asset.acquisition_cost > Decimal::ZERO);
435        assert!(je.is_balanced());
436    }
437
438    #[test]
439    fn test_run_depreciation() {
440        let rng = ChaCha8Rng::seed_from_u64(12345);
441        let mut generator = FAGenerator::new(FAGeneratorConfig::default(), rng);
442
443        let (asset, _) = generator.generate_asset_acquisition(
444            "1000",
445            "MACHINERY",
446            "CNC Machine",
447            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
448            "USD",
449            None,
450        );
451
452        let (run, jes) = generator.run_depreciation(
453            "1000",
454            &[&asset],
455            NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
456            2024,
457            1,
458        );
459
460        assert_eq!(run.status, DepreciationRunStatus::Completed);
461        assert!(run.asset_count > 0);
462        assert!(jes.iter().all(|je| je.is_balanced()));
463    }
464}