1use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum AccountType {
14 Asset,
16 Liability,
18 Equity,
20 Revenue,
22 Expense,
24 Statistical,
26}
27
28impl AccountType {
29 pub fn is_balance_sheet(&self) -> bool {
31 matches!(self, Self::Asset | Self::Liability | Self::Equity)
32 }
33
34 pub fn is_income_statement(&self) -> bool {
36 matches!(self, Self::Revenue | Self::Expense)
37 }
38
39 pub fn normal_debit_balance(&self) -> bool {
41 matches!(self, Self::Asset | Self::Expense)
42 }
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum AccountSubType {
49 Cash,
52 AccountsReceivable,
54 OtherReceivables,
56 Inventory,
58 PrepaidExpenses,
60 FixedAssets,
62 AccumulatedDepreciation,
64 Investments,
66 IntangibleAssets,
68 OtherAssets,
70
71 AccountsPayable,
74 AccruedLiabilities,
76 ShortTermDebt,
78 LongTermDebt,
80 DeferredRevenue,
82 TaxLiabilities,
84 PensionLiabilities,
86 OtherLiabilities,
88
89 CommonStock,
92 RetainedEarnings,
94 AdditionalPaidInCapital,
96 TreasuryStock,
98 OtherComprehensiveIncome,
100 NetIncome,
102
103 ProductRevenue,
106 ServiceRevenue,
108 InterestIncome,
110 DividendIncome,
112 GainOnSale,
114 OtherIncome,
116
117 CostOfGoodsSold,
120 OperatingExpenses,
122 SellingExpenses,
124 AdministrativeExpenses,
126 DepreciationExpense,
128 AmortizationExpense,
130 InterestExpense,
132 TaxExpense,
134 ForeignExchangeLoss,
136 LossOnSale,
138 OtherExpenses,
140
141 SuspenseClearing,
144 GoodsReceivedClearing,
146 BankClearing,
148 IntercompanyClearing,
150}
151
152impl AccountSubType {
153 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, }
207 }
208
209 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#[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#[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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct GLAccount {
295 pub account_number: String,
297
298 pub short_description: String,
300
301 pub long_description: String,
303
304 pub account_type: AccountType,
306
307 pub sub_type: AccountSubType,
309
310 pub account_class: String,
312
313 pub account_group: String,
315
316 pub is_control_account: bool,
318
319 pub is_suspense_account: bool,
321
322 pub parent_account: Option<String>,
324
325 pub hierarchy_level: u8,
327
328 pub normal_debit_balance: bool,
330
331 pub is_postable: bool,
333
334 pub is_blocked: bool,
336
337 pub allowed_doc_types: Vec<String>,
339
340 pub requires_cost_center: bool,
342
343 pub requires_profit_center: bool,
345
346 pub industry_weights: IndustryWeights,
348
349 pub typical_frequency: f64,
351
352 pub typical_amount_range: (f64, f64),
354}
355
356impl GLAccount {
357 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 pub fn account_code(&self) -> &str {
390 &self.account_number
391 }
392
393 pub fn description(&self) -> &str {
395 &self.short_description
396 }
397}
398
399#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
401#[serde(rename_all = "snake_case")]
402pub enum CoAComplexity {
403 #[default]
405 Small,
406 Medium,
408 Large,
410}
411
412impl CoAComplexity {
413 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
428pub struct ChartOfAccounts {
429 pub coa_id: String,
431
432 pub name: String,
434
435 pub country: String,
437
438 pub industry: IndustrySector,
440
441 pub accounts: Vec<GLAccount>,
443
444 pub complexity: CoAComplexity,
446
447 pub account_format: String,
449
450 #[serde(default, skip_serializing_if = "Option::is_none")]
457 pub accounting_framework: Option<String>,
458
459 #[serde(skip)]
461 account_index: HashMap<String, usize>,
462}
463
464impl ChartOfAccounts {
465 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 pub fn with_accounting_framework(mut self, framework: impl Into<String>) -> Self {
490 self.accounting_framework = Some(framework.into());
491 self
492 }
493
494 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 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 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 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 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 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 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 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 pub fn account_count(&self) -> usize {
567 self.accounts.len()
568 }
569
570 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}