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)]
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#[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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct GLAccount {
294 pub account_number: String,
296
297 pub short_description: String,
299
300 pub long_description: String,
302
303 pub account_type: AccountType,
305
306 pub sub_type: AccountSubType,
308
309 pub account_class: String,
311
312 pub account_group: String,
314
315 pub is_control_account: bool,
317
318 pub is_suspense_account: bool,
320
321 pub parent_account: Option<String>,
323
324 pub hierarchy_level: u8,
326
327 pub normal_debit_balance: bool,
329
330 pub is_postable: bool,
332
333 pub is_blocked: bool,
335
336 pub allowed_doc_types: Vec<String>,
338
339 pub requires_cost_center: bool,
341
342 pub requires_profit_center: bool,
344
345 pub industry_weights: IndustryWeights,
347
348 pub typical_frequency: f64,
350
351 pub typical_amount_range: (f64, f64),
353}
354
355impl GLAccount {
356 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 pub fn account_code(&self) -> &str {
389 &self.account_number
390 }
391
392 pub fn description(&self) -> &str {
394 &self.short_description
395 }
396}
397
398#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
400#[serde(rename_all = "snake_case")]
401pub enum CoAComplexity {
402 Small,
404 Medium,
406 Large,
408}
409
410impl CoAComplexity {
411 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#[derive(Debug, Clone, Serialize, Deserialize)]
426pub struct ChartOfAccounts {
427 pub coa_id: String,
429
430 pub name: String,
432
433 pub country: String,
435
436 pub industry: IndustrySector,
438
439 pub accounts: Vec<GLAccount>,
441
442 pub complexity: CoAComplexity,
444
445 pub account_format: String,
447
448 #[serde(skip)]
450 account_index: HashMap<String, usize>,
451}
452
453impl ChartOfAccounts {
454 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 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 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 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 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 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 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 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 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 pub fn account_count(&self) -> usize {
547 self.accounts.len()
548 }
549
550 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}