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    /// Account class code (first digit typically)
311    pub account_class: String,
312
313    /// Account group for reporting
314    pub account_group: String,
315
316    /// Is this a control account (subledger summary)
317    pub is_control_account: bool,
318
319    /// Is this a suspense/clearing account
320    pub is_suspense_account: bool,
321
322    /// Parent account number (for hierarchies)
323    pub parent_account: Option<String>,
324
325    /// Account hierarchy level (1 = top level)
326    pub hierarchy_level: u8,
327
328    /// Normal balance side (true = debit, false = credit)
329    pub normal_debit_balance: bool,
330
331    /// Is posting allowed directly to this account
332    pub is_postable: bool,
333
334    /// Is this account blocked for posting
335    pub is_blocked: bool,
336
337    /// Allowed document types for this account
338    pub allowed_doc_types: Vec<String>,
339
340    /// Required cost center assignment
341    pub requires_cost_center: bool,
342
343    /// Required profit center assignment
344    pub requires_profit_center: bool,
345
346    /// Industry sector relevance scores (0.0-1.0)
347    pub industry_weights: IndustryWeights,
348
349    /// Typical transaction frequency (transactions per month)
350    pub typical_frequency: f64,
351
352    /// Typical transaction amount range (min, max)
353    pub typical_amount_range: (f64, f64),
354
355    /// Accounting framework this account belongs to (e.g., "us_gaap",
356    /// "french_pcg", "german_skr04"). Mirrors the parent
357    /// [`ChartOfAccounts::accounting_framework`] sidecar so per-row
358    /// consumers (CSV / parquet readers) can filter by framework
359    /// without joining back to `coa_meta`. Added in v5.0.1.
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub accounting_framework: Option<String>,
362}
363
364impl GLAccount {
365    /// Create a new GL account with minimal required fields.
366    pub fn new(
367        account_number: String,
368        description: String,
369        account_type: AccountType,
370        sub_type: AccountSubType,
371    ) -> Self {
372        Self {
373            account_number: account_number.clone(),
374            short_description: description.clone(),
375            long_description: description,
376            account_type,
377            sub_type,
378            account_class: account_number.chars().next().unwrap_or('0').to_string(),
379            account_group: "DEFAULT".to_string(),
380            is_control_account: false,
381            is_suspense_account: sub_type.is_suspense(),
382            parent_account: None,
383            hierarchy_level: 1,
384            normal_debit_balance: account_type.normal_debit_balance(),
385            is_postable: true,
386            is_blocked: false,
387            allowed_doc_types: vec!["SA".to_string()],
388            requires_cost_center: matches!(account_type, AccountType::Expense),
389            requires_profit_center: false,
390            industry_weights: IndustryWeights::all_equal(1.0),
391            typical_frequency: 100.0,
392            typical_amount_range: (100.0, 100000.0),
393            accounting_framework: None,
394        }
395    }
396
397    /// Get account code (alias for account_number).
398    pub fn account_code(&self) -> &str {
399        &self.account_number
400    }
401
402    /// Get description (alias for short_description).
403    pub fn description(&self) -> &str {
404        &self.short_description
405    }
406}
407
408/// Chart of Accounts complexity levels.
409#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
410#[serde(rename_all = "snake_case")]
411pub enum CoAComplexity {
412    /// ~100 accounts - small business
413    #[default]
414    Small,
415    /// ~400 accounts - mid-market
416    Medium,
417    /// ~2500 accounts - enterprise (based on paper's max observation)
418    Large,
419}
420
421impl CoAComplexity {
422    /// Get the target account count for this complexity level.
423    pub fn target_count(&self) -> usize {
424        match self {
425            Self::Small => 100,
426            Self::Medium => 400,
427            Self::Large => 2500,
428        }
429    }
430}
431
432/// Complete Chart of Accounts structure.
433///
434/// Contains all GL accounts for an entity along with metadata about
435/// the overall structure.
436#[derive(Debug, Clone, Serialize, Deserialize, Default)]
437pub struct ChartOfAccounts {
438    /// Unique identifier for this CoA
439    pub coa_id: String,
440
441    /// Name/description
442    pub name: String,
443
444    /// Country/region code
445    pub country: String,
446
447    /// Industry sector this CoA is designed for
448    pub industry: IndustrySector,
449
450    /// All accounts in this CoA
451    pub accounts: Vec<GLAccount>,
452
453    /// Complexity level
454    pub complexity: CoAComplexity,
455
456    /// Account number format (e.g., "######" for 6 digits)
457    pub account_format: String,
458
459    /// v4.4.1+ accounting framework for this CoA — "us_gaap", "ifrs",
460    /// "french_gaap", "german_gaap", or "dual_reporting". Populated by
461    /// the orchestrator from `config.accounting_standards.framework`
462    /// when `accounting_standards.enabled = true`; `None` otherwise.
463    /// SDK consumers previously reported this field as null across
464    /// the board — before v4.4.1 it simply didn't exist.
465    #[serde(default, skip_serializing_if = "Option::is_none")]
466    pub accounting_framework: Option<String>,
467
468    /// Index by account number for fast lookup
469    #[serde(skip)]
470    account_index: HashMap<String, usize>,
471}
472
473impl ChartOfAccounts {
474    /// Create a new empty Chart of Accounts.
475    pub fn new(
476        coa_id: String,
477        name: String,
478        country: String,
479        industry: IndustrySector,
480        complexity: CoAComplexity,
481    ) -> Self {
482        Self {
483            coa_id,
484            name,
485            country,
486            industry,
487            accounts: Vec::new(),
488            complexity,
489            account_format: "######".to_string(),
490            accounting_framework: None,
491            account_index: HashMap::new(),
492        }
493    }
494
495    /// v4.4.1+ builder for the accounting framework label (e.g.
496    /// `"us_gaap"`, `"ifrs"`). Typically invoked by the orchestrator
497    /// from the parsed `AccountingFrameworkConfig`.
498    pub fn with_accounting_framework(mut self, framework: impl Into<String>) -> Self {
499        self.accounting_framework = Some(framework.into());
500        self
501    }
502
503    /// Add an account to the CoA.
504    pub fn add_account(&mut self, account: GLAccount) {
505        let idx = self.accounts.len();
506        self.account_index
507            .insert(account.account_number.clone(), idx);
508        self.accounts.push(account);
509    }
510
511    /// Rebuild the account index (call after deserialization).
512    pub fn rebuild_index(&mut self) {
513        self.account_index.clear();
514        for (idx, account) in self.accounts.iter().enumerate() {
515            self.account_index
516                .insert(account.account_number.clone(), idx);
517        }
518    }
519
520    /// Get an account by number.
521    pub fn get_account(&self, account_number: &str) -> Option<&GLAccount> {
522        self.account_index
523            .get(account_number)
524            .map(|&idx| &self.accounts[idx])
525    }
526
527    /// Get all postable accounts.
528    pub fn get_postable_accounts(&self) -> Vec<&GLAccount> {
529        self.accounts
530            .iter()
531            .filter(|a| a.is_postable && !a.is_blocked)
532            .collect()
533    }
534
535    /// Get all accounts of a specific type.
536    pub fn get_accounts_by_type(&self, account_type: AccountType) -> Vec<&GLAccount> {
537        self.accounts
538            .iter()
539            .filter(|a| a.account_type == account_type && a.is_postable && !a.is_blocked)
540            .collect()
541    }
542
543    /// Get all accounts of a specific sub-type.
544    pub fn get_accounts_by_sub_type(&self, sub_type: AccountSubType) -> Vec<&GLAccount> {
545        self.accounts
546            .iter()
547            .filter(|a| a.sub_type == sub_type && a.is_postable && !a.is_blocked)
548            .collect()
549    }
550
551    /// Get suspense/clearing accounts.
552    pub fn get_suspense_accounts(&self) -> Vec<&GLAccount> {
553        self.accounts
554            .iter()
555            .filter(|a| a.is_suspense_account && a.is_postable)
556            .collect()
557    }
558
559    /// Get accounts weighted by industry relevance.
560    pub fn get_industry_weighted_accounts(
561        &self,
562        account_type: AccountType,
563    ) -> Vec<(&GLAccount, f64)> {
564        self.get_accounts_by_type(account_type)
565            .into_iter()
566            .map(|a| {
567                let weight = a.industry_weights.get(self.industry);
568                (a, weight)
569            })
570            .filter(|(_, w)| *w > 0.0)
571            .collect()
572    }
573
574    /// Get total account count.
575    pub fn account_count(&self) -> usize {
576        self.accounts.len()
577    }
578
579    /// Get count of postable accounts.
580    pub fn postable_count(&self) -> usize {
581        self.accounts.iter().filter(|a| a.is_postable).count()
582    }
583}
584
585#[cfg(test)]
586#[allow(clippy::unwrap_used)]
587mod tests {
588    use super::*;
589
590    #[test]
591    fn test_account_type_balance() {
592        assert!(AccountType::Asset.normal_debit_balance());
593        assert!(AccountType::Expense.normal_debit_balance());
594        assert!(!AccountType::Liability.normal_debit_balance());
595        assert!(!AccountType::Revenue.normal_debit_balance());
596        assert!(!AccountType::Equity.normal_debit_balance());
597    }
598
599    #[test]
600    fn test_coa_complexity_count() {
601        assert_eq!(CoAComplexity::Small.target_count(), 100);
602        assert_eq!(CoAComplexity::Medium.target_count(), 400);
603        assert_eq!(CoAComplexity::Large.target_count(), 2500);
604    }
605
606    #[test]
607    fn test_coa_account_lookup() {
608        let mut coa = ChartOfAccounts::new(
609            "TEST".to_string(),
610            "Test CoA".to_string(),
611            "US".to_string(),
612            IndustrySector::Manufacturing,
613            CoAComplexity::Small,
614        );
615
616        coa.add_account(GLAccount::new(
617            "100000".to_string(),
618            "Cash".to_string(),
619            AccountType::Asset,
620            AccountSubType::Cash,
621        ));
622
623        assert!(coa.get_account("100000").is_some());
624        assert!(coa.get_account("999999").is_none());
625    }
626}