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(skip)]
452 account_index: HashMap<String, usize>,
453}
454
455impl ChartOfAccounts {
456 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 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 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 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 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 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 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 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 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 pub fn account_count(&self) -> usize {
549 self.accounts.len()
550 }
551
552 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}