Audit fixes: party money, reconciliations, require policy, K-1 property

An independent recompute of the real books found no numeric
disagreements; review found 15 defects, all fixed with spec-first
regression tests:

- k1: a prepaid credited straight to capital is property; a member
  paying a vendor through 2100 is cash. Docs and B-13 match.
- parties: a reversal of a reversal counts on its root's side;
  liability-to-liability payments (member pays the card) count as paid.
- viewer: party money uses the server's cash accounts and full calendar
  year; per-party "What for" scales multi-party entries.
- reconciliations: refuse a withdrawn statement before journaling; new
  books.void_statement; reconciled lines can't be un-cleared; one shared
  reconcilable-accounts rule for digest and viewer.
- CPA package includes receipts linked to entries; a file can't be both
  filed on and linked to one entry.
- parties = "require" covers payroll, tax remittance (agency) and
  disposal (buyer); no-party marks follow reversal chains and clear when
  a vendor is tagged.
- derived member shares are frozen to exact amounts when posted, so a
  settings change never re-splits history.

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-15 20:16:03 -06:00
1 parent e1b2509c22
commit 8498e6cfab
22 files changed
+1802 -343

No files matched your search

+1 -1
View File
@@ -147,7 +147,7 @@ fn mcp_end_to_end_over_stdio() {
// Tool listing: the narrow tools; chart is read-only.
let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 92, "expected 92 tools, got {}", tools.len());
assert_eq!(tools.len(), 93, "expected 93 tools, got {}", tools.len());
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
assert_eq!(chart["annotations"]["readOnlyHint"], true);
assert!(
+170 -7
View File
@@ -1148,6 +1148,155 @@ fn void_reconciliation_lets_it_be_redone() {
assert_ne!(redo["reconciliation"]["reconciliation_id"], json!(rid));
}
/// books.reconcile SCENARIO 1: a statement whose file was withdrawn is
/// retired; the preview and the confirmation are refused, and nothing is
/// journaled (it used to return an error after journaling
/// reconciliation.completed). books.statements no longer lists it.
#[test]
fn a_withdrawn_statement_is_retired_and_reconciles_nothing() {
let mut m = books("withdrawn-statement");
let (dep, pay) = january(&mut m);
m.clear(dep, 0);
m.clear(pay, 1);
let st = m.statement("1000", "2026-01-01", "2026-01-31", "800.00");
m.ok(
"books.unlink_attachment",
json!({"attachment_id": st, "subject": {"kind": "account", "id": 1}, "reason": "wrong file"}),
);
m.ok(
"books.withdraw_attachment",
json!({"attachment_id": st, "reason": "wrong file"}),
);
let why = m.refuse("books.reconcile", json!({"statement_attachment": st}));
assert!(why.contains("withdrawn"), "{why}");
m.refuse(
"books.reconcile",
json!({"statement_attachment": st, "confirmed": true}),
);
assert_eq!(m.count("reconciliation.completed"), 0);
assert!(
m.read_only("books.reconciliations", json!({}))["reconciliations"]
.as_array()
.unwrap()
.is_empty()
);
assert!(
m.read_only("books.statements", json!({}))["statements"]
.as_array()
.unwrap()
.is_empty()
);
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
}
/// books.void_statement SCENARIOS 1–2: a statement recorded with the wrong
/// closing balance is voided and recorded again on the same file, then
/// reconciles; voiding it while that reconciliation stands is refused.
#[test]
fn a_mis_recorded_statement_is_voided_and_recorded_again() {
let mut m = books("void-statement");
let (dep, pay) = january(&mut m);
m.clear(dep, 0);
m.clear(pay, 1);
let st = m.statement("1000", "2026-01-01", "2026-01-31", "880.00");
m.refuse(
"books.void_statement",
json!({"attachment_id": st, "reason": " "}),
);
let v = m.ok(
"books.void_statement",
json!({"attachment_id": st, "reason": "closing balance mistyped"}),
);
assert_eq!(v["closing_balance"], "880.00");
assert_eq!(m.count("statement.voided"), 1);
m.refuse(
"books.void_statement",
json!({"attachment_id": st, "reason": "again"}),
);
assert!(
m.read_only("books.statements", json!({}))["statements"]
.as_array()
.unwrap()
.is_empty()
);
m.ok(
"books.record_statement",
json!({"attachment_id": st, "account": "1000", "period_start": "2026-01-01",
"period_end": "2026-01-31", "opening_balance": "0.00", "closing_balance": "800.00"}),
);
m.ok(
"books.reconcile",
json!({"statement_attachment": st, "confirmed": true}),
);
m.refuse(
"books.void_statement",
json!({"attachment_id": st, "reason": "why"}),
);
// A line the reconciliation cleared stays cleared while it stands.
m.refuse(
"books.mark_cleared",
json!({"entry_id": pay, "line_index": 1, "cleared": false}),
);
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
}
/// books.export_cpa_package `include_receipts`: receipts/ holds every
/// current file behind each posted entry, filed on it or linked to it (a
/// linked file counts as the entry's receipt, so the receipt policy let
/// the entry post), one copy per entry with an index row; documents/ holds
/// the files behind no entry. A linked-only receipt used to be left out
/// of the package entirely.
#[test]
fn the_cpa_package_carries_receipts_linked_to_entries() {
use base64::Engine as _;
let mut m = books_at("cpa-linked", "0.00", "");
let receipt = m.receipt(None, b"%PDF linked receipt");
let e = m.draft(
"2026-03-01",
json!({}),
&[("6999", "debit", "40.00"), ("1000", "credit", "40.00")],
);
m.link(receipt, json!({"kind": "entry", "id": e}));
// A contract linked to the same entry is evidence for it too.
m.doc(
"contract",
b"%PDF contract",
json!([{"kind": "entry", "id": e}]),
);
m.ok(
"books.post_entry",
json!({"entry_id": e, "confirmed": true}),
);
let pkg = m.ok(
"books.export_cpa_package",
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": false}),
);
let bytes = base64::engine::general_purpose::STANDARD
.decode(pkg["content_base64"].as_str().unwrap())
.unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let names: Vec<String> = (0..zip.len())
.map(|i| zip.by_index(i).unwrap().name().unwrap().to_string())
.collect();
let mut index = String::new();
std::io::Read::read_to_string(&mut zip.by_name("receipts/index.csv").unwrap(), &mut index)
.unwrap();
let rows: Vec<&str> = index.lines().skip(1).collect();
let for_e: Vec<&&str> = rows
.iter()
.filter(|r| r.starts_with(&format!("{e},")))
.collect();
assert_eq!(for_e.len(), 2, "the linked receipt and contract: {index}");
for r in for_e {
let file = r.split(',').nth(4).unwrap();
assert!(names.iter().any(|n| n == file), "{file} in {names:?}");
}
assert!(
!names.iter().any(|n| n.starts_with("documents/contract")),
"the contract is behind an entry, filed with it: {names:?}"
);
}
// ------------------------------------------------- import sources
#[test]
@@ -1243,9 +1392,12 @@ fn unreconciled(m: &mut Mcp) -> Vec<String> {
.collect()
}
/// Cash accounts (`[books] cash_accounts`) and card accounts with
/// statements are listed until reconciled through the end of last month;
/// accounts without activity by then aren't.
/// Cash accounts (`[books] cash_accounts`), card accounts (liabilities
/// named "card", before their first statement, so the first
/// reconciliation is asked for), and any other liability once it has a
/// statement are listed until reconciled through the end of last month;
/// accounts without activity by then aren't (ledger_store::reconcilable,
/// the viewer's rule too).
#[test]
fn the_digest_lists_accounts_not_reconciled_through_last_month() {
let mut m = books("digest");
@@ -1263,10 +1415,15 @@ fn the_digest_lists_accounts_not_reconciled_through_last_month() {
json!({}),
&[("6999", "debit", "40.00"), ("2050", "credit", "40.00")],
);
let owed = m.post(
&day,
json!({}),
&[("6999", "debit", "15.00"), ("2100", "credit", "15.00")],
);
assert_eq!(
unreconciled(&mut m),
["1000"],
"the card has no statement yet"
["1000", "2050"],
"the card is due before its first statement; 2100 isn't a card"
);
let d = m.read_only("books.digest", json!({"since": "2020-01-01"}));
let row = &d["unreconciled_accounts"][0];
@@ -1278,15 +1435,21 @@ fn the_digest_lists_accounts_not_reconciled_through_last_month() {
.unwrap()
.contains("books.record_statement")
);
// The card's statement makes it a card account to reconcile.
let card_st = m.statement("2050", &start, &day, "40.00");
assert_eq!(unreconciled(&mut m), ["1000", "2050"]);
m.clear(card, 1);
m.ok(
"books.reconcile",
json!({"statement_attachment": card_st, "confirmed": true}),
);
assert_eq!(unreconciled(&mut m), ["1000"]);
// A liability not named for a card joins with its first statement.
let owed_st = m.statement("2100", &start, &day, "15.00");
assert_eq!(unreconciled(&mut m), ["1000", "2100"]);
m.clear(owed, 1);
m.ok(
"books.reconcile",
json!({"statement_attachment": owed_st, "confirmed": true}),
);
m.clear(dep, 0);
let st = m.statement("1000", &start, &day, "100.00");
m.ok(
+5
View File
@@ -997,6 +997,11 @@ fn happy_path(name: &str) -> Mcp {
"books.void_reconciliation",
json!({"reconciliation_id": rec, "reason": "redo after the March import"}),
);
// Voided, its statement can be voided too, to record it again.
m.ok(
"books.void_statement",
json!({"attachment_id": stmt, "reason": "closing balance mistyped"}),
);
// Periods.
let p = m.ok(
+154 -1
View File
@@ -1383,7 +1383,8 @@ fn a_joint_reimbursement_names_both_members_and_splits_by_share() {
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
assert_eq!(m.count("entry.parties_set"), 1);
// Named derived on the draft, then fixed at the amounts it posted at.
assert_eq!(m.count("entry.parties_set"), 2);
for who in ["Tobin Harlow", "Morgan Ellis"] {
let t = m.party(who, 2026)["totals"].clone();
assert_eq!(t["paid"], "50.00", "{who}'s half");
@@ -1563,3 +1564,155 @@ fn under_require_several_parties_satisfy_the_policy() {
);
m.read_only("books.verify", json!({}));
}
/// docs/parties-and-attachments.md "Counterparties" (require): the
/// engine's own posting paths name their counterparty or are refused
/// before anything posts. books.run_payroll names the employee party on
/// the entry (so their books.party counts the net pay); a line by name
/// only is refused. books.remit_payroll_taxes needs `agency`;
/// books.dispose_asset needs `buyer` for a sale, and marks a retirement
/// with no proceeds as having none.
#[test]
fn under_require_engine_postings_name_their_counterparty() {
let mut m = books_with("require-engine", "parties = \"require\"\n");
m.accounts(&[
("1500", "Equipment", "asset"),
("1510", "Accumulated Depreciation", "asset"),
("6500", "Depreciation", "expense"),
("7100", "Gain or Loss on Disposal", "revenue"),
]);
let pat = m.add_party("Pat Doe", &["employee"]);
let irs = m.add_party("IRS", &["tax_agency"]);
let buyer = m.add_party("Buyer Co", &[]);
let mut by_name = pat_line("Pat Doe");
by_name.as_object_mut().unwrap().remove("employee");
by_name["employee_name"] = json!("Pat Doe");
let e = m.refuse(
"books.run_payroll",
json!({"date": "2026-02-15", "cash_account": "1000", "lines": [by_name]}),
);
assert!(e.contains("party_required"), "{e}");
let run = m.ok(
"books.run_payroll",
json!({"date": "2026-02-15", "cash_account": "1000", "lines": [pat_line("Pat Doe")]}),
);
let entry = m.read_only("books.get_entry", json!({"entry_id": run["entry_id"]}));
assert_eq!(entry["parties"][0]["party_id"], json!(pat), "{entry}");
assert_eq!(m.party("Pat Doe", 2026)["totals"]["paid"], "773.50");
let e = m.refuse(
"books.remit_payroll_taxes",
json!({"date": "2026-03-01", "cash_account": "1000"}),
);
assert!(e.contains("party_required"), "{e}");
let r = m.ok(
"books.remit_payroll_taxes",
json!({"date": "2026-03-01", "cash_account": "1000", "agency": "IRS"}),
);
let entry = m.read_only("books.get_entry", json!({"entry_id": r["entry_id"]}));
assert_eq!(entry["parties"][0]["party_id"], json!(irs), "{entry}");
// Assets: one sold, one retired.
let reg = |m: &mut Mcp, tag: &str| {
m.ok(
"books.register_asset",
json!({"description": "Laptop", "purchase_date": "2026-01-10", "cost": "1200.00",
"salvage_value": "0.00", "useful_life_months": 36, "cost_account": "1500",
"accum_depr_account": "1510", "expense_account": "6500", "tag": tag,
"tax_basis_rule": "cost"}),
)["asset_id"]
.as_u64()
.unwrap()
};
let (sold, retired) = (reg(&mut m, "LAP-1"), reg(&mut m, "LAP-2"));
let sale = json!({"asset_id": sold, "date": "2026-06-30", "proceeds": "500.00",
"proceeds_account": "1000", "gain_loss_account": "7100",
"confirmed": true});
let before = m.journal();
let e = m.refuse("books.dispose_asset", sale.clone());
assert!(e.contains("party_required"), "{e}");
assert!(m.journal() == before, "refused, nothing journaled");
let mut with_buyer = sale;
with_buyer["buyer"] = json!("Buyer Co");
let d = m.ok("books.dispose_asset", with_buyer);
let entry = m.read_only("books.get_entry", json!({"entry_id": d["entry_id"]}));
assert_eq!(entry["parties"][0]["party_id"], json!(buyer), "{entry}");
let d = m.ok(
"books.dispose_asset",
json!({"asset_id": retired, "date": "2026-06-30", "proceeds": "0.00",
"proceeds_account": "1000", "gain_loss_account": "7100", "confirmed": true}),
);
let entry = m.read_only("books.get_entry", json!({"entry_id": d["entry_id"]}));
assert!(entry["no_party_reason"].is_string(), "{entry}");
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
}
/// fix_derived_shares INVARIANT 1 (docs/parties-and-attachments.md
/// "Shares": posted is forever, the split included): a joint
/// reimbursement posted with derived shares at 50/50 keeps 50.00 each when
/// the members' shares later become 60/40, and when a member is archived;
/// a draft still follows the settings until it posts.
#[test]
fn a_posted_derived_split_survives_settings_changes_and_archiving() {
let mut m = member_books("shares-fixed", "");
let posted = m.post(
"2026-03-01",
both(),
&[("2100", "debit", "100.00"), ("1000", "credit", "100.00")],
);
let e = m.read_only("books.get_entry", json!({"entry_id": posted}));
assert_eq!(
resolved(&e),
[
("Tobin Harlow".to_string(), "50.00".to_string()),
("Morgan Ellis".to_string(), "50.00".to_string())
]
);
assert_eq!(e["parties"][0]["share"], "amount", "fixed when posted: {e}");
assert_eq!(e["parties"][0]["amount"], "50.00", "{e}");
let draft = m.draft(
"2026-03-02",
both(),
&[("2100", "debit", "100.00"), ("1000", "credit", "100.00")],
);
// The members' split changes to 60/40.
let toml = m.data.parent().unwrap().join("wanderbooks.toml");
let text = std::fs::read_to_string(&toml).unwrap();
let changed = text
.replacen("share = \"50%\"", "share = \"60%\"", 1)
.replacen("share = \"50%\"", "share = \"40%\"", 1);
std::fs::write(&toml, changed).unwrap();
// The server reloads it on change: wait until the draft reads 60/40.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let d = loop {
let d = m.ok("books.post_entry", json!({"entry_id": draft}));
if resolved(&d)[0].1 == "60.00" || std::time::Instant::now() > deadline {
break d;
}
std::thread::sleep(std::time::Duration::from_millis(100));
};
assert_eq!(
resolved(&d),
[
("Tobin Harlow".to_string(), "60.00".to_string()),
("Morgan Ellis".to_string(), "40.00".to_string())
],
"a draft follows the settings"
);
let e = m.read_only("books.get_entry", json!({"entry_id": posted}));
assert_eq!(
resolved(&e),
[
("Tobin Harlow".to_string(), "50.00".to_string()),
("Morgan Ellis".to_string(), "50.00".to_string())
],
"posted history keeps its split"
);
// Morgan archived: her history stays, and Tobin's.
let mo = e["parties"][1]["party_id"].as_u64().unwrap();
m.ok(
"books.update_party",
json!({"party": mo.to_string(), "archived": true}),
);
assert_eq!(m.party("Tobin Harlow", 2026)["totals"]["paid"], "50.00");
assert_eq!(m.party(&mo.to_string(), 2026)["totals"]["paid"], "50.00");
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
}