backbone_accounting/domain/
chart_dataset.rs1use crate::domain::entity::{AccountSubtype, AccountType, NormalBalance};
38use serde::Deserialize;
39use std::collections::HashSet;
40
41#[derive(Debug, Clone, Deserialize)]
43pub struct ChartDataset {
44 pub code: String,
46 pub version: String,
48 pub name: String,
50 pub accounts: Vec<ChartAccountDef>,
52}
53
54#[derive(Debug, Clone, Deserialize)]
56pub struct ChartAccountDef {
57 pub number: String,
59 pub code: String,
61 pub name: String,
63 pub account_type: AccountType,
64 pub account_subtype: AccountSubtype,
65 pub normal_balance: NormalBalance,
66 pub parent_code: Option<String>,
68 pub is_reconcilable: bool,
70 pub currency: String,
72 pub sort_order: i32,
74}
75
76pub const REQUIRED_SUBTYPES: [AccountSubtype; 4] = [
78 AccountSubtype::AccountsReceivable,
79 AccountSubtype::AccountsPayable,
80 AccountSubtype::Bank,
81 AccountSubtype::Cash,
82];
83
84#[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
108pub 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 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); assert!(matches!(
247 validate_dataset(&ds),
248 Err(DatasetError::OrphanParent(_, _))
249 ));
250 }
251}