use serde::Deserialize;
use crate::models::{
AccountSubType, AccountType, ChartOfAccounts, CoAComplexity, GLAccount, IndustrySector,
};
pub type Skr04Root = Vec<Skr04Node>;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct Skr04Node {
pub number: u32,
pub label: String,
#[serde(default)]
pub system: String,
#[serde(default)]
pub accounts: Vec<Skr04Node>,
}
const SKR04_2024_JSON: &str = include_str!("../resources/skr04_2024.json");
pub fn load_skr04_2024() -> Result<Skr04Root, serde_json::Error> {
serde_json::from_str(SKR04_2024_JSON)
}
fn flatten_skr04(
nodes: &[Skr04Node],
class_from_parent: u8,
out: &mut Vec<(u32, String, u8)>,
max_accounts: usize,
) {
if out.len() >= max_accounts {
return;
}
for node in nodes {
let class = if node.number < 10 {
node.number as u8
} else {
class_from_parent
};
let is_leaf = node.accounts.is_empty();
let is_postable = is_leaf
|| node.system == "base"
|| node.system == "developed"
|| (node.system == "condensed" && node.accounts.is_empty());
if is_postable && node.number >= 10 {
out.push((node.number, node.label.clone(), class));
}
if !node.accounts.is_empty() && out.len() < max_accounts {
flatten_skr04(&node.accounts, class, out, max_accounts);
}
}
}
fn normalize_skr04_account_number(number: u32) -> String {
format!("{number:04}")
}
fn skr04_to_account_type(class: u8, number: u32) -> (AccountType, AccountSubType) {
use AccountSubType::*;
use AccountType::*;
match class {
0 => {
if (700..800).contains(&number) {
(Asset, AccumulatedDepreciation)
} else if (550..650).contains(&number) {
(Asset, OtherAssets) } else {
(Asset, FixedAssets)
}
}
1 => {
if (1000..1200).contains(&number) {
(Asset, Inventory)
} else if (1200..1300).contains(&number) {
(Asset, AccountsReceivable)
} else if (1300..1600).contains(&number) {
(Asset, OtherReceivables) } else if (1600..1700).contains(&number) || (1800..1900).contains(&number) {
(Asset, Cash) } else if (1900..2000).contains(&number) {
(Asset, PrepaidExpenses)
} else {
(Asset, OtherAssets)
}
}
2 => {
if (2000..2050).contains(&number) {
(Equity, CommonStock)
} else if (2050..2400).contains(&number) {
(Equity, RetainedEarnings) } else if (2900..3000).contains(&number) {
(Equity, OtherComprehensiveIncome) } else {
(Equity, RetainedEarnings)
}
}
3 => {
if (3000..3100).contains(&number) {
(Liability, AccruedLiabilities) } else if (3100..3200).contains(&number) {
if number < 3150 {
(Liability, ShortTermDebt)
} else {
(Liability, LongTermDebt)
}
} else if (3250..3270).contains(&number) {
(Liability, DeferredRevenue) } else if (3300..3400).contains(&number) {
(Liability, AccountsPayable) } else if (3500..3600).contains(&number) {
(Liability, OtherLiabilities) } else if (3700..3800).contains(&number) {
(Liability, AccruedLiabilities) } else if (3800..3900).contains(&number) {
(Liability, TaxLiabilities) } else {
(Liability, OtherLiabilities) }
}
4 => {
if (4000..4200).contains(&number) {
(Revenue, ProductRevenue)
} else if (4200..4500).contains(&number) {
(Revenue, ServiceRevenue)
} else if (4500..4600).contains(&number) {
(Revenue, OtherIncome) } else if (4700..4800).contains(&number) {
(Revenue, ProductRevenue) } else {
(Revenue, OtherIncome) }
}
5 => {
(Expense, CostOfGoodsSold)
}
6 => {
if (6000..6200).contains(&number) {
(Expense, OperatingExpenses) } else if (6200..6300).contains(&number) {
(Expense, DepreciationExpense) } else if (6300..6600).contains(&number) {
(Expense, OperatingExpenses) } else if (6600..6700).contains(&number) {
(Expense, SellingExpenses) } else if (6700..6800).contains(&number) {
(Expense, OperatingExpenses) } else {
(Expense, OtherExpenses) }
}
7 => {
if (7000..7200).contains(&number) {
(Revenue, InterestIncome) } else if (7200..7500).contains(&number) {
(Expense, InterestExpense) } else {
(Expense, OtherExpenses)
}
}
8 => {
(Expense, TaxExpense)
}
9 => {
(Asset, SuspenseClearing)
}
_ => (Asset, OtherAssets),
}
}
pub fn build_chart_of_accounts_from_skr04(
complexity: CoAComplexity,
industry: IndustrySector,
) -> Result<ChartOfAccounts, serde_json::Error> {
let root = load_skr04_2024()?;
let max_accounts = complexity.target_count();
let mut flat = Vec::with_capacity(max_accounts.min(2000));
for class_node in &root {
let class = class_node.number as u8;
flatten_skr04(&class_node.accounts, class, &mut flat, max_accounts);
}
let coa_id = format!("COA_SKR04_2024_{industry:?}_{max_accounts}");
let name = format!("Standardkontenrahmen 04 – {industry:?}");
let mut coa = ChartOfAccounts::new(coa_id, name, "DE".to_string(), industry, complexity);
coa.account_format = "####".to_string();
for (number, label, class) in flat {
let code = normalize_skr04_account_number(number);
let (acc_type, sub_type) = skr04_to_account_type(class, number);
let mut account = GLAccount::new(code, label, acc_type, sub_type);
account.requires_cost_center = matches!(acc_type, AccountType::Expense);
if class == 9 {
account.is_suspense_account = true;
}
coa.add_account(account);
}
Ok(coa)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_load_skr04_2024() {
let root = load_skr04_2024().unwrap();
assert_eq!(root.len(), 10); assert_eq!(root[0].number, 0);
assert_eq!(root[0].label, "Anlagevermögen");
assert_eq!(root[9].number, 9);
}
#[test]
fn test_normalize_skr04_account_number() {
assert_eq!(normalize_skr04_account_number(60), "0060");
assert_eq!(normalize_skr04_account_number(200), "0200");
assert_eq!(normalize_skr04_account_number(1200), "1200");
assert_eq!(normalize_skr04_account_number(6220), "6220");
}
#[test]
fn test_skr04_loader_basic() {
let coa =
build_chart_of_accounts_from_skr04(CoAComplexity::Small, IndustrySector::Manufacturing)
.unwrap();
assert_eq!(coa.country, "DE");
assert_eq!(coa.account_format, "####");
assert!(
coa.account_count() >= 30,
"SKR04 small CoA should have at least 30 accounts, got {}",
coa.account_count()
);
}
#[test]
fn test_skr04_class_coverage() {
let coa =
build_chart_of_accounts_from_skr04(CoAComplexity::Large, IndustrySector::Manufacturing)
.unwrap();
let first_digits: std::collections::HashSet<char> = coa
.accounts
.iter()
.filter_map(|a| a.account_number.chars().next())
.collect();
for digit in ['0', '1', '2', '3', '4', '5', '6', '7', '9'] {
assert!(
first_digits.contains(&digit),
"SKR04 large CoA should have accounts starting with {}",
digit
);
}
let has_tax = coa
.accounts
.iter()
.any(|a| a.account_number.starts_with("76"));
assert!(has_tax, "SKR04 should have tax accounts (76xx)");
}
#[test]
fn test_skr04_4_digit_format() {
let coa =
build_chart_of_accounts_from_skr04(CoAComplexity::Small, IndustrySector::Manufacturing)
.unwrap();
for account in &coa.accounts {
assert_eq!(
account.account_number.len(),
4,
"SKR04 account {} should be 4 digits",
account.account_number
);
assert!(
account.account_number.chars().all(|c| c.is_ascii_digit()),
"SKR04 account {} should be numeric",
account.account_number
);
}
}
}