Skip to main content

datasynth_generators/
coa_generator.rs

1//! Chart of Accounts generator.
2
3use rand::RngExt;
4use tracing::debug;
5
6use datasynth_core::accounts::{
7    asset_class_accounts, cash_accounts, control_accounts, dividend_accounts, dormant_accounts,
8    equity_accounts, expense_accounts, intangible_accounts, inventory_accounts, liability_accounts,
9    manufacturing_accounts, provision_accounts, revenue_accounts, suspense_accounts, tax_accounts,
10    treasury_accounts,
11};
12use datasynth_core::models::*;
13use datasynth_core::pcg_loader;
14use datasynth_core::skr_loader;
15use datasynth_core::traits::Generator;
16use datasynth_core::utils::seeded_rng;
17use rand_chacha::ChaCha8Rng;
18
19/// Accounting framework for CoA generation.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum CoAFramework {
22    /// US GAAP (4-digit accounts)
23    #[default]
24    UsGaap,
25    /// French GAAP / Plan Comptable Général (6-digit accounts)
26    FrenchPcg,
27    /// German GAAP / SKR04 (4-digit accounts)
28    GermanSkr04,
29}
30
31/// Generator for Chart of Accounts.
32pub struct ChartOfAccountsGenerator {
33    rng: ChaCha8Rng,
34    seed: u64,
35    complexity: CoAComplexity,
36    industry: IndustrySector,
37    count: u64,
38    /// Accounting framework for CoA generation.
39    coa_framework: CoAFramework,
40    /// **v5.7.0** — when true, expand canonical accounts into industry-
41    /// specific 6-digit sub-accounts using the embedded packs in
42    /// [`datasynth_core::industry_packs`]. Default: `false`.
43    expand_industry_subaccounts: bool,
44}
45
46// ---------------------------------------------------------------------------
47// SP4.2 W7.1 — Public free function for CoA semantic overlay
48// ---------------------------------------------------------------------------
49
50/// SP4.2 W7.1 — Overlay corpus account semantics from the bundled prior.
51///
52/// For each `GLAccount` in `coa` whose `account_number` appears in `prior`,
53/// overwrite `short_description`, `long_description`, `account_class`,
54/// `account_class_name`, `account_sub_class`, and `account_sub_class_name`
55/// with values from the prior.  Accounts not in the prior keep their
56/// synthetic defaults so structural consistency is preserved.
57///
58/// Returns the number of accounts that were enriched.
59pub fn overlay_coa_semantic(
60    coa: &mut ChartOfAccounts,
61    prior: &datasynth_core::distributions::behavioral_priors::CoaSemanticPrior,
62) -> usize {
63    let mut applied = 0usize;
64    for account in coa.accounts.iter_mut() {
65        if let Some(sem) = prior.accounts.get(&account.account_number) {
66            if !sem.description.is_empty() {
67                account.short_description.clone_from(&sem.description);
68                account.long_description.clone_from(&sem.description);
69            }
70            if let Some(ref cls) = sem.account_class {
71                if !cls.is_empty() {
72                    account.account_class.clone_from(cls);
73                }
74            }
75            if let Some(ref cls_name) = sem.account_class_name {
76                if !cls_name.is_empty() {
77                    account.account_class_name.clone_from(cls_name);
78                }
79            }
80            if let Some(ref sub) = sem.account_sub_class {
81                if !sub.is_empty() {
82                    account.account_sub_class.clone_from(sub);
83                }
84            }
85            if let Some(ref sub_name) = sem.account_sub_class_name {
86                if !sub_name.is_empty() {
87                    account.account_sub_class_name.clone_from(sub_name);
88                }
89            }
90            applied += 1;
91        }
92    }
93    applied
94}
95
96// ---------------------------------------------------------------------------
97// SP4.2 W8.2 — Remap synthetic account numbers to prior-matched corpus ones
98// ---------------------------------------------------------------------------
99
100/// SP4.2 W8.2 — Replace synthetic account numbers with corpus ones from
101/// the prior, preserving each account's `account_type`.  Call this BEFORE
102/// [`overlay_coa_semantic`] so the W7.1 overlay fires at high rate (~80 %).
103///
104/// ### Algorithm
105/// 1. Bucket every prior account by a normalised type string derived from
106///    `AccountSemantic::account_type` (case-insensitive, handles
107///    `"Assets"`/`"Asset"` etc.).
108/// 2. Walk every `GLAccount` in `coa`.  With probability `remap_share`
109///    (default 0.80) replace `account_number` with a uniformly-random prior
110///    account number that shares the same normalised type.  The remaining
111///    20 % keep their synthetic number unchanged so structural variety is
112///    preserved.
113/// 3. Accounts whose type has no prior candidates (e.g. `Statistical`) are
114///    left unchanged regardless of `remap_share`.
115///
116/// Returns the count of accounts whose `account_number` was changed.
117pub fn remap_account_numbers_to_prior<R: rand::Rng>(
118    coa: &mut ChartOfAccounts,
119    prior: &datasynth_core::distributions::behavioral_priors::CoaSemanticPrior,
120    rng: &mut R,
121) -> usize {
122    use std::collections::HashMap;
123
124    /// Normalise the free-text `account_type` string from the prior to match
125    /// the five canonical `AccountType` variants.  Returns `""` for unknown.
126    fn normalise_type(s: &str) -> &'static str {
127        match s.trim().to_lowercase().as_str() {
128            "asset" | "assets" => "asset",
129            "liability" | "liabilities" => "liability",
130            "equity" => "equity",
131            "revenue" | "revenues" | "income" => "revenue",
132            "expense" | "expenses" | "cost" => "expense",
133            _ => "",
134        }
135    }
136
137    // Bucket prior account numbers by normalised type.
138    let mut by_type: HashMap<&'static str, Vec<&String>> = HashMap::new();
139    for (account_number, semantic) in &prior.accounts {
140        let type_key = match &semantic.account_type {
141            Some(t) => normalise_type(t),
142            None => "",
143        };
144        if type_key.is_empty() {
145            continue;
146        }
147        by_type.entry(type_key).or_default().push(account_number);
148    }
149
150    // Also build a fallback bucket with all prior account numbers (used when
151    // the per-type bucket is empty).
152    let all_prior: Vec<&String> = prior.accounts.keys().collect();
153
154    const REMAP_SHARE: f64 = 0.80;
155    let mut remapped = 0usize;
156
157    for account in coa.accounts.iter_mut() {
158        if rng.random_range(0.0..1.0_f64) >= REMAP_SHARE {
159            continue;
160        }
161
162        // Map the GLAccount's AccountType to the normalised type key.
163        let type_key: &'static str = match account.account_type {
164            AccountType::Asset => "asset",
165            AccountType::Liability => "liability",
166            AccountType::Equity => "equity",
167            AccountType::Revenue => "revenue",
168            AccountType::Expense => "expense",
169            AccountType::Statistical => "",
170        };
171
172        // Pick candidates: prefer same-type bucket, fall back to all prior.
173        let candidates: &[&String] = if !type_key.is_empty() {
174            by_type.get(type_key).map(|v| v.as_slice()).unwrap_or(&[])
175        } else {
176            &[]
177        };
178
179        let chosen = if !candidates.is_empty() {
180            let idx = rng.random_range(0..candidates.len());
181            Some(candidates[idx].clone())
182        } else if !all_prior.is_empty() {
183            // Fallback: use any prior number when the type bucket is empty.
184            let idx = rng.random_range(0..all_prior.len());
185            Some(all_prior[idx].clone())
186        } else {
187            None
188        };
189
190        if let Some(new_number) = chosen {
191            account.account_number = new_number;
192            remapped += 1;
193        }
194    }
195
196    remapped
197}
198
199// ---------------------------------------------------------------------------
200// SP6 — CoA description taxonomy overlay
201// ---------------------------------------------------------------------------
202
203/// SP6 — Overlay CoA descriptions from the text-taxonomy prior. For each
204/// account with a `coa_pools` template, fill the template ONCE (stable per
205/// account for this run) and write it to `short_description` +
206/// `long_description`. Accounts without a taxonomy template are left untouched
207/// (the caller still runs `overlay_coa_semantic` for those). Mirrors
208/// `overlay_coa_semantic`'s field-write pattern and iteration form.
209pub fn overlay_coa_taxonomy<R: rand::Rng>(
210    coa: &mut ChartOfAccounts,
211    taxonomy: &datasynth_core::distributions::text_taxonomy::TextTaxonomyPrior,
212    resolver: &mut dyn datasynth_core::distributions::text_taxonomy::PlaceholderResolver,
213    rng: &mut R,
214) {
215    use datasynth_core::distributions::text_taxonomy::PlaceholderGrammar;
216    for account in coa.accounts.iter_mut() {
217        if let Some(entry) = taxonomy.coa_pools.get(&account.account_number) {
218            let filled = PlaceholderGrammar::fill(&entry.template, resolver, rng);
219            if !filled.is_empty() {
220                account.short_description.clone_from(&filled);
221                account.long_description = filled;
222            }
223        }
224    }
225}
226
227impl ChartOfAccountsGenerator {
228    /// Create a new CoA generator.
229    pub fn new(complexity: CoAComplexity, industry: IndustrySector, seed: u64) -> Self {
230        Self {
231            rng: seeded_rng(seed, 0),
232            seed,
233            complexity,
234            industry,
235            count: 0,
236            coa_framework: CoAFramework::UsGaap,
237            expand_industry_subaccounts: false,
238        }
239    }
240
241    /// Use French GAAP (Plan Comptable Général) account structure.
242    ///
243    /// Deprecated: use `with_coa_framework(CoAFramework::FrenchPcg)` instead.
244    pub fn with_french_pcg(mut self, use_pcg: bool) -> Self {
245        if use_pcg {
246            self.coa_framework = CoAFramework::FrenchPcg;
247        }
248        self
249    }
250
251    /// **v5.7.0** — toggle industry-pack sub-account expansion.
252    ///
253    /// When enabled, canonical accounts that have an entry in the
254    /// industry's pack (e.g. `4000` Product Revenue for manufacturing)
255    /// become non-postable control accounts and 2–6 6-digit
256    /// sub-accounts are added per parent.  Default: off.
257    pub fn with_expand_industry_subaccounts(mut self, expand: bool) -> Self {
258        self.expand_industry_subaccounts = expand;
259        self
260    }
261
262    /// Set the accounting framework for CoA generation.
263    pub fn with_coa_framework(mut self, framework: CoAFramework) -> Self {
264        self.coa_framework = framework;
265        self
266    }
267
268    /// Generate a complete chart of accounts.
269    pub fn generate(&mut self) -> ChartOfAccounts {
270        debug!(
271            complexity = ?self.complexity,
272            industry = ?self.industry,
273            seed = self.seed,
274            framework = ?self.coa_framework,
275            "Generating chart of accounts"
276        );
277
278        self.count += 1;
279        let mut coa = match self.coa_framework {
280            CoAFramework::UsGaap => self.generate_default(),
281            CoAFramework::FrenchPcg => self.generate_pcg(),
282            CoAFramework::GermanSkr04 => self.generate_skr(),
283        };
284
285        // v5.0.1 fix (Gap 5): stamp `accounting_framework` on every emitted
286        // GLAccount so the per-row column matches the sidecar
287        // `coa_meta.accounting_framework`. Until this fix the field
288        // defaulted to `None` even though the generator knew the framework.
289        let framework_label = match self.coa_framework {
290            CoAFramework::UsGaap => "us_gaap",
291            CoAFramework::FrenchPcg => "french_pcg",
292            CoAFramework::GermanSkr04 => "german_skr04",
293        };
294        for account in coa.accounts.iter_mut() {
295            account.accounting_framework = Some(framework_label.to_string());
296        }
297        coa
298    }
299
300    /// SP4.2 — Post-process a generated CoA with corpus semantic content.
301    ///
302    /// For each `GLAccount` in `coa` whose `account_number` appears in the
303    /// prior, overwrite:
304    /// - `short_description` and `long_description` with the real description
305    /// - `account_class` / `account_class_name` with the prior's values (when non-empty)
306    /// - `account_sub_class` / `account_sub_class_name` with the prior's values
307    ///
308    /// Account numbers not in the prior are left unchanged so the structural
309    /// consistency expected by downstream generators is preserved.
310    ///
311    /// Returns the number of accounts enriched.
312    pub fn apply_coa_semantic_prior(
313        coa: &mut ChartOfAccounts,
314        prior: &datasynth_core::distributions::behavioral_priors::CoaSemanticPrior,
315    ) -> usize {
316        let enriched = overlay_coa_semantic(coa, prior);
317        tracing::debug!(
318            enriched_accounts = enriched,
319            total_accounts = coa.accounts.len(),
320            "SP4.2 CoA semantic prior applied"
321        );
322        // SP6 wiring complete: overlay_coa_taxonomy is now called at the
323        // orchestrator level in `EnhancedOrchestrator::generate_coa`, right
324        // after this method returns, using SyntheticExampleResolver and a
325        // dedicated seeded RNG.  No changes needed here.
326        enriched
327    }
328
329    /// Generate default (US-style) chart of accounts.
330    fn generate_default(&mut self) -> ChartOfAccounts {
331        let target_count = self.complexity.target_count();
332        let mut coa = ChartOfAccounts::new(
333            format!("COA_{:?}_{}", self.industry, self.complexity.target_count()),
334            format!("{:?} Chart of Accounts", self.industry),
335            "US".to_string(),
336            self.industry,
337            self.complexity,
338        );
339
340        // Seed canonical accounts first so other generators can find them
341        Self::seed_canonical_accounts(&mut coa);
342
343        if self.expand_industry_subaccounts {
344            // v5.7.0 — opt-in industry-pack expansion replaces the
345            // procedural per-type fill.  The pack provides named
346            // sub-accounts (`"Product Revenue — Steel Products"`)
347            // which would otherwise collide with the procedural
348            // generator's `400000`/`400010` slots.  Suspense / clearing
349            // accounts are still seeded since they aren't expanded by
350            // packs and other generators reference them.
351            self.generate_suspense_accounts(&mut coa);
352            Self::expand_with_industry_pack(&mut coa, self.industry);
353        } else {
354            // Generate additional accounts by type
355            self.generate_asset_accounts(&mut coa, target_count / 5);
356            self.generate_liability_accounts(&mut coa, target_count / 6);
357            self.generate_equity_accounts(&mut coa, target_count / 10);
358            self.generate_revenue_accounts(&mut coa, target_count / 5);
359            self.generate_expense_accounts(&mut coa, target_count / 4);
360            self.generate_suspense_accounts(&mut coa);
361        }
362
363        coa
364    }
365
366    /// Expand canonical accounts into sector-specific 6-digit
367    /// sub-accounts using the embedded industry pack for `industry`.
368    ///
369    /// For each canonical parent account that has an expansion entry
370    /// in the pack:
371    ///   1. The parent is flipped to `is_postable = false` (a control
372    ///      / GL-summary account); its existing fields (ISO codes,
373    ///      account_type, sub_type, descriptions) are preserved.
374    ///   2. One new postable `GLAccount` is added per sub-account in
375    ///      the pack.  Sub-account inherits the parent's `account_type`,
376    ///      `sub_type`, ISO codes, `accounting_framework`, and
377    ///      `requires_cost_center` flag.  Number is `parent + suffix`
378    ///      (e.g. `"4000"` + `"10"` → `"400010"`).  Name is
379    ///      `"<parent_name> — <sub_name>"`.
380    ///
381    /// Industries without a shipped pack are a no-op.  Parents not
382    /// found in the seeded COA are silently skipped (defensive — keeps
383    /// expansion forward-compatible if the pack lists an account not
384    /// in a particular `complexity` / `framework` slice).
385    fn expand_with_industry_pack(coa: &mut ChartOfAccounts, industry: IndustrySector) {
386        let pack = match datasynth_core::industry_packs::load_pack(industry) {
387            Ok(Some(p)) => p,
388            Ok(None) => return,
389            Err(e) => {
390                tracing::warn!(
391                    "industry pack for {:?} failed to load: {} — skipping expansion",
392                    industry,
393                    e
394                );
395                return;
396            }
397        };
398
399        for expansion in &pack.expansions {
400            // Snapshot the parent before mutating it (so its fields can
401            // be cloned onto each sub-account).
402            let parent_snapshot = match coa.get_account(&expansion.parent_account) {
403                Some(p) => p.clone(),
404                None => continue,
405            };
406
407            // Flip parent to non-postable control.
408            if let Some(parent_mut) = coa
409                .accounts
410                .iter_mut()
411                .find(|a| a.account_number == expansion.parent_account)
412            {
413                parent_mut.is_postable = false;
414                parent_mut.is_control_account = true;
415            }
416
417            // Add sub-accounts.
418            for sub in &expansion.sub_accounts {
419                let sub_number = datasynth_core::industry_packs::render_sub_account_number(
420                    &expansion.parent_account,
421                    &sub.suffix,
422                );
423                // Skip if a canonical or earlier-pack sub-account already
424                // owns this number — preserves the v5.6.0 COA-coverage
425                // invariant.
426                if coa.get_account(&sub_number).is_some() {
427                    continue;
428                }
429                let sub_name = datasynth_core::industry_packs::render_sub_account_name(
430                    &expansion.parent_name,
431                    &sub.name,
432                );
433                let mut sub_acct = GLAccount::new(
434                    sub_number,
435                    sub_name,
436                    parent_snapshot.account_type,
437                    parent_snapshot.sub_type,
438                );
439                // Inherit fields that should propagate from parent.
440                sub_acct.account_class = parent_snapshot.account_class.clone();
441                sub_acct.account_class_name = parent_snapshot.account_class_name.clone();
442                sub_acct.account_sub_class = parent_snapshot.account_sub_class.clone();
443                sub_acct.account_sub_class_name = parent_snapshot.account_sub_class_name.clone();
444                sub_acct.account_group = parent_snapshot.account_group.clone();
445                sub_acct.parent_account = Some(parent_snapshot.account_number.clone());
446                sub_acct.hierarchy_level = parent_snapshot.hierarchy_level.saturating_add(1);
447                sub_acct.requires_cost_center = parent_snapshot.requires_cost_center;
448                sub_acct.requires_profit_center = parent_snapshot.requires_profit_center;
449                sub_acct.accounting_framework = parent_snapshot.accounting_framework.clone();
450                sub_acct.is_postable = true;
451                sub_acct.is_control_account = false;
452                coa.add_account(sub_acct);
453            }
454        }
455    }
456
457    /// Generate Plan Comptable Général (French GAAP) chart of accounts.
458    /// Uses the comprehensive PCG 2024 structure from [arrhes/PCG](https://github.com/arrhes/PCG) when available.
459    fn generate_pcg(&mut self) -> ChartOfAccounts {
460        match pcg_loader::build_chart_of_accounts_from_pcg_2024(self.complexity, self.industry) {
461            Ok(coa) => coa,
462            Err(_) => self.generate_pcg_fallback(),
463        }
464    }
465
466    /// Generate SKR04 (German GAAP) chart of accounts.
467    /// Uses the embedded SKR04 2024 structure when available.
468    fn generate_skr(&mut self) -> ChartOfAccounts {
469        match skr_loader::build_chart_of_accounts_from_skr04(self.complexity, self.industry) {
470            Ok(coa) => coa,
471            Err(_) => self.generate_skr_fallback(),
472        }
473    }
474
475    /// Fallback simplified SKR04 when the embedded 2024 JSON cannot be loaded.
476    fn generate_skr_fallback(&mut self) -> ChartOfAccounts {
477        use datasynth_core::skr;
478        let target_count = self.complexity.target_count();
479        let mut coa = ChartOfAccounts::new(
480            format!("COA_SKR04_{:?}_{}", self.industry, target_count),
481            format!("Standardkontenrahmen 04 – {:?}", self.industry),
482            "DE".to_string(),
483            self.industry,
484            self.complexity,
485        );
486        coa.account_format = "####".to_string();
487
488        // Seed key SKR04 accounts
489        let key_accounts = [
490            (
491                skr::control_accounts::AR_CONTROL,
492                "Forderungen aus L+L",
493                AccountType::Asset,
494                AccountSubType::AccountsReceivable,
495            ),
496            (
497                skr::control_accounts::AP_CONTROL,
498                "Verbindlichkeiten aus L+L",
499                AccountType::Liability,
500                AccountSubType::AccountsPayable,
501            ),
502            (
503                skr::control_accounts::INVENTORY,
504                "Vorräte",
505                AccountType::Asset,
506                AccountSubType::Inventory,
507            ),
508            (
509                skr::control_accounts::FIXED_ASSETS,
510                "Sachanlagen",
511                AccountType::Asset,
512                AccountSubType::FixedAssets,
513            ),
514            (
515                skr::cash_accounts::OPERATING_CASH,
516                "Bank",
517                AccountType::Asset,
518                AccountSubType::Cash,
519            ),
520            (
521                skr::cash_accounts::PETTY_CASH,
522                "Kasse",
523                AccountType::Asset,
524                AccountSubType::Cash,
525            ),
526            (
527                skr::equity_accounts::COMMON_STOCK,
528                "Gezeichnetes Kapital",
529                AccountType::Equity,
530                AccountSubType::CommonStock,
531            ),
532            (
533                skr::equity_accounts::RETAINED_EARNINGS,
534                "Gewinnvortrag",
535                AccountType::Equity,
536                AccountSubType::RetainedEarnings,
537            ),
538            (
539                skr::revenue_accounts::PRODUCT_REVENUE,
540                "Umsatzerlöse",
541                AccountType::Revenue,
542                AccountSubType::ProductRevenue,
543            ),
544            (
545                skr::revenue_accounts::SERVICE_REVENUE,
546                "Erlöse Leistungen",
547                AccountType::Revenue,
548                AccountSubType::ServiceRevenue,
549            ),
550            (
551                skr::expense_accounts::COGS,
552                "Materialaufwand",
553                AccountType::Expense,
554                AccountSubType::CostOfGoodsSold,
555            ),
556            (
557                skr::expense_accounts::SALARIES_WAGES,
558                "Löhne und Gehälter",
559                AccountType::Expense,
560                AccountSubType::OperatingExpenses,
561            ),
562            (
563                skr::expense_accounts::DEPRECIATION,
564                "Abschreibungen",
565                AccountType::Expense,
566                AccountSubType::DepreciationExpense,
567            ),
568            (
569                skr::expense_accounts::RENT,
570                "Miete",
571                AccountType::Expense,
572                AccountSubType::OperatingExpenses,
573            ),
574        ];
575
576        for (code, name, acc_type, sub_type) in key_accounts {
577            let mut account =
578                GLAccount::new(code.to_string(), name.to_string(), acc_type, sub_type);
579            account.requires_cost_center = acc_type == AccountType::Expense;
580            coa.add_account(account);
581        }
582
583        // Add additional accounts to reach target count
584        let mut num = 4100u32;
585        while coa.account_count() < target_count && num < 9900 {
586            let code = format!("{num:04}");
587            if coa.get_account(&code).is_none() {
588                let class = (num / 1000) as u8;
589                let (acc_type, sub_type) = match class {
590                    0..=1 => (AccountType::Asset, AccountSubType::OtherAssets),
591                    2 => (AccountType::Equity, AccountSubType::RetainedEarnings),
592                    3 => (AccountType::Liability, AccountSubType::OtherLiabilities),
593                    4 => (AccountType::Revenue, AccountSubType::OtherIncome),
594                    5 => (AccountType::Expense, AccountSubType::CostOfGoodsSold),
595                    6 => (AccountType::Expense, AccountSubType::OperatingExpenses),
596                    7 => (AccountType::Expense, AccountSubType::InterestExpense),
597                    _ => (AccountType::Asset, AccountSubType::SuspenseClearing),
598                };
599                coa.add_account(GLAccount::new(
600                    code,
601                    format!("Konto {num}"),
602                    acc_type,
603                    sub_type,
604                ));
605            }
606            num += 10;
607        }
608
609        coa
610    }
611
612    /// Fallback simplified PCG when the embedded 2024 JSON cannot be loaded.
613    fn generate_pcg_fallback(&mut self) -> ChartOfAccounts {
614        let target_count = self.complexity.target_count();
615        let mut coa = ChartOfAccounts::new(
616            format!("COA_PCG_{:?}_{}", self.industry, target_count),
617            format!("Plan Comptable Général – {:?}", self.industry),
618            "FR".to_string(),
619            self.industry,
620            self.complexity,
621        );
622        coa.account_format = "######".to_string();
623
624        self.generate_pcg_class_1(&mut coa, target_count / 10);
625        self.generate_pcg_class_2(&mut coa, target_count / 6);
626        self.generate_pcg_class_3(&mut coa, target_count / 8);
627        self.generate_pcg_class_4(&mut coa, target_count / 5);
628        self.generate_pcg_class_5(&mut coa, target_count / 12);
629        self.generate_pcg_class_6(&mut coa, target_count / 4);
630        self.generate_pcg_class_7(&mut coa, target_count / 5);
631        self.generate_pcg_class_8(&mut coa);
632
633        coa
634    }
635
636    fn generate_pcg_class_1(&mut self, coa: &mut ChartOfAccounts, count: usize) {
637        let items = [
638            (
639                101,
640                "Capital",
641                AccountType::Equity,
642                AccountSubType::CommonStock,
643            ),
644            (
645                129,
646                "Résultat",
647                AccountType::Equity,
648                AccountSubType::RetainedEarnings,
649            ),
650            (
651                164,
652                "Emprunts",
653                AccountType::Liability,
654                AccountSubType::LongTermDebt,
655            ),
656            (
657                151,
658                "Provisions pour risques",
659                AccountType::Liability,
660                AccountSubType::AccruedLiabilities,
661            ),
662        ];
663        for (base, name, acc_type, sub_type) in items {
664            for i in 0..count.max(1) {
665                let num = base * 1000 + (i as u32 % 100);
666                coa.add_account(GLAccount::new(
667                    format!("{num:06}"),
668                    format!("{} {}", name, i + 1),
669                    acc_type,
670                    sub_type,
671                ));
672            }
673        }
674    }
675
676    fn generate_pcg_class_2(&mut self, coa: &mut ChartOfAccounts, count: usize) {
677        for i in 0..count.max(1) {
678            let num = 215000 + (i as u32 % 100);
679            coa.add_account(GLAccount::new(
680                format!("{num:06}"),
681                format!("Immobilisations {}", i + 1),
682                AccountType::Asset,
683                AccountSubType::FixedAssets,
684            ));
685        }
686        for i in 0..(count / 2).max(1) {
687            let num = 281000 + (i as u32 % 100);
688            coa.add_account(GLAccount::new(
689                format!("{num:06}"),
690                format!("Amortissements {}", i + 1),
691                AccountType::Asset,
692                AccountSubType::AccumulatedDepreciation,
693            ));
694        }
695    }
696
697    fn generate_pcg_class_3(&mut self, coa: &mut ChartOfAccounts, count: usize) {
698        for i in 0..count.max(1) {
699            let num = 310000 + (i as u32 % 1000);
700            coa.add_account(GLAccount::new(
701                format!("{num:06}"),
702                format!("Stocks {}", i + 1),
703                AccountType::Asset,
704                AccountSubType::Inventory,
705            ));
706        }
707    }
708
709    fn generate_pcg_class_4(&mut self, coa: &mut ChartOfAccounts, count: usize) {
710        for i in 0..count.max(1) {
711            let num = 411000 + (i as u32 % 1000);
712            coa.add_account(GLAccount::new(
713                format!("{num:06}"),
714                format!("Clients {}", i + 1),
715                AccountType::Asset,
716                AccountSubType::AccountsReceivable,
717            ));
718        }
719        for i in 0..count.max(1) {
720            let num = 401000 + (i as u32 % 1000);
721            coa.add_account(GLAccount::new(
722                format!("{num:06}"),
723                format!("Fournisseurs {}", i + 1),
724                AccountType::Liability,
725                AccountSubType::AccountsPayable,
726            ));
727        }
728        let clearing = GLAccount::new(
729            "408000".to_string(),
730            "Fournisseurs – non encore reçus".to_string(),
731            AccountType::Liability,
732            AccountSubType::GoodsReceivedClearing,
733        );
734        coa.add_account(clearing);
735    }
736
737    fn generate_pcg_class_5(&mut self, coa: &mut ChartOfAccounts, count: usize) {
738        let bases = [
739            (512, "Banque"),
740            (530, "Caisse"),
741            (511, "Valeurs à l'encaissement"),
742        ];
743        for (base, name) in bases {
744            for i in 0..(count / 3).max(1) {
745                let num = base * 1000 + (i as u32 % 100);
746                coa.add_account(GLAccount::new(
747                    format!("{num:06}"),
748                    format!("{} {}", name, i + 1),
749                    AccountType::Asset,
750                    AccountSubType::Cash,
751                ));
752            }
753        }
754    }
755
756    fn generate_pcg_class_6(&mut self, coa: &mut ChartOfAccounts, count: usize) {
757        let bases = [
758            (603, "Achats"),
759            (641, "Rémunérations"),
760            (681, "DAP"),
761            (613, "Loyers"),
762            (661, "Charges financières"),
763        ];
764        for (base, name) in bases {
765            for i in 0..(count / 5).max(1) {
766                let num = base * 1000 + (i as u32 % 100);
767                let mut account = GLAccount::new(
768                    format!("{num:06}"),
769                    format!("{} {}", name, i + 1),
770                    AccountType::Expense,
771                    AccountSubType::OperatingExpenses,
772                );
773                account.requires_cost_center = true;
774                coa.add_account(account);
775            }
776        }
777    }
778
779    fn generate_pcg_class_7(&mut self, coa: &mut ChartOfAccounts, count: usize) {
780        let bases = [
781            (701, "Ventes"),
782            (706, "Prestations"),
783            (758, "Produits divers"),
784        ];
785        for (base, name) in bases {
786            for i in 0..(count / 3).max(1) {
787                let num = base * 1000 + (i as u32 % 100);
788                coa.add_account(GLAccount::new(
789                    format!("{num:06}"),
790                    format!("{} {}", name, i + 1),
791                    AccountType::Revenue,
792                    AccountSubType::ProductRevenue,
793                ));
794            }
795        }
796    }
797
798    fn generate_pcg_class_8(&mut self, coa: &mut ChartOfAccounts) {
799        coa.add_account(GLAccount::new(
800            "808000".to_string(),
801            "Comptes spéciaux".to_string(),
802            AccountType::Asset,
803            AccountSubType::SuspenseClearing,
804        ));
805    }
806
807    /// Insert all canonical accounts from `datasynth_core::accounts` into the CoA.
808    ///
809    /// These are the well-known account numbers (4-digit, 1000-9300 range) that
810    /// other generators reference. They are added before auto-generated accounts
811    /// (which start at 100000+) so there are no collisions.
812    fn seed_canonical_accounts(coa: &mut ChartOfAccounts) {
813        // --- Cash accounts (1000-series, Asset / Cash) ---
814        coa.add_account(GLAccount::new(
815            cash_accounts::OPERATING_CASH.to_string(),
816            "Operating Cash".to_string(),
817            AccountType::Asset,
818            AccountSubType::Cash,
819        ));
820        coa.add_account(GLAccount::new(
821            cash_accounts::BANK_ACCOUNT.to_string(),
822            "Bank Account".to_string(),
823            AccountType::Asset,
824            AccountSubType::Cash,
825        ));
826        coa.add_account(GLAccount::new(
827            cash_accounts::PETTY_CASH.to_string(),
828            "Petty Cash".to_string(),
829            AccountType::Asset,
830            AccountSubType::Cash,
831        ));
832        coa.add_account(GLAccount::new(
833            cash_accounts::WIRE_CLEARING.to_string(),
834            "Wire Transfer Clearing".to_string(),
835            AccountType::Asset,
836            AccountSubType::BankClearing,
837        ));
838
839        // --- Control accounts (Asset side) ---
840        {
841            let mut acct = GLAccount::new(
842                control_accounts::AR_CONTROL.to_string(),
843                "Accounts Receivable Control".to_string(),
844                AccountType::Asset,
845                AccountSubType::AccountsReceivable,
846            );
847            acct.is_control_account = true;
848            coa.add_account(acct);
849        }
850        {
851            let mut acct = GLAccount::new(
852                control_accounts::IC_AR_CLEARING.to_string(),
853                "Intercompany AR Clearing".to_string(),
854                AccountType::Asset,
855                AccountSubType::AccountsReceivable,
856            );
857            acct.is_control_account = true;
858            coa.add_account(acct);
859        }
860        coa.add_account(GLAccount::new(
861            control_accounts::INVENTORY.to_string(),
862            "Inventory".to_string(),
863            AccountType::Asset,
864            AccountSubType::Inventory,
865        ));
866        coa.add_account(GLAccount::new(
867            control_accounts::FIXED_ASSETS.to_string(),
868            "Fixed Assets".to_string(),
869            AccountType::Asset,
870            AccountSubType::FixedAssets,
871        ));
872        coa.add_account(GLAccount::new(
873            control_accounts::ACCUMULATED_DEPRECIATION.to_string(),
874            "Accumulated Depreciation".to_string(),
875            AccountType::Asset,
876            AccountSubType::AccumulatedDepreciation,
877        ));
878
879        // --- Tax asset accounts ---
880        coa.add_account(GLAccount::new(
881            tax_accounts::INPUT_VAT.to_string(),
882            "Input VAT".to_string(),
883            AccountType::Asset,
884            AccountSubType::OtherReceivables,
885        ));
886        coa.add_account(GLAccount::new(
887            tax_accounts::DEFERRED_TAX_ASSET.to_string(),
888            "Deferred Tax Asset".to_string(),
889            AccountType::Asset,
890            AccountSubType::OtherAssets,
891        ));
892
893        // --- Liability / Control accounts (2000-series) ---
894        {
895            let mut acct = GLAccount::new(
896                control_accounts::AP_CONTROL.to_string(),
897                "Accounts Payable Control".to_string(),
898                AccountType::Liability,
899                AccountSubType::AccountsPayable,
900            );
901            acct.is_control_account = true;
902            coa.add_account(acct);
903        }
904        {
905            let mut acct = GLAccount::new(
906                control_accounts::IC_AP_CLEARING.to_string(),
907                "Intercompany AP Clearing".to_string(),
908                AccountType::Liability,
909                AccountSubType::AccountsPayable,
910            );
911            acct.is_control_account = true;
912            coa.add_account(acct);
913        }
914        coa.add_account(GLAccount::new(
915            tax_accounts::SALES_TAX_PAYABLE.to_string(),
916            "Sales Tax Payable".to_string(),
917            AccountType::Liability,
918            AccountSubType::TaxLiabilities,
919        ));
920        coa.add_account(GLAccount::new(
921            tax_accounts::VAT_PAYABLE.to_string(),
922            "VAT Payable".to_string(),
923            AccountType::Liability,
924            AccountSubType::TaxLiabilities,
925        ));
926        coa.add_account(GLAccount::new(
927            tax_accounts::WITHHOLDING_TAX_PAYABLE.to_string(),
928            "Withholding Tax Payable".to_string(),
929            AccountType::Liability,
930            AccountSubType::TaxLiabilities,
931        ));
932        coa.add_account(GLAccount::new(
933            liability_accounts::ACCRUED_EXPENSES.to_string(),
934            "Accrued Expenses".to_string(),
935            AccountType::Liability,
936            AccountSubType::AccruedLiabilities,
937        ));
938        coa.add_account(GLAccount::new(
939            liability_accounts::ACCRUED_SALARIES.to_string(),
940            "Accrued Salaries".to_string(),
941            AccountType::Liability,
942            AccountSubType::AccruedLiabilities,
943        ));
944        coa.add_account(GLAccount::new(
945            liability_accounts::ACCRUED_BENEFITS.to_string(),
946            "Accrued Benefits".to_string(),
947            AccountType::Liability,
948            AccountSubType::AccruedLiabilities,
949        ));
950        coa.add_account(GLAccount::new(
951            liability_accounts::UNEARNED_REVENUE.to_string(),
952            "Unearned Revenue".to_string(),
953            AccountType::Liability,
954            AccountSubType::DeferredRevenue,
955        ));
956        coa.add_account(GLAccount::new(
957            liability_accounts::SHORT_TERM_DEBT.to_string(),
958            "Short-Term Debt".to_string(),
959            AccountType::Liability,
960            AccountSubType::ShortTermDebt,
961        ));
962        coa.add_account(GLAccount::new(
963            tax_accounts::DEFERRED_TAX_LIABILITY.to_string(),
964            "Deferred Tax Liability".to_string(),
965            AccountType::Liability,
966            AccountSubType::TaxLiabilities,
967        ));
968        coa.add_account(GLAccount::new(
969            liability_accounts::LONG_TERM_DEBT.to_string(),
970            "Long-Term Debt".to_string(),
971            AccountType::Liability,
972            AccountSubType::LongTermDebt,
973        ));
974        coa.add_account(GLAccount::new(
975            liability_accounts::IC_PAYABLE.to_string(),
976            "Intercompany Payable".to_string(),
977            AccountType::Liability,
978            AccountSubType::OtherLiabilities,
979        ));
980        {
981            let mut acct = GLAccount::new(
982                control_accounts::GR_IR_CLEARING.to_string(),
983                "GR/IR Clearing".to_string(),
984                AccountType::Liability,
985                AccountSubType::GoodsReceivedClearing,
986            );
987            acct.is_suspense_account = true;
988            coa.add_account(acct);
989        }
990
991        // --- Equity accounts (3000-series) ---
992        coa.add_account(GLAccount::new(
993            equity_accounts::COMMON_STOCK.to_string(),
994            "Common Stock".to_string(),
995            AccountType::Equity,
996            AccountSubType::CommonStock,
997        ));
998        coa.add_account(GLAccount::new(
999            equity_accounts::APIC.to_string(),
1000            "Additional Paid-In Capital".to_string(),
1001            AccountType::Equity,
1002            AccountSubType::AdditionalPaidInCapital,
1003        ));
1004        coa.add_account(GLAccount::new(
1005            equity_accounts::RETAINED_EARNINGS.to_string(),
1006            "Retained Earnings".to_string(),
1007            AccountType::Equity,
1008            AccountSubType::RetainedEarnings,
1009        ));
1010        coa.add_account(GLAccount::new(
1011            equity_accounts::CURRENT_YEAR_EARNINGS.to_string(),
1012            "Current Year Earnings".to_string(),
1013            AccountType::Equity,
1014            AccountSubType::NetIncome,
1015        ));
1016        coa.add_account(GLAccount::new(
1017            equity_accounts::TREASURY_STOCK.to_string(),
1018            "Treasury Stock".to_string(),
1019            AccountType::Equity,
1020            AccountSubType::TreasuryStock,
1021        ));
1022        coa.add_account(GLAccount::new(
1023            equity_accounts::CTA.to_string(),
1024            "Currency Translation Adjustment".to_string(),
1025            AccountType::Equity,
1026            AccountSubType::OtherComprehensiveIncome,
1027        ));
1028
1029        // --- Revenue accounts (4000-series) ---
1030        coa.add_account(GLAccount::new(
1031            revenue_accounts::PRODUCT_REVENUE.to_string(),
1032            "Product Revenue".to_string(),
1033            AccountType::Revenue,
1034            AccountSubType::ProductRevenue,
1035        ));
1036        coa.add_account(GLAccount::new(
1037            revenue_accounts::SALES_DISCOUNTS.to_string(),
1038            "Sales Discounts".to_string(),
1039            AccountType::Revenue,
1040            AccountSubType::ProductRevenue,
1041        ));
1042        coa.add_account(GLAccount::new(
1043            revenue_accounts::SALES_RETURNS.to_string(),
1044            "Sales Returns and Allowances".to_string(),
1045            AccountType::Revenue,
1046            AccountSubType::ProductRevenue,
1047        ));
1048        coa.add_account(GLAccount::new(
1049            revenue_accounts::SERVICE_REVENUE.to_string(),
1050            "Service Revenue".to_string(),
1051            AccountType::Revenue,
1052            AccountSubType::ServiceRevenue,
1053        ));
1054        coa.add_account(GLAccount::new(
1055            revenue_accounts::IC_REVENUE.to_string(),
1056            "Intercompany Revenue".to_string(),
1057            AccountType::Revenue,
1058            AccountSubType::OtherIncome,
1059        ));
1060        coa.add_account(GLAccount::new(
1061            revenue_accounts::OTHER_REVENUE.to_string(),
1062            "Other Revenue".to_string(),
1063            AccountType::Revenue,
1064            AccountSubType::OtherIncome,
1065        ));
1066
1067        // --- Expense accounts (5000-7xxx series) ---
1068        {
1069            let mut acct = GLAccount::new(
1070                expense_accounts::COGS.to_string(),
1071                "Cost of Goods Sold".to_string(),
1072                AccountType::Expense,
1073                AccountSubType::CostOfGoodsSold,
1074            );
1075            acct.requires_cost_center = true;
1076            coa.add_account(acct);
1077        }
1078        {
1079            let mut acct = GLAccount::new(
1080                expense_accounts::RAW_MATERIALS.to_string(),
1081                "Raw Materials".to_string(),
1082                AccountType::Expense,
1083                AccountSubType::CostOfGoodsSold,
1084            );
1085            acct.requires_cost_center = true;
1086            coa.add_account(acct);
1087        }
1088        {
1089            let mut acct = GLAccount::new(
1090                expense_accounts::DIRECT_LABOR.to_string(),
1091                "Direct Labor".to_string(),
1092                AccountType::Expense,
1093                AccountSubType::CostOfGoodsSold,
1094            );
1095            acct.requires_cost_center = true;
1096            coa.add_account(acct);
1097        }
1098        {
1099            let mut acct = GLAccount::new(
1100                expense_accounts::MANUFACTURING_OVERHEAD.to_string(),
1101                "Manufacturing Overhead".to_string(),
1102                AccountType::Expense,
1103                AccountSubType::CostOfGoodsSold,
1104            );
1105            acct.requires_cost_center = true;
1106            coa.add_account(acct);
1107        }
1108        {
1109            let mut acct = GLAccount::new(
1110                expense_accounts::DEPRECIATION.to_string(),
1111                "Depreciation Expense".to_string(),
1112                AccountType::Expense,
1113                AccountSubType::DepreciationExpense,
1114            );
1115            acct.requires_cost_center = true;
1116            coa.add_account(acct);
1117        }
1118        {
1119            let mut acct = GLAccount::new(
1120                expense_accounts::SALARIES_WAGES.to_string(),
1121                "Salaries and Wages".to_string(),
1122                AccountType::Expense,
1123                AccountSubType::OperatingExpenses,
1124            );
1125            acct.requires_cost_center = true;
1126            coa.add_account(acct);
1127        }
1128        {
1129            let mut acct = GLAccount::new(
1130                expense_accounts::BENEFITS.to_string(),
1131                "Benefits Expense".to_string(),
1132                AccountType::Expense,
1133                AccountSubType::OperatingExpenses,
1134            );
1135            acct.requires_cost_center = true;
1136            coa.add_account(acct);
1137        }
1138        {
1139            let mut acct = GLAccount::new(
1140                expense_accounts::RENT.to_string(),
1141                "Rent Expense".to_string(),
1142                AccountType::Expense,
1143                AccountSubType::OperatingExpenses,
1144            );
1145            acct.requires_cost_center = true;
1146            coa.add_account(acct);
1147        }
1148        {
1149            let mut acct = GLAccount::new(
1150                expense_accounts::UTILITIES.to_string(),
1151                "Utilities Expense".to_string(),
1152                AccountType::Expense,
1153                AccountSubType::OperatingExpenses,
1154            );
1155            acct.requires_cost_center = true;
1156            coa.add_account(acct);
1157        }
1158        {
1159            let mut acct = GLAccount::new(
1160                expense_accounts::OFFICE_SUPPLIES.to_string(),
1161                "Office Supplies".to_string(),
1162                AccountType::Expense,
1163                AccountSubType::AdministrativeExpenses,
1164            );
1165            acct.requires_cost_center = true;
1166            coa.add_account(acct);
1167        }
1168        {
1169            let mut acct = GLAccount::new(
1170                expense_accounts::TRAVEL_ENTERTAINMENT.to_string(),
1171                "Travel and Entertainment".to_string(),
1172                AccountType::Expense,
1173                AccountSubType::SellingExpenses,
1174            );
1175            acct.requires_cost_center = true;
1176            coa.add_account(acct);
1177        }
1178        {
1179            let mut acct = GLAccount::new(
1180                expense_accounts::PROFESSIONAL_FEES.to_string(),
1181                "Professional Fees".to_string(),
1182                AccountType::Expense,
1183                AccountSubType::AdministrativeExpenses,
1184            );
1185            acct.requires_cost_center = true;
1186            coa.add_account(acct);
1187        }
1188        {
1189            let mut acct = GLAccount::new(
1190                expense_accounts::INSURANCE.to_string(),
1191                "Insurance Expense".to_string(),
1192                AccountType::Expense,
1193                AccountSubType::OperatingExpenses,
1194            );
1195            acct.requires_cost_center = true;
1196            coa.add_account(acct);
1197        }
1198        {
1199            let mut acct = GLAccount::new(
1200                expense_accounts::BAD_DEBT.to_string(),
1201                "Bad Debt Expense".to_string(),
1202                AccountType::Expense,
1203                AccountSubType::OperatingExpenses,
1204            );
1205            acct.requires_cost_center = true;
1206            coa.add_account(acct);
1207        }
1208        {
1209            let mut acct = GLAccount::new(
1210                expense_accounts::INTEREST_EXPENSE.to_string(),
1211                "Interest Expense".to_string(),
1212                AccountType::Expense,
1213                AccountSubType::InterestExpense,
1214            );
1215            acct.requires_cost_center = true;
1216            coa.add_account(acct);
1217        }
1218        {
1219            let mut acct = GLAccount::new(
1220                expense_accounts::PURCHASE_DISCOUNTS.to_string(),
1221                "Purchase Discounts".to_string(),
1222                AccountType::Expense,
1223                AccountSubType::OtherExpenses,
1224            );
1225            acct.requires_cost_center = true;
1226            coa.add_account(acct);
1227        }
1228        {
1229            let mut acct = GLAccount::new(
1230                expense_accounts::FX_GAIN_LOSS.to_string(),
1231                "FX Gain/Loss".to_string(),
1232                AccountType::Expense,
1233                AccountSubType::ForeignExchangeLoss,
1234            );
1235            acct.requires_cost_center = true;
1236            coa.add_account(acct);
1237        }
1238
1239        // --- Tax expense (8000-series) ---
1240        {
1241            let mut acct = GLAccount::new(
1242                tax_accounts::TAX_EXPENSE.to_string(),
1243                "Tax Expense".to_string(),
1244                AccountType::Expense,
1245                AccountSubType::TaxExpense,
1246            );
1247            acct.requires_cost_center = true;
1248            coa.add_account(acct);
1249        }
1250
1251        // --- Asset class accounts (FA subledger acquisition + acc. depreciation) ---
1252        Self::seed_asset_class_accounts(coa);
1253
1254        // --- Manufacturing accounts (WIP, finished goods, variances, warranty) ---
1255        Self::seed_manufacturing_accounts(coa);
1256
1257        // --- Intangible assets (goodwill, customer relationships, etc.) ---
1258        Self::seed_intangible_accounts(coa);
1259
1260        // --- Treasury / hedging / debt accounts ---
1261        Self::seed_treasury_accounts(coa);
1262
1263        // --- Provisions (IAS 37 / ASC 450) ---
1264        Self::seed_provision_accounts(coa);
1265
1266        // --- Dividend accounts (declared / payable) ---
1267        Self::seed_dividend_accounts(coa);
1268
1269        // --- Pension / share-based compensation ---
1270        Self::seed_compensation_accounts(coa);
1271
1272        // --- Inventory subledger sub-accounts (write-up income / write-down expense) ---
1273        Self::seed_inventory_subledger_accounts(coa);
1274
1275        // --- Tax accounts not seeded above (income tax payable, tax receivable) ---
1276        Self::seed_additional_tax_accounts(coa);
1277
1278        // --- Equity accounts not seeded above (income summary, dividends paid) ---
1279        Self::seed_additional_equity_accounts(coa);
1280
1281        // --- Dormant / blocked legacy accounts (anomaly-strategy targets) ---
1282        Self::seed_dormant_accounts(coa);
1283
1284        // --- Suspense / Clearing accounts (9000-series) ---
1285        {
1286            let mut acct = GLAccount::new(
1287                suspense_accounts::GENERAL_SUSPENSE.to_string(),
1288                "General Suspense".to_string(),
1289                AccountType::Asset,
1290                AccountSubType::SuspenseClearing,
1291            );
1292            acct.is_suspense_account = true;
1293            coa.add_account(acct);
1294        }
1295        {
1296            let mut acct = GLAccount::new(
1297                suspense_accounts::PAYROLL_CLEARING.to_string(),
1298                "Payroll Clearing".to_string(),
1299                AccountType::Asset,
1300                AccountSubType::SuspenseClearing,
1301            );
1302            acct.is_suspense_account = true;
1303            coa.add_account(acct);
1304        }
1305        {
1306            let mut acct = GLAccount::new(
1307                suspense_accounts::BANK_RECONCILIATION_SUSPENSE.to_string(),
1308                "Bank Reconciliation Suspense".to_string(),
1309                AccountType::Asset,
1310                AccountSubType::BankClearing,
1311            );
1312            acct.is_suspense_account = true;
1313            coa.add_account(acct);
1314        }
1315        {
1316            let mut acct = GLAccount::new(
1317                suspense_accounts::IC_ELIMINATION_SUSPENSE.to_string(),
1318                "IC Elimination Suspense".to_string(),
1319                AccountType::Asset,
1320                AccountSubType::IntercompanyClearing,
1321            );
1322            acct.is_suspense_account = true;
1323            coa.add_account(acct);
1324        }
1325    }
1326
1327    /// Seed asset-class acquisition + accumulated-depreciation contras +
1328    /// FA-subledger expense / disposal accounts so JEs from the fixed-asset
1329    /// generator always resolve.
1330    fn seed_asset_class_accounts(coa: &mut ChartOfAccounts) {
1331        let acquisitions = [
1332            (asset_class_accounts::LAND, "Land"),
1333            (asset_class_accounts::BUILDINGS, "Buildings"),
1334            (
1335                asset_class_accounts::BUILDING_IMPROVEMENTS,
1336                "Building Improvements",
1337            ),
1338            (
1339                asset_class_accounts::MACHINERY_EQUIPMENT,
1340                "Machinery & Equipment",
1341            ),
1342            (asset_class_accounts::VEHICLES, "Vehicles"),
1343            (asset_class_accounts::OFFICE_EQUIPMENT, "Office Equipment"),
1344            (asset_class_accounts::COMPUTER_HARDWARE, "Computer Hardware"),
1345            (
1346                asset_class_accounts::SOFTWARE_INTANGIBLES,
1347                "Software / Intangibles",
1348            ),
1349            (
1350                asset_class_accounts::FURNITURE_FIXTURES,
1351                "Furniture & Fixtures",
1352            ),
1353            (
1354                asset_class_accounts::LEASEHOLD_IMPROVEMENTS,
1355                "Leasehold Improvements",
1356            ),
1357            (asset_class_accounts::OTHER_ASSETS, "Other Fixed Assets"),
1358            (asset_class_accounts::LOW_VALUE_ASSETS, "Low-Value Assets"),
1359            (
1360                asset_class_accounts::CONSTRUCTION_IN_PROGRESS,
1361                "Construction in Progress",
1362            ),
1363        ];
1364        for (number, name) in acquisitions {
1365            coa.add_account(GLAccount::new(
1366                number.to_string(),
1367                name.to_string(),
1368                AccountType::Asset,
1369                AccountSubType::FixedAssets,
1370            ));
1371        }
1372
1373        let depreciation_contras = [
1374            (asset_class_accounts::ACC_DEP_LAND, "Acc. Dep. — Land"),
1375            (
1376                asset_class_accounts::ACC_DEP_BUILDINGS,
1377                "Acc. Dep. — Buildings",
1378            ),
1379            (
1380                asset_class_accounts::ACC_DEP_MACHINERY,
1381                "Acc. Dep. — Machinery",
1382            ),
1383            (
1384                asset_class_accounts::ACC_DEP_VEHICLES,
1385                "Acc. Dep. — Vehicles",
1386            ),
1387            (
1388                asset_class_accounts::ACC_DEP_OFFICE_EQUIPMENT,
1389                "Acc. Dep. — Office Equipment",
1390            ),
1391            (
1392                asset_class_accounts::ACC_DEP_SOFTWARE,
1393                "Acc. Dep. — Software / Intangibles",
1394            ),
1395            (
1396                asset_class_accounts::ACC_DEP_FURNITURE,
1397                "Acc. Dep. — Furniture",
1398            ),
1399            (
1400                asset_class_accounts::ACC_DEP_LEASEHOLD,
1401                "Acc. Dep. — Leasehold Improvements",
1402            ),
1403            (asset_class_accounts::ACC_DEP_OTHER, "Acc. Dep. — Other"),
1404            (asset_class_accounts::ACC_DEP_CIP, "Acc. Dep. — CIP"),
1405        ];
1406        for (number, name) in depreciation_contras {
1407            coa.add_account(GLAccount::new(
1408                number.to_string(),
1409                name.to_string(),
1410                AccountType::Asset,
1411                AccountSubType::AccumulatedDepreciation,
1412            ));
1413        }
1414
1415        // FA-subledger depreciation expense (7100) — distinct from the
1416        // GL-level DEPRECIATION (6000) which is also seeded.
1417        {
1418            let mut acct = GLAccount::new(
1419                asset_class_accounts::DEPRECIATION_EXPENSE.to_string(),
1420                "FA Depreciation Expense".to_string(),
1421                AccountType::Expense,
1422                AccountSubType::DepreciationExpense,
1423            );
1424            acct.requires_cost_center = true;
1425            coa.add_account(acct);
1426        }
1427        coa.add_account(GLAccount::new(
1428            asset_class_accounts::GAIN_ON_DISPOSAL.to_string(),
1429            "Gain on Disposal of Fixed Assets".to_string(),
1430            AccountType::Revenue,
1431            AccountSubType::GainOnSale,
1432        ));
1433        coa.add_account(GLAccount::new(
1434            asset_class_accounts::LOSS_ON_DISPOSAL.to_string(),
1435            "Loss on Disposal of Fixed Assets".to_string(),
1436            AccountType::Expense,
1437            AccountSubType::LossOnSale,
1438        ));
1439    }
1440
1441    /// Seed manufacturing-cost-flow accounts (WIP, finished goods, variances, warranty).
1442    fn seed_manufacturing_accounts(coa: &mut ChartOfAccounts) {
1443        coa.add_account(GLAccount::new(
1444            manufacturing_accounts::FINISHED_GOODS.to_string(),
1445            "Finished Goods".to_string(),
1446            AccountType::Asset,
1447            AccountSubType::Inventory,
1448        ));
1449        coa.add_account(GLAccount::new(
1450            manufacturing_accounts::WIP.to_string(),
1451            "Work in Process".to_string(),
1452            AccountType::Asset,
1453            AccountSubType::Inventory,
1454        ));
1455        coa.add_account(GLAccount::new(
1456            manufacturing_accounts::LABOR_ACCRUAL.to_string(),
1457            "Labor Accrual".to_string(),
1458            AccountType::Liability,
1459            AccountSubType::AccruedLiabilities,
1460        ));
1461        coa.add_account(GLAccount::new(
1462            manufacturing_accounts::WARRANTY_PROVISION.to_string(),
1463            "Warranty Provision".to_string(),
1464            AccountType::Liability,
1465            AccountSubType::OtherLiabilities,
1466        ));
1467
1468        let variance_accounts = [
1469            (
1470                manufacturing_accounts::SCRAP_EXPENSE,
1471                "Scrap Expense",
1472                AccountSubType::CostOfGoodsSold,
1473            ),
1474            (
1475                manufacturing_accounts::OVERHEAD_APPLIED,
1476                "Overhead Applied",
1477                AccountSubType::CostOfGoodsSold,
1478            ),
1479            (
1480                manufacturing_accounts::MATERIAL_PRICE_VARIANCE,
1481                "Material Price Variance",
1482                AccountSubType::CostOfGoodsSold,
1483            ),
1484            (
1485                manufacturing_accounts::MATERIAL_USAGE_VARIANCE,
1486                "Material Usage Variance",
1487                AccountSubType::CostOfGoodsSold,
1488            ),
1489            (
1490                manufacturing_accounts::LABOR_RATE_VARIANCE,
1491                "Labor Rate Variance",
1492                AccountSubType::CostOfGoodsSold,
1493            ),
1494            (
1495                manufacturing_accounts::LABOR_EFFICIENCY_VARIANCE,
1496                "Labor Efficiency Variance",
1497                AccountSubType::CostOfGoodsSold,
1498            ),
1499            (
1500                manufacturing_accounts::OVERHEAD_VOLUME_VARIANCE,
1501                "Overhead Volume Variance",
1502                AccountSubType::CostOfGoodsSold,
1503            ),
1504            (
1505                manufacturing_accounts::WARRANTY_EXPENSE,
1506                "Warranty Expense",
1507                AccountSubType::OperatingExpenses,
1508            ),
1509        ];
1510        for (number, name, sub) in variance_accounts {
1511            let mut acct = GLAccount::new(
1512                number.to_string(),
1513                name.to_string(),
1514                AccountType::Expense,
1515                sub,
1516            );
1517            acct.requires_cost_center = true;
1518            coa.add_account(acct);
1519        }
1520    }
1521
1522    /// Seed intangible-asset accounts (goodwill, customer relationships, etc.).
1523    fn seed_intangible_accounts(coa: &mut ChartOfAccounts) {
1524        let intangibles = [
1525            (
1526                intangible_accounts::GOODWILL,
1527                "Goodwill",
1528                AccountType::Asset,
1529                AccountSubType::IntangibleAssets,
1530            ),
1531            (
1532                intangible_accounts::CUSTOMER_RELATIONSHIPS,
1533                "Customer Relationships",
1534                AccountType::Asset,
1535                AccountSubType::IntangibleAssets,
1536            ),
1537            (
1538                intangible_accounts::TRADE_NAME,
1539                "Trade Name / Brand",
1540                AccountType::Asset,
1541                AccountSubType::IntangibleAssets,
1542            ),
1543            (
1544                intangible_accounts::TECHNOLOGY,
1545                "Technology / Developed Software",
1546                AccountType::Asset,
1547                AccountSubType::IntangibleAssets,
1548            ),
1549            (
1550                intangible_accounts::ACCUMULATED_AMORTIZATION,
1551                "Accumulated Amortization",
1552                AccountType::Asset,
1553                AccountSubType::AccumulatedDepreciation,
1554            ),
1555            (
1556                intangible_accounts::AMORTIZATION_EXPENSE,
1557                "Amortization Expense — Intangibles",
1558                AccountType::Expense,
1559                AccountSubType::AmortizationExpense,
1560            ),
1561            (
1562                intangible_accounts::BARGAIN_PURCHASE_GAIN,
1563                "Bargain Purchase Gain",
1564                AccountType::Revenue,
1565                AccountSubType::OtherIncome,
1566            ),
1567        ];
1568        for (number, name, ty, sub) in intangibles {
1569            coa.add_account(GLAccount::new(
1570                number.to_string(),
1571                name.to_string(),
1572                ty,
1573                sub,
1574            ));
1575        }
1576    }
1577
1578    /// Seed treasury / hedging / debt accounts.
1579    fn seed_treasury_accounts(coa: &mut ChartOfAccounts) {
1580        let entries = [
1581            (
1582                treasury_accounts::INTEREST_PAYABLE,
1583                "Interest Payable",
1584                AccountType::Liability,
1585                AccountSubType::AccruedLiabilities,
1586            ),
1587            (
1588                treasury_accounts::DEBT_PREMIUM,
1589                "Debt Premium",
1590                AccountType::Liability,
1591                AccountSubType::LongTermDebt,
1592            ),
1593            (
1594                treasury_accounts::DEBT_DISCOUNT,
1595                "Debt Discount",
1596                AccountType::Liability,
1597                AccountSubType::LongTermDebt,
1598            ),
1599            (
1600                treasury_accounts::DERIVATIVE_ASSET,
1601                "Derivative Asset",
1602                AccountType::Asset,
1603                AccountSubType::OtherAssets,
1604            ),
1605            (
1606                treasury_accounts::DERIVATIVE_LIABILITY,
1607                "Derivative Liability",
1608                AccountType::Liability,
1609                AccountSubType::OtherLiabilities,
1610            ),
1611            (
1612                treasury_accounts::OCI_CASH_FLOW_HEDGE,
1613                "OCI — Cash Flow Hedge Reserve",
1614                AccountType::Equity,
1615                AccountSubType::OtherComprehensiveIncome,
1616            ),
1617            (
1618                treasury_accounts::HEDGE_INEFFECTIVENESS,
1619                "Hedge Ineffectiveness",
1620                AccountType::Expense,
1621                AccountSubType::OtherExpenses,
1622            ),
1623            (
1624                treasury_accounts::CASH_POOL_IC_RECEIVABLE,
1625                "IC Receivable — Cash Pool",
1626                AccountType::Asset,
1627                AccountSubType::AccountsReceivable,
1628            ),
1629            (
1630                treasury_accounts::CASH_POOL_IC_PAYABLE,
1631                "IC Payable — Cash Pool",
1632                AccountType::Liability,
1633                AccountSubType::AccountsPayable,
1634            ),
1635        ];
1636        for (number, name, ty, sub) in entries {
1637            coa.add_account(GLAccount::new(
1638                number.to_string(),
1639                name.to_string(),
1640                ty,
1641                sub,
1642            ));
1643        }
1644    }
1645
1646    /// Seed provision accounts (IAS 37 / ASC 450).
1647    fn seed_provision_accounts(coa: &mut ChartOfAccounts) {
1648        coa.add_account(GLAccount::new(
1649            provision_accounts::PROVISION_LIABILITY.to_string(),
1650            "Provision Liability".to_string(),
1651            AccountType::Liability,
1652            AccountSubType::OtherLiabilities,
1653        ));
1654        let mut prov_exp = GLAccount::new(
1655            provision_accounts::PROVISION_EXPENSE.to_string(),
1656            "Provision Expense".to_string(),
1657            AccountType::Expense,
1658            AccountSubType::OperatingExpenses,
1659        );
1660        prov_exp.requires_cost_center = true;
1661        coa.add_account(prov_exp);
1662    }
1663
1664    /// Seed dividend accounts.
1665    fn seed_dividend_accounts(coa: &mut ChartOfAccounts) {
1666        coa.add_account(GLAccount::new(
1667            dividend_accounts::DIVIDENDS_PAYABLE.to_string(),
1668            "Dividends Payable".to_string(),
1669            AccountType::Liability,
1670            AccountSubType::OtherLiabilities,
1671        ));
1672        coa.add_account(GLAccount::new(
1673            dividend_accounts::DIVIDENDS_DECLARED.to_string(),
1674            "Dividends Declared".to_string(),
1675            AccountType::Equity,
1676            AccountSubType::RetainedEarnings,
1677        ));
1678    }
1679
1680    /// Seed pension and share-based compensation accounts.
1681    fn seed_compensation_accounts(coa: &mut ChartOfAccounts) {
1682        coa.add_account(GLAccount::new(
1683            liability_accounts::NET_PENSION_LIABILITY.to_string(),
1684            "Net Pension Liability".to_string(),
1685            AccountType::Liability,
1686            AccountSubType::PensionLiabilities,
1687        ));
1688        coa.add_account(GLAccount::new(
1689            equity_accounts::OCI_REMEASUREMENTS.to_string(),
1690            "OCI — Pension Remeasurements".to_string(),
1691            AccountType::Equity,
1692            AccountSubType::OtherComprehensiveIncome,
1693        ));
1694        coa.add_account(GLAccount::new(
1695            equity_accounts::APIC_STOCK_COMP.to_string(),
1696            "APIC — Stock Compensation".to_string(),
1697            AccountType::Equity,
1698            AccountSubType::AdditionalPaidInCapital,
1699        ));
1700        let mut pension_exp = GLAccount::new(
1701            expense_accounts::PENSION_EXPENSE.to_string(),
1702            "Pension Expense".to_string(),
1703            AccountType::Expense,
1704            AccountSubType::OperatingExpenses,
1705        );
1706        pension_exp.requires_cost_center = true;
1707        coa.add_account(pension_exp);
1708        let mut stock_comp = GLAccount::new(
1709            expense_accounts::STOCK_COMP_EXPENSE.to_string(),
1710            "Stock-Based Compensation Expense".to_string(),
1711            AccountType::Expense,
1712            AccountSubType::OperatingExpenses,
1713        );
1714        stock_comp.requires_cost_center = true;
1715        coa.add_account(stock_comp);
1716    }
1717
1718    /// Seed inventory subledger sub-accounts beyond the GL control (1200).
1719    fn seed_inventory_subledger_accounts(coa: &mut ChartOfAccounts) {
1720        coa.add_account(GLAccount::new(
1721            inventory_accounts::WRITEUP_INCOME.to_string(),
1722            "Inventory Write-up Income".to_string(),
1723            AccountType::Revenue,
1724            AccountSubType::OtherIncome,
1725        ));
1726        let mut writedown = GLAccount::new(
1727            inventory_accounts::WRITEDOWN_EXPENSE.to_string(),
1728            "Inventory Write-down Expense".to_string(),
1729            AccountType::Expense,
1730            AccountSubType::CostOfGoodsSold,
1731        );
1732        writedown.requires_cost_center = true;
1733        coa.add_account(writedown);
1734    }
1735
1736    /// Seed tax accounts not already covered by `seed_canonical_accounts`.
1737    fn seed_additional_tax_accounts(coa: &mut ChartOfAccounts) {
1738        coa.add_account(GLAccount::new(
1739            tax_accounts::INCOME_TAX_PAYABLE.to_string(),
1740            "Income Tax Payable".to_string(),
1741            AccountType::Liability,
1742            AccountSubType::TaxLiabilities,
1743        ));
1744        coa.add_account(GLAccount::new(
1745            tax_accounts::TAX_RECEIVABLE.to_string(),
1746            "Tax Receivable".to_string(),
1747            AccountType::Asset,
1748            AccountSubType::OtherReceivables,
1749        ));
1750    }
1751
1752    /// Seed dormant / blocked legacy accounts.
1753    ///
1754    /// These mirror the targets of the `DormantAccountActivity` anomaly
1755    /// strategy in `datasynth_generators::anomaly::strategies`. Real
1756    /// COAs retain such accounts (legacy suspense, predecessor-system
1757    /// clearing, obsolete migration accounts, retained QA / test
1758    /// accounts) as `is_blocked = true` so audit trails remain
1759    /// resolvable; we follow the same pattern. With `is_postable =
1760    /// false`, normal generators won't pick them up — only the anomaly
1761    /// strategy does, which is exactly the fraud-signature behaviour
1762    /// being modelled.
1763    fn seed_dormant_accounts(coa: &mut ChartOfAccounts) {
1764        let entries = [
1765            (
1766                dormant_accounts::LEGACY_SUSPENSE,
1767                "Legacy Suspense (migrated)",
1768                AccountType::Asset,
1769                AccountSubType::SuspenseClearing,
1770            ),
1771            (
1772                dormant_accounts::LEGACY_CLEARING,
1773                "Legacy Clearing (predecessor system)",
1774                AccountType::Liability,
1775                AccountSubType::OtherLiabilities,
1776            ),
1777            (
1778                dormant_accounts::OBSOLETE,
1779                "Obsolete Account",
1780                AccountType::Equity,
1781                AccountSubType::OtherComprehensiveIncome,
1782            ),
1783            (
1784                dormant_accounts::TEST_ACCOUNT,
1785                "Test Account (QA residue)",
1786                AccountType::Asset,
1787                AccountSubType::SuspenseClearing,
1788            ),
1789        ];
1790        for (number, name, ty, sub) in entries {
1791            let mut acct = GLAccount::new(number.to_string(), name.to_string(), ty, sub);
1792            acct.is_blocked = true;
1793            acct.is_postable = false;
1794            acct.is_suspense_account = matches!(sub, AccountSubType::SuspenseClearing);
1795            coa.add_account(acct);
1796        }
1797    }
1798
1799    /// Seed equity accounts not already covered by `seed_canonical_accounts`.
1800    fn seed_additional_equity_accounts(coa: &mut ChartOfAccounts) {
1801        coa.add_account(GLAccount::new(
1802            equity_accounts::INCOME_SUMMARY.to_string(),
1803            "Income Summary".to_string(),
1804            AccountType::Equity,
1805            AccountSubType::NetIncome,
1806        ));
1807        coa.add_account(GLAccount::new(
1808            equity_accounts::DIVIDENDS_PAID.to_string(),
1809            "Dividends Paid".to_string(),
1810            AccountType::Equity,
1811            AccountSubType::RetainedEarnings,
1812        ));
1813    }
1814
1815    fn generate_asset_accounts(&mut self, coa: &mut ChartOfAccounts, count: usize) {
1816        let sub_types = vec![
1817            (AccountSubType::Cash, "Cash", 0.15),
1818            (
1819                AccountSubType::AccountsReceivable,
1820                "Accounts Receivable",
1821                0.20,
1822            ),
1823            (AccountSubType::Inventory, "Inventory", 0.15),
1824            (AccountSubType::PrepaidExpenses, "Prepaid Expenses", 0.10),
1825            (AccountSubType::FixedAssets, "Fixed Assets", 0.25),
1826            (
1827                AccountSubType::AccumulatedDepreciation,
1828                "Accumulated Depreciation",
1829                0.10,
1830            ),
1831            (AccountSubType::OtherAssets, "Other Assets", 0.05),
1832        ];
1833
1834        let mut account_num = 100000u32;
1835        for (sub_type, name_prefix, weight) in sub_types {
1836            let sub_count = ((count as f64) * weight).round() as usize;
1837            for i in 0..sub_count.max(1) {
1838                let account = GLAccount::new(
1839                    format!("{account_num}"),
1840                    format!("{} {}", name_prefix, i + 1),
1841                    AccountType::Asset,
1842                    sub_type,
1843                );
1844                coa.add_account(account);
1845                account_num += 10;
1846            }
1847        }
1848    }
1849
1850    fn generate_liability_accounts(&mut self, coa: &mut ChartOfAccounts, count: usize) {
1851        let sub_types = vec![
1852            (AccountSubType::AccountsPayable, "Accounts Payable", 0.25),
1853            (
1854                AccountSubType::AccruedLiabilities,
1855                "Accrued Liabilities",
1856                0.20,
1857            ),
1858            (AccountSubType::ShortTermDebt, "Short-Term Debt", 0.15),
1859            (AccountSubType::LongTermDebt, "Long-Term Debt", 0.15),
1860            (AccountSubType::DeferredRevenue, "Deferred Revenue", 0.15),
1861            (AccountSubType::TaxLiabilities, "Tax Liabilities", 0.10),
1862        ];
1863
1864        let mut account_num = 200000u32;
1865        for (sub_type, name_prefix, weight) in sub_types {
1866            let sub_count = ((count as f64) * weight).round() as usize;
1867            for i in 0..sub_count.max(1) {
1868                let account = GLAccount::new(
1869                    format!("{account_num}"),
1870                    format!("{} {}", name_prefix, i + 1),
1871                    AccountType::Liability,
1872                    sub_type,
1873                );
1874                coa.add_account(account);
1875                account_num += 10;
1876            }
1877        }
1878    }
1879
1880    fn generate_equity_accounts(&mut self, coa: &mut ChartOfAccounts, count: usize) {
1881        let sub_types = vec![
1882            (AccountSubType::CommonStock, "Common Stock", 0.20),
1883            (AccountSubType::RetainedEarnings, "Retained Earnings", 0.30),
1884            (AccountSubType::AdditionalPaidInCapital, "APIC", 0.20),
1885            (AccountSubType::OtherComprehensiveIncome, "OCI", 0.30),
1886        ];
1887
1888        let mut account_num = 300000u32;
1889        for (sub_type, name_prefix, weight) in sub_types {
1890            let sub_count = ((count as f64) * weight).round() as usize;
1891            for i in 0..sub_count.max(1) {
1892                let account = GLAccount::new(
1893                    format!("{account_num}"),
1894                    format!("{} {}", name_prefix, i + 1),
1895                    AccountType::Equity,
1896                    sub_type,
1897                );
1898                coa.add_account(account);
1899                account_num += 10;
1900            }
1901        }
1902    }
1903
1904    fn generate_revenue_accounts(&mut self, coa: &mut ChartOfAccounts, count: usize) {
1905        let sub_types = vec![
1906            (AccountSubType::ProductRevenue, "Product Revenue", 0.40),
1907            (AccountSubType::ServiceRevenue, "Service Revenue", 0.30),
1908            (AccountSubType::InterestIncome, "Interest Income", 0.10),
1909            (AccountSubType::OtherIncome, "Other Income", 0.20),
1910        ];
1911
1912        let mut account_num = 400000u32;
1913        for (sub_type, name_prefix, weight) in sub_types {
1914            let sub_count = ((count as f64) * weight).round() as usize;
1915            for i in 0..sub_count.max(1) {
1916                let account = GLAccount::new(
1917                    format!("{account_num}"),
1918                    format!("{} {}", name_prefix, i + 1),
1919                    AccountType::Revenue,
1920                    sub_type,
1921                );
1922                coa.add_account(account);
1923                account_num += 10;
1924            }
1925        }
1926    }
1927
1928    fn generate_expense_accounts(&mut self, coa: &mut ChartOfAccounts, count: usize) {
1929        let sub_types = vec![
1930            (AccountSubType::CostOfGoodsSold, "COGS", 0.20),
1931            (
1932                AccountSubType::OperatingExpenses,
1933                "Operating Expenses",
1934                0.25,
1935            ),
1936            (AccountSubType::SellingExpenses, "Selling Expenses", 0.15),
1937            (
1938                AccountSubType::AdministrativeExpenses,
1939                "Admin Expenses",
1940                0.15,
1941            ),
1942            (AccountSubType::DepreciationExpense, "Depreciation", 0.10),
1943            (AccountSubType::InterestExpense, "Interest Expense", 0.05),
1944            (AccountSubType::TaxExpense, "Tax Expense", 0.05),
1945            (AccountSubType::OtherExpenses, "Other Expenses", 0.05),
1946        ];
1947
1948        let mut account_num = 500000u32;
1949        for (sub_type, name_prefix, weight) in sub_types {
1950            let sub_count = ((count as f64) * weight).round() as usize;
1951            for i in 0..sub_count.max(1) {
1952                let mut account = GLAccount::new(
1953                    format!("{account_num}"),
1954                    format!("{} {}", name_prefix, i + 1),
1955                    AccountType::Expense,
1956                    sub_type,
1957                );
1958                account.requires_cost_center = true;
1959                coa.add_account(account);
1960                account_num += 10;
1961            }
1962        }
1963    }
1964
1965    fn generate_suspense_accounts(&mut self, coa: &mut ChartOfAccounts) {
1966        let suspense_types = vec![
1967            (AccountSubType::SuspenseClearing, "Suspense Clearing"),
1968            (AccountSubType::GoodsReceivedClearing, "GR/IR Clearing"),
1969            (AccountSubType::BankClearing, "Bank Clearing"),
1970            (
1971                AccountSubType::IntercompanyClearing,
1972                "Intercompany Clearing",
1973            ),
1974        ];
1975
1976        let mut account_num = 199000u32;
1977        for (sub_type, name) in suspense_types {
1978            let mut account = GLAccount::new(
1979                format!("{account_num}"),
1980                name.to_string(),
1981                AccountType::Asset,
1982                sub_type,
1983            );
1984            account.is_suspense_account = true;
1985            coa.add_account(account);
1986            account_num += 100;
1987        }
1988    }
1989}
1990
1991impl Generator for ChartOfAccountsGenerator {
1992    type Item = ChartOfAccounts;
1993    type Config = (CoAComplexity, IndustrySector);
1994
1995    fn new(config: Self::Config, seed: u64) -> Self {
1996        Self::new(config.0, config.1, seed)
1997    }
1998
1999    fn generate_one(&mut self) -> Self::Item {
2000        self.generate()
2001    }
2002
2003    fn reset(&mut self) {
2004        self.rng = seeded_rng(self.seed, 0);
2005        self.count = 0;
2006        self.coa_framework = CoAFramework::UsGaap;
2007    }
2008
2009    fn count(&self) -> u64 {
2010        self.count
2011    }
2012
2013    fn seed(&self) -> u64 {
2014        self.seed
2015    }
2016}
2017
2018#[cfg(test)]
2019mod tests {
2020    use super::*;
2021
2022    #[test]
2023    fn test_generate_small_coa() {
2024        let mut gen =
2025            ChartOfAccountsGenerator::new(CoAComplexity::Small, IndustrySector::Manufacturing, 42);
2026        let coa = gen.generate();
2027
2028        assert!(coa.account_count() >= 50);
2029        assert!(!coa.get_suspense_accounts().is_empty());
2030    }
2031
2032    #[test]
2033    fn test_generate_pcg_coa() {
2034        let mut gen =
2035            ChartOfAccountsGenerator::new(CoAComplexity::Small, IndustrySector::Manufacturing, 42)
2036                .with_french_pcg(true);
2037        let coa = gen.generate();
2038
2039        assert_eq!(coa.country, "FR");
2040        assert!(coa.name.contains("Plan Comptable") || coa.name.contains("PCG"));
2041        assert!(coa.account_count() >= 20);
2042        // PCG accounts are 6-digit (e.g. 411000, 601000)
2043        let first = coa.accounts.first().expect("has accounts");
2044        assert_eq!(first.account_number.len(), 6);
2045    }
2046
2047    /// Verifies PCG (Plan Comptable Général) account structure: 6-digit format,
2048    /// all accounts numeric, and coverage of at least two PCG classes (1–8).
2049    /// Works for both the embedded PCG 2024 loader and the fallback generator.
2050    #[test]
2051    fn test_pcg_account_structure() {
2052        let mut gen =
2053            ChartOfAccountsGenerator::new(CoAComplexity::Small, IndustrySector::Manufacturing, 42)
2054                .with_french_pcg(true);
2055        let coa = gen.generate();
2056
2057        assert_eq!(
2058            coa.account_format, "######",
2059            "PCG uses 6-digit account format"
2060        );
2061        assert!(
2062            coa.account_count() >= 20,
2063            "PCG CoA has minimum account count"
2064        );
2065
2066        let account_numbers: Vec<_> = coa
2067            .accounts
2068            .iter()
2069            .map(|a| a.account_number.as_str())
2070            .collect();
2071        for num in &account_numbers {
2072            assert_eq!(num.len(), 6, "every PCG account is 6 digits: {}", num);
2073            assert!(
2074                num.chars().all(|c| c.is_ascii_digit()),
2075                "PCG account is numeric: {}",
2076                num
2077            );
2078        }
2079
2080        // All account numbers must belong to a PCG class (first digit 1–8)
2081        let first_digits: std::collections::HashSet<char> = account_numbers
2082            .iter()
2083            .filter_map(|s| s.chars().next())
2084            .collect();
2085        let pcg_classes: std::collections::HashSet<_> =
2086            ['1', '2', '3', '4', '5', '6', '7', '8'].into();
2087        assert!(
2088            !first_digits.is_empty() && first_digits.is_subset(&pcg_classes),
2089            "PCG account numbers must be in classes 1–8, got first digits: {:?}",
2090            first_digits
2091        );
2092    }
2093
2094    /// SP6 T11 — `overlay_coa_taxonomy` fills a template once per account and
2095    /// leaves accounts without a template entry untouched.
2096    #[test]
2097    fn overlay_coa_taxonomy_fills_template_once_per_account() {
2098        use datasynth_core::distributions::text_taxonomy::{
2099            SyntheticExampleResolver, TemplateEntry, TextTaxonomyPrior,
2100        };
2101        use rand::SeedableRng;
2102
2103        // Build a small CoA. The US-GAAP generator seeds "2000" (AP_CONTROL)
2104        // as a canonical account — use that as our taxonomy key.
2105        let mut gen =
2106            ChartOfAccountsGenerator::new(CoAComplexity::Small, IndustrySector::Manufacturing, 1);
2107        let mut coa = gen.generate();
2108
2109        // Confirm the account is present before wiring the taxonomy.
2110        assert!(
2111            coa.accounts.iter().any(|a| a.account_number == "2000"),
2112            "canonical AP_CONTROL account '2000' expected in small CoA"
2113        );
2114
2115        let mut tx = TextTaxonomyPrior::default();
2116        tx.coa_pools.insert(
2117            "2000".to_string(),
2118            TemplateEntry {
2119                template: "Kreditoren {company}".to_string(),
2120                probability: 1.0,
2121                synthetic_example: "Kreditoren Example GmbH".to_string(),
2122            },
2123        );
2124        let mut resolver = SyntheticExampleResolver;
2125        let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(9);
2126        overlay_coa_taxonomy(&mut coa, &tx, &mut resolver, &mut rng);
2127
2128        let acct = coa
2129            .accounts
2130            .iter()
2131            .find(|a| a.account_number == "2000")
2132            .expect("account '2000' present after overlay");
2133        assert!(
2134            acct.short_description.starts_with("Kreditoren "),
2135            "expected 'Kreditoren …', got '{}'",
2136            acct.short_description
2137        );
2138        assert!(
2139            !acct.short_description.contains('{'),
2140            "template placeholder left unfilled: '{}'",
2141            acct.short_description
2142        );
2143        assert_eq!(
2144            acct.short_description, acct.long_description,
2145            "short and long descriptions should be identical after taxonomy overlay"
2146        );
2147
2148        // SP6 stability invariant (spec §5.3): once overlay_coa_taxonomy fires
2149        // for an account, subsequent reads must return the byte-identical
2150        // description — the fill happens ONCE per account per run, not per
2151        // access. (No second overlay call; this asserts the stored state is
2152        // stable across reads on the same CoA.)
2153        let first = acct.short_description.clone();
2154        let acct_again = coa
2155            .accounts
2156            .iter()
2157            .find(|a| a.account_number == "2000")
2158            .expect("account '2000' still present");
2159        assert_eq!(acct_again.short_description, first);
2160        assert_eq!(acct_again.long_description, first);
2161    }
2162}