store journals payroll.taxes_remitted; viewer tests for the numbers mutation found unchecked
check / check (push) Canceled after 0s

A payroll tax remittance was journaled as a plain entry, so the viewer
labeled it "Journal entry" and, at a 0.00 threshold, flagged it as
missing a receipt (the server posts it without one, like a payroll
run). The store now names it with payroll.taxes_remitted, as it names
runs with payroll.run; replay and the projection pass over it, and the
viewer's journal page describes it.

tests/view_model.rs: the cash history is never empty when cash moved
and ends on the last day it did; the prior month runs to the same day;
burn averages over only the months the books cover, half away from
zero; cash accounts found by name when the settings name none; card
accounts; each entry's accounts debits first, once each; only what
arrived since the last refresh is fresh; integrity counts files again
without rereading them; the invoice's customer, the bill payment's
vendor, payroll and remittance origins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-14 18:03:10 -06:00
1 parent f0fc725ca2
commit c1f92ed3e8
5 files changed
+341 -1

No files matched your search

+1 -1
View File
@@ -387,7 +387,7 @@ impl Books {
&self.settings.company.name &self.settings.company.name
} }
/// Cash balance at the end of each day it changed. /// Cash balance at the end of each day a cash account moved.
pub fn cash_history(&self) -> Vec<(NaiveDate, Decimal)> { pub fn cash_history(&self) -> Vec<(NaiveDate, Decimal)> {
balance_history(&self.ledger, &self.cash_accounts) balance_history(&self.ledger, &self.cash_accounts)
} }
+8
View File
@@ -251,6 +251,14 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
"Payroll run".into(), "Payroll run".into(),
entry_id.map(Route::Entry), entry_id.map(Route::Entry),
), ),
"payroll.taxes_remitted" => ev(
icon::BANK,
format!(
"Remitted payroll taxes ({})",
fmt::dollars(s(p, "total").parse().unwrap_or_default())
),
entry_id.map(Route::Entry),
),
"line.cleared" => ev( "line.cleared" => ev(
icon::CHECK_SQUARE, icon::CHECK_SQUARE,
format!("Cleared a line of #{}", entry_id.unwrap_or(0)), format!("Cleared a line of #{}", entry_id.unwrap_or(0)),
@@ -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 3eecfe5edc181e6caa0c4157dda266dacbca83c62d0b95b8792177443100d78f # shrinks to p = Plan { today: 2026-03-31, fy_month: 1, entries: [(0, 0, 1, 1), (1, 0, 2, 1)] }
+307
View File
@@ -182,6 +182,16 @@ proptest! {
for (d, v) in &h { for (d, v) in &h {
prop_assert_eq!(*v, cash(&l, *d), "cash on {}", d); prop_assert_eq!(*v, cash(&l, *d), "cash on {}", d);
} }
// ... and it isn't empty when a cash account moved: its last point
// is the last day one did (a transfer between them included).
let cash_idx = [0usize, 1];
let last_move = p
.entries
.iter()
.filter(|e| cash_idx.contains(&e.1) || cash_idx.contains(&e.2))
.map(|e| t - Days::new(e.0))
.max();
prop_assert_eq!(h.last().map(|x| x.0), last_move);
// Names and labels. // Names and labels.
for (code, name, _) in CHART { for (code, name, _) in CHART {
let id = l.chart().find_by_code(code).unwrap().id; let id = l.chart().find_by_code(code).unwrap().id;
@@ -295,3 +305,300 @@ fn percent_change_and_tax_forms() {
} }
assert_eq!(fmt::compact(d("-1234")), "\u{2212}$1.2k"); assert_eq!(fmt::compact(d("-1234")), "\u{2212}$1.2k");
} }
fn store_at(tag: &str) -> (PathBuf, Store) {
let d = dir(tag);
let s = Store::create(&d.join("books-data"), Currency::USD).unwrap();
(d, s)
}
fn source(d: &std::path::Path, toml: &str, today: NaiveDate) -> Source {
std::fs::write(d.join("wanderbooks.toml"), toml).unwrap();
Source {
data_dir: d.join("books-data"),
books_toml: Some(d.join("wanderbooks.toml")),
today: Some(today),
}
}
fn post(s: &mut Store, date: NaiveDate, dr: u64, cr: u64, cents: i64) -> u64 {
let e = s.create_entry(date, "x").unwrap();
let m = Money::new(Decimal::new(cents, 2), Currency::USD);
s.add_line(e, dr, Side::Debit, m).unwrap();
s.add_line(e, cr, Side::Credit, m).unwrap();
s.post_entry(e).unwrap();
e
}
/// Kpis INVARIANTS 4 and 6 on fixed dates: the prior month runs to the
/// same day; burn averages over only the months the books cover.
#[test]
fn comparisons_and_burn_on_fixed_dates() {
let (d, mut s) = store_at("burn");
let cash = s
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let rev = s
.add_account("4000", "Sales", AccountType::Revenue, None, false)
.unwrap();
let exp = s
.add_account("6000", "Rent", AccountType::Expense, None, false)
.unwrap();
let today = NaiveDate::from_ymd_opt(2026, 5, 10).unwrap();
// The books start in March: burn covers March and April, not February.
post(
&mut s,
NaiveDate::from_ymd_opt(2026, 3, 3).unwrap(),
exp,
cash,
30_000,
);
post(
&mut s,
NaiveDate::from_ymd_opt(2026, 4, 10).unwrap(),
exp,
cash,
10_000,
);
post(
&mut s,
NaiveDate::from_ymd_opt(2026, 4, 11).unwrap(),
cash,
rev,
7_000,
); // after the same day
post(
&mut s,
NaiveDate::from_ymd_opt(2026, 5, 2).unwrap(),
cash,
rev,
1_000,
);
drop(s);
let b = data::load_once(&source(&d, "[books]\ncash_accounts = [\"1000\"]\n", today)).unwrap();
let k = &b.kpis;
assert_eq!(k.revenue_mtd, Decimal::new(1_000, 2));
assert_eq!(k.expenses_prev_month, Decimal::new(10_000, 2), "April 1-10");
assert_eq!(
k.revenue_prev_month,
Decimal::ZERO,
"April 11 is after the same day"
);
// Feb-Apr loss: 300 + 100 - 70 = 330 over the 2 months covered.
assert_eq!(k.monthly_burn, Some(Decimal::new(16_500, 2)));
// A profitable stretch has no burn; books starting this month have none.
let (d2, mut s2) = store_at("burn-new");
let c2 = s2
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let e2 = s2
.add_account("6000", "Rent", AccountType::Expense, None, false)
.unwrap();
post(
&mut s2,
NaiveDate::from_ymd_opt(2026, 5, 1).unwrap(),
e2,
c2,
5_000,
);
drop(s2);
let b2 = data::load_once(&source(&d2, "", today)).unwrap();
assert_eq!(b2.kpis.monthly_burn, None);
// Three full months of loss: divided by 3, half away from zero.
let (d3, mut s3) = store_at("burn-three");
let c3 = s3
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let e3 = s3
.add_account("6000", "Rent", AccountType::Expense, None, false)
.unwrap();
post(
&mut s3,
NaiveDate::from_ymd_opt(2025, 12, 1).unwrap(),
e3,
c3,
1,
);
post(
&mut s3,
NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
e3,
c3,
1_000,
);
drop(s3);
let b3 = data::load_once(&source(&d3, "", today)).unwrap();
// 10.00 / 3 = 3.333... -> 3.33
assert_eq!(b3.kpis.monthly_burn, Some(Decimal::new(333, 2)));
for x in [d, d2, d3] {
let _ = std::fs::remove_dir_all(x);
}
}
/// The view model's indexes: cash accounts by name when the settings name
/// none, card accounts, each entry's accounts (debits first, once each),
/// and what's new since the last refresh.
#[test]
fn indexes_cash_cards_accounts_and_fresh() {
let (d, mut s) = store_at("index");
let checking = s
.add_account("1000", "Business Checking", AccountType::Asset, None, false)
.unwrap();
let savings = s
.add_account("1010", "Savings", AccountType::Asset, None, false)
.unwrap();
let group = s
.add_account("1090", "Cash accounts", AccountType::Asset, None, true)
.unwrap();
let equip = s
.add_account("1500", "Equipment", AccountType::Asset, None, false)
.unwrap();
let card = s
.add_account(
"2050",
"Chase Ink Card",
AccountType::Liability,
None,
false,
)
.unwrap();
let cardish = s
.add_account("1600", "Card reader", AccountType::Asset, None, false)
.unwrap();
let rent = s
.add_account("6000", "Rent", AccountType::Expense, None, false)
.unwrap();
let today = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
// Two debits to one account, then two credits.
let e = s.create_entry(today, "split").unwrap();
let m = |c| Money::new(Decimal::new(c, 2), Currency::USD);
s.add_line(e, rent, Side::Debit, m(100)).unwrap();
s.add_line(e, rent, Side::Debit, m(200)).unwrap();
s.add_line(e, card, Side::Credit, m(250)).unwrap();
s.add_line(e, checking, Side::Credit, m(50)).unwrap();
s.post_entry(e).unwrap();
drop(s);
let src = source(&d, "", today);
let b = data::load_once(&src).unwrap();
let mut cash = b.cash_accounts.clone();
cash.sort();
assert_eq!(
cash,
vec![checking, savings],
"by name, postable assets only"
);
let _ = (group, equip, cardish);
assert_eq!(b.card_accounts, vec![card], "liabilities named card");
assert_eq!(b.entry(e).unwrap().accounts, vec![rent, card, checking]);
assert!(b.fresh.is_empty(), "a first load marks nothing new");
// A new entry after a refresh is the only fresh one.
let seq = b.seq();
let mut s = Store::open(&d.join("books-data")).unwrap();
let e2 = post(&mut s, today, rent, checking, 999);
drop(s);
let snap = ledger_store::Snapshot::load(&src.data_dir).unwrap();
let settings = ledger_config::load(src.books_toml.as_ref().unwrap()).unwrap();
let mut verified = std::collections::HashSet::new();
let integrity = ledger_gui::books::Integrity::check(&snap, &mut verified);
let b2 = ledger_gui::books::Books::build(&snap, settings, today, Some(seq), integrity).unwrap();
assert_eq!(b2.fresh, [e2].into_iter().collect());
let _ = std::fs::remove_dir_all(&d);
}
/// Integrity counts every stored file, read once: a second check with the
/// same memory counts them again without rereading; a damaged one fails.
#[test]
fn integrity_counts_files_once_verified() {
let (d, mut s) = store_at("integrity");
let cash = s
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let rent = s
.add_account("6000", "Rent", AccountType::Expense, None, false)
.unwrap();
let e = post(
&mut s,
NaiveDate::from_ymd_opt(2026, 1, 2).unwrap(),
rent,
cash,
100,
);
s.attach_receipt(Some(e), "a.pdf", "application/pdf", b"one", None)
.unwrap();
s.attach_receipt(Some(e), "b.pdf", "application/pdf", b"two", None)
.unwrap();
drop(s);
let snap = ledger_store::Snapshot::load(&d.join("books-data")).unwrap();
let mut verified = std::collections::HashSet::new();
let first = ledger_gui::books::Integrity::check(&snap, &mut verified);
assert_eq!((first.files_ok, first.file_failures.len()), (2, 0));
let again = ledger_gui::books::Integrity::check(&snap, &mut verified);
assert_eq!((again.files_ok, again.file_failures.len()), (2, 0));
let _ = std::fs::remove_dir_all(&d);
}
/// Origins and parties for the rest of the journal's kinds: the invoice
/// itself shows its customer, a bill payment its vendor, and a payroll
/// tax remittance is labeled as one (so the receipt rule for hand-made
/// entries doesn't flag it).
#[test]
fn more_origins_and_parties() {
let (d, mut s) = store_at("origin2");
let cash = s
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let today = NaiveDate::from_ymd_opt(2026, 7, 15).unwrap();
let cust = s.add_customer("Acme Buyer").unwrap();
let vend = s.add_vendor("Paper Co").unwrap();
let line = |a: i64| {
vec![DocumentLine {
description: "x".into(),
amount: Decimal::new(a, 2),
}]
};
s.create_invoice(cust, today, today + Days::new(30), line(10_000))
.unwrap();
let bill = s
.create_bill(vend, today, today + Days::new(30), line(5_000))
.unwrap();
let paid = s
.pay_bill(bill, today, Decimal::new(5_000, 2), cash)
.unwrap();
s.run_payroll(
today,
vec![ledger_core::PayrollLine {
employee_name: "Pat".into(),
gross_pay: Decimal::new(100_000, 2),
federal_withholding: Decimal::new(10_000, 2),
state_withholding: Decimal::new(3_000, 2),
ss_employee: Decimal::new(6_200, 2),
medicare_employee: Decimal::new(1_450, 2),
ss_employer: Decimal::new(6_200, 2),
medicare_employer: Decimal::new(1_450, 2),
other_deductions: Decimal::ZERO,
}],
cash,
)
.unwrap();
let remit = s.remit_payroll_taxes(today, cash).unwrap();
drop(s);
let b = data::load_once(&source(
&d,
"[books]\nreceipt_threshold = \"0.00\"\n",
today,
))
.unwrap();
let inv = b.entries.iter().find(|x| x.origin == "Invoice").unwrap();
assert_eq!(inv.party.as_deref(), Some("Acme Buyer"));
let pay = b.entry(paid).unwrap();
assert_eq!(pay.origin, "Bill payment");
assert_eq!(pay.party.as_deref(), Some("Paper Co"));
assert_eq!(
b.entries.iter().filter(|x| x.origin == "Payroll").count(),
1
);
let r = b.entry(remit).unwrap();
assert_eq!(r.origin, "Payroll taxes");
assert!(!r.missing_receipt, "a remittance isn't a hand-made entry");
let _ = std::fs::remove_dir_all(&d);
}
+18
View File
@@ -1755,6 +1755,17 @@ impl Store {
"lines": plan.iter().map(|(a, s, m)| plain_line_json(*a, *s, *m)).collect::<Vec<_>>(), "lines": plan.iter().map(|(a, s, m)| plain_line_json(*a, *s, *m)).collect::<Vec<_>>(),
}), }),
)?); )?);
// Name the entry as a remittance, as payroll.run names a run's
// (readers like the viewer tell it from a hand-made entry by this).
let total: Decimal = plan
.iter()
.filter(|(_, s, _)| *s == Side::Debit)
.map(|(_, _, m)| m.amount)
.sum();
journaled.push(self.append_journal(
"payroll.taxes_remitted",
json!({"entry_id": entry_id, "date": date.to_string(), "total": total.to_string()}),
)?);
let live = self.ledger.remit_payroll_taxes(date, cash_account)?; let live = self.ledger.remit_payroll_taxes(date, cash_account)?;
debug_assert_eq!(live, entry_id, "id allocator diverged from journal"); debug_assert_eq!(live, entry_id, "id allocator diverged from journal");
for line in &journaled { for line in &journaled {
@@ -3294,6 +3305,10 @@ pub(crate) fn replay_line_into_ledger(
| "attachment.withdrawn" => { | "attachment.withdrawn" => {
// Not accounting state; the projection records them. // Not accounting state; the projection records them.
} }
"payroll.taxes_remitted" => {
// Names the remittance entry (rebuilt by the entry events just
// before it) for readers; no ledger state of its own.
}
"asset.registered" => { "asset.registered" => {
let want = get_u64(p, "id")?; let want = get_u64(p, "id")?;
let got = ledger.register_asset(NewAsset { let got = ledger.register_asset(NewAsset {
@@ -3758,6 +3773,9 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
)?; )?;
} }
} }
"payroll.taxes_remitted" => {
// The entry was projected by its own events; this only names it.
}
"receipt.attached" | "document.filed" => { "receipt.attached" | "document.filed" => {
conn.execute( conn.execute(
"INSERT OR IGNORE INTO blobs(sha256, size_bytes, mime_type, original_name, created_at) VALUES (?,?,?,?,?)", "INSERT OR IGNORE INTO blobs(sha256, size_bytes, mime_type, original_name, created_at) VALUES (?,?,?,?,?)",