use std::fs;
use std::path::Path;
use chrono::NaiveDate;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use tempfile::TempDir;
use datasynth_core::models::balance::{
AccountCategory, AccountType, TrialBalance, TrialBalanceLine, TrialBalanceType,
};
use datasynth_group::errors::GroupError;
use datasynth_group::load_entity_trial_balance;
fn balanced_tb(id: &str, company_code: &str) -> TrialBalance {
let mut tb = TrialBalance::new(
id.to_string(),
company_code.to_string(),
NaiveDate::from_ymd_opt(2025, 12, 31).expect("valid date"),
2025,
12,
"USD".to_string(),
TrialBalanceType::Adjusted,
);
tb.add_line(TrialBalanceLine {
account_code: "1100".to_string(),
account_description: "Cash".to_string(),
category: AccountCategory::CurrentAssets,
account_type: AccountType::Asset,
opening_balance: Decimal::ZERO,
period_debits: dec!(10000),
period_credits: Decimal::ZERO,
closing_balance: dec!(10000),
debit_balance: dec!(10000),
credit_balance: Decimal::ZERO,
cost_center: None,
profit_center: None,
});
tb.add_line(TrialBalanceLine {
account_code: "3100".to_string(),
account_description: "Common Stock".to_string(),
category: AccountCategory::Equity,
account_type: AccountType::Equity,
opening_balance: Decimal::ZERO,
period_debits: Decimal::ZERO,
period_credits: dec!(10000),
closing_balance: dec!(10000),
debit_balance: Decimal::ZERO,
credit_balance: dec!(10000),
cost_center: None,
profit_center: None,
});
debug_assert!(tb.is_balanced, "fixture builder must produce balanced TB");
tb
}
fn write_tb_file(entity_dir: &Path, body: &[u8]) {
let pc_dir = entity_dir.join("period_close");
fs::create_dir_all(&pc_dir).expect("create period_close dir");
fs::write(pc_dir.join("trial_balances.json"), body).expect("write TB JSON");
}
fn write_tb_array(entity_dir: &Path, tbs: &[TrialBalance]) {
let json = serde_json::to_vec_pretty(tbs).expect("serialise TB array");
write_tb_file(entity_dir, &json);
}
#[test]
fn loads_balanced_single_tb() {
let tmp = TempDir::new().expect("tempdir");
let entity_dir = tmp.path();
let original = balanced_tb("TB-ACME_SA-2025-12", "ACME_SA");
let expected_id = original.trial_balance_id.clone();
let expected_code = original.company_code.clone();
let expected_ccy = original.currency.clone();
let expected_debits = original.total_debits;
let expected_credits = original.total_credits;
write_tb_array(entity_dir, std::slice::from_ref(&original));
let loaded = load_entity_trial_balance(entity_dir).expect("loader must succeed on balanced TB");
assert_eq!(loaded.trial_balance_id, expected_id);
assert_eq!(loaded.company_code, expected_code);
assert_eq!(loaded.currency, expected_ccy);
assert!(loaded.is_balanced);
assert_eq!(loaded.total_debits, expected_debits);
assert_eq!(loaded.total_credits, expected_credits);
assert_eq!(loaded.lines.len(), 2);
}
#[test]
fn missing_file_is_io_not_found() {
let tmp = TempDir::new().expect("tempdir");
let err = load_entity_trial_balance(tmp.path()).expect_err("missing file must error");
match err {
GroupError::Io(io_err) => {
assert_eq!(
io_err.kind(),
std::io::ErrorKind::NotFound,
"expected NotFound, got {:?}",
io_err.kind()
);
}
other => panic!("expected GroupError::Io(NotFound), got {other:?}"),
}
}
#[test]
fn corrupt_json_is_serde_error() {
let tmp = TempDir::new().expect("tempdir");
write_tb_file(tmp.path(), b"{ this is not json");
let err = load_entity_trial_balance(tmp.path()).expect_err("corrupt JSON must error");
assert!(
matches!(err, GroupError::Serde(_)),
"expected GroupError::Serde, got {err:?}"
);
}
#[test]
fn empty_array_is_aggregate_error() {
let tmp = TempDir::new().expect("tempdir");
let entity_subdir = tmp.path().join("ACME_SA");
fs::create_dir_all(&entity_subdir).expect("create entity subdir");
write_tb_file(&entity_subdir, b"[]");
let err = load_entity_trial_balance(&entity_subdir).expect_err("empty array must error");
match err {
GroupError::Aggregate(msg) => {
assert!(
msg.contains("ACME_SA"),
"error message must name the entity, got {msg:?}"
);
assert!(
msg.contains("empty"),
"error message must describe the empty-array condition, got {msg:?}"
);
}
other => panic!("expected GroupError::Aggregate, got {other:?}"),
}
}
#[test]
fn multi_period_archive_picks_latest_period() {
let tmp = TempDir::new().expect("tempdir");
let entity_subdir = tmp.path().join("ACME_USA");
fs::create_dir_all(&entity_subdir).expect("create entity subdir");
let mut tb1 = balanced_tb("TB-ACME_USA-2025-11", "ACME_USA");
tb1.fiscal_year = 2025;
tb1.fiscal_period = 11;
let mut tb2 = balanced_tb("TB-ACME_USA-2025-12", "ACME_USA");
tb2.fiscal_year = 2025;
tb2.fiscal_period = 12;
write_tb_array(&entity_subdir, &[tb1, tb2]);
let loaded =
load_entity_trial_balance(&entity_subdir).expect("multi-period archive must succeed");
assert_eq!(
loaded.trial_balance_id, "TB-ACME_USA-2025-12",
"loader must pick the latest fiscal_period (December over November)"
);
assert_eq!(loaded.fiscal_year, 2025);
assert_eq!(loaded.fiscal_period, 12);
}
#[test]
fn unbalanced_tb_is_aggregate_error() {
let tmp = TempDir::new().expect("tempdir");
let entity_subdir = tmp.path().join("ACME_DE");
fs::create_dir_all(&entity_subdir).expect("create entity subdir");
let mut tb = balanced_tb("TB-ACME_DE-2025-12", "ACME_DE");
tb.add_line(TrialBalanceLine {
account_code: "1200".to_string(),
account_description: "Receivables".to_string(),
category: AccountCategory::CurrentAssets,
account_type: AccountType::Asset,
opening_balance: Decimal::ZERO,
period_debits: dec!(5000),
period_credits: Decimal::ZERO,
closing_balance: dec!(5000),
debit_balance: dec!(5000),
credit_balance: Decimal::ZERO,
cost_center: None,
profit_center: None,
});
debug_assert!(
!tb.is_balanced,
"fixture must produce an unbalanced TB after the extra debit"
);
write_tb_array(&entity_subdir, &[tb]);
let tb = load_entity_trial_balance(&entity_subdir)
.expect("unbalanced TB must load successfully under v5.0 fraud-tolerance contract");
assert!(!tb.is_balanced, "loaded TB preserves the imbalance flag");
assert!(
(tb.total_debits - tb.total_credits).abs() > Decimal::new(1, 2),
"loaded TB preserves the imbalance amount"
);
}
#[test]
fn corrupt_balanced_flag_is_aggregate_error() {
let tmp = TempDir::new().expect("tempdir");
let entity_subdir = tmp.path().join("ACME_SA");
fs::create_dir_all(&entity_subdir).expect("create entity subdir");
let mut tb = balanced_tb("TB-ACME_SA-2025-12", "ACME_SA");
tb.total_debits = dec!(10000);
tb.total_credits = dec!(9000);
write_tb_array(&entity_subdir, &[tb]);
let tb = load_entity_trial_balance(&entity_subdir)
.expect("corrupt-flag TB must load under v5.0 fraud-tolerance contract");
assert!(
tb.is_balanced,
"loaded TB preserves the lying balanced flag"
);
assert_eq!(tb.total_debits, dec!(10000));
assert_eq!(tb.total_credits, dec!(9000));
}