use crate::domain::entity::{AccountSubtype, AccountType, NormalBalance};
use serde::Deserialize;
use std::collections::HashSet;
#[derive(Debug, Clone, Deserialize)]
pub struct ChartDataset {
pub code: String,
pub version: String,
pub name: String,
pub accounts: Vec<ChartAccountDef>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ChartAccountDef {
pub number: String,
pub code: String,
pub name: String,
pub account_type: AccountType,
pub account_subtype: AccountSubtype,
pub normal_balance: NormalBalance,
pub parent_code: Option<String>,
pub is_reconcilable: bool,
pub currency: String,
pub sort_order: i32,
}
pub const REQUIRED_SUBTYPES: [AccountSubtype; 4] = [
AccountSubtype::AccountsReceivable,
AccountSubtype::AccountsPayable,
AccountSubtype::Bank,
AccountSubtype::Cash,
];
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum DatasetError {
#[error("account '{0}' has an empty number or code")]
EmptyIdentifier(String),
#[error("account '{0}' has an empty name")]
EmptyName(String),
#[error("duplicate account number '{0}'")]
DuplicateNumber(String),
#[error("duplicate account code '{0}'")]
DuplicateCode(String),
#[error("duplicate sort_order {0} — dataset order must be unambiguous")]
DuplicateSortOrder(i32),
#[error("account '{0}' currency '{1}' must be a 3-letter uppercase ISO code")]
InvalidCurrency(String, String),
#[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")]
CodeNumberMismatch(String, String, String),
#[error("account '{0}' references parent '{1}' which does not appear earlier in the dataset")]
OrphanParent(String, String),
#[error("dataset is missing a required '{0}' account")]
MissingRequired(&'static str),
}
pub fn validate_dataset(ds: &ChartDataset) -> Result<(), DatasetError> {
let mut seen_numbers: HashSet<&str> = HashSet::new();
let mut seen_codes: HashSet<&str> = HashSet::new();
let mut seen_sort_orders: HashSet<i32> = HashSet::new();
for a in &ds.accounts {
if a.number.trim().is_empty() || a.code.trim().is_empty() {
return Err(DatasetError::EmptyIdentifier(a.name.clone()));
}
if a.name.trim().is_empty() {
return Err(DatasetError::EmptyName(a.number.clone()));
}
if a.code != a.number.replace('.', "") {
return Err(DatasetError::CodeNumberMismatch(
a.name.clone(),
a.code.clone(),
a.number.clone(),
));
}
if !seen_numbers.insert(a.number.as_str()) {
return Err(DatasetError::DuplicateNumber(a.number.clone()));
}
if !seen_codes.insert(a.code.as_str()) {
return Err(DatasetError::DuplicateCode(a.code.clone()));
}
if !seen_sort_orders.insert(a.sort_order) {
return Err(DatasetError::DuplicateSortOrder(a.sort_order));
}
let currency_ok =
a.currency.len() == 3 && a.currency.bytes().all(|b| b.is_ascii_uppercase());
if !currency_ok {
return Err(DatasetError::InvalidCurrency(
a.name.clone(),
a.currency.clone(),
));
}
if let Some(parent) = &a.parent_code {
if !seen_codes.contains(parent.as_str()) {
return Err(DatasetError::OrphanParent(a.code.clone(), parent.clone()));
}
}
}
for required in REQUIRED_SUBTYPES {
if !ds.accounts.iter().any(|a| a.account_subtype == required) {
return Err(DatasetError::MissingRequired(match required {
AccountSubtype::AccountsReceivable => "accounts_receivable",
AccountSubtype::AccountsPayable => "accounts_payable",
AccountSubtype::Bank => "bank",
AccountSubtype::Cash => "cash",
_ => unreachable!("REQUIRED_SUBTYPES only holds these four"),
}));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn def(number: &str, subtype: AccountSubtype) -> ChartAccountDef {
ChartAccountDef {
number: number.to_string(),
code: number.replace('.', ""),
name: format!("Account {number}"),
account_type: AccountType::Asset,
account_subtype: subtype,
normal_balance: NormalBalance::Debit,
parent_code: None,
is_reconcilable: false,
currency: "IDR".to_string(),
sort_order: number.replace('.', "").parse().unwrap_or(0),
}
}
fn minimal_dataset() -> ChartDataset {
ChartDataset {
code: "TEST".into(),
version: "1".into(),
name: "Test chart".into(),
accounts: vec![
def("1100", AccountSubtype::Bank),
def("1200", AccountSubtype::Cash),
def("1300", AccountSubtype::AccountsReceivable),
def("2100", AccountSubtype::AccountsPayable),
],
}
}
#[test]
fn minimal_dataset_passes() {
assert_eq!(validate_dataset(&minimal_dataset()), Ok(()));
}
#[test]
fn rejects_duplicate_numbers() {
let mut ds = minimal_dataset();
ds.accounts.push(def("1100", AccountSubtype::Tax));
assert_eq!(
validate_dataset(&ds),
Err(DatasetError::DuplicateNumber("1100".into()))
);
}
#[test]
fn rejects_orphan_parent() {
let mut ds = minimal_dataset();
let mut child = def("1110", AccountSubtype::Bank);
child.parent_code = Some("9999".into());
ds.accounts.push(child);
assert!(matches!(
validate_dataset(&ds),
Err(DatasetError::OrphanParent(_, _))
));
}
#[test]
fn requires_ar_ap_bank_cash() {
let mut ds = minimal_dataset();
ds.accounts
.retain(|a| a.account_subtype != AccountSubtype::Cash);
assert!(matches!(
validate_dataset(&ds),
Err(DatasetError::MissingRequired("cash"))
));
}
#[test]
fn parent_referenced_later_is_orphan() {
let mut ds = minimal_dataset();
let mut child = def("1150", AccountSubtype::Bank);
child.parent_code = Some("1100".into());
ds.accounts.insert(0, child); assert!(matches!(
validate_dataset(&ds),
Err(DatasetError::OrphanParent(_, _))
));
}
}