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            acquisition_date,
183            consideration,
184            purchase_price_allocation: ppa,
185            goodwill,
186            framework: framework.to_string(),
187        }
188    }
189
190    /// Draw a random consideration amount between ~1M and ~50M (log-normal).
191    fn sample_consideration_amount(&mut self) -> Decimal {
192        // Log-normal centered around ln(10M) ≈ 16.1 with σ = 1.0
193        let mu = 16.1_f64;
194        let sigma = 1.0_f64;
195        let log_normal = LogNormal::new(mu, sigma).expect("valid log-normal params");
196        let raw: f64 = log_normal.sample(&mut self.rng);
197        // Clamp to [1M, 50M]
198        let clamped = raw.clamp(1_000_000.0, 50_000_000.0);
199        // Round to nearest 1000
200        let rounded = (clamped / 1_000.0).round() * 1_000.0;
201        Decimal::from_f64_retain(rounded).unwrap_or(Decimal::from(10_000_000u64))
202    }
203
204    /// Build the consideration breakdown: 60-90% cash, remainder shares/contingent.
205    fn build_consideration(&mut self, total: Decimal) -> AcquisitionConsideration {
206        let cash_pct = self.rng.random_range(0.60_f64..=0.90_f64);
207        let cash_pct_dec = Decimal::from_f64_retain(cash_pct).unwrap_or(dec!(0.75));
208        let cash = (total * cash_pct_dec).round_dp(2);
209
210        let remainder = total - cash;
211
212        // 40% chance of contingent consideration from remaining balance
213        let contingent = if self.rng.random_bool(0.40) {
214            let contingent_pct = self.rng.random_range(0.30_f64..=0.60_f64);
215            let contingent_pct_dec = Decimal::from_f64_retain(contingent_pct).unwrap_or(dec!(0.40));
216            let c = (remainder * contingent_pct_dec).round_dp(2);
217            Some(c)
218        } else {
219            None
220        };
221
222        let shares_issued_value = if remainder > Decimal::ZERO {
223            let shares = remainder - contingent.unwrap_or(Decimal::ZERO);
224            if shares > Decimal::ZERO {
225                Some(shares.round_dp(2))
226            } else {
227                None
228            }
229        } else {
230            None
231        };
232
233        AcquisitionConsideration {
234            cash,
235            shares_issued_value,
236            contingent_consideration: contingent,
237            total,
238        }
239    }
240
241    /// Build the purchase price allocation with 4-6 asset/liability line items.
242    fn build_ppa(&mut self, total_consideration: Decimal, _currency: &str) -> AcquisitionPpa {
243        let mut assets: Vec<AcquisitionFvAdjustment> = Vec::new();
244        let mut liabilities: Vec<AcquisitionFvAdjustment> = Vec::new();
245
246        // 1. PP&E – step-up 10-25% of book value
247        let ppe_book = self.pct_of(total_consideration, 0.25_f64, 0.45_f64);
248        let ppe_stepup_pct = self.rng.random_range(0.10_f64..=0.25_f64);
249        let ppe_fv = self.apply_step_up(ppe_book, ppe_stepup_pct);
250        assets.push(AcquisitionFvAdjustment {
251            asset_or_liability: "Property, Plant & Equipment".to_string(),
252            book_value: ppe_book,
253            fair_value: ppe_fv,
254            step_up: ppe_fv - ppe_book,
255            useful_life_years: None, // PP&E amortized separately
256        });
257
258        // 2. Customer Relationships – new intangible, 15-25% of total consideration
259        let cr_fv = self.pct_of(total_consideration, 0.15_f64, 0.25_f64);
260        let cr_life = self.rng.random_range(10u32..=15u32);
261        assets.push(AcquisitionFvAdjustment {
262            asset_or_liability: "Customer Relationships".to_string(),
263            book_value: Decimal::ZERO,
264            fair_value: cr_fv,
265            step_up: cr_fv,
266            useful_life_years: Some(cr_life),
267        });
268
269        // 3. Trade Name – 5-10% of consideration
270        let tn_fv = self.pct_of(total_consideration, 0.05_f64, 0.10_f64);
271        let tn_life = self.rng.random_range(15u32..=20u32);
272        assets.push(AcquisitionFvAdjustment {
273            asset_or_liability: "Trade Name".to_string(),
274            book_value: Decimal::ZERO,
275            fair_value: tn_fv,
276            step_up: tn_fv,
277            useful_life_years: Some(tn_life),
278        });
279
280        // 4. Technology / Developed Software – 5-15% of consideration
281        let tech_fv = self.pct_of(total_consideration, 0.05_f64, 0.15_f64);
282        let tech_life = self.rng.random_range(5u32..=8u32);
283        assets.push(AcquisitionFvAdjustment {
284            asset_or_liability: "Developed Technology".to_string(),
285            book_value: Decimal::ZERO,
286            fair_value: tech_fv,
287            step_up: tech_fv,
288            useful_life_years: Some(tech_life),
289        });
290
291        // 5. Inventory – step-up 3-8% of book value
292        let inv_book = self.pct_of(total_consideration, 0.10_f64, 0.20_f64);
293        let inv_stepup_pct = self.rng.random_range(0.03_f64..=0.08_f64);
294        let inv_fv = self.apply_step_up(inv_book, inv_stepup_pct);
295        assets.push(AcquisitionFvAdjustment {
296            asset_or_liability: "Inventory".to_string(),
297            book_value: inv_book,
298            fair_value: inv_fv,
299            step_up: inv_fv - inv_book,
300            useful_life_years: None,
301        });
302
303        // 6. (optional) AR – at book value
304        if self.rng.random_bool(0.70) {
305            let ar_book = self.pct_of(total_consideration, 0.05_f64, 0.15_f64);
306            assets.push(AcquisitionFvAdjustment {
307                asset_or_liability: "Accounts Receivable".to_string(),
308                book_value: ar_book,
309                fair_value: ar_book, // typically at book for collectible AR
310                step_up: Decimal::ZERO,
311                useful_life_years: None,
312            });
313        }
314
315        // Liabilities assumed
316        // Accounts Payable
317        let ap_book = self.pct_of(total_consideration, 0.08_f64, 0.18_f64);
318        liabilities.push(AcquisitionFvAdjustment {
319            asset_or_liability: "Accounts Payable".to_string(),
320            book_value: ap_book,
321            fair_value: ap_book,
322            step_up: Decimal::ZERO,
323            useful_life_years: None,
324        });
325
326        // Long-term debt (70% chance)
327        if self.rng.random_bool(0.70) {
328            let debt_book = self.pct_of(total_consideration, 0.10_f64, 0.25_f64);
329            // Debt FV may differ slightly from book value when interest rates have moved
330            let debt_fv_adj = self.rng.random_range(-0.05_f64..=0.05_f64);
331            let debt_fv = self.apply_step_up(debt_book, debt_fv_adj);
332            liabilities.push(AcquisitionFvAdjustment {
333                asset_or_liability: "Long-term Debt".to_string(),
334                book_value: debt_book,
335                fair_value: debt_fv,
336                step_up: debt_fv - debt_book,
337                useful_life_years: None,
338            });
339        }
340
341        // Deferred Revenue (if any)
342        if self.rng.random_bool(0.40) {
343            let def_rev = self.pct_of(total_consideration, 0.02_f64, 0.06_f64);
344            liabilities.push(AcquisitionFvAdjustment {
345                asset_or_liability: "Deferred Revenue".to_string(),
346                book_value: def_rev,
347                fair_value: def_rev,
348                step_up: Decimal::ZERO,
349                useful_life_years: None,
350            });
351        }
352
353        // Compute net identifiable assets at FV
354        let total_asset_fv: Decimal = assets.iter().map(|a| a.fair_value).sum();
355        let total_liability_fv: Decimal = liabilities.iter().map(|l| l.fair_value).sum();
356        let net_identifiable_assets_fv = total_asset_fv - total_liability_fv;
357
358        AcquisitionPpa {
359            identifiable_assets: assets,
360            identifiable_liabilities: liabilities,
361            net_identifiable_assets_fv,
362        }
363    }
364
365    /// Generate the Day 1 acquisition journal entry:
366    ///   DR acquired assets at fair value
367    ///   DR Goodwill
368    ///   CR acquired liabilities at fair value
369    ///   CR Cash / Consideration
370    fn generate_day1_journal_entries(
371        &mut self,
372        company_code: &str,
373        currency: &str,
374        bc: &BusinessCombination,
375    ) -> Vec<JournalEntry> {
376        let doc_id = self.uuid_factory.next();
377        let mut header = JournalEntryHeader::with_deterministic_id(
378            company_code.to_string(),
379            bc.acquisition_date,
380            doc_id,
381        );
382        header.document_type = "BC".to_string();
383        header.currency = currency.to_string();
384        header.source = TransactionSource::Manual;
385        header.header_text = Some(format!("Acquisition of {} – Day 1 PPA", bc.acquiree_name));
386        header.reference = Some(bc.id.clone());
387
388        let mut je = JournalEntry::new(header);
389        let mut line_num: u32 = 1;
390
391        // DR acquired assets
392        for adj in &bc.purchase_price_allocation.identifiable_assets {
393            if adj.fair_value > Decimal::ZERO {
394                let account = asset_gl_account(&adj.asset_or_liability);
395                let mut line = JournalEntryLine::debit(doc_id, line_num, account, adj.fair_value);
396                line.line_text = Some(format!("Acquired asset: {}", adj.asset_or_liability));
397                je.add_line(line);
398                line_num += 1;
399            }
400        }
401
402        // DR Goodwill (if any)
403        if bc.goodwill > Decimal::ZERO {
404            let mut line =
405                JournalEntryLine::debit(doc_id, line_num, GOODWILL.to_string(), bc.goodwill);
406            line.line_text = Some(format!("Goodwill – acquisition of {}", bc.acquiree_name));
407            je.add_line(line);
408            line_num += 1;
409        }
410
411        // CR acquired liabilities
412        for adj in &bc.purchase_price_allocation.identifiable_liabilities {
413            if adj.fair_value > Decimal::ZERO {
414                let account = liability_gl_account(&adj.asset_or_liability);
415                let mut line = JournalEntryLine::credit(doc_id, line_num, account, adj.fair_value);
416                line.line_text = Some(format!("Assumed liability: {}", adj.asset_or_liability));
417                je.add_line(line);
418                line_num += 1;
419            }
420        }
421
422        // CR Cash for cash portion of consideration
423        if bc.consideration.cash > Decimal::ZERO {
424            let mut line = JournalEntryLine::credit(
425                doc_id,
426                line_num,
427                OPERATING_CASH.to_string(),
428                bc.consideration.cash,
429            );
430            line.line_text = Some("Cash paid – business combination".to_string());
431            je.add_line(line);
432            line_num += 1;
433        }
434
435        // CR Shares issued (if any) – APIC placeholder account "3100"
436        if let Some(shares_val) = bc.consideration.shares_issued_value {
437            if shares_val > Decimal::ZERO {
438                let mut line =
439                    JournalEntryLine::credit(doc_id, line_num, "3100".to_string(), shares_val);
440                line.line_text = Some("Shares issued – business combination".to_string());
441                je.add_line(line);
442                line_num += 1;
443            }
444        }
445
446        // CR Contingent consideration liability (if any) – account "2800"
447        if let Some(contingent) = bc.consideration.contingent_consideration {
448            if contingent > Decimal::ZERO {
449                let mut line =
450                    JournalEntryLine::credit(doc_id, line_num, "2800".to_string(), contingent);
451                line.line_text = Some("Contingent consideration liability".to_string());
452                je.add_line(line);
453                line_num += 1;
454            }
455        }
456
457        // If bargain purchase (consideration < net identifiable assets): CR Gain
458        let raw_goodwill =
459            bc.consideration.total - bc.purchase_price_allocation.net_identifiable_assets_fv;
460        if raw_goodwill < Decimal::ZERO {
461            let gain = (-raw_goodwill).round_dp(2);
462            let mut line =
463                JournalEntryLine::credit(doc_id, line_num, BARGAIN_PURCHASE_GAIN.to_string(), gain);
464            line.line_text = Some("Bargain purchase gain".to_string());
465            je.add_line(line);
466        }
467
468        vec![je]
469    }
470
471    /// Generate amortization JEs for finite-lived acquired intangibles, one
472    /// JE per fiscal period (month) within the generation window where
473    /// the combination date falls before the period end.
474    fn generate_amortization_journal_entries(
475        &mut self,
476        company_code: &str,
477        currency: &str,
478        bc: &BusinessCombination,
479        start_date: NaiveDate,
480        end_date: NaiveDate,
481    ) -> Vec<JournalEntry> {
482        let mut jes = Vec::new();
483
484        // Collect finite-lived intangibles from PPA
485        let intangibles: Vec<(&AcquisitionFvAdjustment, u32)> = bc
486            .purchase_price_allocation
487            .identifiable_assets
488            .iter()
489            .filter_map(|adj| adj.useful_life_years.map(|life| (adj, life)))
490            .filter(|(adj, _)| adj.fair_value > Decimal::ZERO)
491            .collect();
492
493        if intangibles.is_empty() {
494            return jes;
495        }
496
497        // Build list of month-end posting dates within the window
498        let mut period_dates: Vec<NaiveDate> = Vec::new();
499        let acq_date = bc.acquisition_date;
500        let mut current =
501            NaiveDate::from_ymd_opt(start_date.year(), start_date.month(), 1).unwrap_or(start_date);
502
503        loop {
504            // Last day of the current month
505            let month_end = last_day_of_month(current.year(), current.month());
506            if month_end > end_date {
507                break;
508            }
509            // Only post amortization after acquisition
510            if month_end > acq_date {
511                period_dates.push(month_end);
512            }
513            // Advance to next month
514            let next_month = current.month() % 12 + 1;
515            let next_year = if current.month() == 12 {
516                current.year() + 1
517            } else {
518                current.year()
519            };
520            match NaiveDate::from_ymd_opt(next_year, next_month, 1) {
521                Some(d) => current = d,
522                None => break,
523            }
524        }
525
526        for period_end in period_dates {
527            let doc_id = self.uuid_factory.next();
528            let mut header = JournalEntryHeader::with_deterministic_id(
529                company_code.to_string(),
530                period_end,
531                doc_id,
532            );
533            header.document_type = "AM".to_string();
534            header.currency = currency.to_string();
535            header.source = TransactionSource::Automated;
536            header.header_text = Some(format!(
537                "Amortization – acquired intangibles ({})",
538                bc.acquiree_name
539            ));
540            header.reference = Some(bc.id.clone());
541
542            let mut je = JournalEntry::new(header);
543            let mut line_num: u32 = 1;
544            for (adj, life_years) in &intangibles {
545                // Monthly amortization = fair_value / (useful_life_years * 12)
546                let months = Decimal::from(*life_years) * Decimal::from(12u32);
547                let monthly_amort = (adj.fair_value / months).round_dp(2);
548
549                if monthly_amort == Decimal::ZERO {
550                    continue;
551                }
552
553                let amort_account = intangible_amort_account(&adj.asset_or_liability);
554
555                // DR Amortization Expense
556                let mut dr_line = JournalEntryLine::debit(
557                    doc_id,
558                    line_num,
559                    AMORTIZATION_EXPENSE.to_string(),
560                    monthly_amort,
561                );
562                dr_line.line_text = Some(format!("Amortization – {}", adj.asset_or_liability));
563                je.add_line(dr_line);
564                line_num += 1;
565
566                // CR Accumulated Amortization
567                let mut cr_line =
568                    JournalEntryLine::credit(doc_id, line_num, amort_account, monthly_amort);
569                cr_line.line_text =
570                    Some(format!("Accum. amortization – {}", adj.asset_or_liability));
571                je.add_line(cr_line);
572                line_num += 1;
573            }
574
575            // Only add JE if it has lines
576            if !je.lines.is_empty() {
577                jes.push(je);
578            }
579        }
580
581        jes
582    }
583
584    // =========================================================================
585    // Utility helpers
586    // =========================================================================
587
588    /// Return an amount that is `pct_min .. pct_max` percent of `base`, rounded to 2 dp.
589    fn pct_of(&mut self, base: Decimal, pct_min: f64, pct_max: f64) -> Decimal {
590        let pct = self.rng.random_range(pct_min..=pct_max);
591        let pct_dec = Decimal::from_f64_retain(pct)
592            .unwrap_or(Decimal::from_f64_retain(pct_min).unwrap_or(Decimal::ONE));
593        (base * pct_dec).round_dp(2)
594    }
595
596    /// Apply a percentage step-up to a book value; returns fair value.
597    fn apply_step_up(&mut self, book_value: Decimal, step_up_pct: f64) -> Decimal {
598        let pct_dec = Decimal::from_f64_retain(step_up_pct).unwrap_or(Decimal::ZERO);
599        (book_value * (Decimal::ONE + pct_dec)).round_dp(2)
600    }
601
602    /// Generate a random acquisition date within the period, biased toward
603    /// the first three quarters to leave room for amortization.
604    fn random_date_in_period(&mut self, start: NaiveDate, end: NaiveDate) -> NaiveDate {
605        let total_days = (end - start).num_days();
606        if total_days <= 0 {
607            return start;
608        }
609        // Use first 75% of the window so amortization JEs can be generated
610        let usable_days = (total_days * 3 / 4).max(1);
611        let offset = self.rng.random_range(0i64..usable_days);
612        start + chrono::Duration::days(offset)
613    }
614}
615
616// ============================================================================
617// GL account mapping helpers
618// ============================================================================
619
620/// Map an asset description to its GL account number.
621fn asset_gl_account(description: &str) -> String {
622    match description {
623        "Property, Plant & Equipment" => FIXED_ASSETS.to_string(),
624        "Customer Relationships" => CUSTOMER_RELATIONSHIPS.to_string(),
625        "Trade Name" => TRADE_NAME.to_string(),
626        "Developed Technology" => TECHNOLOGY.to_string(),
627        "Inventory" => "1200".to_string(),
628        "Accounts Receivable" => "1100".to_string(),
629        _ => "1890".to_string(), // Other intangible assets
630    }
631}
632
633/// Map a liability description to its GL account number.
634fn liability_gl_account(description: &str) -> String {
635    match description {
636        "Accounts Payable" => "2000".to_string(),
637        "Long-term Debt" => "2600".to_string(),
638        "Deferred Revenue" => "2300".to_string(),
639        _ => "2890".to_string(), // Other assumed liabilities
640    }
641}
642
643/// Map an intangible asset to its accumulated amortization contra-account.
644fn intangible_amort_account(description: &str) -> String {
645    // All finite-lived intangibles use ACCUMULATED_AMORTIZATION.
646    // In a real system these would be sub-accounts; for simplicity we use one.
647    let _ = description;
648    ACCUMULATED_AMORTIZATION.to_string()
649}
650
651/// Return the last calendar day of the given year/month.
652fn last_day_of_month(year: i32, month: u32) -> NaiveDate {
653    let next_month = month % 12 + 1;
654    let next_year = if month == 12 { year + 1 } else { year };
655    NaiveDate::from_ymd_opt(next_year, next_month, 1)
656        .and_then(|d| d.pred_opt())
657        .unwrap_or_else(|| {
658            // Fallback: use the 28th which is always valid.
659            NaiveDate::from_ymd_opt(year, month, 28)
660                .unwrap_or(NaiveDate::from_ymd_opt(year, 1, 28).unwrap_or(NaiveDate::MIN))
661        })
662}
663
664// ============================================================================
665// Tests
666// ============================================================================
667
668#[cfg(test)]
669#[allow(clippy::unwrap_used)]
670mod tests {
671    use super::*;
672
673    fn make_gen() -> BusinessCombinationGenerator {
674        BusinessCombinationGenerator::new(42)
675    }
676
677    fn make_dates() -> (NaiveDate, NaiveDate) {
678        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
679        let end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
680        (start, end)
681    }
682
683    #[test]
684    fn test_basic_generation() {
685        let mut gen = make_gen();
686        let (start, end) = make_dates();
687        let snap = gen.generate("C001", "USD", start, end, 2, "IFRS");
688
689        assert_eq!(snap.combinations.len(), 2);
690        assert!(!snap.journal_entries.is_empty());
691    }
692
693    #[test]
694    fn test_goodwill_equals_consideration_minus_net_assets() {
695        let mut gen = make_gen();
696        let (start, end) = make_dates();
697        let snap = gen.generate("C001", "USD", start, end, 3, "US_GAAP");
698
699        for bc in &snap.combinations {
700            let raw_goodwill =
701                bc.consideration.total - bc.purchase_price_allocation.net_identifiable_assets_fv;
702            if raw_goodwill >= Decimal::ZERO {
703                assert_eq!(bc.goodwill, raw_goodwill, "Goodwill mismatch for {}", bc.id);
704            } else {
705                // Bargain purchase: goodwill must be zero
706                assert_eq!(
707                    bc.goodwill,
708                    Decimal::ZERO,
709                    "Bargain purchase goodwill should be zero for {}",
710                    bc.id
711                );
712            }
713        }
714    }
715
716    #[test]
717    fn test_at_least_4_identifiable_assets() {
718        let mut gen = make_gen();
719        let (start, end) = make_dates();
720        let snap = gen.generate("C001", "USD", start, end, 3, "IFRS");
721
722        for bc in &snap.combinations {
723            assert!(
724                bc.purchase_price_allocation.identifiable_assets.len() >= 4,
725                "PPA should have at least 4 assets, got {} for {}",
726                bc.purchase_price_allocation.identifiable_assets.len(),
727                bc.id
728            );
729        }
730    }
731
732    #[test]
733    fn test_day1_jes_balanced() {
734        let mut gen = make_gen();
735        let (start, end) = make_dates();
736        let snap = gen.generate("C001", "USD", start, end, 2, "IFRS");
737
738        // Collect only Day 1 JEs (document_type "BC")
739        let day1_jes: Vec<_> = snap
740            .journal_entries
741            .iter()
742            .filter(|je| je.header.document_type == "BC")
743            .collect();
744
745        assert!(!day1_jes.is_empty(), "Should have Day 1 JEs");
746
747        for je in &day1_jes {
748            let total_debits: Decimal = je.lines.iter().map(|l| l.debit_amount).sum();
749            let total_credits: Decimal = je.lines.iter().map(|l| l.credit_amount).sum();
750            assert_eq!(
751                total_debits, total_credits,
752                "Day 1 JE {} is unbalanced: debits={}, credits={}",
753                je.header.document_id, total_debits, total_credits
754            );
755        }
756    }
757
758    #[test]
759    fn test_amortization_jes_balanced() {
760        let mut gen = make_gen();
761        let (start, end) = make_dates();
762        let snap = gen.generate("C001", "USD", start, end, 2, "IFRS");
763
764        let amort_jes: Vec<_> = snap
765            .journal_entries
766            .iter()
767            .filter(|je| je.header.document_type == "AM")
768            .collect();
769
770        assert!(!amort_jes.is_empty(), "Should have amortization JEs");
771
772        for je in &amort_jes {
773            let total_debits: Decimal = je.lines.iter().map(|l| l.debit_amount).sum();
774            let total_credits: Decimal = je.lines.iter().map(|l| l.credit_amount).sum();
775            assert_eq!(
776                total_debits, total_credits,
777                "Amortization JE {} is unbalanced: debits={}, credits={}",
778                je.header.document_id, total_debits, total_credits
779            );
780        }
781    }
782
783    #[test]
784    fn test_ppa_fair_values_positive_for_assets() {
785        let mut gen = make_gen();
786        let (start, end) = make_dates();
787        let snap = gen.generate("C001", "USD", start, end, 2, "US_GAAP");
788
789        for bc in &snap.combinations {
790            for adj in &bc.purchase_price_allocation.identifiable_assets {
791                assert!(
792                    adj.fair_value > Decimal::ZERO,
793                    "Asset {} should have positive fair value for {}",
794                    adj.asset_or_liability,
795                    bc.id
796                );
797            }
798        }
799    }
800
801    #[test]
802    fn test_consideration_total_correct() {
803        let mut gen = make_gen();
804        let (start, end) = make_dates();
805        let snap = gen.generate("C001", "USD", start, end, 3, "IFRS");
806
807        for bc in &snap.combinations {
808            let c = &bc.consideration;
809            let computed_total = c.cash
810                + c.shares_issued_value.unwrap_or(Decimal::ZERO)
811                + c.contingent_consideration.unwrap_or(Decimal::ZERO);
812            assert_eq!(
813                computed_total, c.total,
814                "Consideration components don't add up for {}",
815                bc.id
816            );
817        }
818    }
819
820    #[test]
821    fn test_deterministic_output() {
822        let (start, end) = make_dates();
823        let mut gen1 = BusinessCombinationGenerator::new(99);
824        let mut gen2 = BusinessCombinationGenerator::new(99);
825
826        let snap1 = gen1.generate("C001", "USD", start, end, 2, "IFRS");
827        let snap2 = gen2.generate("C001", "USD", start, end, 2, "IFRS");
828
829        assert_eq!(snap1.combinations.len(), snap2.combinations.len());
830        for (a, b) in snap1.combinations.iter().zip(snap2.combinations.iter()) {
831            assert_eq!(a.id, b.id);
832            assert_eq!(a.goodwill, b.goodwill);
833            assert_eq!(a.consideration.total, b.consideration.total);
834        }
835        assert_eq!(snap1.journal_entries.len(), snap2.journal_entries.len());
836    }
837
838    #[test]
839    fn test_zero_count_returns_empty() {
840        let mut gen = make_gen();
841        let (start, end) = make_dates();
842        let snap = gen.generate("C001", "USD", start, end, 0, "IFRS");
843        assert!(snap.combinations.is_empty());
844        assert!(snap.journal_entries.is_empty());
845    }
846}