Skip to main content

datasynth_core/
skr_loader.rs

1//! Loader for the SKR04 (Standardkontenrahmen 04) chart of accounts.
2//!
3//! Mirrors `pcg_loader.rs` for German GAAP. Loads the embedded `skr04_2024.json`
4//! tree and builds a `ChartOfAccounts` with correct 4-digit account numbers.
5
6use serde::Deserialize;
7
8use crate::models::{
9    AccountSubType, AccountType, ChartOfAccounts, CoAComplexity, GLAccount, IndustrySector,
10};
11
12/// Root of the SKR04 JSON: array of top-level classes (0–9).
13pub type Skr04Root = Vec<Skr04Node>;
14
15/// One node in the SKR04 tree (class, group, or account).
16#[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
27/// Embedded SKR04 2024 JSON.
28const SKR04_2024_JSON: &str = include_str!("../resources/skr04_2024.json");
29
30/// Load the SKR04 2024 tree from the embedded JSON.
31pub fn load_skr04_2024() -> Result<Skr04Root, serde_json::Error> {
32    serde_json::from_str(SKR04_2024_JSON)
33}
34
35/// Flatten the SKR04 tree into (account_number, label, class) for postable accounts.
36fn 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        // Top-level class nodes have number 0-9
47        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        // Only include 4-digit accounts (10+)
60        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
70/// Normalize an SKR04 account number to 4-digit format.
71///
72/// - 2-digit (60) → "0060"
73/// - 3-digit (200) → "0200"
74/// - 4-digit (1200) → "1200"
75fn normalize_skr04_account_number(number: u32) -> String {
76    format!("{number:04}")
77}
78
79/// Map SKR04 class and account number to AccountType and AccountSubType.
80fn skr04_to_account_type(class: u8, number: u32) -> (AccountType, AccountSubType) {
81    use AccountSubType::*;
82    use AccountType::*;
83
84    match class {
85        0 => {
86            // Fixed assets
87            if (700..800).contains(&number) {
88                (Asset, AccumulatedDepreciation)
89            } else if (550..650).contains(&number) {
90                (Asset, OtherAssets) // Financial assets
91            } else {
92                (Asset, FixedAssets)
93            }
94        }
95        1 => {
96            // Current assets
97            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) // includes VAT receivable (1570-1599)
103            } else if (1600..1700).contains(&number) || (1800..1900).contains(&number) {
104                (Asset, Cash) // Kasse (16xx) / Bank (18xx)
105            } else if (1900..2000).contains(&number) {
106                (Asset, PrepaidExpenses)
107            } else {
108                (Asset, OtherAssets)
109            }
110        }
111        2 => {
112            // Equity
113            if (2000..2050).contains(&number) {
114                (Equity, CommonStock)
115            } else if (2050..2400).contains(&number) {
116                (Equity, RetainedEarnings) // Reserves
117            } else if (2900..3000).contains(&number) {
118                (Equity, OtherComprehensiveIncome) // CTA, results
119            } else {
120                (Equity, RetainedEarnings)
121            }
122        }
123        3 => {
124            // Liabilities
125            if (3000..3100).contains(&number) {
126                (Liability, AccruedLiabilities) // Provisions
127            } 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) // Received advances
135            } else if (3300..3400).contains(&number) {
136                (Liability, AccountsPayable) // Trade payables
137            } else if (3500..3600).contains(&number) {
138                (Liability, OtherLiabilities) // IC payables
139            } else if (3700..3800).contains(&number) {
140                (Liability, AccruedLiabilities) // Personnel payables
141            } else if (3800..3900).contains(&number) {
142                (Liability, TaxLiabilities) // VAT
143            } else {
144                (Liability, OtherLiabilities) // Passive accruals (39xx) and others
145            }
146        }
147        4 => {
148            // Revenue
149            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) // IC revenue
155            } else if (4700..4800).contains(&number) {
156                (Revenue, ProductRevenue) // Sales deductions
157            } else {
158                (Revenue, OtherIncome) // Other operating income (49xx) and others
159            }
160        }
161        5 => {
162            // Material / COGS
163            (Expense, CostOfGoodsSold)
164        }
165        6 => {
166            // Personnel & other operating expenses
167            if (6000..6200).contains(&number) {
168                (Expense, OperatingExpenses) // Personnel
169            } else if (6200..6300).contains(&number) {
170                (Expense, DepreciationExpense) // Depreciation
171            } else if (6300..6600).contains(&number) {
172                (Expense, OperatingExpenses) // Premises, insurance, vehicles (63xx-65xx)
173            } else if (6600..6700).contains(&number) {
174                (Expense, SellingExpenses) // Advertising, travel
175            } else if (6700..6800).contains(&number) {
176                (Expense, OperatingExpenses) // Transport
177            } else {
178                (Expense, OtherExpenses) // FX, irregular items
179            }
180        }
181        7 => {
182            // Financial income/expense
183            if (7000..7200).contains(&number) {
184                (Revenue, InterestIncome) // Financial income
185            } else if (7200..7500).contains(&number) {
186                (Expense, InterestExpense) // Financial expense
187            } else {
188                (Expense, OtherExpenses)
189            }
190        }
191        8 => {
192            // Tax / extraordinary (note: in our JSON, tax accounts have numbers 76xx)
193            (Expense, TaxExpense)
194        }
195        9 => {
196            // Statistical
197            (Asset, SuspenseClearing)
198        }
199        _ => (Asset, OtherAssets),
200    }
201}
202
203/// Build a Chart of Accounts from the SKR04 2024 structure.
204///
205/// Respects `complexity` by limiting the number of accounts.
206pub 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); // Classes 0-9
247        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        // Classes 0-7 and 9 have distinct first-digit ranges.
287        // Class 8 (Steuern/außerordentliches Ergebnis) uses 76xx-77xx numbers
288        // which overlap with class 7 — this is correct per SKR04 structure.
289        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        // Verify we have tax accounts (class 8 structural) with 76xx numbers
298        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}