Skip to main content

datasynth_core/models/
chart_of_accounts.rs

1//! Chart of Accounts structures for GL account management.
2//!
3//! Defines the hierarchical structure of financial accounts used in
4//! the general ledger, including account classifications aligned with
5//! standard financial reporting requirements.
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// Primary account type classification following standard financial statement structure.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum AccountType {
14    /// Assets - resources owned by the entity
15    Asset,
16    /// Liabilities - obligations owed to others
17    Liability,
18    /// Equity - residual interest in assets after deducting liabilities
19    Equity,
20    /// Revenue - income from operations
21    Revenue,
22    /// Expense - costs incurred in operations
23    Expense,
24    /// Statistical - non-financial tracking accounts
25    Statistical,
26}
27
28impl AccountType {
29    /// Returns true if this is a balance sheet account type.
30    pub fn is_balance_sheet(&self) -> bool {
31        matches!(self, Self::Asset | Self::Liability | Self::Equity)
32    }
33
34    /// Returns true if this is an income statement account type.
35    pub fn is_income_statement(&self) -> bool {
36        matches!(self, Self::Revenue | Self::Expense)
37    }
38
39    /// Returns the normal balance side (true = debit, false = credit).
40    pub fn normal_debit_balance(&self) -> bool {
41        matches!(self, Self::Asset | Self::Expense)
42    }
43}
44
45/// Detailed sub-classification for accounts within each type.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum AccountSubType {
49    // Assets
50    /// Cash and cash equivalents
51    Cash,
52    /// Trade receivables
53    AccountsReceivable,
54    /// Other receivables
55    OtherReceivables,
56    /// Raw materials, WIP, finished goods
57    Inventory,
58    /// Prepaid expenses and deferred charges
59    PrepaidExpenses,
60    /// Property, plant and equipment
61    FixedAssets,
62    /// Contra-asset for depreciation
63    AccumulatedDepreciation,
64    /// Long-term investments
65    Investments,
66    /// Patents, trademarks, goodwill
67    IntangibleAssets,
68    /// Miscellaneous assets
69    OtherAssets,
70
71    // Liabilities
72    /// Trade payables
73    AccountsPayable,
74    /// Accrued expenses
75    AccruedLiabilities,
76    /// Short-term borrowings
77    ShortTermDebt,
78    /// Long-term borrowings
79    LongTermDebt,
80    /// Unearned revenue
81    DeferredRevenue,
82    /// Current and deferred taxes payable
83    TaxLiabilities,
84    /// Pension and other post-employment benefits
85    PensionLiabilities,
86    /// Miscellaneous liabilities
87    OtherLiabilities,
88
89    // Equity
90    /// Par value of shares issued
91    CommonStock,
92    /// Accumulated profits
93    RetainedEarnings,
94    /// Premium on share issuance
95    AdditionalPaidInCapital,
96    /// Repurchased shares
97    TreasuryStock,
98    /// Unrealized gains/losses
99    OtherComprehensiveIncome,
100    /// Current period profit/loss
101    NetIncome,
102
103    // Revenue
104    /// Sales of products
105    ProductRevenue,
106    /// Sales of services
107    ServiceRevenue,
108    /// Interest earned
109    InterestIncome,
110    /// Dividends received
111    DividendIncome,
112    /// Gains on asset sales
113    GainOnSale,
114    /// Miscellaneous income
115    OtherIncome,
116
117    // Expense
118    /// Direct costs of goods sold
119    CostOfGoodsSold,
120    /// General operating expenses
121    OperatingExpenses,
122    /// Sales and marketing costs
123    SellingExpenses,
124    /// G&A costs
125    AdministrativeExpenses,
126    /// Depreciation of fixed assets
127    DepreciationExpense,
128    /// Amortization of intangibles
129    AmortizationExpense,
130    /// Interest on borrowings
131    InterestExpense,
132    /// Income tax expense
133    TaxExpense,
134    /// Foreign exchange losses
135    ForeignExchangeLoss,
136    /// Losses on asset sales
137    LossOnSale,
138    /// Miscellaneous expenses
139    OtherExpenses,
140
141    // Suspense/Clearing
142    /// Clearing accounts for temporary postings
143    SuspenseClearing,
144    /// GR/IR clearing
145    GoodsReceivedClearing,
146    /// Bank clearing accounts
147    BankClearing,
148    /// Intercompany clearing
149    IntercompanyClearing,
150}
151
152impl AccountSubType {
153    /// Get the parent account type for this sub-type.
154    pub fn account_type(&self) -> AccountType {
155        match self {
156            Self::Cash
157            | Self::AccountsReceivable
158            | Self::OtherReceivables
159            | Self::Inventory
160            | Self::PrepaidExpenses
161            | Self::FixedAssets
162            | Self::AccumulatedDepreciation
163            | Self::Investments
164            | Self::IntangibleAssets
165            | Self::OtherAssets => AccountType::Asset,
166
167            Self::AccountsPayable
168            | Self::AccruedLiabilities
169            | Self::ShortTermDebt
170            | Self::LongTermDebt
171            | Self::DeferredRevenue
172            | Self::TaxLiabilities
173            | Self::PensionLiabilities
174            | Self::OtherLiabilities => AccountType::Liability,
175
176            Self::CommonStock
177            | Self::RetainedEarnings
178            | Self::AdditionalPaidInCapital
179            | Self::TreasuryStock
180            | Self::OtherComprehensiveIncome
181            | Self::NetIncome => AccountType::Equity,
182
183            Self::ProductRevenue
184            | Self::ServiceRevenue
185            | Self::InterestIncome
186            | Self::DividendIncome
187            | Self::GainOnSale
188            | Self::OtherIncome => AccountType::Revenue,
189
190            Self::CostOfGoodsSold
191            | Self::OperatingExpenses
192            | Self::SellingExpenses
193            | Self::AdministrativeExpenses
194            | Self::DepreciationExpense
195            | Self::AmortizationExpense
196            | Self::InterestExpense
197            | Self::TaxExpense
198            | Self::ForeignExchangeLoss
199            | Self::LossOnSale
200            | Self::OtherExpenses => AccountType::Expense,
201
202            Self::SuspenseClearing
203            | Self::GoodsReceivedClearing
204            | Self::BankClearing
205            | Self::IntercompanyClearing => AccountType::Asset, // Clearing accounts typically treated as assets
206        }
207    }
208
209    /// Check if this is a suspense/clearing account type.
210    pub fn is_suspense(&self) -> bool {
211        matches!(
212            self,
213            Self::SuspenseClearing
214                | Self::GoodsReceivedClearing
215                | Self::BankClearing
216                | Self::IntercompanyClearing
217        )
218    }
219}
220
221/// Industry sector for account relevance weighting.
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
223#[serde(rename_all = "snake_case")]
224pub enum IndustrySector {
225    #[default]
226    Manufacturing,
227    Retail,
228    FinancialServices,
229    Healthcare,
230    Technology,
231    ProfessionalServices,
232    Energy,
233    Transportation,
234    RealEstate,
235    Telecommunications,
236}
237
238/// Industry relevance weights for account selection during generation.
239///
240/// Weights from 0.0 to 1.0 indicating how relevant an account is for each industry.
241#[derive(Debug, Clone, Default, Serialize, Deserialize)]
242pub struct IndustryWeights {
243    pub manufacturing: f64,
244    pub retail: f64,
245    pub financial_services: f64,
246    pub healthcare: f64,
247    pub technology: f64,
248    pub professional_services: f64,
249    pub energy: f64,
250    pub transportation: f64,
251    pub real_estate: f64,
252    pub telecommunications: f64,
253}
254
255impl IndustryWeights {
256    /// Create weights where all industries have equal relevance.
257    pub fn all_equal(weight: f64) -> Self {
258        Self {
259            manufacturing: weight,
260            retail: weight,
261            financial_services: weight,
262            healthcare: weight,
263            technology: weight,
264            professional_services: weight,
265            energy: weight,
266            transportation: weight,
267            real_estate: weight,
268            telecommunications: weight,
269        }
270    }
271
272    /// Get weight for a specific industry.
273    pub fn get(&self, industry: IndustrySector) -> f64 {
274        match industry {
275            IndustrySector::Manufacturing => self.manufacturing,
276            IndustrySector::Retail => self.retail,
277            IndustrySector::FinancialServices => self.financial_services,
278            IndustrySector::Healthcare => self.healthcare,
279            IndustrySector::Technology => self.technology,
280            IndustrySector::ProfessionalServices => self.professional_services,
281            IndustrySector::Energy => self.energy,
282            IndustrySector::Transportation => self.transportation,
283            IndustrySector::RealEstate => self.real_estate,
284            IndustrySector::Telecommunications => self.telecommunications,
285        }
286    }
287}
288
289/// Individual GL account definition.
290///
291/// Represents a single account in the chart of accounts with all necessary
292/// metadata for realistic transaction generation.
293#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct GLAccount {
295    /// Account number (e.g., "100000", "400100")
296    pub account_number: String,
297
298    /// Short description
299    pub short_description: String,
300
301    /// Long description
302    pub long_description: String,
303
304    /// Primary account type
305    pub account_type: AccountType,
306
307    /// Detailed sub-type classification
308    pub sub_type: AccountSubType,
309
310    /// ISO 21378 (Audit Data Collection) Level-2 account class code
311    /// (e.g. `"A.B"` for Trade Receivables, `"X.A"` for Cost of Goods
312    /// Sold). Derived from `sub_type` via
313    /// [`crate::iso21378::from_account_sub_type`]. **v5.6.0 schema
314    /// change**: prior versions populated this with the first digit of
315    /// `account_number` (e.g. `"1"`); the new value is the descriptive
316    /// ISO code consumers can filter / group by directly.
317    pub account_class: String,
318
319    /// ISO 21378 Level-2 account class name (e.g. `"Trade
320    /// Receivables"`, `"Cost of Goods Sold"`). Added in v5.6.0.
321    #[serde(default)]
322    pub account_class_name: String,
323
324    /// ISO 21378 Level-3 account sub-class code (e.g. `"A.B.A"` for
325    /// Trade Accounts Receivable). Added in v5.6.0.
326    #[serde(default)]
327    pub account_sub_class: String,
328
329    /// ISO 21378 Level-3 account sub-class name (e.g. `"Trade Accounts
330    /// Receivable"`). Added in v5.6.0.
331    #[serde(default)]
332    pub account_sub_class_name: String,
333
334    /// Account group for reporting
335    pub account_group: String,
336
337    /// Is this a control account (subledger summary)
338    pub is_control_account: bool,
339
340    /// Is this a suspense/clearing account
341    pub is_suspense_account: bool,
342
343    /// Parent account number (for hierarchies)
344    pub parent_account: Option<String>,
345
346    /// Account hierarchy level (1 = top level)
347    pub hierarchy_level: u8,
348
349    /// Normal balance side (true = debit, false = credit)
350    pub normal_debit_balance: bool,
351
352    /// Is posting allowed directly to this account
353    pub is_postable: bool,
354
355    /// Is this account blocked for posting
356    pub is_blocked: bool,
357
358    /// Allowed document types for this account
359    pub allowed_doc_types: Vec<String>,
360
361    /// Required cost center assignment
362    pub requires_cost_center: bool,
363
364    /// Required profit center assignment
365    pub requires_profit_center: bool,
366
367    /// Industry sector relevance scores (0.0-1.0)
368    pub industry_weights: IndustryWeights,
369
370    /// Typical transaction frequency (transactions per month)
371    pub typical_frequency: f64,
372
373    /// Typical transaction amount range (min, max)
374    pub typical_amount_range: (f64, f64),
375
376    /// Accounting framework this account belongs to (e.g., "us_gaap",
377    /// "french_pcg", "german_skr04"). Mirrors the parent
378    /// [`ChartOfAccounts::accounting_framework`] sidecar so per-row
379    /// consumers (CSV / parquet readers) can filter by framework
380    /// without joining back to `coa_meta`. Added in v5.0.1.
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub accounting_framework: Option<String>,
383}
384
385impl GLAccount {
386    /// Create a new GL account with minimal required fields.
387    ///
388    /// `account_class`, `account_class_name`, `account_sub_class`,
389    /// `account_sub_class_name` are auto-derived from `sub_type` via
390    /// the ISO 21378 mapping in [`crate::iso21378`].
391    pub fn new(
392        account_number: String,
393        description: String,
394        account_type: AccountType,
395        sub_type: AccountSubType,
396    ) -> Self {
397        let adc_sub = crate::iso21378::from_account_sub_type(sub_type);
398        let adc_class = adc_sub.adc_class();
399        Self {
400            account_number,
401            short_description: description.clone(),
402            long_description: description,
403            account_type,
404            sub_type,
405            account_class: adc_class.code().to_string(),
406            account_class_name: adc_class.name().to_string(),
407            account_sub_class: adc_sub.code().to_string(),
408            account_sub_class_name: adc_sub.name().to_string(),
409            account_group: "DEFAULT".to_string(),
410            is_control_account: false,
411            is_suspense_account: sub_type.is_suspense(),
412            parent_account: None,
413            hierarchy_level: 1,
414            normal_debit_balance: account_type.normal_debit_balance(),
415            is_postable: true,
416            is_blocked: false,
417            allowed_doc_types: vec!["SA".to_string()],
418            requires_cost_center: matches!(account_type, AccountType::Expense),
419            requires_profit_center: false,
420            industry_weights: IndustryWeights::all_equal(1.0),
421            typical_frequency: 100.0,
422            typical_amount_range: (100.0, 100000.0),
423            accounting_framework: None,
424        }
425    }
426
427    /// Get account code (alias for account_number).
428    pub fn account_code(&self) -> &str {
429        &self.account_number
430    }
431
432    /// Get description (alias for short_description).
433    pub fn description(&self) -> &str {
434        &self.short_description
435    }
436}
437
438/// Chart of Accounts complexity levels.
439#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
440#[serde(rename_all = "snake_case")]
441pub enum CoAComplexity {
442    /// ~100 accounts - small business
443    #[default]
444    Small,
445    /// ~400 accounts - mid-market
446    Medium,
447    /// ~2500 accounts - enterprise (based on paper's max observation)
448    Large,
449}
450
451impl CoAComplexity {
452    /// Get the target account count for this complexity level.
453    pub fn target_count(&self) -> usize {
454        match self {
455            Self::Small => 100,
456            Self::Medium => 400,
457            Self::Large => 2500,
458        }
459    }
460}
461
462/// Complete Chart of Accounts structure.
463///
464/// Contains all GL accounts for an entity along with metadata about
465/// the overall structure.
466#[derive(Debug, Clone, Serialize, Deserialize, Default)]
467pub struct ChartOfAccounts {
468    /// Unique identifier for this CoA
469    pub coa_id: String,
470
471    /// Name/description
472    pub name: String,
473
474    /// Country/region code
475    pub country: String,
476
477    /// Industry sector this CoA is designed for
478    pub industry: IndustrySector,
479
480    /// All accounts in this CoA
481    pub accounts: Vec<GLAccount>,
482
483    /// Complexity level
484    pub complexity: CoAComplexity,
485
486    /// Account number format (e.g., "######" for 6 digits)
487    pub account_format: String,
488
489    /// v4.4.1+ accounting framework for this CoA — "us_gaap", "ifrs",
490    /// "french_gaap", "german_gaap", or "dual_reporting". Populated by
491    /// the orchestrator from `config.accounting_standards.framework`
492    /// when `accounting_standards.enabled = true`; `None` otherwise.
493    /// SDK consumers previously reported this field as null across
494    /// the board — before v4.4.1 it simply didn't exist.
495    #[serde(default, skip_serializing_if = "Option::is_none")]
496    pub accounting_framework: Option<String>,
497
498    /// Index by account number for fast lookup
499    #[serde(skip)]
500    account_index: HashMap<String, usize>,
501}
502
503impl ChartOfAccounts {
504    /// Create a new empty Chart of Accounts.
505    pub fn new(
506        coa_id: String,
507        name: String,
508        country: String,
509        industry: IndustrySector,
510        complexity: CoAComplexity,
511    ) -> Self {
512        Self {
513            coa_id,
514            name,
515            country,
516            industry,
517            accounts: Vec::new(),
518            complexity,
519            account_format: "######".to_string(),
520            accounting_framework: None,
521            account_index: HashMap::new(),
522        }
523    }
524
525    /// v4.4.1+ builder for the accounting framework label (e.g.
526    /// `"us_gaap"`, `"ifrs"`). Typically invoked by the orchestrator
527    /// from the parsed `AccountingFrameworkConfig`.
528    pub fn with_accounting_framework(mut self, framework: impl Into<String>) -> Self {
529        self.accounting_framework = Some(framework.into());
530        self
531    }
532
533    /// Add an account to the CoA.
534    pub fn add_account(&mut self, account: GLAccount) {
535        let idx = self.accounts.len();
536        self.account_index
537            .insert(account.account_number.clone(), idx);
538        self.accounts.push(account);
539    }
540
541    /// Rebuild the account index (call after deserialization).
542    pub fn rebuild_index(&mut self) {
543        self.account_index.clear();
544        for (idx, account) in self.accounts.iter().enumerate() {
545            self.account_index
546                .insert(account.account_number.clone(), idx);
547        }
548    }
549
550    /// Get an account by number.
551    pub fn get_account(&self, account_number: &str) -> Option<&GLAccount> {
552        self.account_index
553            .get(account_number)
554            .map(|&idx| &self.accounts[idx])
555    }
556
557    /// Get all postable accounts.
558    pub fn get_postable_accounts(&self) -> Vec<&GLAccount> {
559        self.accounts
560            .iter()
561            .filter(|a| a.is_postable && !a.is_blocked)
562            .collect()
563    }
564
565    /// Get all accounts of a specific type.
566    pub fn get_accounts_by_type(&self, account_type: AccountType) -> Vec<&GLAccount> {
567        self.accounts
568            .iter()
569            .filter(|a| a.account_type == account_type && a.is_postable && !a.is_blocked)
570            .collect()
571    }
572
573    /// Get all accounts of a specific sub-type.
574    pub fn get_accounts_by_sub_type(&self, sub_type: AccountSubType) -> Vec<&GLAccount> {
575        self.accounts
576            .iter()
577            .filter(|a| a.sub_type == sub_type && a.is_postable && !a.is_blocked)
578            .collect()
579    }
580
581    /// Get suspense/clearing accounts.
582    pub fn get_suspense_accounts(&self) -> Vec<&GLAccount> {
583        self.accounts
584            .iter()
585            .filter(|a| a.is_suspense_account && a.is_postable)
586            .collect()
587    }
588
589    /// **v5.7.0** — pick a postable sub-account for a parent canonical
590    /// account, deterministic per `document_id`.
591    ///
592    /// When the COA was generated with
593    /// `expand_industry_subaccounts: true`, each canonical parent (e.g.
594    /// `"4000"`) is non-postable and its real postings target one of
595    /// its 6-digit sub-accounts (`"400010"`, `"400020"`, …). This
596    /// helper deterministically selects one of those sub-accounts based
597    /// on a stable hash of `document_id` and the configured weights:
598    ///
599    /// 1. If `parent_account` doesn't exist in the COA, returns `None`.
600    /// 2. If no sub-accounts (children with `parent_account == parent`)
601    ///    exist, returns `parent_account` itself (legacy behaviour —
602    ///    expansion was not enabled or this parent is not in the
603    ///    industry pack).
604    /// 3. Otherwise hashes `document_id` to a position in the cumulative
605    ///    weight distribution and returns that sub-account number.
606    ///
607    /// Determinism: the same `(parent_account, document_id)` pair
608    /// always returns the same sub-account, across regenerations and
609    /// across platforms.
610    pub fn pick_subaccount_for_document(
611        &self,
612        parent_account: &str,
613        document_id: uuid::Uuid,
614    ) -> Option<String> {
615        // 1. Parent must exist.
616        self.get_account(parent_account)?;
617
618        // 2. Collect sub-accounts.
619        let subs: Vec<&GLAccount> = self
620            .accounts
621            .iter()
622            .filter(|a| {
623                a.parent_account.as_deref() == Some(parent_account)
624                    && a.is_postable
625                    && !a.is_blocked
626            })
627            .collect();
628
629        if subs.is_empty() {
630            return Some(parent_account.to_string());
631        }
632
633        // 3. Hash document_id + parent to a u64 (FNV-1a — same family
634        //    used by uuid_factory for determinism).
635        let mut hash: u64 = 0xcbf29ce484222325;
636        for byte in document_id.as_bytes() {
637            hash ^= u64::from(*byte);
638            hash = hash.wrapping_mul(0x100000001b3);
639        }
640        for byte in parent_account.as_bytes() {
641            hash ^= u64::from(*byte);
642            hash = hash.wrapping_mul(0x100000001b3);
643        }
644
645        // Default per-sub weight is 1.0 (since GLAccount doesn't carry
646        // a weight field; the industry pack's weights are applied at
647        // expansion time by ordering — earlier entries are higher-
648        // weight, but we don't preserve them on GLAccount).
649        // For v5.7.0 MVP we use uniform-by-position selection. The
650        // pack's weight ordering still influences which sub-accounts
651        // get added (high-weight ones present, low-weight rare).
652        let idx = (hash as usize) % subs.len();
653        Some(subs[idx].account_number.clone())
654    }
655
656    /// Get accounts weighted by industry relevance.
657    pub fn get_industry_weighted_accounts(
658        &self,
659        account_type: AccountType,
660    ) -> Vec<(&GLAccount, f64)> {
661        self.get_accounts_by_type(account_type)
662            .into_iter()
663            .map(|a| {
664                let weight = a.industry_weights.get(self.industry);
665                (a, weight)
666            })
667            .filter(|(_, w)| *w > 0.0)
668            .collect()
669    }
670
671    /// Get total account count.
672    pub fn account_count(&self) -> usize {
673        self.accounts.len()
674    }
675
676    /// Get count of postable accounts.
677    pub fn postable_count(&self) -> usize {
678        self.accounts.iter().filter(|a| a.is_postable).count()
679    }
680}
681
682#[cfg(test)]
683#[allow(clippy::unwrap_used)]
684mod tests {
685    use super::*;
686
687    #[test]
688    fn test_account_type_balance() {
689        assert!(AccountType::Asset.normal_debit_balance());
690        assert!(AccountType::Expense.normal_debit_balance());
691        assert!(!AccountType::Liability.normal_debit_balance());
692        assert!(!AccountType::Revenue.normal_debit_balance());
693        assert!(!AccountType::Equity.normal_debit_balance());
694    }
695
696    #[test]
697    fn test_coa_complexity_count() {
698        assert_eq!(CoAComplexity::Small.target_count(), 100);
699        assert_eq!(CoAComplexity::Medium.target_count(), 400);
700        assert_eq!(CoAComplexity::Large.target_count(), 2500);
701    }
702
703    #[test]
704    fn test_coa_account_lookup() {
705        let mut coa = ChartOfAccounts::new(
706            "TEST".to_string(),
707            "Test CoA".to_string(),
708            "US".to_string(),
709            IndustrySector::Manufacturing,
710            CoAComplexity::Small,
711        );
712
713        coa.add_account(GLAccount::new(
714            "100000".to_string(),
715            "Cash".to_string(),
716            AccountType::Asset,
717            AccountSubType::Cash,
718        ));
719
720        assert!(coa.get_account("100000").is_some());
721        assert!(coa.get_account("999999").is_none());
722    }
723}