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:
1 parent
e1b2509c22
commit
8498e6cfab
22 files changed
+1802
-343
No files matched your search
@@ -603,3 +603,49 @@ fn books_show_opens_a_reconciliation() {
|
||||
h.state_mut().show(ViewerTarget::Reconciliation { id: 999 });
|
||||
assert_eq!(h.state_mut().route(), &Route::Reconciliation(r));
|
||||
}
|
||||
|
||||
/// ledger_store::reconcilable INVARIANT 1, the digest's rule: a liability
|
||||
/// not named for a card (an Amex) joins the accounts to reconcile once it
|
||||
/// has a recorded statement, so Needs attention asks for it as the digest
|
||||
/// does (it used to be listed by the digest only).
|
||||
#[test]
|
||||
fn a_liability_with_a_statement_needs_reconciling_whatever_its_name() {
|
||||
let mut f = fixture("amex");
|
||||
let amex = f
|
||||
.store
|
||||
.add_account("2060", "Amex", AccountType::Liability, None, false)
|
||||
.unwrap();
|
||||
let software = f.store.ledger().chart().find_by_code("6300").unwrap().id;
|
||||
let e = f.store.create_entry(d(2026, 9, 3), "Amex charge").unwrap();
|
||||
f.store
|
||||
.add_line(e, software, Side::Debit, usd(1_000))
|
||||
.unwrap();
|
||||
f.store.add_line(e, amex, Side::Credit, usd(1_000)).unwrap();
|
||||
f.store.post_entry(e).unwrap();
|
||||
let b = load(&f.dir.0);
|
||||
assert!(!b.reconcilable_accounts().contains(&amex));
|
||||
let (att, _, _) = f
|
||||
.store
|
||||
.file_document(
|
||||
"statement",
|
||||
"amex-aug.pdf",
|
||||
"application/pdf",
|
||||
b"amex aug",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
f.store
|
||||
.record_statement(Statement {
|
||||
attachment_id: att,
|
||||
account_id: amex,
|
||||
period_start: d(2026, 8, 1),
|
||||
period_end: d(2026, 8, 31),
|
||||
opening_balance: Decimal::ZERO,
|
||||
closing_balance: Decimal::ZERO,
|
||||
})
|
||||
.unwrap();
|
||||
let b = load(&f.dir.0);
|
||||
assert!(b.reconcilable_accounts().contains(&amex));
|
||||
assert_eq!(b.reconciliation_due(amex), Some(d(2026, 9, 30)));
|
||||
assert!(reconcile_items(&b).contains(&"Reconcile Amex through Sep 30, 2026".to_string()));
|
||||
}
|
||||
@@ -362,11 +362,14 @@ fn each_partys_totals_are_the_books_numbers() {
|
||||
assert!(out > Decimal::ZERO);
|
||||
assert_eq!(info(f.irs).paid_ytd, out);
|
||||
|
||||
// Pat: both runs (one by party, one by name), no tagged entries.
|
||||
// Pat: both runs (one by party, one by name). The run by party names
|
||||
// Pat on its entry (Ledger::run_payroll INVARIANT 3): its net pay,
|
||||
// 1,000.00 − 100.00 − 30.00 − 62.00 − 14.50 = 793.50, paid to Pat;
|
||||
// the run by name only names no one.
|
||||
let pat = info(f.pat);
|
||||
assert_eq!(pat.payroll_runs.len(), 2);
|
||||
assert!(pat.entries.is_empty());
|
||||
assert_eq!(pat.paid_ytd, Decimal::ZERO);
|
||||
assert_eq!(pat.entries.len(), 1);
|
||||
assert_eq!(pat.paid_ytd, cents(79_350));
|
||||
|
||||
// Every entry's party is the one whose list holds it.
|
||||
for e in &b.entries {
|
||||
@@ -1437,7 +1440,7 @@ fn owed_to_members_reads_the_financing_accounts_first() {
|
||||
|
||||
/// Without [books] cash_accounts the capital statement reads money as
|
||||
/// core does (every asset account but receivables: Books::
|
||||
/// k1_cash_accounts), so the laptop on 1500 reads as cash: Tobin's 2026
|
||||
/// money_cash_accounts), so the laptop on 1500 reads as cash: Tobin's 2026
|
||||
/// contributions are $600.00 cash, none property. The Cash page keeps its
|
||||
/// name-based guess ("Bluevine" names no cash word: no cash accounts).
|
||||
#[test]
|
||||
@@ -1446,7 +1449,7 @@ fn without_cash_accounts_the_capital_statement_reads_money_as_core_does() {
|
||||
write_member_toml(&m.dir.0, "");
|
||||
let b = load_with_toml(&m.dir.0);
|
||||
let id = |c: &str| b.ledger.chart().find_by_code(c).unwrap().id;
|
||||
assert_eq!(b.k1_cash_accounts, vec![id("1000"), id("1500")]);
|
||||
assert_eq!(b.money_cash_accounts, vec![id("1000"), id("1500")]);
|
||||
assert!(
|
||||
b.cash_accounts.is_empty(),
|
||||
"the Cash page's guess is unchanged"
|
||||
@@ -1471,3 +1474,76 @@ fn without_cash_accounts_the_capital_statement_reads_money_as_core_does() {
|
||||
(cents(160_000), Decimal::ZERO)
|
||||
);
|
||||
}
|
||||
|
||||
/// PartyTotalsOut's promise ("the viewer shows the same numbers"): with no
|
||||
/// `[books] cash_accounts`, party money reads cash as core does (every
|
||||
/// asset account but receivables, as the server passes it), and the year
|
||||
/// is the calendar year, so a posted entry dated after today counts.
|
||||
/// Equipment bought from Checking (an asset for an asset: no money moved,
|
||||
/// under core's every-asset fallback) and software dated next month, both
|
||||
/// with Apple: 99.00 paid, in the viewer and the server alike (the viewer
|
||||
/// used to say 2,000.00: its name-based cash guess, cut at today).
|
||||
#[test]
|
||||
fn party_money_is_the_servers_without_cash_accounts_and_after_today() {
|
||||
use ledger_gui::books::PartyPeriod;
|
||||
let dir = TempDir::new("server-numbers");
|
||||
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
|
||||
let checking = s
|
||||
.add_account("1000", "Checking", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let equipment = s
|
||||
.add_account("1500", "Equipment", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let capital = s
|
||||
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let software = s
|
||||
.add_account("6200", "Software", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let e = s.create_entry(d(2026, 1, 1), "Opening").unwrap();
|
||||
s.add_line(e, checking, Side::Debit, usd(1_000_000))
|
||||
.unwrap();
|
||||
s.add_line(e, capital, Side::Credit, usd(1_000_000))
|
||||
.unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
let apple_v = s.add_vendor("Apple").unwrap();
|
||||
let apple = s.ledger().party_of_vendor(apple_v).unwrap();
|
||||
for (date, account, c) in [
|
||||
(d(2026, 5, 1), equipment, 200_000),
|
||||
(d(2026, 11, 20), software, 9_900),
|
||||
] {
|
||||
let e = s.create_entry(date, "Apple").unwrap();
|
||||
s.add_line(e, account, Side::Debit, usd(c)).unwrap();
|
||||
s.add_line(e, checking, Side::Credit, usd(c)).unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
s.set_entry_party(e, Some(apple)).unwrap();
|
||||
}
|
||||
let oracle = s.ledger().clone();
|
||||
drop(s);
|
||||
let b = load(&dir.0);
|
||||
// What books.party computes: activity_with over the calendar year
|
||||
// with the (empty) cash_accounts setting.
|
||||
let server = ledger_core::parties::activity_with(
|
||||
&oracle,
|
||||
apple,
|
||||
(d(2026, 1, 1), d(2026, 12, 31)),
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
assert_eq!(
|
||||
(server.paid, server.received),
|
||||
(cents(9_900), Decimal::ZERO)
|
||||
);
|
||||
let span = b.party_span(PartyPeriod::ThisYear);
|
||||
assert_eq!(span, (d(2026, 1, 1), d(2026, 11, 20)));
|
||||
let flow = b.party_flow(apple, span, true);
|
||||
assert_eq!((flow.paid, flow.received), (server.paid, server.received));
|
||||
assert_eq!(b.party(apple).unwrap().paid_ytd, cents(9_900));
|
||||
let months = b.party_months(apple, span);
|
||||
assert_eq!(months.len(), 11);
|
||||
assert_eq!(months.iter().map(|m| m.paid).sum::<Decimal>(), cents(9_900));
|
||||
assert_eq!(
|
||||
b.party_span(PartyPeriod::AllTime),
|
||||
(d(2026, 1, 1), d(2026, 11, 20))
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user