Skip to main content

backbone_accounting/domain/
chart_dataset.rs

1//! Chart-of-accounts datasets — the data half of the chart install engine.
2//!
3//! A [`ChartDataset`] is a versioned, parents-first description of a national chart of
4//! accounts (e.g. the Indonesian SAK chart): numbers, names, classification, and tree
5//! shape. It is DATA, not database state — no template tables exist anywhere. The
6//! install engine (`chart_install_service`) turns a dataset into real `accounts` rows;
7//! rows carry `chart_code`/`chart_version` provenance so re-installs
8//! recognize their own rows and leave manual ones alone.
9//!
10//! Header/detail flags, level, and path are NOT part of a dataset — they are derived
11//! from the tree at install time ("has children ⇒ header"), so a dataset can never
12//! drift from the shape it describes.
13//!
14//! Dataset invariants, enforced by [`validate_dataset`] before any write happens:
15//! - numbers and codes are unique and non-empty; a code IS its number with the dots
16//!   stripped (identity is hashed from the code while collision matching matches on
17//!   the number, so the two must never diverge)
18//! - names are non-empty; currencies are 3-letter uppercase ISO codes; sort orders
19//!   are unique
20//! - every `parent_code` refers to an account appearing EARLIER in the list
21//!   (parents-first order makes the tree acyclic by construction)
22//! - at least one AR, one AP, one bank, and one cash account exist — the posting and
23//!   settlement paths assume those subtypes are present.
24//!
25//! ## The version contract: account codes are identity, and identity is stable
26//!
27//! A new dataset version may re-classify, re-name, or re-shape accounts freely,
28//! but corrections must keep every account's NUMBER (and therefore its code)
29//! stable. The engine's deterministic ids hash the code, so a renumbered account
30//! is a NEW identity: the old row is neither updated nor removed — it lingers as
31//! a fully live, chart-stamped posting target after the newer version installs
32//! (and once postings exist, the postings gate freezes any later cleanup).
33//! The same applies to codes dropped from a later version. Until a deprecation
34//! sweep exists, removing or renumbering an account is a manual, operator-driven
35//! archival step, deliberately outside the engine.
36
37use crate::domain::entity::{AccountSubtype, AccountType, NormalBalance};
38use serde::Deserialize;
39use std::collections::HashSet;
40
41/// A registered chart of accounts, ready to install.
42#[derive(Debug, Clone, Deserialize)]
43pub struct ChartDataset {
44    /// Stable dataset identifier (e.g. `ID_SAK`). Part of the deterministic account id.
45    pub code: String,
46    /// Dataset version (e.g. `2026.1`). Corrections ship as a new version.
47    pub version: String,
48    /// Human-readable chart name.
49    pub name: String,
50    /// Account definitions in parents-first order (every parent appears before its children).
51    pub accounts: Vec<ChartAccountDef>,
52}
53
54/// One account definition inside a [`ChartDataset`].
55#[derive(Debug, Clone, Deserialize)]
56pub struct ChartAccountDef {
57    /// Display/lookup number, dots allowed (e.g. `1171.000`).
58    pub number: String,
59    /// Compact code: the number with dots stripped (e.g. `1171000`). Unique per dataset.
60    pub code: String,
61    /// Localized name (Indonesian for the SAK chart).
62    pub name: String,
63    pub account_type: AccountType,
64    pub account_subtype: AccountSubtype,
65    pub normal_balance: NormalBalance,
66    /// Parent's `code`, or `None` for roots. The parent must appear earlier in the dataset.
67    pub parent_code: Option<String>,
68    /// Requires reconciliation against statements/bank feeds (bank, cash, AR/AP).
69    pub is_reconcilable: bool,
70    /// ISO currency code (e.g. `IDR`).
71    pub currency: String,
72    /// Display order within the chart.
73    pub sort_order: i32,
74}
75
76/// What a dataset must minimally contain for the posting/settlement paths to work.
77pub const REQUIRED_SUBTYPES: [AccountSubtype; 4] = [
78    AccountSubtype::AccountsReceivable,
79    AccountSubtype::AccountsPayable,
80    AccountSubtype::Bank,
81    AccountSubtype::Cash,
82];
83
84/// Why a dataset failed validation. Carries the offending identifiers so the caller
85/// can surface them (error Displays must not swallow the detail).
86#[derive(Debug, Clone, PartialEq, thiserror::Error)]
87pub enum DatasetError {
88    #[error("account '{0}' has an empty number or code")]
89    EmptyIdentifier(String),
90    #[error("account '{0}' has an empty name")]
91    EmptyName(String),
92    #[error("duplicate account number '{0}'")]
93    DuplicateNumber(String),
94    #[error("duplicate account code '{0}'")]
95    DuplicateCode(String),
96    #[error("duplicate sort_order {0} — dataset order must be unambiguous")]
97    DuplicateSortOrder(i32),
98    #[error("account '{0}' currency '{1}' must be a 3-letter uppercase ISO code")]
99    InvalidCurrency(String, String),
100    #[error("account '{0}' code '{1}' must be its number '{2}' with dots stripped: identity hashes the code while collision matching matches the number, so the two must not diverge")]
101    CodeNumberMismatch(String, String, String),
102    #[error("account '{0}' references parent '{1}' which does not appear earlier in the dataset")]
103    OrphanParent(String, String),
104    #[error("dataset is missing a required '{0}' account")]
105    MissingRequired(&'static str),
106}
107
108/// Validate dataset invariants (see module docs). Pure — no I/O.
109pub fn validate_dataset(ds: &ChartDataset) -> Result<(), DatasetError> {
110    let mut seen_numbers: HashSet<&str> = HashSet::new();
111    let mut seen_codes: HashSet<&str> = HashSet::new();
112    let mut seen_sort_orders: HashSet<i32> = HashSet::new();
113
114    for a in &ds.accounts {
115        if a.number.trim().is_empty() || a.code.trim().is_empty() {
116            return Err(DatasetError::EmptyIdentifier(a.name.clone()));
117        }
118        if a.name.trim().is_empty() {
119            return Err(DatasetError::EmptyName(a.number.clone()));
120        }
121        if a.code != a.number.replace('.', "") {
122            return Err(DatasetError::CodeNumberMismatch(
123                a.name.clone(),
124                a.code.clone(),
125                a.number.clone(),
126            ));
127        }
128        if !seen_numbers.insert(a.number.as_str()) {
129            return Err(DatasetError::DuplicateNumber(a.number.clone()));
130        }
131        if !seen_codes.insert(a.code.as_str()) {
132            return Err(DatasetError::DuplicateCode(a.code.clone()));
133        }
134        if !seen_sort_orders.insert(a.sort_order) {
135            return Err(DatasetError::DuplicateSortOrder(a.sort_order));
136        }
137        let currency_ok =
138            a.currency.len() == 3 && a.currency.bytes().all(|b| b.is_ascii_uppercase());
139        if !currency_ok {
140            return Err(DatasetError::InvalidCurrency(
141                a.name.clone(),
142                a.currency.clone(),
143            ));
144        }
145        if let Some(parent) = &a.parent_code {
146            // Parents-first order: the parent must already have been defined. This
147            // check subsumes both "parent missing" and "cycle" (a cycle always has a
148            // first edge whose parent has not appeared yet).
149            if !seen_codes.contains(parent.as_str()) {
150                return Err(DatasetError::OrphanParent(a.code.clone(), parent.clone()));
151            }
152        }
153    }
154
155    for required in REQUIRED_SUBTYPES {
156        if !ds.accounts.iter().any(|a| a.account_subtype == required) {
157            return Err(DatasetError::MissingRequired(match required {
158                AccountSubtype::AccountsReceivable => "accounts_receivable",
159                AccountSubtype::AccountsPayable => "accounts_payable",
160                AccountSubtype::Bank => "bank",
161                AccountSubtype::Cash => "cash",
162                _ => unreachable!("REQUIRED_SUBTYPES only holds these four"),
163            }));
164        }
165    }
166    Ok(())
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    fn def(number: &str, subtype: AccountSubtype) -> ChartAccountDef {
174        ChartAccountDef {
175            number: number.to_string(),
176            code: number.replace('.', ""),
177            name: format!("Account {number}"),
178            account_type: AccountType::Asset,
179            account_subtype: subtype,
180            normal_balance: NormalBalance::Debit,
181            parent_code: None,
182            is_reconcilable: false,
183            currency: "IDR".to_string(),
184            sort_order: number.replace('.', "").parse().unwrap_or(0),
185        }
186    }
187
188    fn minimal_dataset() -> ChartDataset {
189        ChartDataset {
190            code: "TEST".into(),
191            version: "1".into(),
192            name: "Test chart".into(),
193            accounts: vec![
194                def("1100", AccountSubtype::Bank),
195                def("1200", AccountSubtype::Cash),
196                def("1300", AccountSubtype::AccountsReceivable),
197                def("2100", AccountSubtype::AccountsPayable),
198            ],
199        }
200    }
201
202    #[test]
203    fn minimal_dataset_passes() {
204        assert_eq!(validate_dataset(&minimal_dataset()), Ok(()));
205    }
206
207    #[test]
208    fn rejects_duplicate_numbers() {
209        let mut ds = minimal_dataset();
210        ds.accounts.push(def("1100", AccountSubtype::Tax));
211        assert_eq!(
212            validate_dataset(&ds),
213            Err(DatasetError::DuplicateNumber("1100".into()))
214        );
215    }
216
217    #[test]
218    fn rejects_orphan_parent() {
219        let mut ds = minimal_dataset();
220        let mut child = def("1110", AccountSubtype::Bank);
221        child.parent_code = Some("9999".into());
222        ds.accounts.push(child);
223        assert!(matches!(
224            validate_dataset(&ds),
225            Err(DatasetError::OrphanParent(_, _))
226        ));
227    }
228
229    #[test]
230    fn requires_ar_ap_bank_cash() {
231        let mut ds = minimal_dataset();
232        ds.accounts
233            .retain(|a| a.account_subtype != AccountSubtype::Cash);
234        assert!(matches!(
235            validate_dataset(&ds),
236            Err(DatasetError::MissingRequired("cash"))
237        ));
238    }
239
240    #[test]
241    fn parent_referenced_later_is_orphan() {
242        let mut ds = minimal_dataset();
243        let mut child = def("1150", AccountSubtype::Bank);
244        child.parent_code = Some("1100".into());
245        ds.accounts.insert(0, child); // child BEFORE its parent
246        assert!(matches!(
247            validate_dataset(&ds),
248            Err(DatasetError::OrphanParent(_, _))
249        ));
250    }
251}