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)]
223#[serde(rename_all = "snake_case")]
224pub enum IndustrySector {
225    Manufacturing,
226    Retail,
227    FinancialServices,
228    Healthcare,
229    Technology,
230    ProfessionalServices,
231    Energy,
232    Transportation,
233    RealEstate,
234    Telecommunications,
235}
236
237/// Industry relevance weights for account selection during generation.
238///
239/// Weights from 0.0 to 1.0 indicating how relevant an account is for each industry.
240#[derive(Debug, Clone, Default, Serialize, Deserialize)]
241pub struct IndustryWeights {
242    pub manufacturing: f64,
243    pub retail: f64,
244    pub financial_services: f64,
245    pub healthcare: f64,
246    pub technology: f64,
247    pub professional_services: f64,
248    pub energy: f64,
249    pub transportation: f64,
250    pub real_estate: f64,
251    pub telecommunications: f64,
252}
253
254impl IndustryWeights {
255    /// Create weights where all industries have equal relevance.
256    pub fn all_equal(weight: f64) -> Self {
257        Self {
258            manufacturing: weight,
259            retail: weight,
260            financial_services: weight,
261            healthcare: weight,
262            technology: weight,
263            professional_services: weight,
264            energy: weight,
265            transportation: weight,
266            real_estate: weight,
267            telecommunications: weight,
268        }
269    }
270
271    /// Get weight for a specific industry.
272    pub fn get(&self, industry: IndustrySector) -> f64 {
273        match industry {
274            IndustrySector::Manufacturing => self.manufacturing,
275            IndustrySector::Retail => self.retail,
276            IndustrySector::FinancialServices => self.financial_services,
277            IndustrySector::Healthcare => self.healthcare,
278            IndustrySector::Technology => self.technology,
279            IndustrySector::ProfessionalServices => self.professional_services,
280            IndustrySector::Energy => self.energy,
281            IndustrySector::Transportation => self.transportation,
282            IndustrySector::RealEstate => self.real_estate,
283            IndustrySector::Telecommunications => self.telecommunications,
284        }
285    }
286}
287
288/// Individual GL account definition.
289///
290/// Represents a single account in the chart of accounts with all necessary
291/// metadata for realistic transaction generation.
292#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct GLAccount {
294    /// Account number (e.g., "100000", "400100")
295    pub account_number: String,
296
297    /// Short description
298    pub short_description: String,
299
300    /// Long description
301    pub long_description: String,
302
303    /// Primary account type
304    pub account_type: AccountType,
305
306    /// Detailed sub-type classification
307    pub sub_type: AccountSubType,
308
309    /// Account class code (first digit typically)
310    pub account_class: String,
311
312    /// Account group for reporting
313    pub account_group: String,
314
315    /// Is this a control account (subledger summary)
316    pub is_control_account: bool,
317
318    /// Is this a suspense/clearing account
319    pub is_suspense_account: bool,
320
321    /// Parent account number (for hierarchies)
322    pub parent_account: Option<String>,
323
324    /// Account hierarchy level (1 = top level)
325    pub hierarchy_level: u8,
326
327    /// Normal balance side (true = debit, false = credit)
328    pub normal_debit_balance: bool,
329
330    /// Is posting allowed directly to this account
331    pub is_postable: bool,
332
333    /// Is this account blocked for posting
334    pub is_blocked: bool,
335
336    /// Allowed document types for this account
337    pub allowed_doc_types: Vec<String>,
338
339    /// Required cost center assignment
340    pub requires_cost_center: bool,
341
342    /// Required profit center assignment
343    pub requires_profit_center: bool,
344
345    /// Industry sector relevance scores (0.0-1.0)
346    pub industry_weights: IndustryWeights,
347
348    /// Typical transaction frequency (transactions per month)
349    pub typical_frequency: f64,
350
351    /// Typical transaction amount range (min, max)
352    pub typical_amount_range: (f64, f64),
353}
354
355impl GLAccount {
356    /// Create a new GL account with minimal required fields.
357    pub fn new(
358        account_number: String,
359        description: String,
360        account_type: AccountType,
361        sub_type: AccountSubType,
362    ) -> Self {
363        Self {
364            account_number: account_number.clone(),
365            short_description: description.clone(),
366            long_description: description,
367            account_type,
368            sub_type,
369            account_class: account_number.chars().next().unwrap_or('0').to_string(),
370            account_group: "DEFAULT".to_string(),
371            is_control_account: false,
372            is_suspense_account: sub_type.is_suspense(),
373            parent_account: None,
374            hierarchy_level: 1,
375            normal_debit_balance: account_type.normal_debit_balance(),
376            is_postable: true,
377            is_blocked: false,
378            allowed_doc_types: vec!["SA".to_string()],
379            requires_cost_center: matches!(account_type, AccountType::Expense),
380            requires_profit_center: false,
381            industry_weights: IndustryWeights::all_equal(1.0),
382            typical_frequency: 100.0,
383            typical_amount_range: (100.0, 100000.0),
384        }
385    }
386
387    /// Get account code (alias for account_number).
388    pub fn account_code(&self) -> &str {
389        &self.account_number
390    }
391
392    /// Get description (alias for short_description).
393    pub fn description(&self) -> &str {
394        &self.short_description
395    }
396}
397
398/// Chart of Accounts complexity levels.
399#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
400#[serde(rename_all = "snake_case")]
401pub enum CoAComplexity {
402    /// ~100 accounts - small business
403    Small,
404    /// ~400 accounts - mid-market
405    Medium,
406    /// ~2500 accounts - enterprise (based on paper's max observation)
407    Large,
408}
409
410impl CoAComplexity {
411    /// Get the target account count for this complexity level.
412    pub fn target_count(&self) -> usize {
413        match self {
414            Self::Small => 100,
415            Self::Medium => 400,
416            Self::Large => 2500,
417        }
418    }
419}
420
421/// Complete Chart of Accounts structure.
422///
423/// Contains all GL accounts for an entity along with metadata about
424/// the overall structure.
425#[derive(Debug, Clone, Serialize, Deserialize)]
426pub struct ChartOfAccounts {
427    /// Unique identifier for this CoA
428    pub coa_id: String,
429
430    /// Name/description
431    pub name: String,
432
433    /// Country/region code
434    pub country: String,
435
436    /// Industry sector this CoA is designed for
437    pub industry: IndustrySector,
438
439    /// All accounts in this CoA
440    pub accounts: Vec<GLAccount>,
441
442    /// Complexity level
443    pub complexity: CoAComplexity,
444
445    /// Account number format (e.g., "######" for 6 digits)
446    pub account_format: String,
447
448    /// Index by account number for fast lookup
449    #[serde(skip)]
450    account_index: HashMap<String, usize>,
451}
452
453impl ChartOfAccounts {
454    /// Create a new empty Chart of Accounts.
455    pub fn new(
456        coa_id: String,
457        name: String,
458        country: String,
459        industry: IndustrySector,
460        complexity: CoAComplexity,
461    ) -> Self {
462        Self {
463            coa_id,
464            name,
465            country,
466            industry,
467            accounts: Vec::new(),
468            complexity,
469            account_format: "######".to_string(),
470            account_index: HashMap::new(),
471        }
472    }
473
474    /// Add an account to the CoA.
475    pub fn add_account(&mut self, account: GLAccount) {
476        let idx = self.accounts.len();
477        self.account_index
478            .insert(account.account_number.clone(), idx);
479        self.accounts.push(account);
480    }
481
482    /// Rebuild the account index (call after deserialization).
483    pub fn rebuild_index(&mut self) {
484        self.account_index.clear();
485        for (idx, account) in self.accounts.iter().enumerate() {
486            self.account_index
487                .insert(account.account_number.clone(), idx);
488        }
489    }
490
491    /// Get an account by number.
492    pub fn get_account(&self, account_number: &str) -> Option<&GLAccount> {
493        self.account_index
494            .get(account_number)
495            .map(|&idx| &self.accounts[idx])
496    }
497
498    /// Get all postable accounts.
499    pub fn get_postable_accounts(&self) -> Vec<&GLAccount> {
500        self.accounts
501            .iter()
502            .filter(|a| a.is_postable && !a.is_blocked)
503            .collect()
504    }
505
506    /// Get all accounts of a specific type.
507    pub fn get_accounts_by_type(&self, account_type: AccountType) -> Vec<&GLAccount> {
508        self.accounts
509            .iter()
510            .filter(|a| a.account_type == account_type && a.is_postable && !a.is_blocked)
511            .collect()
512    }
513
514    /// Get all accounts of a specific sub-type.
515    pub fn get_accounts_by_sub_type(&self, sub_type: AccountSubType) -> Vec<&GLAccount> {
516        self.accounts
517            .iter()
518            .filter(|a| a.sub_type == sub_type && a.is_postable && !a.is_blocked)
519            .collect()
520    }
521
522    /// Get suspense/clearing accounts.
523    pub fn get_suspense_accounts(&self) -> Vec<&GLAccount> {
524        self.accounts
525            .iter()
526            .filter(|a| a.is_suspense_account && a.is_postable)
527            .collect()
528    }
529
530    /// Get accounts weighted by industry relevance.
531    pub fn get_industry_weighted_accounts(
532        &self,
533        account_type: AccountType,
534    ) -> Vec<(&GLAccount, f64)> {
535        self.get_accounts_by_type(account_type)
536            .into_iter()
537            .map(|a| {
538                let weight = a.industry_weights.get(self.industry);
539                (a, weight)
540            })
541            .filter(|(_, w)| *w > 0.0)
542            .collect()
543    }
544
545    /// Get total account count.
546    pub fn account_count(&self) -> usize {
547        self.accounts.len()
548    }
549
550    /// Get count of postable accounts.
551    pub fn postable_count(&self) -> usize {
552        self.accounts.iter().filter(|a| a.is_postable).count()
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    #[test]
561    fn test_account_type_balance() {
562        assert!(AccountType::Asset.normal_debit_balance());
563        assert!(AccountType::Expense.normal_debit_balance());
564        assert!(!AccountType::Liability.normal_debit_balance());
565        assert!(!AccountType::Revenue.normal_debit_balance());
566        assert!(!AccountType::Equity.normal_debit_balance());
567    }
568
569    #[test]
570    fn test_coa_complexity_count() {
571        assert_eq!(CoAComplexity::Small.target_count(), 100);
572        assert_eq!(CoAComplexity::Medium.target_count(), 400);
573        assert_eq!(CoAComplexity::Large.target_count(), 2500);
574    }
575
576    #[test]
577    fn test_coa_account_lookup() {
578        let mut coa = ChartOfAccounts::new(
579            "TEST".to_string(),
580            "Test CoA".to_string(),
581            "US".to_string(),
582            IndustrySector::Manufacturing,
583            CoAComplexity::Small,
584        );
585
586        coa.add_account(GLAccount::new(
587            "100000".to_string(),
588            "Cash".to_string(),
589            AccountType::Asset,
590            AccountSubType::Cash,
591        ));
592
593        assert!(coa.get_account("100000").is_some());
594        assert!(coa.get_account("999999").is_none());
595    }
596}