Skip to main content

datasynth_generators/standards/
business_combination_generator.rs

1//! Business Combination Generator (IFRS 3 / ASC 805).
2//!
3//! Generates synthetic business combinations with:
4//! - Realistic consideration amounts (1M – 50M based on company size)
5//! - Purchase price allocation with 4–6 fair value adjustments
6//! - Goodwill computation (or bargain purchase gain)
7//! - Day 1 journal entries recording all acquired assets/liabilities
8//! - Subsequent amortization JEs for finite-lived acquired intangibles
9
10use chrono::{Datelike, NaiveDate};
11use datasynth_core::accounts::{
12    cash_accounts::OPERATING_CASH, control_accounts::FIXED_ASSETS, intangible_accounts::*,
13};
14use datasynth_core::models::{
15    business_combination::{
16        AcquisitionConsideration, AcquisitionFvAdjustment, AcquisitionPpa, BusinessCombination,
17    },
18    journal_entry::{JournalEntry, JournalEntryHeader, JournalEntryLine, TransactionSource},
19};
20use datasynth_core::utils::seeded_rng;
21use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
22use rand::prelude::*;
23use rand_chacha::ChaCha8Rng;
24use rand_distr::LogNormal;
25use rust_decimal::Decimal;
26use rust_decimal_macros::dec;
27
28// ============================================================================
29// Constants
30// ============================================================================
31
32/// Acquiree company names used for generated acquisitions.
33const ACQUIREE_NAMES: &[&str] = &[
34    "Apex Innovations Ltd",
35    "BlueCrest Technologies Inc",
36    "Cascade Manufacturing Co",
37    "Deltron Systems GmbH",
38    "Elevate Software Corp",
39    "FusionTech Solutions",
40    "GlobalEdge Partners",
41    "Harbinger Analytics Inc",
42    "IronBridge Industries",
43    "Jetstream Logistics Ltd",
44    "Keystone Digital GmbH",
45    "Lighthouse Pharma Corp",
46    "Meridian Energy Solutions",
47    "NovaTrend Consulting",
48    "Oceanic Data Systems",
49    "Pinnacle Biotech AG",
50    "Quickstep Retail Group",
51    "Redwood Semiconductor",
52    "Silverline Communications",
53    "TrueVision AI Corp",
54];
55
56// ============================================================================
57// Output snapshot
58// ============================================================================
59
60/// All output from one run of the business combination generator.
61#[derive(Debug, Default)]
62pub struct BusinessCombinationSnapshot {
63    /// Business combination records.
64    pub combinations: Vec<BusinessCombination>,
65    /// All generated journal entries (Day 1 + amortization).
66    pub journal_entries: Vec<JournalEntry>,
67}
68
69// ============================================================================
70// Generator
71// ============================================================================
72
73/// Generates synthetic business combinations with purchase price allocation,
74/// goodwill computation, Day 1 journal entries, and amortization schedules.
75pub struct BusinessCombinationGenerator {
76    rng: ChaCha8Rng,
77    uuid_factory: DeterministicUuidFactory,
78}
79
80impl BusinessCombinationGenerator {
81    /// Create a new generator with a deterministic seed.
82    pub fn new(seed: u64) -> Self {
83        Self {
84            rng: seeded_rng(seed, 0),
85            uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::BusinessCombination),
86        }
87    }
88
89    /// Generate business combinations for a company.
90    ///
91    /// # Arguments
92    /// * `company_code` – Acquirer company code
93    /// * `currency` – Transaction currency (ISO 4217)
94    /// * `start_date` – Start of the generation period
95    /// * `end_date` – End of the generation period
96    /// * `acquisition_count` – How many acquisitions to generate (1-5)
97    /// * `framework` – "IFRS" or "US_GAAP"
98    pub fn generate(
99        &mut self,
100        company_code: &str,
101        currency: &str,
102        start_date: NaiveDate,
103        end_date: NaiveDate,
104        acquisition_count: usize,
105        framework: &str,
106    ) -> BusinessCombinationSnapshot {
107        if acquisition_count == 0 {
108            return BusinessCombinationSnapshot::default();
109        }
110
111        let count = acquisition_count.min(5);
112        let mut snapshot = BusinessCombinationSnapshot::default();
113
114        for i in 0..count {
115            let combination =
116                self.generate_one(company_code, currency, start_date, end_date, i, framework);
117
118            // Day 1 JEs
119            let day1_jes = self.generate_day1_journal_entries(company_code, currency, &combination);
120            snapshot.journal_entries.extend(day1_jes);
121
122            // Amortization JEs for finite-lived intangibles
123            let amort_jes = self.generate_amortization_journal_entries(
124                company_code,
125                currency,
126                &combination,
127                start_date,
128                end_date,
129            );
130            snapshot.journal_entries.extend(amort_jes);
131
132            snapshot.combinations.push(combination);
133        }
134
135        snapshot
136    }
137
138    // =========================================================================
139    // Private helpers
140    // =========================================================================
141
142    fn generate_one(
143        &mut self,
144        company_code: &str,
145        currency: &str,
146        start_date: NaiveDate,
147        end_date: NaiveDate,
148        index: usize,
149        framework: &str,
150    ) -> BusinessCombination {
151        let id = format!(
152            "BC-{}-{:04}",
153            company_code,
154            self.rng.random_range(1u32..=9999u32)
155        );
156
157        let acquiree_name = ACQUIREE_NAMES[index % ACQUIREE_NAMES.len()].to_string();
158
159        // Acquisition date: random day within [start_date + 30 days, end_date - 30 days]
160        let acquisition_date = self.random_date_in_period(start_date, end_date);
161
162        // --- Consideration ---
163        let total_consideration = self.sample_consideration_amount();
164        let consideration = self.build_consideration(total_consideration);
165
166        // --- PPA ---
167        let ppa = self.build_ppa(total_consideration, currency);
168
169        // --- Goodwill ---
170        let raw_goodwill = total_consideration - ppa.net_identifiable_assets_fv;
171        let goodwill = if raw_goodwill > Decimal::ZERO {
172            raw_goodwill
173        } else {
174            // Bargain purchase: IFRS 3/ASC 805 require gain recognition; goodwill = 0
175            Decimal::ZERO
176        };
177
178        BusinessCombination {
179            id,
180            acquirer_entity: company_code.to_string(),
181            acquiree_name,
182            // v5.2: not yet wired through to a manifest entity code in
183            // the synthetic generator — leave None.  Production
184            // generators that map acquisitions to consolidation graph
185            // entities can populate this directly.
186            acquiree_entity_code: None,
187            acquisition_date,
188            consideration,
189            purchase_price_allocation: ppa,
190            goodwill,
191            // v5.2: default to the v5.0–v5.1 proportionate basis so
192            // existing fixtures remain unchanged.  Engagements that
193            // need full-goodwill measurement set both
194            // `nci_measurement_method` and `acquisition_date_nci_fair_value`
195            // post-hoc on the generated record.
196            nci_measurement_method:
197                datasynth_core::models::intercompany::NciMeasurementMethod::Proportionate,
198            acquisition_date_nci_fair_value: None,
199            framework: framework.to_string(),
200        }
201    }
202
203    /// Draw a random consideration amount between ~1M and ~50M (log-normal).
204    fn sample_consideration_amount(&mut self) -> Decimal {
205        // Log-normal centered around ln(10M) ≈ 16.1 with σ = 1.0
206        let mu = 16.1_f64;
207        let sigma = 1.0_f64;
208        let log_normal = LogNormal::new(mu, sigma).expect("valid log-normal params");
209        let raw: f64 = log_normal.sample(&mut self.rng);
210        // Clamp to [1M, 50M]
211        let clamped = raw.clamp(1_000_000.0, 50_000_000.0);
212        // Round to nearest 1000
213        let rounded = (clamped / 1_000.0).round() * 1_000.0;
214        Decimal::from_f64_retain(rounded).unwrap_or(Decimal::from(10_000_000u64))
215    }
216
217    /// Build the consideration breakdown: 60-90% cash, remainder shares/contingent.
218    fn build_consideration(&mut self, total: Decimal) -> AcquisitionConsideration {
219        let cash_pct = self.rng.random_range(0.60_f64..=0.90_f64);
220        let cash_pct_dec = Decimal::from_f64_retain(cash_pct).unwrap_or(dec!(0.75));
221        let cash = (total * cash_pct_dec).round_dp(2);
222
223        let remainder = total - cash;
224
225        // 40% chance of contingent consideration from remaining balance
226        let contingent = if self.rng.random_bool(0.40) {
227            let contingent_pct = self.rng.random_range(0.30_f64..=0.60_f64);
228            let contingent_pct_dec = Decimal::from_f64_retain(contingent_pct).unwrap_or(dec!(0.40));
229            let c = (remainder * contingent_pct_dec).round_dp(2);
230            Some(c)
231        } else {
232            None
233        };
234
235        let shares_issued_value = if remainder > Decimal::ZERO {
236            let shares = remainder - contingent.unwrap_or(Decimal::ZERO);
237            if shares > Decimal::ZERO {
238                Some(shares.round_dp(2))
239            } else {
240                None
241            }
242        } else {
243            None
244        };
245
246        AcquisitionConsideration {
247            cash,
248            shares_issued_value,
249            contingent_consideration: contingent,
250            total,
251        }
252    }
253
254    /// Build the purchase price allocation with 4-6 asset/liability line items.
255    fn build_ppa(&mut self, total_consideration: Decimal, _currency: &str) -> AcquisitionPpa {
256        let mut assets: Vec<AcquisitionFvAdjustment> = Vec::new();
257        let mut liabilities: Vec<AcquisitionFvAdjustment> = Vec::new();
258
259        // 1. PP&E – step-up 10-25% of book value
260        let ppe_book = self.pct_of(total_consideration, 0.25_f64, 0.45_f64);
261        let ppe_stepup_pct = self.rng.random_range(0.10_f64..=0.25_f64);
262        let ppe_fv = self.apply_step_up(ppe_book, ppe_stepup_pct);
263        assets.push(AcquisitionFvAdjustment {
264            asset_or_liability: "Property, Plant & Equipment".to_string(),
265            book_value: ppe_book,
266            fair_value: ppe_fv,
267            step_up: ppe_fv - ppe_book,
268            useful_life_years: None, // PP&E amortized separately
269        });
270
271        // 2. Customer Relationships – new intangible, 15-25% of total consideration
272        let cr_fv = self.pct_of(total_consideration, 0.15_f64, 0.25_f64);
273        let cr_life = self.rng.random_range(10u32..=15u32);
274        assets.push(AcquisitionFvAdjustment {
275            asset_or_liability: "Customer Relationships".to_string(),
276            book_value: Decimal::ZERO,
277            fair_value: cr_fv,
278            step_up: cr_fv,
279            useful_life_years: Some(cr_life),
280        });
281
282        // 3. Trade Name – 5-10% of consideration
283        let tn_fv = self.pct_of(total_consideration, 0.05_f64, 0.10_f64);
284        let tn_life = self.rng.random_range(15u32..=20u32);
285        assets.push(AcquisitionFvAdjustment {
286            asset_or_liability: "Trade Name".to_string(),
287            book_value: Decimal::ZERO,
288            fair_value: tn_fv,
289            step_up: tn_fv,
290            useful_life_years: Some(tn_life),
291        });
292
293        // 4. Technology / Developed Software – 5-15% of consideration
294        let tech_fv = self.pct_of(total_consideration, 0.05_f64, 0.15_f64);
295        let tech_life = self.rng.random_range(5u32..=8u32);
296        assets.push(AcquisitionFvAdjustment {
297            asset_or_liability: "Developed Technology".to_string(),
298            book_value: Decimal::ZERO,
299            fair_value: tech_fv,
300            step_up: tech_fv,
301            useful_life_years: Some(tech_life),
302        });
303
304        // 5. Inventory – step-up 3-8% of book value
305        let inv_book = self.pct_of(total_consideration, 0.10_f64, 0.20_f64);
306        let inv_stepup_pct = self.rng.random_range(0.03_f64..=0.08_f64);
307        let inv_fv = self.apply_step_up(inv_book, inv_stepup_pct);
308        assets.push(AcquisitionFvAdjustment {
309            asset_or_liability: "Inventory".to_string(),
310            book_value: inv_book,
311            fair_value: inv_fv,
312            step_up: inv_fv - inv_book,
313            useful_life_years: None,
314        });
315
316        // 6. (optional) AR – at book value
317        if self.rng.random_bool(0.70) {
318            let ar_book = self.pct_of(total_consideration, 0.05_f64, 0.15_f64);
319            assets.push(AcquisitionFvAdjustment {
320                asset_or_liability: "Accounts Receivable".to_string(),
321                book_value: ar_book,
322                fair_value: ar_book, // typically at book for collectible AR
323                step_up: Decimal::ZERO,
324                useful_life_years: None,
325            });
326        }
327
328        // Liabilities assumed
329        // Accounts Payable
330        let ap_book = self.pct_of(total_consideration, 0.08_f64, 0.18_f64);
331        liabilities.push(AcquisitionFvAdjustment {
332            asset_or_liability: "Accounts Payable".to_string(),
333            book_value: ap_book,
334            fair_value: ap_book,
335            step_up: Decimal::ZERO,
336            useful_life_years: None,
337        });
338
339        // Long-term debt (70% chance)
340        if self.rng.random_bool(0.70) {
341            let debt_book = self.pct_of(total_consideration, 0.10_f64, 0.25_f64);
342            // Debt FV may differ slightly from book value when interest rates have moved
343            let debt_fv_adj = self.rng.random_range(-0.05_f64..=0.05_f64);
344            let debt_fv = self.apply_step_up(debt_book, debt_fv_adj);
345            liabilities.push(AcquisitionFvAdjustment {
346                asset_or_liability: "Long-term Debt".to_string(),
347                book_value: debt_book,
348                fair_value: debt_fv,
349                step_up: debt_fv - debt_book,
350                useful_life_years: None,
351            });
352        }
353
354        // Deferred Revenue (if any)
355        if self.rng.random_bool(0.40) {
356            let def_rev = self.pct_of(total_consideration, 0.02_f64, 0.06_f64);
357            liabilities.push(AcquisitionFvAdjustment {
358                asset_or_liability: "Deferred Revenue".to_string(),
359                book_value: def_rev,
360                fair_value: def_rev,
361                step_up: Decimal::ZERO,
362                useful_life_years: None,
363            });
364        }
365
366        // Compute net identifiable assets at FV
367        let total_asset_fv: Decimal = assets.iter().map(|a| a.fair_value).sum();
368        let total_liability_fv: Decimal = liabilities.iter().map(|l| l.fair_value).sum();
369        let net_identifiable_assets_fv = total_asset_fv - total_liability_fv;
370
371        AcquisitionPpa {
372            identifiable_assets: assets,
373            identifiable_liabilities: liabilities,
374            net_identifiable_assets_fv,
375        }
376    }
377
378    /// Generate the Day 1 acquisition journal entry:
379    ///   DR acquired assets at fair value
380    ///   DR Goodwill
381    ///   CR acquired liabilities at fair value
382    ///   CR Cash / Consideration
383    fn generate_day1_journal_entries(
384        &mut self,
385        company_code: &str,
386        currency: &str,
387        bc: &BusinessCombination,
388    ) -> Vec<JournalEntry> {
389        let doc_id = self.uuid_factory.next();
390        let mut header = JournalEntryHeader::with_deterministic_id(
391            company_code.to_string(),
392            bc.acquisition_date,
393            doc_id,
394        );
395        header.document_type = "BC".to_string();
396        header.currency = currency.to_string();
397        header.source = TransactionSource::Manual;
398        header.header_text = Some(format!("Acquisition of {} – Day 1 PPA", bc.acquiree_name));
399        header.reference = Some(bc.id.clone());
400
401        let mut je = JournalEntry::new(header);
402        let mut line_num: u32 = 1;
403
404        // DR acquired assets
405        for adj in &bc.purchase_price_allocation.identifiable_assets {
406            if adj.fair_value > Decimal::ZERO {
407                let account = asset_gl_account(&adj.asset_or_liability);
408                let mut line = JournalEntryLine::debit(doc_id, line_num, account, adj.fair_value);
409                line.line_text = Some(format!("Acquired asset: {}", adj.asset_or_liability));
410                je.add_line(line);
411                line_num += 1;
412            }
413        }
414
415        // DR Goodwill (if any)
416        if bc.goodwill > Decimal::ZERO {
417            let mut line =
418                JournalEntryLine::debit(doc_id, line_num, GOODWILL.to_string(), bc.goodwill);
419            line.line_text = Some(format!("Goodwill – acquisition of {}", bc.acquiree_name));
420            je.add_line(line);
421            line_num += 1;
422        }
423
424        // CR acquired liabilities
425        for adj in &bc.purchase_price_allocation.identifiable_liabilities {
426            if adj.fair_value > Decimal::ZERO {
427                let account = liability_gl_account(&adj.asset_or_liability);
428                let mut line = JournalEntryLine::credit(doc_id, line_num, account, adj.fair_value);
429                line.line_text = Some(format!("Assumed liability: {}", adj.asset_or_liability));
430                je.add_line(line);
431                line_num += 1;
432            }
433        }
434
435        // CR Cash for cash portion of consideration
436        if bc.consideration.cash > Decimal::ZERO {
437            let mut line = JournalEntryLine::credit(
438                doc_id,
439                line_num,
440                OPERATING_CASH.to_string(),
441                bc.consideration.cash,
442            );
443            line.line_text = Some("Cash paid – business combination".to_string());
444            je.add_line(line);
445            line_num += 1;
446        }
447
448        // CR Shares issued (if any) – APIC placeholder account "3100"
449        if let Some(shares_val) = bc.consideration.shares_issued_value {
450            if shares_val > Decimal::ZERO {
451                let mut line =
452                    JournalEntryLine::credit(doc_id, line_num, "3100".to_string(), shares_val);
453                line.line_text = Some("Shares issued – business combination".to_string());
454                je.add_line(line);
455                line_num += 1;
456            }
457        }
458
459        // CR Contingent consideration liability (if any).
460        // Reuses the pension-liability slot (2800) per the canonical account map.
461        if let Some(contingent) = bc.consideration.contingent_consideration {
462            if contingent > Decimal::ZERO {
463                let mut line = JournalEntryLine::credit(
464                    doc_id,
465                    line_num,
466                    datasynth_core::accounts::liability_accounts::NET_PENSION_LIABILITY.to_string(),
467                    contingent,
468                );
469                line.line_text = Some("Contingent consideration liability".to_string());
470                je.add_line(line);
471                line_num += 1;
472            }
473        }
474
475        // If bargain purchase (consideration < net identifiable assets): CR Gain
476        let raw_goodwill =
477            bc.consideration.total - bc.purchase_price_allocation.net_identifiable_assets_fv;
478        if raw_goodwill < Decimal::ZERO {
479            let gain = (-raw_goodwill).round_dp(2);
480            let mut line =
481                JournalEntryLine::credit(doc_id, line_num, BARGAIN_PURCHASE_GAIN.to_string(), gain);
482            line.line_text = Some("Bargain purchase gain".to_string());
483            je.add_line(line);
484        }
485
486        vec![je]
487    }
488
489    /// Generate amortization JEs for finite-lived acquired intangibles, one
490    /// JE per fiscal period (month) within the generation window where
491    /// the combination date falls before the period end.
492    fn generate_amortization_journal_entries(
493        &mut self,
494        company_code: &str,
495        currency: &str,
496        bc: &BusinessCombination,
497        start_date: NaiveDate,
498        end_date: NaiveDate,
499    ) -> Vec<JournalEntry> {
500        let mut jes = Vec::new();
501
502        // Collect finite-lived intangibles from PPA
503        let intangibles: Vec<(&AcquisitionFvAdjustment, u32)> = bc
504            .purchase_price_allocation
505            .identifiable_assets
506            .iter()
507            .filter_map(|adj| adj.useful_life_years.map(|life| (adj, life)))
508            .filter(|(adj, _)| adj.fair_value > Decimal::ZERO)
509            .collect();
510
511        if intangibles.is_empty() {
512            return jes;
513        }
514
515        // Build list of month-end posting dates within the window
516        let mut period_dates: Vec<NaiveDate> = Vec::new();
517        let acq_date = bc.acquisition_date;
518        let mut current =
519            NaiveDate::from_ymd_opt(start_date.year(), start_date.month(), 1).unwrap_or(start_date);
520
521        loop {
522            // Last day of the current month
523            let month_end = last_day_of_month(current.year(), current.month());
524            if month_end > end_date {
525                break;
526            }
527            // Only post amortization after acquisition
528            if month_end > acq_date {
529                period_dates.push(month_end);
530            }
531            // Advance to next month
532            let next_month = current.month() % 12 + 1;
533            let next_year = if current.month() == 12 {
534                current.year() + 1
535            } else {
536                current.year()
537            };
538            match NaiveDate::from_ymd_opt(next_year, next_month, 1) {
539                Some(d) => current = d,
540                None => break,
541            }
542        }
543
544        for period_end in period_dates {
545            let doc_id = self.uuid_factory.next();
546            let mut header = JournalEntryHeader::with_deterministic_id(
547                company_code.to_string(),
548                period_end,
549                doc_id,
550            );
551            header.document_type = "AM".to_string();
552            header.currency = currency.to_string();
553            header.source = TransactionSource::Automated;
554            header.header_text = Some(format!(
555                "Amortization – acquired intangibles ({})",
556                bc.acquiree_name
557            ));
558            header.reference = Some(bc.id.clone());
559
560            let mut je = JournalEntry::new(header);
561            let mut line_num: u32 = 1;
562            for (adj, life_years) in &intangibles {
563                // Monthly amortization = fair_value / (useful_life_years * 12)
564                let months = Decimal::from(*life_years) * Decimal::from(12u32);
565                let monthly_amort = (adj.fair_value / months).round_dp(2);
566
567                if monthly_amort == Decimal::ZERO {
568                    continue;
569                }
570
571                let amort_account = intangible_amort_account(&adj.asset_or_liability);
572
573                // DR Amortization Expense
574                let mut dr_line = JournalEntryLine::debit(
575                    doc_id,
576                    line_num,
577                    AMORTIZATION_EXPENSE.to_string(),
578                    monthly_amort,
579                );
580                dr_line.line_text = Some(format!("Amortization – {}", adj.asset_or_liability));
581                je.add_line(dr_line);
582                line_num += 1;
583
584                // CR Accumulated Amortization
585                let mut cr_line =
586                    JournalEntryLine::credit(doc_id, line_num, amort_account, monthly_amort);
587                cr_line.line_text =
588                    Some(format!("Accum. amortization – {}", adj.asset_or_liability));
589                je.add_line(cr_line);
590                line_num += 1;
591            }
592
593            // Only add JE if it has lines
594            if !je.lines.is_empty() {
595                jes.push(je);
596            }
597        }
598
599        jes
600    }
601
602    // =========================================================================
603    // Utility helpers
604    // =========================================================================
605
606    /// Return an amount that is `pct_min .. pct_max` percent of `base`, rounded to 2 dp.
607    fn pct_of(&mut self, base: Decimal, pct_min: f64, pct_max: f64) -> Decimal {
608        let pct = self.rng.random_range(pct_min..=pct_max);
609        let pct_dec = Decimal::from_f64_retain(pct)
610            .unwrap_or(Decimal::from_f64_retain(pct_min).unwrap_or(Decimal::ONE));
611        (base * pct_dec).round_dp(2)
612    }
613
614    /// Apply a percentage step-up to a book value; returns fair value.
615    fn apply_step_up(&mut self, book_value: Decimal, step_up_pct: f64) -> Decimal {
616        let pct_dec = Decimal::from_f64_retain(step_up_pct).unwrap_or(Decimal::ZERO);
617        (book_value * (Decimal::ONE + pct_dec)).round_dp(2)
618    }
619
620    /// Generate a random acquisition date within the period, biased toward
621    /// the first three quarters to leave room for amortization.
622    fn random_date_in_period(&mut self, start: NaiveDate, end: NaiveDate) -> NaiveDate {
623        let total_days = (end - start).num_days();
624        if total_days <= 0 {
625            return start;
626        }
627        // Use first 75% of the window so amortization JEs can be generated
628        let usable_days = (total_days * 3 / 4).max(1);
629        let offset = self.rng.random_range(0i64..usable_days);
630        start + chrono::Duration::days(offset)
631    }
632}
633
634// ============================================================================
635// GL account mapping helpers
636// ============================================================================
637
638/// Map an asset description to its GL account number.
639fn asset_gl_account(description: &str) -> String {
640    match description {
641        "Property, Plant & Equipment" => FIXED_ASSETS.to_string(),
642        "Customer Relationships" => CUSTOMER_RELATIONSHIPS.to_string(),
643        "Trade Name" => TRADE_NAME.to_string(),
644        "Developed Technology" => TECHNOLOGY.to_string(),
645        "Inventory" => "1200".to_string(),
646        "Accounts Receivable" => "1100".to_string(),
647        _ => "1890".to_string(), // Other intangible assets
648    }
649}
650
651/// Map a liability description to its GL account number.
652fn liability_gl_account(description: &str) -> String {
653    match description {
654        "Accounts Payable" => "2000".to_string(),
655        "Long-term Debt" => "2600".to_string(),
656        "Deferred Revenue" => "2300".to_string(),
657        _ => "2890".to_string(), // Other assumed liabilities
658    }
659}
660
661/// Map an intangible asset to its accumulated amortization contra-account.
662fn intangible_amort_account(description: &str) -> String {
663    // All finite-lived intangibles use ACCUMULATED_AMORTIZATION.
664    // In a real system these would be sub-accounts; for simplicity we use one.
665    let _ = description;
666    ACCUMULATED_AMORTIZATION.to_string()
667}
668
669/// Return the last calendar day of the given year/month.
670fn last_day_of_month(year: i32, month: u32) -> NaiveDate {
671    let next_month = month % 12 + 1;
672    let next_year = if month == 12 { year + 1 } else { year };
673    NaiveDate::from_ymd_opt(next_year, next_month, 1)
674        .and_then(|d| d.pred_opt())
675        .unwrap_or_else(|| {
676            // Fallback: use the 28th which is always valid.
677            NaiveDate::from_ymd_opt(year, month, 28)
678                .unwrap_or(NaiveDate::from_ymd_opt(year, 1, 28).unwrap_or(NaiveDate::MIN))
679        })
680}
681
682// ============================================================================
683// Tests
684// ============================================================================
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689
690    fn make_gen() -> BusinessCombinationGenerator {
691        BusinessCombinationGenerator::new(42)
692    }
693
694    fn make_dates() -> (NaiveDate, NaiveDate) {
695        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
696        let end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
697        (start, end)
698    }
699
700    #[test]
701    fn test_basic_generation() {
702        let mut gen = make_gen();
703        let (start, end) = make_dates();
704        let snap = gen.generate("C001", "USD", start, end, 2, "IFRS");
705
706        assert_eq!(snap.combinations.len(), 2);
707        assert!(!snap.journal_entries.is_empty());
708    }
709
710    #[test]
711    fn test_goodwill_equals_consideration_minus_net_assets() {
712        let mut gen = make_gen();
713        let (start, end) = make_dates();
714        let snap = gen.generate("C001", "USD", start, end, 3, "US_GAAP");
715
716        for bc in &snap.combinations {
717            let raw_goodwill =
718                bc.consideration.total - bc.purchase_price_allocation.net_identifiable_assets_fv;
719            if raw_goodwill >= Decimal::ZERO {
720                assert_eq!(bc.goodwill, raw_goodwill, "Goodwill mismatch for {}", bc.id);
721            } else {
722                // Bargain purchase: goodwill must be zero
723                assert_eq!(
724                    bc.goodwill,
725                    Decimal::ZERO,
726                    "Bargain purchase goodwill should be zero for {}",
727                    bc.id
728                );
729            }
730        }
731    }
732
733    #[test]
734    fn test_at_least_4_identifiable_assets() {
735        let mut gen = make_gen();
736        let (start, end) = make_dates();
737        let snap = gen.generate("C001", "USD", start, end, 3, "IFRS");
738
739        for bc in &snap.combinations {
740            assert!(
741                bc.purchase_price_allocation.identifiable_assets.len() >= 4,
742                "PPA should have at least 4 assets, got {} for {}",
743                bc.purchase_price_allocation.identifiable_assets.len(),
744                bc.id
745            );
746        }
747    }
748
749    #[test]
750    fn test_day1_jes_balanced() {
751        let mut gen = make_gen();
752        let (start, end) = make_dates();
753        let snap = gen.generate("C001", "USD", start, end, 2, "IFRS");
754
755        // Collect only Day 1 JEs (document_type "BC")
756        let day1_jes: Vec<_> = snap
757            .journal_entries
758            .iter()
759            .filter(|je| je.header.document_type == "BC")
760            .collect();
761
762        assert!(!day1_jes.is_empty(), "Should have Day 1 JEs");
763
764        for je in &day1_jes {
765            let total_debits: Decimal = je.lines.iter().map(|l| l.debit_amount).sum();
766            let total_credits: Decimal = je.lines.iter().map(|l| l.credit_amount).sum();
767            assert_eq!(
768                total_debits, total_credits,
769                "Day 1 JE {} is unbalanced: debits={}, credits={}",
770                je.header.document_id, total_debits, total_credits
771            );
772        }
773    }
774
775    #[test]
776    fn test_amortization_jes_balanced() {
777        let mut gen = make_gen();
778        let (start, end) = make_dates();
779        let snap = gen.generate("C001", "USD", start, end, 2, "IFRS");
780
781        let amort_jes: Vec<_> = snap
782            .journal_entries
783            .iter()
784            .filter(|je| je.header.document_type == "AM")
785            .collect();
786
787        assert!(!amort_jes.is_empty(), "Should have amortization JEs");
788
789        for je in &amort_jes {
790            let total_debits: Decimal = je.lines.iter().map(|l| l.debit_amount).sum();
791            let total_credits: Decimal = je.lines.iter().map(|l| l.credit_amount).sum();
792            assert_eq!(
793                total_debits, total_credits,
794                "Amortization JE {} is unbalanced: debits={}, credits={}",
795                je.header.document_id, total_debits, total_credits
796            );
797        }
798    }
799
800    #[test]
801    fn test_ppa_fair_values_positive_for_assets() {
802        let mut gen = make_gen();
803        let (start, end) = make_dates();
804        let snap = gen.generate("C001", "USD", start, end, 2, "US_GAAP");
805
806        for bc in &snap.combinations {
807            for adj in &bc.purchase_price_allocation.identifiable_assets {
808                assert!(
809                    adj.fair_value > Decimal::ZERO,
810                    "Asset {} should have positive fair value for {}",
811                    adj.asset_or_liability,
812                    bc.id
813                );
814            }
815        }
816    }
817
818    #[test]
819    fn test_consideration_total_correct() {
820        let mut gen = make_gen();
821        let (start, end) = make_dates();
822        let snap = gen.generate("C001", "USD", start, end, 3, "IFRS");
823
824        for bc in &snap.combinations {
825            let c = &bc.consideration;
826            let computed_total = c.cash
827                + c.shares_issued_value.unwrap_or(Decimal::ZERO)
828                + c.contingent_consideration.unwrap_or(Decimal::ZERO);
829            assert_eq!(
830                computed_total, c.total,
831                "Consideration components don't add up for {}",
832                bc.id
833            );
834        }
835    }
836
837    #[test]
838    fn test_deterministic_output() {
839        let (start, end) = make_dates();
840        let mut gen1 = BusinessCombinationGenerator::new(99);
841        let mut gen2 = BusinessCombinationGenerator::new(99);
842
843        let snap1 = gen1.generate("C001", "USD", start, end, 2, "IFRS");
844        let snap2 = gen2.generate("C001", "USD", start, end, 2, "IFRS");
845
846        assert_eq!(snap1.combinations.len(), snap2.combinations.len());
847        for (a, b) in snap1.combinations.iter().zip(snap2.combinations.iter()) {
848            assert_eq!(a.id, b.id);
849            assert_eq!(a.goodwill, b.goodwill);
850            assert_eq!(a.consideration.total, b.consideration.total);
851        }
852        assert_eq!(snap1.journal_entries.len(), snap2.journal_entries.len());
853    }
854
855    #[test]
856    fn test_zero_count_returns_empty() {
857        let mut gen = make_gen();
858        let (start, end) = make_dates();
859        let snap = gen.generate("C001", "USD", start, end, 0, "IFRS");
860        assert!(snap.combinations.is_empty());
861        assert!(snap.journal_entries.is_empty());
862    }
863}