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 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub accounting_framework: Option<String>,
362}
363
364impl GLAccount {
365 pub fn new(
367 account_number: String,
368 description: String,
369 account_type: AccountType,
370 sub_type: AccountSubType,
371 ) -> Self {
372 Self {
373 account_number: account_number.clone(),
374 short_description: description.clone(),
375 long_description: description,
376 account_type,
377 sub_type,
378 account_class: account_number.chars().next().unwrap_or('0').to_string(),
379 account_group: "DEFAULT".to_string(),
380 is_control_account: false,
381 is_suspense_account: sub_type.is_suspense(),
382 parent_account: None,
383 hierarchy_level: 1,
384 normal_debit_balance: account_type.normal_debit_balance(),
385 is_postable: true,
386 is_blocked: false,
387 allowed_doc_types: vec!["SA".to_string()],
388 requires_cost_center: matches!(account_type, AccountType::Expense),
389 requires_profit_center: false,
390 industry_weights: IndustryWeights::all_equal(1.0),
391 typical_frequency: 100.0,
392 typical_amount_range: (100.0, 100000.0),
393 accounting_framework: None,
394 }
395 }
396
397 pub fn account_code(&self) -> &str {
399 &self.account_number
400 }
401
402 pub fn description(&self) -> &str {
404 &self.short_description
405 }
406}
407
408#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
410#[serde(rename_all = "snake_case")]
411pub enum CoAComplexity {
412 #[default]
414 Small,
415 Medium,
417 Large,
419}
420
421impl CoAComplexity {
422 pub fn target_count(&self) -> usize {
424 match self {
425 Self::Small => 100,
426 Self::Medium => 400,
427 Self::Large => 2500,
428 }
429 }
430}
431
432#[derive(Debug, Clone, Serialize, Deserialize, Default)]
437pub struct ChartOfAccounts {
438 pub coa_id: String,
440
441 pub name: String,
443
444 pub country: String,
446
447 pub industry: IndustrySector,
449
450 pub accounts: Vec<GLAccount>,
452
453 pub complexity: CoAComplexity,
455
456 pub account_format: String,
458
459 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub accounting_framework: Option<String>,
467
468 #[serde(skip)]
470 account_index: HashMap<String, usize>,
471}
472
473impl ChartOfAccounts {
474 pub fn new(
476 coa_id: String,
477 name: String,
478 country: String,
479 industry: IndustrySector,
480 complexity: CoAComplexity,
481 ) -> Self {
482 Self {
483 coa_id,
484 name,
485 country,
486 industry,
487 accounts: Vec::new(),
488 complexity,
489 account_format: "######".to_string(),
490 accounting_framework: None,
491 account_index: HashMap::new(),
492 }
493 }
494
495 pub fn with_accounting_framework(mut self, framework: impl Into<String>) -> Self {
499 self.accounting_framework = Some(framework.into());
500 self
501 }
502
503 pub fn add_account(&mut self, account: GLAccount) {
505 let idx = self.accounts.len();
506 self.account_index
507 .insert(account.account_number.clone(), idx);
508 self.accounts.push(account);
509 }
510
511 pub fn rebuild_index(&mut self) {
513 self.account_index.clear();
514 for (idx, account) in self.accounts.iter().enumerate() {
515 self.account_index
516 .insert(account.account_number.clone(), idx);
517 }
518 }
519
520 pub fn get_account(&self, account_number: &str) -> Option<&GLAccount> {
522 self.account_index
523 .get(account_number)
524 .map(|&idx| &self.accounts[idx])
525 }
526
527 pub fn get_postable_accounts(&self) -> Vec<&GLAccount> {
529 self.accounts
530 .iter()
531 .filter(|a| a.is_postable && !a.is_blocked)
532 .collect()
533 }
534
535 pub fn get_accounts_by_type(&self, account_type: AccountType) -> Vec<&GLAccount> {
537 self.accounts
538 .iter()
539 .filter(|a| a.account_type == account_type && a.is_postable && !a.is_blocked)
540 .collect()
541 }
542
543 pub fn get_accounts_by_sub_type(&self, sub_type: AccountSubType) -> Vec<&GLAccount> {
545 self.accounts
546 .iter()
547 .filter(|a| a.sub_type == sub_type && a.is_postable && !a.is_blocked)
548 .collect()
549 }
550
551 pub fn get_suspense_accounts(&self) -> Vec<&GLAccount> {
553 self.accounts
554 .iter()
555 .filter(|a| a.is_suspense_account && a.is_postable)
556 .collect()
557 }
558
559 pub fn get_industry_weighted_accounts(
561 &self,
562 account_type: AccountType,
563 ) -> Vec<(&GLAccount, f64)> {
564 self.get_accounts_by_type(account_type)
565 .into_iter()
566 .map(|a| {
567 let weight = a.industry_weights.get(self.industry);
568 (a, weight)
569 })
570 .filter(|(_, w)| *w > 0.0)
571 .collect()
572 }
573
574 pub fn account_count(&self) -> usize {
576 self.accounts.len()
577 }
578
579 pub fn postable_count(&self) -> usize {
581 self.accounts.iter().filter(|a| a.is_postable).count()
582 }
583}
584
585#[cfg(test)]
586#[allow(clippy::unwrap_used)]
587mod tests {
588 use super::*;
589
590 #[test]
591 fn test_account_type_balance() {
592 assert!(AccountType::Asset.normal_debit_balance());
593 assert!(AccountType::Expense.normal_debit_balance());
594 assert!(!AccountType::Liability.normal_debit_balance());
595 assert!(!AccountType::Revenue.normal_debit_balance());
596 assert!(!AccountType::Equity.normal_debit_balance());
597 }
598
599 #[test]
600 fn test_coa_complexity_count() {
601 assert_eq!(CoAComplexity::Small.target_count(), 100);
602 assert_eq!(CoAComplexity::Medium.target_count(), 400);
603 assert_eq!(CoAComplexity::Large.target_count(), 2500);
604 }
605
606 #[test]
607 fn test_coa_account_lookup() {
608 let mut coa = ChartOfAccounts::new(
609 "TEST".to_string(),
610 "Test CoA".to_string(),
611 "US".to_string(),
612 IndustrySector::Manufacturing,
613 CoAComplexity::Small,
614 );
615
616 coa.add_account(GLAccount::new(
617 "100000".to_string(),
618 "Cash".to_string(),
619 AccountType::Asset,
620 AccountSubType::Cash,
621 ));
622
623 assert!(coa.get_account("100000").is_some());
624 assert!(coa.get_account("999999").is_none());
625 }
626}