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

+117
View File
@@ -1252,6 +1252,40 @@ fn entry_flow_scenarios() {
// Split payment: part bank, part card.
let f = case(&[(exp, D, "100.00"), (bank, C, "60.00"), (card, C, "40.00")]);
assert_eq!(f.net, dec("100.00"));
// 7. A member pays the card bill from their own funds, or a loan pays
// it off: the issuer is paid, for the card, as from the bank.
for funder in [members, loan] {
let f = case(&[(card, D, "300.00"), (funder, C, "300.00")]);
assert_eq!(f.net, dec("300.00"));
assert_eq!(f.what_for, vec![(card, dec("300.00"))]);
}
// Part bank, part a member.
let f = case(&[
(card, D, "300.00"),
(bank, C, "200.00"),
(members, C, "100.00"),
]);
assert_eq!(f.net, dec("300.00"));
assert_eq!(f.what_for, vec![(card, dec("300.00"))]);
// 4. A charge and its refund on one card offset there: no card paid
// down, 80.00 paid by card.
let f = case(&[
(exp, D, "100.00"),
(card, C, "100.00"),
(card, D, "20.00"),
(exp, C, "20.00"),
]);
assert_eq!(f.net, dec("80.00"));
assert_eq!(f.what_for, vec![(exp, dec("80.00"))]);
// Paying the card from the bank while the issuer charges a fee on it:
// 300.00 cash plus the 50.00 fee by card.
let f = case(&[
(card, D, "300.00"),
(bank, C, "300.00"),
(exp, D, "50.00"),
(card, C, "50.00"),
]);
assert_eq!(f.net, dec("350.00"));
}
// ------------------------------------------------------- party shares
@@ -1698,6 +1732,62 @@ mod shares {
});
}
/// set_entry_no_party INVARIANT 1 and SCENARIO 3: a marked transfer's
/// reversal carries the mark; marking a reversed entry marks the
/// whole chain; naming a party on any of them clears it on all.
#[test]
fn a_no_party_mark_follows_the_reversal_chain() {
let mut b = books();
let own = |b: &mut Books| {
let (due, bank) = (b.due, b.bank);
post(
&mut b.l,
&[(due, Side::Debit, "10.00"), (bank, Side::Credit, "10.00")],
)
};
let e = own(&mut b);
b.l.set_entry_no_party(e, "transfer between our own accounts")
.unwrap();
let r = b.l.reverse_entry(e, date(2026, 5, 2), "undo").unwrap();
assert_eq!(
b.l.entry_no_party(r),
Some("transfer between our own accounts")
);
// Marked after it was reversed: the whole chain.
let e2 = own(&mut b);
let r2 = b.l.reverse_entry(e2, date(2026, 5, 2), "undo").unwrap();
b.l.set_entry_no_party(r2, "accrual").unwrap();
assert_eq!(b.l.entry_no_party(e2), Some("accrual"));
assert_eq!(b.l.entry_no_party(r2), Some("accrual"));
// A party named on the original clears both.
b.l.set_entry_party(e2, Some(b.acme)).unwrap();
assert_eq!(b.l.entry_no_party(e2), None);
assert_eq!(b.l.entry_no_party(r2), None);
refused(&mut b.l, "chain names a party", |l| {
l.set_entry_no_party(r2, "none")
});
}
/// set_entry_vendor SCENARIO 3: tagging a vendor names its party and
/// clears a no-party mark, so the entry never carries both; clearing
/// the vendor afterwards leaves no stale mark to satisfy "require".
#[test]
fn tagging_a_vendor_clears_a_no_party_mark() {
let mut b = books();
let e = b.l.try_create_entry(date(2026, 5, 1), "hosting").unwrap();
b.l.add_line(e, b.exp, Side::Debit, usd("12.00")).unwrap();
b.l.add_line(e, b.bank, Side::Credit, usd("12.00")).unwrap();
b.l.set_entry_no_party(e, "none really").unwrap();
let v = b.l.add_vendor("Namecheap").unwrap();
let pv = b.l.party_of_vendor(v).unwrap();
b.l.set_entry_vendor(e, Some(v)).unwrap();
assert_eq!(b.l.entry_parties(e), &[PartyShare::whole(pv)]);
assert_eq!(b.l.entry_no_party(e), None);
b.l.set_entry_vendor(e, None).unwrap();
assert!(b.l.entry_parties(e).is_empty());
assert_eq!(b.l.entry_no_party(e), None);
}
/// Reversals carry every share; the pair nets to zero for each party.
#[test]
fn reversal_carries_all_shares() {
@@ -1739,6 +1829,33 @@ mod shares {
assert!(a.entries.contains(&e));
}
/// activity_with INVARIANT 2: a reversal of a reversal reinstates the
/// payment on its root original's side: paid, never a negative
/// received.
#[test]
fn reversing_a_reversal_reinstates_the_payment_as_paid() {
let mut b = books();
let a = post(
&mut b.l,
&[
(b.exp, Side::Debit, "100.00"),
(b.bank, Side::Credit, "100.00"),
],
);
b.l.set_entry_party(a, Some(b.acme)).unwrap();
let r1 = b.l.reverse_entry(a, date(2026, 5, 2), "undo").unwrap();
let r2 = b.l.reverse_entry(r1, date(2026, 5, 3), "redo").unwrap();
let act = ledger_core::parties::activity(&b.l, b.acme, year(), &[b.bank]);
assert_eq!(act.entries, std::collections::BTreeSet::from([a, r1, r2]));
assert_eq!((act.paid, act.received), (d("100.00"), Decimal::ZERO));
// A third level takes it back out again, still on the paid side.
let _r3 =
b.l.reverse_entry(r2, date(2026, 5, 4), "undo again")
.unwrap();
let act = ledger_core::parties::activity(&b.l, b.acme, year(), &[b.bank]);
assert_eq!((act.paid, act.received), (Decimal::ZERO, Decimal::ZERO));
}
fn cents() -> impl Strategy<Value = Decimal> {
(1i64..10_000_000).prop_map(|c| Decimal::new(c, 2))
}