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,
318
319 #[serde(default)]
322 pub account_class_name: String,
323
324 #[serde(default)]
327 pub account_sub_class: String,
328
329 #[serde(default)]
332 pub account_sub_class_name: String,
333
334 pub account_group: String,
336
337 pub is_control_account: bool,
339
340 pub is_suspense_account: bool,
342
343 pub parent_account: Option<String>,
345
346 pub hierarchy_level: u8,
348
349 pub normal_debit_balance: bool,
351
352 pub is_postable: bool,
354
355 pub is_blocked: bool,
357
358 pub allowed_doc_types: Vec<String>,
360
361 pub requires_cost_center: bool,
363
364 pub requires_profit_center: bool,
366
367 pub industry_weights: IndustryWeights,
369
370 pub typical_frequency: f64,
372
373 pub typical_amount_range: (f64, f64),
375
376 #[serde(default, skip_serializing_if = "Option::is_none")]
382 pub accounting_framework: Option<String>,
383}
384
385impl GLAccount {
386 pub fn new(
392 account_number: String,
393 description: String,
394 account_type: AccountType,
395 sub_type: AccountSubType,
396 ) -> Self {
397 let adc_sub = crate::iso21378::from_account_sub_type(sub_type);
398 let adc_class = adc_sub.adc_class();
399 Self {
400 account_number,
401 short_description: description.clone(),
402 long_description: description,
403 account_type,
404 sub_type,
405 account_class: adc_class.code().to_string(),
406 account_class_name: adc_class.name().to_string(),
407 account_sub_class: adc_sub.code().to_string(),
408 account_sub_class_name: adc_sub.name().to_string(),
409 account_group: "DEFAULT".to_string(),
410 is_control_account: false,
411 is_suspense_account: sub_type.is_suspense(),
412 parent_account: None,
413 hierarchy_level: 1,
414 normal_debit_balance: account_type.normal_debit_balance(),
415 is_postable: true,
416 is_blocked: false,
417 allowed_doc_types: vec!["SA".to_string()],
418 requires_cost_center: matches!(account_type, AccountType::Expense),
419 requires_profit_center: false,
420 industry_weights: IndustryWeights::all_equal(1.0),
421 typical_frequency: 100.0,
422 typical_amount_range: (100.0, 100000.0),
423 accounting_framework: None,
424 }
425 }
426
427 pub fn account_code(&self) -> &str {
429 &self.account_number
430 }
431
432 pub fn description(&self) -> &str {
434 &self.short_description
435 }
436}
437
438#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
440#[serde(rename_all = "snake_case")]
441pub enum CoAComplexity {
442 #[default]
444 Small,
445 Medium,
447 Large,
449}
450
451impl CoAComplexity {
452 pub fn target_count(&self) -> usize {
454 match self {
455 Self::Small => 100,
456 Self::Medium => 400,
457 Self::Large => 2500,
458 }
459 }
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize, Default)]
467pub struct ChartOfAccounts {
468 pub coa_id: String,
470
471 pub name: String,
473
474 pub country: String,
476
477 pub industry: IndustrySector,
479
480 pub accounts: Vec<GLAccount>,
482
483 pub complexity: CoAComplexity,
485
486 pub account_format: String,
488
489 #[serde(default, skip_serializing_if = "Option::is_none")]
496 pub accounting_framework: Option<String>,
497
498 #[serde(skip)]
500 account_index: HashMap<String, usize>,
501}
502
503impl ChartOfAccounts {
504 pub fn new(
506 coa_id: String,
507 name: String,
508 country: String,
509 industry: IndustrySector,
510 complexity: CoAComplexity,
511 ) -> Self {
512 Self {
513 coa_id,
514 name,
515 country,
516 industry,
517 accounts: Vec::new(),
518 complexity,
519 account_format: "######".to_string(),
520 accounting_framework: None,
521 account_index: HashMap::new(),
522 }
523 }
524
525 pub fn with_accounting_framework(mut self, framework: impl Into<String>) -> Self {
529 self.accounting_framework = Some(framework.into());
530 self
531 }
532
533 pub fn add_account(&mut self, account: GLAccount) {
535 let idx = self.accounts.len();
536 self.account_index
537 .insert(account.account_number.clone(), idx);
538 self.accounts.push(account);
539 }
540
541 pub fn rebuild_index(&mut self) {
543 self.account_index.clear();
544 for (idx, account) in self.accounts.iter().enumerate() {
545 self.account_index
546 .insert(account.account_number.clone(), idx);
547 }
548 }
549
550 pub fn get_account(&self, account_number: &str) -> Option<&GLAccount> {
552 self.account_index
553 .get(account_number)
554 .map(|&idx| &self.accounts[idx])
555 }
556
557 pub fn get_postable_accounts(&self) -> Vec<&GLAccount> {
559 self.accounts
560 .iter()
561 .filter(|a| a.is_postable && !a.is_blocked)
562 .collect()
563 }
564
565 pub fn get_accounts_by_type(&self, account_type: AccountType) -> Vec<&GLAccount> {
567 self.accounts
568 .iter()
569 .filter(|a| a.account_type == account_type && a.is_postable && !a.is_blocked)
570 .collect()
571 }
572
573 pub fn get_accounts_by_sub_type(&self, sub_type: AccountSubType) -> Vec<&GLAccount> {
575 self.accounts
576 .iter()
577 .filter(|a| a.sub_type == sub_type && a.is_postable && !a.is_blocked)
578 .collect()
579 }
580
581 pub fn get_suspense_accounts(&self) -> Vec<&GLAccount> {
583 self.accounts
584 .iter()
585 .filter(|a| a.is_suspense_account && a.is_postable)
586 .collect()
587 }
588
589 pub fn get_industry_weighted_accounts(
591 &self,
592 account_type: AccountType,
593 ) -> Vec<(&GLAccount, f64)> {
594 self.get_accounts_by_type(account_type)
595 .into_iter()
596 .map(|a| {
597 let weight = a.industry_weights.get(self.industry);
598 (a, weight)
599 })
600 .filter(|(_, w)| *w > 0.0)
601 .collect()
602 }
603
604 pub fn account_count(&self) -> usize {
606 self.accounts.len()
607 }
608
609 pub fn postable_count(&self) -> usize {
611 self.accounts.iter().filter(|a| a.is_postable).count()
612 }
613}
614
615#[cfg(test)]
616#[allow(clippy::unwrap_used)]
617mod tests {
618 use super::*;
619
620 #[test]
621 fn test_account_type_balance() {
622 assert!(AccountType::Asset.normal_debit_balance());
623 assert!(AccountType::Expense.normal_debit_balance());
624 assert!(!AccountType::Liability.normal_debit_balance());
625 assert!(!AccountType::Revenue.normal_debit_balance());
626 assert!(!AccountType::Equity.normal_debit_balance());
627 }
628
629 #[test]
630 fn test_coa_complexity_count() {
631 assert_eq!(CoAComplexity::Small.target_count(), 100);
632 assert_eq!(CoAComplexity::Medium.target_count(), 400);
633 assert_eq!(CoAComplexity::Large.target_count(), 2500);
634 }
635
636 #[test]
637 fn test_coa_account_lookup() {
638 let mut coa = ChartOfAccounts::new(
639 "TEST".to_string(),
640 "Test CoA".to_string(),
641 "US".to_string(),
642 IndustrySector::Manufacturing,
643 CoAComplexity::Small,
644 );
645
646 coa.add_account(GLAccount::new(
647 "100000".to_string(),
648 "Cash".to_string(),
649 AccountType::Asset,
650 AccountSubType::Cash,
651 ));
652
653 assert!(coa.get_account("100000").is_some());
654 assert!(coa.get_account("999999").is_none());
655 }
656}