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

+104 -59
View File
@@ -17,18 +17,19 @@
//! 2. contributions_cash + contributions_property = contributions,
//! exactly, per member.
//! 3. Property is what an entry crediting a member's capital account
//! debits to asset accounts that are neither cash (`cash`),
//! receivables, nor prepaid: equipment, a vehicle, software a member
//! hands the company. The rest of a contribution is cash: deposits,
//! and what a member paid for the company (an expense, a debt they
//! took on, e.g. Dr 2100 / Cr capital, or a service paid in advance).
//! A prepaid account is the balance account of a prepaid-expense
//! schedule, or an asset account whose name says "prepaid" (1300
//! Prepaid Expenses before its schedule exists): a member paying the
//! company's service in advance paid cash on its behalf, and it reads
//! the same booked to capital directly or through 2100. Item L
//! property is property the member owned, with its own basis
//! (§723), never cash they spent for the company.
//! debits to asset accounts that are neither cash (`cash`) nor
//! receivables: equipment, a vehicle, software, or the unexpired part
//! of a service contract (AppleCare+, a prepaid subscription) that a
//! member owned and hands the company, each with its own basis
//! (§723). The rest of a contribution is cash: deposits, and what a
//! member paid for the company (an expense booked straight to
//! capital, or a debt they took on, e.g. Dr 2100 / Cr capital). A
//! member who pays a vendor for the company's prepaid service is
//! booked through Due to Shareholders (Dr prepaid / Cr 2100), then
//! converted (Dr 2100 / Cr capital): cash. A prepaid credited
//! straight to capital is a contract they handed over: property.
//! (DECISION, owner, 2026-10-15, after the AppleCare+ contributed
//! with the MacBook read as cash.)
//! 4. Within one entry, the property (at most the members' credits in
//! it) is shared among the members credited in proportion to their
//! credits, to the cent, leftover cents by largest remainder
@@ -52,12 +53,16 @@
//! 1. A joint 1,000.00 contribution (Dr 1000 / Cr 3000 500, Cr 3001
//! 500) reversed the next day: each member 0.00 contributed, 0.00
//! distributed.
//! 2. Members prepay an annual service from joint funds straight to
//! capital (Dr 1300 Prepaid 3,000 / Cr 3000 1,500, Cr 3001 1,500):
//! 1,500.00 cash each, no property.
//! 2. Members pay a vendor ahead for the company's annual service from
//! joint funds, through Due to Shareholders (Dr 1300 Prepaid 3,000 /
//! Cr 2100 3,000), later converted to capital (Dr 2100 3,000 / Cr
//! 3000 1,500, Cr 3001 1,500): 1,500.00 cash each, no property.
//! 3. A joint mixed contribution (Dr 1000 600, Dr 1500 400 / Cr 3000
//! 500, Cr 3001 500): each member 300.00 cash and 200.00 property,
//! whichever member is listed first.
//! 4. Members hand the company a service contract they already owned,
//! straight to capital (Dr 1300 Prepaid 3,000 / Cr 3000 1,500, Cr
//! 3001 1,500): 1,500.00 property each, no cash.
//! ```
use crate::reports::{MemberCapital, members_equity};
@@ -165,18 +170,13 @@ pub fn k1_detail(
let receivables = ledger.role_account(&crate::RECEIVABLES);
let closings = ledger.closing_entry_ids();
let cancelled = crate::reports::cancelled_in_span(ledger, start, end);
let prepaid: Vec<AccountId> = ledger
.schedules()
.filter(|s| s.kind == crate::schedules::ScheduleKind::PrepaidExpense)
.map(|s| s.balance_account)
.collect();
let property_account = |id: AccountId| {
!cash.contains(&id)
&& Some(id) != receivables
&& !prepaid.contains(&id)
&& ledger.chart().get(id).is_some_and(|a| {
a.account_type == AccountType::Asset && !a.name.to_lowercase().contains("prepaid")
})
&& ledger
.chart()
.get(id)
.is_some_and(|a| a.account_type == AccountType::Asset)
};
let sum = |e: &crate::JournalEntry, side: Side, account: AccountId| -> Decimal {
e.lines()
@@ -701,13 +701,13 @@ mod tests {
assert_eq!(split(&bk, &ms), vec![back, back]);
}
/// INVARIANT 3 and SCENARIO 2, by hand: members prepay a 3,000 annual
/// service straight to capital (Dr 1300 Prepaid / Cr 3000 1,500, Cr
/// 3001 1,500): 1,500 cash each, no property, as when routed through
/// 2100. A prepaid-expense schedule's account is prepaid whatever its
/// name.
/// INVARIANT 3, by hand: a contract the members hand over (Dr 1300
/// Prepaid 25 / Cr 3000 12.50, Cr 3001 12.50, the AppleCare+ with the
/// MacBook) is property, 12.50 each. Members paying a 3,000 annual
/// service for the company go through 2100: Dr 1300 / Cr 2100 3,000,
/// then Dr 2100 / Cr 3000 1,500, Cr 3001 1,500: 1,500 cash each.
#[test]
fn a_prepaid_service_paid_by_members_is_cash() {
fn a_contract_handed_over_is_property_a_service_paid_for_is_cash() {
let mut bk = books();
let (prepaid, a, b) = (bk.prepaid, bk.a, bk.b);
post(
@@ -715,45 +715,90 @@ mod tests {
1,
5,
&[
(prepaid, Side::Debit, "3000"),
(prepaid, Side::Debit, "25"),
(a, Side::Credit, "12.50"),
(b, Side::Credit, "12.50"),
],
);
let ms = members(&bk);
let handed = (d("0"), d("12.50"), d("0"));
assert_eq!(split(&bk, &ms), vec![handed, handed]);
let due =
bk.l.add_account(
"2100",
"Due to Members",
AccountType::Liability,
None,
false,
)
.unwrap();
post(
&mut bk.l,
2,
1,
&[(prepaid, Side::Debit, "3000"), (due, Side::Credit, "3000")],
);
post(
&mut bk.l,
2,
2,
&[
(due, Side::Debit, "3000"),
(a, Side::Credit, "1500"),
(b, Side::Credit, "1500"),
],
);
let each = (d("1500"), d("12.50"), d("0"));
assert_eq!(split(&bk, &ms), vec![each, each]);
}
/// SCENARIOS 2 and 4 exactly as the module doc states them: 3,000 of
/// prepaid service through 2100 is 1,500.00 cash each; the same 3,000
/// straight to capital is 1,500.00 property each.
#[test]
fn scenarios_2_and_4_prepaid_through_2100_is_cash_straight_to_capital_is_property() {
let mut bk = books();
let (prepaid, a, b) = (bk.prepaid, bk.a, bk.b);
let due =
bk.l.add_account(
"2100",
"Due to Shareholders",
AccountType::Liability,
None,
false,
)
.unwrap();
post(
&mut bk.l,
1,
5,
&[(prepaid, Side::Debit, "3000"), (due, Side::Credit, "3000")],
);
post(
&mut bk.l,
1,
6,
&[
(due, Side::Debit, "3000"),
(a, Side::Credit, "1500"),
(b, Side::Credit, "1500"),
],
);
let ms = members(&bk);
let each = (d("1500"), d("0"), d("0"));
assert_eq!(split(&bk, &ms), vec![each, each]);
// An account named otherwise, backing a prepaid schedule.
let ins =
bk.l.add_account(
"1310",
"Insurance paid ahead",
AccountType::Asset,
None,
false,
)
.unwrap();
let exp =
bk.l.add_account("6300", "Insurance", AccountType::Expense, None, false)
.unwrap();
let cash = (d("1500"), d("0"), d("0"));
assert_eq!(split(&bk, &ms), vec![cash, cash]);
post(
&mut bk.l,
2,
1,
&[(ins, Side::Debit, "1200"), (a, Side::Credit, "1200")],
&[
(prepaid, Side::Debit, "3000"),
(a, Side::Credit, "1500"),
(b, Side::Credit, "1500"),
],
);
assert_eq!(split(&bk, &ms)[0].1, d("1200"), "no schedule yet: property");
bk.l.register_schedule(crate::schedules::NewSchedule {
description: "2026 insurance".into(),
kind: crate::schedules::ScheduleKind::PrepaidExpense,
total: Money::new(d("1200"), Currency::USD),
start: NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
months: 12,
balance_account: ins,
pl_account: exp,
})
.unwrap();
assert_eq!(split(&bk, &ms)[0], (d("2700"), d("0"), d("0")));
let both = (d("1500"), d("1500"), d("0"));
assert_eq!(split(&bk, &ms), vec![both, both]);
}
/// INVARIANT 4 and SCENARIO 3, by hand: Dr 1000 600, Dr 1500 400 /
+87 -8
View File
@@ -1386,6 +1386,11 @@ impl Ledger {
if let Some(p) = self.entry_parties.get(&entry).cloned() {
self.entry_parties.insert(id, p);
}
// An entry with no counterparty is reversed with none either
// (set_entry_no_party INVARIANT 1).
if let Some(why) = self.entry_no_party.get(&entry).cloned() {
self.entry_no_party.insert(id, why);
}
Ok(id)
}
@@ -2461,8 +2466,10 @@ impl Ledger {
/// 2. Tagging MUST change no amount (it's metadata, allowed on posted
/// entries).
/// 3. On an entry naming no party or one: Some(v) makes v's party the
/// entry's one party (all of it); None clears the party too when
/// it was the vendor's.
/// entry's one party (all of it) and clears a no-party mark
/// (set_entry_parties INVARIANT 3: an entry never names a party
/// and carries the mark at once); None clears the party too when
/// it was the vendor's, and leaves no mark.
/// 4. On an entry naming several parties, Some(v) with v's party
/// among them sets the tag and keeps every party and share as
/// they are. IF v's party is not among them (or v has none):
@@ -2542,6 +2549,9 @@ impl Ledger {
self.entry_parties
.insert(entry, vec![crate::PartyShare::whole(p)]);
}
if self.entry_parties.contains_key(&entry) {
self.entry_no_party.remove(&entry);
}
}
None => {
// The party tag goes too only when it was the vendor's.
@@ -3175,14 +3185,25 @@ impl Ledger {
/// ```text
/// SOURCE: docs/parties-and-attachments.md "Counterparties"; DECISION
/// (owner, 2026-10-15).
/// INVARIANTS:
/// 1. An entry and its reversal (and theirs: Self::reversal_chain)
/// carry the same mark, as they name the same parties
/// (set_entry_parties INVARIANT 5): marking any one marks the
/// chain, and reverse_entry copies the mark to the reversal.
/// SCENARIOS:
/// 1. IF the entry is unknown, already names a party, or the reason
/// is blank or fails the party text rules: SHOULD refuse.
/// 2. Tagging the entry with a party later clears the mark.
/// 2. Tagging the entry with a party later clears the mark
/// (set_entry_parties, set_entry_party, set_entry_vendor), on the
/// whole chain.
/// 3. A transfer between the company's own accounts marked, then
/// reversed: the reversal is marked with the same reason.
/// ```
pub fn set_entry_no_party(&mut self, entry: EntryId, reason: &str) -> Result<(), LedgerError> {
self.check_set_entry_no_party(entry, reason)?;
self.entry_no_party.insert(entry, reason.trim().to_string());
for e in self.reversal_chain(entry) {
self.entry_no_party.insert(e, reason.trim().to_string());
}
Ok(())
}
@@ -3195,10 +3216,16 @@ impl Ledger {
if !self.entries.contains_key(&entry) {
return Err(LedgerError::UnknownEntry(entry));
}
if self.entry_parties.contains_key(&entry) {
return Err(LedgerError::InvalidParty(format!(
"entry {entry} names a party; clear it first"
)));
if let Some(e) = self
.reversal_chain(entry)
.into_iter()
.find(|e| self.entry_parties.contains_key(e))
{
return Err(LedgerError::InvalidParty(if e == entry {
format!("entry {entry} names a party; clear it first")
} else {
format!("entry {entry}'s reversal chain entry {e} names a party; clear it first")
}));
}
if reason.trim().is_empty() {
return Err(LedgerError::InvalidParty(
@@ -4102,9 +4129,18 @@ impl Ledger {
/// FICA, Cr 2110-2150 the withholdings and both shares of FICA, Cr cash
/// net pay.
/// 2. Net pay MUST equal gross minus the employee's withholdings.
/// 3. When every line with net pay names its employee party, the entry
/// names those parties (Self::entry_parties), each with its net pay
/// (summed over its lines): the whole of it for one employee, else
/// an exact amount each, so each employee's books.party counts what
/// they were paid. Lines named by employee_name only (or shares
/// Self::set_entry_parties would refuse) leave the entry naming no
/// party (the agent tags it).
/// SCENARIOS:
/// 1. IF any amount is negative, gross is zero, or withholdings exceed
/// gross: SHOULD refuse.
/// 2. Pat (net 1,500.00) and Sam (net 900.00), both parties: the entry
/// names Pat 1,500.00 and Sam 900.00.
/// ```
pub fn run_payroll(
&mut self,
@@ -4131,6 +4167,7 @@ impl Ledger {
self.add_line(entry, account, side, amount)?;
}
self.post_entry(entry)?;
self.name_payroll_parties(entry, &lines)?;
let id = self.next_payroll_run_id;
self.next_payroll_run_id += 1;
self.payroll_runs.insert(
@@ -4145,6 +4182,45 @@ impl Ledger {
Ok(id)
}
/// [`Self::run_payroll`] INVARIANT 3: name the employees a payroll
/// entry paid as its parties, each with their net pay. Live and on
/// replay ([`Self::replay_payroll_run`]) alike.
fn name_payroll_parties(
&mut self,
entry: EntryId,
lines: &[PayrollLine],
) -> Result<(), LedgerError> {
let mut paid: Vec<(crate::PartyId, Decimal)> = Vec::new();
let mut unnamed = false;
for l in lines.iter().filter(|l| !l.net_pay().is_zero()) {
match l.employee {
Some(p) => match paid.iter_mut().find(|(x, _)| *x == p) {
Some((_, v)) => *v += l.net_pay(),
None => paid.push((p, l.net_pay())),
},
None => unnamed = true,
}
}
if !unnamed && !paid.is_empty() {
let shares: Vec<crate::PartyShare> = match paid.as_slice() {
[(p, _)] => vec![crate::PartyShare::whole(*p)],
_ => paid
.iter()
.map(|&(party, v)| crate::PartyShare {
party,
share: crate::Share::Amount(v),
})
.collect(),
};
// Shares set_entry_parties would refuse (two employees with
// vendor records) leave the entry for the agent to tag.
if self.check_set_entry_parties(entry, &shares).is_ok() {
self.set_entry_parties(entry, &shares)?;
}
}
Ok(())
}
/// Current balances of the payroll tax payable accounts (`2110`–`2150`),
/// in code order. Accounts that don't exist yet count as zero and are
/// skipped, as are zero balances. Balances are normal-side: positive
@@ -4245,6 +4321,9 @@ impl Ledger {
return Err(LedgerError::ReplayIdMismatch(id, self.next_payroll_run_id));
}
self.next_payroll_run_id += 1;
if self.entries.contains_key(&entry_id) {
self.name_payroll_parties(entry_id, &lines)?;
}
self.payroll_runs.insert(
id,
PayrollRun {
+71 -17
View File
@@ -514,22 +514,38 @@ pub struct EntryFlow {
/// down = min(liab_dr, cash_cr): cash paying down a liability (its
/// creditor is paid; the liability is what for);
/// up = min(liab_cr, cash_dr): a liability raising cash (borrowing: the
/// lender pays the company).
/// lender pays the company);
/// left(a), for each money liability a: its debits less what of them
/// `down` takes, less its credits less what of them `up` takes (`down`
/// and `up` taken from its lines in line order), so one account's
/// debits and credits offset first;
/// across = min(Σ left(a) > 0, Σ −left(a) < 0): one liability paying
/// down another (a member paying the card from their own funds: Dr
/// 2050 / Cr 2100; a loan paying off a card): the debited liability's
/// creditor is paid, by the credited one's.
/// INVARIANTS:
/// 1. net = (cash_cr − cash_dr) + (liab_cr − liab_dr) + down − up.
/// 1. net = (cash_cr − cash_dr) + (liab_cr − liab_dr) + down − up
/// + across.
/// 2. what_for: every line not on a money account (debits − credits),
/// plus `down` on the debited liabilities and −`up` on the credited
/// ones (in line order), zero sums left out, accounts merged; its sum
/// is net exactly, because the entry balances.
/// ones (in line order), plus `across` on the liabilities with
/// left(a) > 0 (in order of first appearance), zero sums left out,
/// accounts merged; its sum is net exactly, because the entry
/// balances.
/// SCENARIOS:
/// 1. Expense from the bank, on a card, or paid by a member for the
/// company (Dr expense / Cr 1000, 2050, or 2100): net is the amount.
/// 2. Reimbursing members (Dr 2100 / Cr 1000) or paying a card (Dr 2050 /
/// Cr 1000): net is the amount, what for is the liability.
/// 3. A loan (Dr 1000 / Cr loan): net is minus the amount (received).
/// 4. A refund (Dr 1000 or Dr card / Cr expense): negative net.
/// 4. A refund (Dr 1000 or Dr card / Cr expense): negative net. A charge
/// and a refund on the same card in one entry net on that card: no
/// card paid down.
/// 5. A transfer between the company's own cash accounts: zero.
/// 6. A member contributing property (Dr equipment / Cr capital): zero.
/// 7. A member paying the card bill from their own funds (Dr 2050 / Cr
/// 2100), or a loan paying it off (Dr 2050 / Cr loan): net is the
/// amount, what for is the card, as if paid from the bank.
/// ```
pub fn entry_flow(entry: &crate::JournalEntry, money: &MoneyAccounts) -> EntryFlow {
use crate::Side;
@@ -552,40 +568,72 @@ pub fn entry_flow(entry: &crate::JournalEntry, money: &MoneyAccounts) -> EntryFl
}
let down = liab_dr.min(cash_cr);
let up = liab_cr.min(cash_dr);
let net = (cash_cr - cash_dr) + (liab_cr - liab_dr) + down - up;
let mut by: std::collections::BTreeMap<crate::AccountId, Decimal> = Default::default();
let (mut down_left, mut up_left) = (down, up);
let mut order: Vec<crate::AccountId> = Vec::new();
// left(a) per money liability, in order of first appearance.
let mut left: Vec<(crate::AccountId, Decimal)> = Vec::new();
let add = |by: &mut std::collections::BTreeMap<crate::AccountId, Decimal>,
order: &mut Vec<crate::AccountId>,
account: crate::AccountId,
part: Decimal| {
if !part.is_zero() {
if !by.contains_key(&account) {
order.push(account);
}
*by.entry(account).or_default() += part;
}
};
for l in entry.lines() {
let a = l.amount.amount;
let part = if money.cash.contains(&l.account) {
Decimal::ZERO
} else if money.liabilities.contains(&l.account) {
match l.side {
let (part, rest) = match l.side {
Side::Debit => {
let take = a.min(down_left);
down_left -= take;
take
(take, a - take)
}
Side::Credit => {
let take = a.min(up_left);
up_left -= take;
-take
(-take, -(a - take))
}
};
match left.iter_mut().find(|(x, _)| *x == l.account) {
Some((_, v)) => *v += rest,
None => left.push((l.account, rest)),
}
part
} else {
match l.side {
Side::Debit => a,
Side::Credit => -a,
}
};
if !part.is_zero() {
if !by.contains_key(&l.account) {
order.push(l.account);
}
*by.entry(l.account).or_default() += part;
add(&mut by, &mut order, l.account, part);
}
let owed_down: Decimal = left
.iter()
.map(|(_, v)| *v)
.filter(|v| *v > Decimal::ZERO)
.sum();
let owed_up: Decimal = -left
.iter()
.map(|(_, v)| *v)
.filter(|v| *v < Decimal::ZERO)
.sum::<Decimal>();
let across = owed_down.min(owed_up);
let mut across_left = across;
for (account, v) in left {
if v > Decimal::ZERO && !across_left.is_zero() {
let take = v.min(across_left);
across_left -= take;
add(&mut by, &mut order, account, take);
}
}
let net = (cash_cr - cash_dr) + (liab_cr - liab_dr) + down - up + across;
EntryFlow {
net,
what_for: order
@@ -625,7 +673,9 @@ pub fn activity(
/// zero counts the party's resolved share of it
/// (crate::shares::resolve with `members`; the whole net for a sole
/// party): positive is a payment, negative a receipt. A reversal
/// counts on its original's side, so it subtracts.
/// counts on its root original's side (the first entry of
/// Ledger::reversal_chain), so a reversal subtracts and a reversal
/// of that reversal adds back, on the same side.
/// 3. An entry moving no money (net zero, like property contributed)
/// counts nothing. An entry whose shares don't resolve counts
/// nothing and is listed in `unresolved`.
@@ -672,8 +722,12 @@ pub fn activity_with(
continue;
}
};
let side = match e.reversal_of().and_then(|o| ledger.get_entry(o)) {
Some(original) => net_out(original),
let side = match e.reversal_of() {
Some(_) => ledger
.reversal_chain(id)
.first()
.and_then(|&root| ledger.get_entry(root))
.map_or(net, net_out),
None => net,
};
if side > Decimal::ZERO {
+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))
}