import: tests for the XLSX ledger and journal sheets, header and footer, tax summary differences, cash flow adjustments, owner limit
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
1 parent
11e33edc60
commit
abba6241e7
2 files changed
+266
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
# Seeds for failure cases proptest has generated in the past. It is
|
||||||
|
# automatically read and these particular cases re-run before any
|
||||||
|
# novel cases are generated.
|
||||||
|
#
|
||||||
|
# It is recommended to check this file in to source control so that
|
||||||
|
# everyone who runs the test benefits from these saved cases.
|
||||||
|
cc 32fa871fdd86d0ed0796633bcaab87af1143b4541818db7017872dd006fee657 # shrinks to (specs, oe, end_off) = ([EntrySpec { day: 0, accts: [7, 14], k: 1, debits: [100, 100, 100], seeds: [0, 6482613244230351999, 7089996084298427956], memo: "6—#" }], false, 156)
|
||||||
@@ -2354,3 +2354,262 @@ fn entity_type_parse() {
|
|||||||
);
|
);
|
||||||
assert_eq!(EntityType::parse("llc"), None);
|
assert_eq!(EntityType::parse("llc"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// XLSX general ledger and general journal sheets, header and footer
|
||||||
|
// (added after the store-style mutation run found them untested).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn num_at(cells: &BTreeMap<(u32, u32), Cell>, r: u32, c: u32) -> Option<Decimal> {
|
||||||
|
match cells.get(&(r, c)) {
|
||||||
|
Some(Cell::Num(v)) => Some(parse_num(v)),
|
||||||
|
Some(Cell::Text(t)) => parse_money_text(t),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_at(cells: &BTreeMap<(u32, u32), Cell>, r: u32, c: u32) -> Option<&str> {
|
||||||
|
match cells.get(&(r, c)) {
|
||||||
|
Some(Cell::Text(t)) => Some(t.as_str()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })]
|
||||||
|
|
||||||
|
/// The general ledger sheet: per account a beginning balance, each
|
||||||
|
/// posting with its running balance (each step moves it by the posting's
|
||||||
|
/// debit minus credit, the same sign throughout an account), and a total
|
||||||
|
/// row whose debits and credits foot and whose balance is the last one.
|
||||||
|
#[test]
|
||||||
|
fn xlsx_general_ledger_runs_and_foots((specs, oe, end_off) in books_strategy()) {
|
||||||
|
let b = build_books(&specs, oe);
|
||||||
|
let (s, e) = (d(2025, 7, 1), d(2025, 7, 1) + chrono::Duration::days(end_off));
|
||||||
|
let gl = general_ledger(&b.ledger, s, e).unwrap();
|
||||||
|
let bytes = report_to_xlsx(&XlsxReport::GeneralLedger(&gl), &meta()).unwrap();
|
||||||
|
let cells = sheet_cells(&bytes);
|
||||||
|
for a in &gl.accounts {
|
||||||
|
let name = format!("{} {}", a.code, a.name);
|
||||||
|
// The heading is the account's name alone in the first column
|
||||||
|
// (the Split column holds account names too).
|
||||||
|
let head = cells
|
||||||
|
.iter()
|
||||||
|
.find(|((_, c), v)| *c == 0 && **v == Cell::Text(name.clone()))
|
||||||
|
.map(|((r, _), _)| *r)
|
||||||
|
.expect("account heading");
|
||||||
|
prop_assert_eq!(text_at(&cells, head + 1, 2), Some("Beginning balance"));
|
||||||
|
let mut bal = num_at(&cells, head + 1, 6).unwrap();
|
||||||
|
prop_assert_eq!(bal, a.beginning_balance);
|
||||||
|
let (mut dr, mut cr) = (Decimal::ZERO, Decimal::ZERO);
|
||||||
|
let mut sign: Option<Decimal> = None;
|
||||||
|
let mut r = head + 2;
|
||||||
|
while text_at(&cells, r, 0) != Some(format!("Total for {name}").as_str()) {
|
||||||
|
let debit = num_at(&cells, r, 4).unwrap_or_default();
|
||||||
|
let credit = num_at(&cells, r, 5).unwrap_or_default();
|
||||||
|
let next = num_at(&cells, r, 6).unwrap();
|
||||||
|
let step = debit - credit;
|
||||||
|
if !step.is_zero() {
|
||||||
|
let s = (next - bal) / step;
|
||||||
|
prop_assert!(s == Decimal::ONE || s == -Decimal::ONE, "row {}", r);
|
||||||
|
if let Some(prev) = sign { prop_assert_eq!(prev, s); }
|
||||||
|
sign = Some(s);
|
||||||
|
}
|
||||||
|
dr += debit;
|
||||||
|
cr += credit;
|
||||||
|
bal = next;
|
||||||
|
r += 1;
|
||||||
|
prop_assert!(r < head + 10_000, "no total row for {}", name);
|
||||||
|
}
|
||||||
|
prop_assert_eq!(num_at(&cells, r, 4).unwrap(), dr);
|
||||||
|
prop_assert_eq!(num_at(&cells, r, 5).unwrap(), cr);
|
||||||
|
prop_assert_eq!(num_at(&cells, r, 6).unwrap(), bal);
|
||||||
|
prop_assert_eq!(bal, a.ending_balance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The general journal sheet: exactly the posted lines dated in the
|
||||||
|
/// range (each with its date inside it), and a total row whose debits
|
||||||
|
/// and credits foot and are equal.
|
||||||
|
#[test]
|
||||||
|
fn xlsx_general_journal_holds_the_range_and_foots((specs, oe, end_off) in books_strategy()) {
|
||||||
|
let b = build_books(&specs, oe);
|
||||||
|
let (s, e) = (d(2025, 7, 10), d(2025, 7, 10) + chrono::Duration::days(end_off / 2));
|
||||||
|
let bytes = report_to_xlsx(&XlsxReport::GeneralJournal(&b.ledger, s, e), &meta()).unwrap();
|
||||||
|
let cells = sheet_cells(&bytes);
|
||||||
|
let want_lines: usize = b
|
||||||
|
.ledger
|
||||||
|
.posted_entries()
|
||||||
|
.filter(|x| x.date() >= s && x.date() <= e)
|
||||||
|
.map(|x| x.lines().len())
|
||||||
|
.sum();
|
||||||
|
let header = row_of(&cells, "DATE", 0).expect("column headings");
|
||||||
|
let (mut dr, mut cr, mut lines) = (Decimal::ZERO, Decimal::ZERO, 0usize);
|
||||||
|
let mut r = header + 1;
|
||||||
|
while text_at(&cells, r, 0) != Some("Total") {
|
||||||
|
let date: NaiveDate = text_at(&cells, r, 0).unwrap().parse().unwrap();
|
||||||
|
prop_assert!(date >= s && date <= e, "{} outside the range", date);
|
||||||
|
dr += num_at(&cells, r, 5).unwrap_or_default();
|
||||||
|
cr += num_at(&cells, r, 6).unwrap_or_default();
|
||||||
|
lines += 1;
|
||||||
|
r += 1;
|
||||||
|
}
|
||||||
|
prop_assert_eq!(lines, want_lines);
|
||||||
|
prop_assert_eq!(num_at(&cells, r, 5).unwrap(), dr);
|
||||||
|
prop_assert_eq!(num_at(&cells, r, 6).unwrap(), cr);
|
||||||
|
prop_assert_eq!(dr, cr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every sheet names the company, the report, and its period at the top,
|
||||||
|
/// and ends with the footer (basis, preparation date, journal seq).
|
||||||
|
#[test]
|
||||||
|
fn xlsx_sheets_carry_header_and_footer() {
|
||||||
|
let b = build_books(&[], false);
|
||||||
|
let (s, e) = (d(2025, 7, 1), d(2025, 7, 31));
|
||||||
|
let pl = profit_and_loss_for_period(&b.ledger, s, e, None).unwrap();
|
||||||
|
let st = report::profit_and_loss(&pl);
|
||||||
|
let gl = general_ledger(&b.ledger, s, e).unwrap();
|
||||||
|
for (rep, title) in [
|
||||||
|
(XlsxReport::Statement(&st), "Profit and Loss"),
|
||||||
|
(XlsxReport::GeneralLedger(&gl), "General Ledger"),
|
||||||
|
(
|
||||||
|
XlsxReport::GeneralJournal(&b.ledger, s, e),
|
||||||
|
"General Journal",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let cells = sheet_cells(&report_to_xlsx(&rep, &meta()).unwrap());
|
||||||
|
let texts: Vec<&str> = cells
|
||||||
|
.values()
|
||||||
|
.filter_map(|c| match c {
|
||||||
|
Cell::Text(t) => Some(t.as_str()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let m = meta();
|
||||||
|
for want in [
|
||||||
|
m.company.as_str(),
|
||||||
|
title,
|
||||||
|
&report::period_label(s, e),
|
||||||
|
&m.footer(),
|
||||||
|
] {
|
||||||
|
prop_assert_contains(&texts, want, title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prop_assert_contains(texts: &[&str], want: &str, ctx: &str) {
|
||||||
|
assert!(
|
||||||
|
texts.iter().any(|t| t.contains(want)),
|
||||||
|
"{ctx}: no cell holds {want:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tax summary statement's Difference column is blank where book and
|
||||||
|
/// tax agree and book - tax where they don't (meals at 50%); separately
|
||||||
|
/// stated items sit in their own section, outside the net.
|
||||||
|
#[test]
|
||||||
|
fn tax_summary_difference_and_schedule_k() {
|
||||||
|
use ledger_core::tax::{TaxForm, tax_summary};
|
||||||
|
let mut l = Ledger::new(Currency::USD);
|
||||||
|
let cash = l
|
||||||
|
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let mut acct = |code: &str, t, cat: &str| {
|
||||||
|
let id = l.add_account(code, code, t, None, false).unwrap();
|
||||||
|
l.set_account_tax(id, Some(cat), None).unwrap();
|
||||||
|
id
|
||||||
|
};
|
||||||
|
let sales = acct("4000", AccountType::Revenue, "gross_receipts");
|
||||||
|
let interest = acct("4200", AccountType::Revenue, "interest_income");
|
||||||
|
let meals = acct("6450", AccountType::Expense, "meals");
|
||||||
|
for (dr, cr, c) in [
|
||||||
|
(cash, sales, 100_000),
|
||||||
|
(cash, interest, 2_500),
|
||||||
|
(meals, cash, 10_000),
|
||||||
|
] {
|
||||||
|
let e = l.create_entry(d(2026, 2, 1), "x");
|
||||||
|
l.add_line(e, dr, Side::Debit, usd(c)).unwrap();
|
||||||
|
l.add_line(e, cr, Side::Credit, usd(c)).unwrap();
|
||||||
|
l.post_entry(e).unwrap();
|
||||||
|
}
|
||||||
|
let ts = tax_summary(&l, d(2026, 1, 1), d(2026, 12, 31), Some(TaxForm::Form1065)).unwrap();
|
||||||
|
let st = report::tax_summary(&ts);
|
||||||
|
let row = |label: &str| {
|
||||||
|
st.rows
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.label.starts_with(label))
|
||||||
|
.unwrap_or_else(|| panic!("no row {label}"))
|
||||||
|
};
|
||||||
|
let sales_row = row("Gross receipts");
|
||||||
|
assert_eq!(
|
||||||
|
sales_row.amounts,
|
||||||
|
vec![Some(cents(100_000)), Some(cents(100_000)), None]
|
||||||
|
);
|
||||||
|
let meals_row = row("Meals");
|
||||||
|
assert_eq!(
|
||||||
|
meals_row.amounts,
|
||||||
|
vec![Some(cents(10_000)), Some(cents(5_000)), Some(cents(5_000))]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
st.rows
|
||||||
|
.iter()
|
||||||
|
.any(|r| r.label.starts_with("Separately stated"))
|
||||||
|
);
|
||||||
|
let net = row("Income less deductions");
|
||||||
|
assert_eq!(
|
||||||
|
net.amounts[0],
|
||||||
|
Some(cents(90_000)),
|
||||||
|
"interest is outside the net"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cash flow's "Adjustments" subheading and depreciation line appear
|
||||||
|
/// only when there is something to adjust.
|
||||||
|
#[test]
|
||||||
|
fn cash_flow_adjustments_appear_only_when_there_are_some() {
|
||||||
|
let mk = |depreciation: Decimal, wc: Vec<ledger_core::CashFlowLine>| {
|
||||||
|
ledger_core::CashFlowStatement {
|
||||||
|
start: d(2026, 1, 1),
|
||||||
|
end: d(2026, 1, 31),
|
||||||
|
net_income: cents(1_000),
|
||||||
|
depreciation,
|
||||||
|
cash_from_operations: cents(1_000)
|
||||||
|
+ depreciation
|
||||||
|
+ wc.iter().map(|l| l.amount).sum::<Decimal>(),
|
||||||
|
working_capital: wc,
|
||||||
|
investing: vec![],
|
||||||
|
cash_from_investing: Decimal::ZERO,
|
||||||
|
financing: vec![],
|
||||||
|
cash_from_financing: Decimal::ZERO,
|
||||||
|
net_change_in_cash: Decimal::ZERO,
|
||||||
|
cash_beginning: Decimal::ZERO,
|
||||||
|
cash_ending: Decimal::ZERO,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let has = |st: &Statement, p: &str| st.rows.iter().any(|r| r.label.starts_with(p));
|
||||||
|
let plain = report::cash_flow(&mk(Decimal::ZERO, vec![]), &[]);
|
||||||
|
assert!(!has(&plain, "Adjustments") && !has(&plain, "Depreciation"));
|
||||||
|
let dep = report::cash_flow(&mk(cents(100), vec![]), &[]);
|
||||||
|
assert!(has(&dep, "Adjustments") && has(&dep, "Depreciation"));
|
||||||
|
let wc = report::cash_flow(
|
||||||
|
&mk(
|
||||||
|
Decimal::ZERO,
|
||||||
|
vec![ledger_core::CashFlowLine {
|
||||||
|
label: "Increase in AR".into(),
|
||||||
|
amount: cents(-50),
|
||||||
|
account: None,
|
||||||
|
}],
|
||||||
|
),
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
assert!(has(&wc, "Adjustments") && !has(&wc, "Depreciation"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A template takes at most 90 owners.
|
||||||
|
#[test]
|
||||||
|
fn templates_take_at_most_90_owners() {
|
||||||
|
let owners = |n: usize| (0..n).map(|i| format!("P{i}")).collect::<Vec<_>>();
|
||||||
|
assert!(chart_template(EntityType::Partnership, &owners(90)).is_ok());
|
||||||
|
assert!(chart_template(EntityType::Partnership, &owners(91)).is_err());
|
||||||
|
}
|
||||||
Reference in new issue
Block a user