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