1use serde::Deserialize;
7
8use crate::models::{
9 AccountSubType, AccountType, ChartOfAccounts, CoAComplexity, GLAccount, IndustrySector,
10};
11
12pub type Skr04Root = Vec<Skr04Node>;
14
15#[derive(Debug, Clone, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub struct Skr04Node {
19 pub number: u32,
20 pub label: String,
21 #[serde(default)]
22 pub system: String,
23 #[serde(default)]
24 pub accounts: Vec<Skr04Node>,
25}
26
27const SKR04_2024_JSON: &str = include_str!("../resources/skr04_2024.json");
29
30pub fn load_skr04_2024() -> Result<Skr04Root, serde_json::Error> {
32 serde_json::from_str(SKR04_2024_JSON)
33}
34
35fn flatten_skr04(
37 nodes: &[Skr04Node],
38 class_from_parent: u8,
39 out: &mut Vec<(u32, String, u8)>,
40 max_accounts: usize,
41) {
42 if out.len() >= max_accounts {
43 return;
44 }
45 for node in nodes {
46 let class = if node.number < 10 {
48 node.number as u8
49 } else {
50 class_from_parent
51 };
52
53 let is_leaf = node.accounts.is_empty();
54 let is_postable = is_leaf
55 || node.system == "base"
56 || node.system == "developed"
57 || (node.system == "condensed" && node.accounts.is_empty());
58
59 if is_postable && node.number >= 10 {
61 out.push((node.number, node.label.clone(), class));
62 }
63
64 if !node.accounts.is_empty() && out.len() < max_accounts {
65 flatten_skr04(&node.accounts, class, out, max_accounts);
66 }
67 }
68}
69
70fn normalize_skr04_account_number(number: u32) -> String {
76 format!("{number:04}")
77}
78
79fn skr04_to_account_type(class: u8, number: u32) -> (AccountType, AccountSubType) {
81 use AccountSubType::*;
82 use AccountType::*;
83
84 match class {
85 0 => {
86 if (700..800).contains(&number) {
88 (Asset, AccumulatedDepreciation)
89 } else if (550..650).contains(&number) {
90 (Asset, OtherAssets) } else {
92 (Asset, FixedAssets)
93 }
94 }
95 1 => {
96 if (1000..1200).contains(&number) {
98 (Asset, Inventory)
99 } else if (1200..1300).contains(&number) {
100 (Asset, AccountsReceivable)
101 } else if (1300..1600).contains(&number) {
102 (Asset, OtherReceivables) } else if (1600..1700).contains(&number) || (1800..1900).contains(&number) {
104 (Asset, Cash) } else if (1900..2000).contains(&number) {
106 (Asset, PrepaidExpenses)
107 } else {
108 (Asset, OtherAssets)
109 }
110 }
111 2 => {
112 if (2000..2050).contains(&number) {
114 (Equity, CommonStock)
115 } else if (2050..2400).contains(&number) {
116 (Equity, RetainedEarnings) } else if (2900..3000).contains(&number) {
118 (Equity, OtherComprehensiveIncome) } else {
120 (Equity, RetainedEarnings)
121 }
122 }
123 3 => {
124 if (3000..3100).contains(&number) {
126 (Liability, AccruedLiabilities) } else if (3100..3200).contains(&number) {
128 if number < 3150 {
129 (Liability, ShortTermDebt)
130 } else {
131 (Liability, LongTermDebt)
132 }
133 } else if (3250..3270).contains(&number) {
134 (Liability, DeferredRevenue) } else if (3300..3400).contains(&number) {
136 (Liability, AccountsPayable) } else if (3500..3600).contains(&number) {
138 (Liability, OtherLiabilities) } else if (3700..3800).contains(&number) {
140 (Liability, AccruedLiabilities) } else if (3800..3900).contains(&number) {
142 (Liability, TaxLiabilities) } else {
144 (Liability, OtherLiabilities) }
146 }
147 4 => {
148 if (4000..4200).contains(&number) {
150 (Revenue, ProductRevenue)
151 } else if (4200..4500).contains(&number) {
152 (Revenue, ServiceRevenue)
153 } else if (4500..4600).contains(&number) {
154 (Revenue, OtherIncome) } else if (4700..4800).contains(&number) {
156 (Revenue, ProductRevenue) } else {
158 (Revenue, OtherIncome) }
160 }
161 5 => {
162 (Expense, CostOfGoodsSold)
164 }
165 6 => {
166 if (6000..6200).contains(&number) {
168 (Expense, OperatingExpenses) } else if (6200..6300).contains(&number) {
170 (Expense, DepreciationExpense) } else if (6300..6600).contains(&number) {
172 (Expense, OperatingExpenses) } else if (6600..6700).contains(&number) {
174 (Expense, SellingExpenses) } else if (6700..6800).contains(&number) {
176 (Expense, OperatingExpenses) } else {
178 (Expense, OtherExpenses) }
180 }
181 7 => {
182 if (7000..7200).contains(&number) {
184 (Revenue, InterestIncome) } else if (7200..7500).contains(&number) {
186 (Expense, InterestExpense) } else {
188 (Expense, OtherExpenses)
189 }
190 }
191 8 => {
192 (Expense, TaxExpense)
194 }
195 9 => {
196 (Asset, SuspenseClearing)
198 }
199 _ => (Asset, OtherAssets),
200 }
201}
202
203pub fn build_chart_of_accounts_from_skr04(
207 complexity: CoAComplexity,
208 industry: IndustrySector,
209) -> Result<ChartOfAccounts, serde_json::Error> {
210 let root = load_skr04_2024()?;
211 let max_accounts = complexity.target_count();
212 let mut flat = Vec::with_capacity(max_accounts.min(2000));
213
214 for class_node in &root {
215 let class = class_node.number as u8;
216 flatten_skr04(&class_node.accounts, class, &mut flat, max_accounts);
217 }
218
219 let coa_id = format!("COA_SKR04_2024_{industry:?}_{max_accounts}");
220 let name = format!("Standardkontenrahmen 04 – {industry:?}");
221 let mut coa = ChartOfAccounts::new(coa_id, name, "DE".to_string(), industry, complexity);
222 coa.account_format = "####".to_string();
223
224 for (number, label, class) in flat {
225 let code = normalize_skr04_account_number(number);
226 let (acc_type, sub_type) = skr04_to_account_type(class, number);
227 let mut account = GLAccount::new(code, label, acc_type, sub_type);
228 account.requires_cost_center = matches!(acc_type, AccountType::Expense);
229 if class == 9 {
230 account.is_suspense_account = true;
231 }
232 coa.add_account(account);
233 }
234
235 Ok(coa)
236}
237
238#[cfg(test)]
239#[allow(clippy::unwrap_used)]
240mod tests {
241 use super::*;
242
243 #[test]
244 fn test_load_skr04_2024() {
245 let root = load_skr04_2024().unwrap();
246 assert_eq!(root.len(), 10); assert_eq!(root[0].number, 0);
248 assert_eq!(root[0].label, "Anlagevermögen");
249 assert_eq!(root[9].number, 9);
250 }
251
252 #[test]
253 fn test_normalize_skr04_account_number() {
254 assert_eq!(normalize_skr04_account_number(60), "0060");
255 assert_eq!(normalize_skr04_account_number(200), "0200");
256 assert_eq!(normalize_skr04_account_number(1200), "1200");
257 assert_eq!(normalize_skr04_account_number(6220), "6220");
258 }
259
260 #[test]
261 fn test_skr04_loader_basic() {
262 let coa =
263 build_chart_of_accounts_from_skr04(CoAComplexity::Small, IndustrySector::Manufacturing)
264 .unwrap();
265 assert_eq!(coa.country, "DE");
266 assert_eq!(coa.account_format, "####");
267 assert!(
268 coa.account_count() >= 30,
269 "SKR04 small CoA should have at least 30 accounts, got {}",
270 coa.account_count()
271 );
272 }
273
274 #[test]
275 fn test_skr04_class_coverage() {
276 let coa =
277 build_chart_of_accounts_from_skr04(CoAComplexity::Large, IndustrySector::Manufacturing)
278 .unwrap();
279
280 let first_digits: std::collections::HashSet<char> = coa
281 .accounts
282 .iter()
283 .filter_map(|a| a.account_number.chars().next())
284 .collect();
285
286 for digit in ['0', '1', '2', '3', '4', '5', '6', '7', '9'] {
290 assert!(
291 first_digits.contains(&digit),
292 "SKR04 large CoA should have accounts starting with {}",
293 digit
294 );
295 }
296
297 let has_tax = coa
299 .accounts
300 .iter()
301 .any(|a| a.account_number.starts_with("76"));
302 assert!(has_tax, "SKR04 should have tax accounts (76xx)");
303 }
304
305 #[test]
306 fn test_skr04_4_digit_format() {
307 let coa =
308 build_chart_of_accounts_from_skr04(CoAComplexity::Small, IndustrySector::Manufacturing)
309 .unwrap();
310
311 for account in &coa.accounts {
312 assert_eq!(
313 account.account_number.len(),
314 4,
315 "SKR04 account {} should be 4 digits",
316 account.account_number
317 );
318 assert!(
319 account.account_number.chars().all(|c| c.is_ascii_digit()),
320 "SKR04 account {} should be numeric",
321 account.account_number
322 );
323 }
324 }
325}