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    /// Index by account number for fast lookup
451    #[serde(skip)]
452    account_index: HashMap<String, usize>,
453}
454
455impl ChartOfAccounts {
456    /// Create a new empty Chart of Accounts.
457    pub fn new(
458        coa_id: String,
459        name: String,
460        country: String,
461        industry: IndustrySector,
462        complexity: CoAComplexity,
463    ) -> Self {
464        Self {
465            coa_id,
466            name,
467            country,
468            industry,
469            accounts: Vec::new(),
470            complexity,
471            account_format: "######".to_string(),
472            account_index: HashMap::new(),
473        }
474    }
475
476    /// Add an account to the CoA.
477    pub fn add_account(&mut self, account: GLAccount) {
478        let idx = self.accounts.len();
479        self.account_index
480            .insert(account.account_number.clone(), idx);
481        self.accounts.push(account);
482    }
483
484    /// Rebuild the account index (call after deserialization).
485    pub fn rebuild_index(&mut self) {
486        self.account_index.clear();
487        for (idx, account) in self.accounts.iter().enumerate() {
488            self.account_index
489                .insert(account.account_number.clone(), idx);
490        }
491    }
492
493    /// Get an account by number.
494    pub fn get_account(&self, account_number: &str) -> Option<&GLAccount> {
495        self.account_index
496            .get(account_number)
497            .map(|&idx| &self.accounts[idx])
498    }
499
500    /// Get all postable accounts.
501    pub fn get_postable_accounts(&self) -> Vec<&GLAccount> {
502        self.accounts
503            .iter()
504            .filter(|a| a.is_postable && !a.is_blocked)
505            .collect()
506    }
507
508    /// Get all accounts of a specific type.
509    pub fn get_accounts_by_type(&self, account_type: AccountType) -> Vec<&GLAccount> {
510        self.accounts
511            .iter()
512            .filter(|a| a.account_type == account_type && a.is_postable && !a.is_blocked)
513            .collect()
514    }
515
516    /// Get all accounts of a specific sub-type.
517    pub fn get_accounts_by_sub_type(&self, sub_type: AccountSubType) -> Vec<&GLAccount> {
518        self.accounts
519            .iter()
520            .filter(|a| a.sub_type == sub_type && a.is_postable && !a.is_blocked)
521            .collect()
522    }
523
524    /// Get suspense/clearing accounts.
525    pub fn get_suspense_accounts(&self) -> Vec<&GLAccount> {
526        self.accounts
527            .iter()
528            .filter(|a| a.is_suspense_account && a.is_postable)
529            .collect()
530    }
531
532    /// Get accounts weighted by industry relevance.
533    pub fn get_industry_weighted_accounts(
534        &self,
535        account_type: AccountType,
536    ) -> Vec<(&GLAccount, f64)> {
537        self.get_accounts_by_type(account_type)
538            .into_iter()
539            .map(|a| {
540                let weight = a.industry_weights.get(self.industry);
541                (a, weight)
542            })
543            .filter(|(_, w)| *w > 0.0)
544            .collect()
545    }
546
547    /// Get total account count.
548    pub fn account_count(&self) -> usize {
549        self.accounts.len()
550    }
551
552    /// Get count of postable accounts.
553    pub fn postable_count(&self) -> usize {
554        self.accounts.iter().filter(|a| a.is_postable).count()
555    }
556}
557
558#[cfg(test)]
559#[allow(clippy::unwrap_used)]
560mod tests {
561    use super::*;
562
563    #[test]
564    fn test_account_type_balance() {
565        assert!(AccountType::Asset.normal_debit_balance());
566        assert!(AccountType::Expense.normal_debit_balance());
567        assert!(!AccountType::Liability.normal_debit_balance());
568        assert!(!AccountType::Revenue.normal_debit_balance());
569        assert!(!AccountType::Equity.normal_debit_balance());
570    }
571
572    #[test]
573    fn test_coa_complexity_count() {
574        assert_eq!(CoAComplexity::Small.target_count(), 100);
575        assert_eq!(CoAComplexity::Medium.target_count(), 400);
576        assert_eq!(CoAComplexity::Large.target_count(), 2500);
577    }
578
579    #[test]
580    fn test_coa_account_lookup() {
581        let mut coa = ChartOfAccounts::new(
582            "TEST".to_string(),
583            "Test CoA".to_string(),
584            "US".to_string(),
585            IndustrySector::Manufacturing,
586            CoAComplexity::Small,
587        );
588
589        coa.add_account(GLAccount::new(
590            "100000".to_string(),
591            "Cash".to_string(),
592            AccountType::Asset,
593            AccountSubType::Cash,
594        ));
595
596        assert!(coa.get_account("100000").is_some());
597        assert!(coa.get_account("999999").is_none());
598    }
599}