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

+16 -8
View File
@@ -81,18 +81,26 @@ impl Default for Company {
impl Company { impl Company {
/// The members as party shares see them ([`ledger_core::PartyMember`]): /// The members as party shares see them ([`ledger_core::PartyMember`]):
/// each member whose name (ignoring case and surrounding space) is a /// each member whose name (ignoring case and surrounding space) is a
/// party with the member role not archived, and whose capital account /// party with the member role, and whose capital account is in the
/// is in the chart. Members missing either are left out, so their /// chart. The party not archived of that name when there is one, else
/// shares can't be derived (and the viewer shows no capital /// the most recent archived one: archiving a member ("no longer dealt
/// statement for them). /// with; history stays") never takes their history away. Members
/// missing either are left out, so their shares can't be derived (and
/// the viewer shows no capital statement for them).
pub fn party_members(&self, ledger: &ledger_core::Ledger) -> Vec<ledger_core::PartyMember> { pub fn party_members(&self, ledger: &ledger_core::Ledger) -> Vec<ledger_core::PartyMember> {
let is_member = |p: &&ledger_core::Party| p.roles.contains(&ledger_core::Role::Member);
self.members self.members
.iter() .iter()
.filter_map(|m| { .filter_map(|m| {
let party = ledger.party_by_name(&m.name)?; let key = ledger_core::parties::name_key(&m.name);
if !party.roles.contains(&ledger_core::Role::Member) { let party = match ledger.party_by_name(&m.name) {
return None; Some(p) => Some(p).filter(is_member)?,
} None => ledger
.parties()
.filter(|p| p.archived && ledger_core::parties::name_key(&p.name) == key)
.filter(is_member)
.max_by_key(|p| p.id)?,
};
Some(ledger_core::PartyMember { Some(ledger_core::PartyMember {
party: party.id, party: party.id,
share: m.share, share: m.share,
+104 -59
View File
@@ -17,18 +17,19 @@
//! 2. contributions_cash + contributions_property = contributions, //! 2. contributions_cash + contributions_property = contributions,
//! exactly, per member. //! exactly, per member.
//! 3. Property is what an entry crediting a member's capital account //! 3. Property is what an entry crediting a member's capital account
//! debits to asset accounts that are neither cash (`cash`), //! debits to asset accounts that are neither cash (`cash`) nor
//! receivables, nor prepaid: equipment, a vehicle, software a member //! receivables: equipment, a vehicle, software, or the unexpired part
//! hands the company. The rest of a contribution is cash: deposits, //! of a service contract (AppleCare+, a prepaid subscription) that a
//! and what a member paid for the company (an expense, a debt they //! member owned and hands the company, each with its own basis
//! took on, e.g. Dr 2100 / Cr capital, or a service paid in advance). //! (§723). The rest of a contribution is cash: deposits, and what a
//! A prepaid account is the balance account of a prepaid-expense //! member paid for the company (an expense booked straight to
//! schedule, or an asset account whose name says "prepaid" (1300 //! capital, or a debt they took on, e.g. Dr 2100 / Cr capital). A
//! Prepaid Expenses before its schedule exists): a member paying the //! member who pays a vendor for the company's prepaid service is
//! company's service in advance paid cash on its behalf, and it reads //! booked through Due to Shareholders (Dr prepaid / Cr 2100), then
//! the same booked to capital directly or through 2100. Item L //! converted (Dr 2100 / Cr capital): cash. A prepaid credited
//! property is property the member owned, with its own basis //! straight to capital is a contract they handed over: property.
//! (§723), never cash they spent for the company. //! (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 //! 4. Within one entry, the property (at most the members' credits in
//! it) is shared among the members credited in proportion to their //! it) is shared among the members credited in proportion to their
//! credits, to the cent, leftover cents by largest remainder //! 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 //! 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 //! 500) reversed the next day: each member 0.00 contributed, 0.00
//! distributed. //! distributed.
//! 2. Members prepay an annual service from joint funds straight to //! 2. Members pay a vendor ahead for the company's annual service from
//! capital (Dr 1300 Prepaid 3,000 / Cr 3000 1,500, Cr 3001 1,500): //! joint funds, through Due to Shareholders (Dr 1300 Prepaid 3,000 /
//! 1,500.00 cash each, no property. //! 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 //! 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, //! 500, Cr 3001 500): each member 300.00 cash and 200.00 property,
//! whichever member is listed first. //! 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}; use crate::reports::{MemberCapital, members_equity};
@@ -165,18 +170,13 @@ pub fn k1_detail(
let receivables = ledger.role_account(&crate::RECEIVABLES); let receivables = ledger.role_account(&crate::RECEIVABLES);
let closings = ledger.closing_entry_ids(); let closings = ledger.closing_entry_ids();
let cancelled = crate::reports::cancelled_in_span(ledger, start, end); 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| { let property_account = |id: AccountId| {
!cash.contains(&id) !cash.contains(&id)
&& Some(id) != receivables && Some(id) != receivables
&& !prepaid.contains(&id) && ledger
&& ledger.chart().get(id).is_some_and(|a| { .chart()
a.account_type == AccountType::Asset && !a.name.to_lowercase().contains("prepaid") .get(id)
}) .is_some_and(|a| a.account_type == AccountType::Asset)
}; };
let sum = |e: &crate::JournalEntry, side: Side, account: AccountId| -> Decimal { let sum = |e: &crate::JournalEntry, side: Side, account: AccountId| -> Decimal {
e.lines() e.lines()
@@ -701,13 +701,13 @@ mod tests {
assert_eq!(split(&bk, &ms), vec![back, back]); assert_eq!(split(&bk, &ms), vec![back, back]);
} }
/// INVARIANT 3 and SCENARIO 2, by hand: members prepay a 3,000 annual /// INVARIANT 3, by hand: a contract the members hand over (Dr 1300
/// service straight to capital (Dr 1300 Prepaid / Cr 3000 1,500, Cr /// Prepaid 25 / Cr 3000 12.50, Cr 3001 12.50, the AppleCare+ with the
/// 3001 1,500): 1,500 cash each, no property, as when routed through /// MacBook) is property, 12.50 each. Members paying a 3,000 annual
/// 2100. A prepaid-expense schedule's account is prepaid whatever its /// service for the company go through 2100: Dr 1300 / Cr 2100 3,000,
/// name. /// then Dr 2100 / Cr 3000 1,500, Cr 3001 1,500: 1,500 cash each.
#[test] #[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 mut bk = books();
let (prepaid, a, b) = (bk.prepaid, bk.a, bk.b); let (prepaid, a, b) = (bk.prepaid, bk.a, bk.b);
post( post(
@@ -715,45 +715,90 @@ mod tests {
1, 1,
5, 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"), (a, Side::Credit, "1500"),
(b, Side::Credit, "1500"), (b, Side::Credit, "1500"),
], ],
); );
let ms = members(&bk); let ms = members(&bk);
let each = (d("1500"), d("0"), d("0")); let cash = (d("1500"), d("0"), d("0"));
assert_eq!(split(&bk, &ms), vec![each, each]); assert_eq!(split(&bk, &ms), vec![cash, cash]);
// 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();
post( post(
&mut bk.l, &mut bk.l,
2, 2,
1, 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"); let both = (d("1500"), d("1500"), d("0"));
bk.l.register_schedule(crate::schedules::NewSchedule { assert_eq!(split(&bk, &ms), vec![both, both]);
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")));
} }
/// INVARIANT 4 and SCENARIO 3, by hand: Dr 1000 600, Dr 1500 400 / /// 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() { if let Some(p) = self.entry_parties.get(&entry).cloned() {
self.entry_parties.insert(id, p); 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) Ok(id)
} }
@@ -2461,8 +2466,10 @@ impl Ledger {
/// 2. Tagging MUST change no amount (it's metadata, allowed on posted /// 2. Tagging MUST change no amount (it's metadata, allowed on posted
/// entries). /// entries).
/// 3. On an entry naming no party or one: Some(v) makes v's party the /// 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 /// entry's one party (all of it) and clears a no-party mark
/// it was the vendor's. /// (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 /// 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 /// 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): /// they are. IF v's party is not among them (or v has none):
@@ -2542,6 +2549,9 @@ impl Ledger {
self.entry_parties self.entry_parties
.insert(entry, vec![crate::PartyShare::whole(p)]); .insert(entry, vec![crate::PartyShare::whole(p)]);
} }
if self.entry_parties.contains_key(&entry) {
self.entry_no_party.remove(&entry);
}
} }
None => { None => {
// The party tag goes too only when it was the vendor's. // The party tag goes too only when it was the vendor's.
@@ -3175,14 +3185,25 @@ impl Ledger {
/// ```text /// ```text
/// SOURCE: docs/parties-and-attachments.md "Counterparties"; DECISION /// SOURCE: docs/parties-and-attachments.md "Counterparties"; DECISION
/// (owner, 2026-10-15). /// (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: /// SCENARIOS:
/// 1. IF the entry is unknown, already names a party, or the reason /// 1. IF the entry is unknown, already names a party, or the reason
/// is blank or fails the party text rules: SHOULD refuse. /// 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> { pub fn set_entry_no_party(&mut self, entry: EntryId, reason: &str) -> Result<(), LedgerError> {
self.check_set_entry_no_party(entry, reason)?; 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(()) Ok(())
} }
@@ -3195,10 +3216,16 @@ impl Ledger {
if !self.entries.contains_key(&entry) { if !self.entries.contains_key(&entry) {
return Err(LedgerError::UnknownEntry(entry)); return Err(LedgerError::UnknownEntry(entry));
} }
if self.entry_parties.contains_key(&entry) { if let Some(e) = self
return Err(LedgerError::InvalidParty(format!( .reversal_chain(entry)
"entry {entry} names a party; clear it first" .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() { if reason.trim().is_empty() {
return Err(LedgerError::InvalidParty( return Err(LedgerError::InvalidParty(
@@ -4102,9 +4129,18 @@ impl Ledger {
/// FICA, Cr 2110-2150 the withholdings and both shares of FICA, Cr cash /// FICA, Cr 2110-2150 the withholdings and both shares of FICA, Cr cash
/// net pay. /// net pay.
/// 2. Net pay MUST equal gross minus the employee's withholdings. /// 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: /// SCENARIOS:
/// 1. IF any amount is negative, gross is zero, or withholdings exceed /// 1. IF any amount is negative, gross is zero, or withholdings exceed
/// gross: SHOULD refuse. /// 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( pub fn run_payroll(
&mut self, &mut self,
@@ -4131,6 +4167,7 @@ impl Ledger {
self.add_line(entry, account, side, amount)?; self.add_line(entry, account, side, amount)?;
} }
self.post_entry(entry)?; self.post_entry(entry)?;
self.name_payroll_parties(entry, &lines)?;
let id = self.next_payroll_run_id; let id = self.next_payroll_run_id;
self.next_payroll_run_id += 1; self.next_payroll_run_id += 1;
self.payroll_runs.insert( self.payroll_runs.insert(
@@ -4145,6 +4182,45 @@ impl Ledger {
Ok(id) 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`), /// Current balances of the payroll tax payable accounts (`2110`–`2150`),
/// in code order. Accounts that don't exist yet count as zero and are /// in code order. Accounts that don't exist yet count as zero and are
/// skipped, as are zero balances. Balances are normal-side: positive /// 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)); return Err(LedgerError::ReplayIdMismatch(id, self.next_payroll_run_id));
} }
self.next_payroll_run_id += 1; self.next_payroll_run_id += 1;
if self.entries.contains_key(&entry_id) {
self.name_payroll_parties(entry_id, &lines)?;
}
self.payroll_runs.insert( self.payroll_runs.insert(
id, id,
PayrollRun { PayrollRun {
+71 -17
View File
@@ -514,22 +514,38 @@ pub struct EntryFlow {
/// down = min(liab_dr, cash_cr): cash paying down a liability (its /// down = min(liab_dr, cash_cr): cash paying down a liability (its
/// creditor is paid; the liability is what for); /// creditor is paid; the liability is what for);
/// up = min(liab_cr, cash_dr): a liability raising cash (borrowing: the /// 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: /// 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), /// 2. what_for: every line not on a money account (debits − credits),
/// plus `down` on the debited liabilities and −`up` on the credited /// plus `down` on the debited liabilities and −`up` on the credited
/// ones (in line order), zero sums left out, accounts merged; its sum /// ones (in line order), plus `across` on the liabilities with
/// is net exactly, because the entry balances. /// left(a) > 0 (in order of first appearance), zero sums left out,
/// accounts merged; its sum is net exactly, because the entry
/// balances.
/// SCENARIOS: /// SCENARIOS:
/// 1. Expense from the bank, on a card, or paid by a member for the /// 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. /// 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 / /// 2. Reimbursing members (Dr 2100 / Cr 1000) or paying a card (Dr 2050 /
/// Cr 1000): net is the amount, what for is the liability. /// Cr 1000): net is the amount, what for is the liability.
/// 3. A loan (Dr 1000 / Cr loan): net is minus the amount (received). /// 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. /// 5. A transfer between the company's own cash accounts: zero.
/// 6. A member contributing property (Dr equipment / Cr capital): 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 { pub fn entry_flow(entry: &crate::JournalEntry, money: &MoneyAccounts) -> EntryFlow {
use crate::Side; 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 down = liab_dr.min(cash_cr);
let up = liab_cr.min(cash_dr); 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 by: std::collections::BTreeMap<crate::AccountId, Decimal> = Default::default();
let (mut down_left, mut up_left) = (down, up); let (mut down_left, mut up_left) = (down, up);
let mut order: Vec<crate::AccountId> = Vec::new(); 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() { for l in entry.lines() {
let a = l.amount.amount; let a = l.amount.amount;
let part = if money.cash.contains(&l.account) { let part = if money.cash.contains(&l.account) {
Decimal::ZERO Decimal::ZERO
} else if money.liabilities.contains(&l.account) { } else if money.liabilities.contains(&l.account) {
match l.side { let (part, rest) = match l.side {
Side::Debit => { Side::Debit => {
let take = a.min(down_left); let take = a.min(down_left);
down_left -= take; down_left -= take;
take (take, a - take)
} }
Side::Credit => { Side::Credit => {
let take = a.min(up_left); let take = a.min(up_left);
up_left -= take; 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 { } else {
match l.side { match l.side {
Side::Debit => a, Side::Debit => a,
Side::Credit => -a, Side::Credit => -a,
} }
}; };
if !part.is_zero() { add(&mut by, &mut order, l.account, part);
if !by.contains_key(&l.account) { }
order.push(l.account); let owed_down: Decimal = left
} .iter()
*by.entry(l.account).or_default() += part; .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 { EntryFlow {
net, net,
what_for: order what_for: order
@@ -625,7 +673,9 @@ pub fn activity(
/// zero counts the party's resolved share of it /// zero counts the party's resolved share of it
/// (crate::shares::resolve with `members`; the whole net for a sole /// (crate::shares::resolve with `members`; the whole net for a sole
/// party): positive is a payment, negative a receipt. A reversal /// 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) /// 3. An entry moving no money (net zero, like property contributed)
/// counts nothing. An entry whose shares don't resolve counts /// counts nothing. An entry whose shares don't resolve counts
/// nothing and is listed in `unresolved`. /// nothing and is listed in `unresolved`.
@@ -672,8 +722,12 @@ pub fn activity_with(
continue; continue;
} }
}; };
let side = match e.reversal_of().and_then(|o| ledger.get_entry(o)) { let side = match e.reversal_of() {
Some(original) => net_out(original), Some(_) => ledger
.reversal_chain(id)
.first()
.and_then(|&root| ledger.get_entry(root))
.map_or(net, net_out),
None => net, None => net,
}; };
if side > Decimal::ZERO { if side > Decimal::ZERO {
+117
View File
@@ -1252,6 +1252,40 @@ fn entry_flow_scenarios() {
// Split payment: part bank, part card. // Split payment: part bank, part card.
let f = case(&[(exp, D, "100.00"), (bank, C, "60.00"), (card, C, "40.00")]); let f = case(&[(exp, D, "100.00"), (bank, C, "60.00"), (card, C, "40.00")]);
assert_eq!(f.net, dec("100.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 // ------------------------------------------------------- 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. /// Reversals carry every share; the pair nets to zero for each party.
#[test] #[test]
fn reversal_carries_all_shares() { fn reversal_carries_all_shares() {
@@ -1739,6 +1829,33 @@ mod shares {
assert!(a.entries.contains(&e)); 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> { fn cents() -> impl Strategy<Value = Decimal> {
(1i64..10_000_000).prop_map(|c| Decimal::new(c, 2)) (1i64..10_000_000).prop_map(|c| Decimal::new(c, 2))
} }
+165 -109
View File
@@ -63,8 +63,9 @@ pub struct EntryInfo {
/// One party an entry names, with its share. /// One party an entry names, with its share.
/// ///
/// ```text /// ```text
/// SOURCE: ledger_core::shares (resolve), with Books::cash_accounts and /// SOURCE: ledger_core::shares (resolve), with Books::money_cash_accounts
/// Books::members, as the server's books.get_entry resolves them. /// and Books::members, as the server's books.get_entry resolves
/// them.
/// INVARIANTS: /// INVARIANTS:
/// 1. resolved: the party's part from ledger_core::shares::resolve, None /// 1. resolved: the party's part from ledger_core::shares::resolve, None
/// when the entry's shares don't resolve; over an entry's parties the /// when the entry's shares don't resolve; over an entry's parties the
@@ -124,11 +125,12 @@ impl AssetTrail {
/// The party's entries: Ledger::entry_parties names it, alone or among /// The party's entries: Ledger::entry_parties names it, alone or among
/// others (core tags hand tags, vendor tags, bills, invoices, their /// others (core tags hand tags, vendor tags, bills, invoices, their
/// payments, reversals). /// payments, reversals).
/// The year: the calendar year of `today`, Jan 1 through today (the /// The year: the calendar year of `today`, Jan 1 through Dec 31 (the
/// year 1099s are counted in). /// year 1099s are counted in, and books.party's year: a posted entry,
/// invoice, or bill dated later this year counts).
/// Money: Books::activity (ledger_core::parties::activity_with with /// Money: Books::activity (ledger_core::parties::activity_with with
/// Books::cash_accounts and Books::members: a party's share of an /// Books::money_cash_accounts and Books::members: a party's share of
/// entry naming several; the same numbers books.party reports). /// an entry naming several; the same numbers books.party reports).
/// INVARIANTS: /// INVARIANTS:
/// 1. entries: every entry that is the party's, drafts included, in /// 1. entries: every entry that is the party's, drafts included, in
/// Books::entries order (newest first). /// Books::entries order (newest first).
@@ -233,17 +235,19 @@ pub struct Books {
pub members: Vec<ledger_core::PartyMember>, pub members: Vec<ledger_core::PartyMember>,
/// `[books] financing_accounts`, resolved (empty when not set). /// `[books] financing_accounts`, resolved (empty when not set).
pub financing_accounts: Vec<AccountId>, pub financing_accounts: Vec<AccountId>,
/// The cash accounts the member capital statement hands /// The cash accounts every number the server also reports reads
/// ledger_core::k1 to tell cash from property: `[books] /// money with: `[books] cash_accounts`, resolved; when that is empty,
/// cash_accounts`, resolved; when that is empty, core's fallback /// core's fallback (ledger_core::parties::money_accounts: every asset
/// (ledger_core::parties::money_accounts: every asset account but /// account but receivables), exactly as the server passes them. Party
/// receivables), so the viewer reads money as core does. Without the /// money (Books::activity, shares, what for), the party 1099 line, and
/// setting every contribution therefore reads as cash: property can't /// the member capital statement (ledger_core::k1, to tell cash from
/// be told from cash without knowing which accounts hold cash. /// property) all use it, so the viewer shows books.party's,
/// Kept apart from `cash_accounts`, whose name-based guess the Cash /// books.parties' and the K-1 export's numbers. Without the setting
/// page, dashboard and charts keep (changing those is out of scope: /// every contribution therefore reads as cash: property can't be told
/// "every asset" would put equipment on the Cash page). /// from cash without knowing which accounts hold cash. Kept apart from
pub k1_cash_accounts: Vec<AccountId>, /// `cash_accounts`, whose name-based guess the Cash page, dashboard and
/// charts keep ("every asset" would put equipment on the Cash page).
pub money_cash_accounts: Vec<AccountId>,
/// `[books] investing_accounts`, resolved (empty when not set). /// `[books] investing_accounts`, resolved (empty when not set).
pub investing_accounts: Vec<AccountId>, pub investing_accounts: Vec<AccountId>,
pub card_accounts: Vec<AccountId>, pub card_accounts: Vec<AccountId>,
@@ -292,8 +296,8 @@ pub enum PartyPeriod {
/// get back?" (owner, 2026-10-15) with the books' own numbers. /// get back?" (owner, 2026-10-15) with the books' own numbers.
/// SOURCE: ledger_core::parties::activity_with (paid, received) and /// SOURCE: ledger_core::parties::activity_with (paid, received) and
/// ledger_core::parties::entry_flow (what for), with /// ledger_core::parties::entry_flow (what for), with
/// Books::cash_accounts and Books::members, as PartyInfo uses /// Books::money_cash_accounts and Books::members, as PartyInfo
/// them. /// uses them.
/// INVARIANTS: /// INVARIANTS:
/// 1. paid, received: Books::activity(party, span)'s, exactly. /// 1. paid, received: Books::activity(party, span)'s, exactly.
/// 2. net() = paid − received. /// 2. net() = paid − received.
@@ -364,11 +368,11 @@ pub struct CapitalLine {
/// SOURCE: ledger_core::k1::k1_detail (members_equity's roll-forward, /// SOURCE: ledger_core::k1::k1_detail (members_equity's roll-forward,
/// split into cash and property); ledger_config::Company::members /// split into cash and property); ledger_config::Company::members
/// ([[company.members]], all of them, in order, as the CPA /// ([[company.members]], all of them, in order, as the CPA
/// package builds its MemberCapital list); Books::k1_cash_accounts; /// package builds its MemberCapital list); Books::money_cash_accounts;
/// Books::financing_accounts; DECISION (the due-to accounts, and /// Books::financing_accounts; DECISION (the due-to accounts, and
/// the cash fallback, below). /// the cash fallback, below).
/// DEFINITIONS: /// DEFINITIONS:
/// row: k1_detail(span, members, k1_cash_accounts).rows[i], i this /// row: k1_detail(span, members, money_cash_accounts).rows[i], i this
/// member's position in [[company.members]] (matched by capital /// member's position in [[company.members]] (matched by capital
/// account). /// account).
/// INVARIANTS: /// INVARIANTS:
@@ -538,14 +542,13 @@ impl Books {
let receipt_threshold = policy.threshold(); let receipt_threshold = policy.threshold();
let cash_accounts = cash_accounts(&ledger, &settings); let cash_accounts = cash_accounts(&ledger, &settings);
let members = settings.company.party_members(&ledger); let members = settings.company.party_members(&ledger);
let share_money = ledger_core::parties::money_accounts(&ledger, &cash_accounts);
let codes = |cs: &[String]| -> Vec<AccountId> { let codes = |cs: &[String]| -> Vec<AccountId> {
cs.iter() cs.iter()
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id)) .filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
.collect() .collect()
}; };
let financing_accounts = codes(&settings.financing_accounts); let financing_accounts = codes(&settings.financing_accounts);
let k1_cash_accounts = { let money_cash_accounts = {
let configured = codes(&settings.cash_accounts); let configured = codes(&settings.cash_accounts);
if configured.is_empty() { if configured.is_empty() {
let mut all: Vec<AccountId> = ledger_core::parties::money_accounts(&ledger, &[]) let mut all: Vec<AccountId> = ledger_core::parties::money_accounts(&ledger, &[])
@@ -558,15 +561,9 @@ impl Books {
configured configured
} }
}; };
let share_money = ledger_core::parties::money_accounts(&ledger, &money_cash_accounts);
let investing_accounts = codes(&settings.investing_accounts); let investing_accounts = codes(&settings.investing_accounts);
let card_accounts: Vec<AccountId> = ledger let card_accounts = ledger_store::reconcilable::card_accounts(&ledger);
.chart()
.all()
.filter(|a| {
a.account_type == AccountType::Liability && a.name.to_lowercase().contains("card")
})
.map(|a| a.id)
.collect();
// Journal events per entry. // Journal events per entry.
let mut events: HashMap<EntryId, Vec<usize>> = HashMap::new(); let mut events: HashMap<EntryId, Vec<usize>> = HashMap::new();
@@ -755,7 +752,7 @@ impl Books {
cash_accounts, cash_accounts,
members, members,
financing_accounts, financing_accounts,
k1_cash_accounts, money_cash_accounts,
investing_accounts, investing_accounts,
card_accounts, card_accounts,
fresh, fresh,
@@ -962,8 +959,7 @@ impl Books {
fn party_infos(&self) -> BTreeMap<PartyId, PartyInfo> { fn party_infos(&self) -> BTreeMap<PartyId, PartyInfo> {
let l = &self.ledger; let l = &self.ledger;
let year = self.today.year(); let year = self.today.year();
let year_start = NaiveDate::from_ymd_opt(year, 1, 1).expect("valid"); let in_year = |d: NaiveDate| d.year() == year;
let in_year = |d: NaiveDate| d >= year_start && d <= self.today;
let mut out: BTreeMap<PartyId, PartyInfo> = l let mut out: BTreeMap<PartyId, PartyInfo> = l
.parties() .parties()
.map(|p| { .map(|p| {
@@ -977,7 +973,7 @@ impl Books {
}) })
.collect(); .collect();
for (id, info) in out.iter_mut() { for (id, info) in out.iter_mut() {
let a = self.activity(*id, (year_start, self.today)); let a = self.activity(*id, self.party_span(PartyPeriod::ThisYear));
info.entries = self info.entries = self
.entries .entries
.iter() .iter()
@@ -1281,15 +1277,15 @@ impl Books {
} }
/// The accounts the books expect to reconcile monthly: the cash /// The accounts the books expect to reconcile monthly: the cash
/// accounts, then the card accounts, each once. /// accounts, then the card accounts, then any other liability with a
/// recorded statement, each once (ledger_store::reconcilable, the
/// server's digest rule).
pub fn reconcilable_accounts(&self) -> Vec<AccountId> { pub fn reconcilable_accounts(&self) -> Vec<AccountId> {
let mut out = self.cash_accounts.clone(); ledger_store::reconcilable::reconcilable_accounts(
for a in &self.card_accounts { &self.ledger,
if !out.contains(a) { &self.cash_accounts,
out.push(*a); &self.statements,
} )
}
out
} }
/// When an account is due a reconciliation: the date it should be /// When an account is due a reconciliation: the date it should be
@@ -1301,7 +1297,9 @@ impl Books {
/// attachments.md, Reconciliations: "an account with activity /// attachments.md, Reconciliations: "an account with activity
/// not reconciled through last month's end"). /// not reconciled through last month's end").
/// SOURCE: that rule; CONVENTION (a bank or card statement closes /// SOURCE: that rule; CONVENTION (a bank or card statement closes
/// monthly; AGENTS.md's monthly close reconciles the bank). /// monthly; AGENTS.md's monthly close reconciles the bank);
/// ledger_store::reconcilable (the server's digest reads the
/// same function).
/// INVARIANTS: /// INVARIANTS:
/// 1. Some(last_month_end()) iff the account has a posted line /// 1. Some(last_month_end()) iff the account has a posted line
/// dated on or before last_month_end() (activity to reconcile) /// dated on or before last_month_end() (activity to reconcile)
@@ -1310,28 +1308,48 @@ impl Books {
/// 2. None otherwise (including activity only this month). /// 2. None otherwise (including activity only this month).
/// ``` /// ```
pub fn reconciliation_due(&self, account: AccountId) -> Option<NaiveDate> { pub fn reconciliation_due(&self, account: AccountId) -> Option<NaiveDate> {
let through = self.last_month_end(); ledger_store::reconcilable::reconciliation_due(
let active = self &self.ledger,
.ledger &self.reconciliations,
.posted_entries() account,
.any(|e| e.date() <= through && e.lines().iter().any(|l| l.account == account)); self.today,
(active && self.reconciled_through(account).is_none_or(|d| d < through)).then_some(through) )
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// A party's money over a span // A party's money over a span
// ------------------------------------------------------------------ // ------------------------------------------------------------------
/// The dates a [`PartyPeriod`] covers: a year is January 1 to /// The dates a [`PartyPeriod`] covers.
/// December 31, cut at today; all time starts at the earliest entry ///
/// in the books (today when there is none). /// ```text
/// GOAL: the viewer's totals are books.party's for the same year
/// (PartyTotalsOut: "the viewer shows the same numbers").
/// INVARIANTS:
/// 1. A year is January 1 to December 31, cut at the later of today
/// and its last posted entry: no posted entry of the year falls
/// outside it (so its sums are the calendar year's exactly), and
/// months after today with nothing posted aren't shown.
/// 2. All time starts at the earliest entry in the books (today when
/// there is none) and ends at the later of today and the last
/// posted entry.
/// ```
pub fn party_span(&self, period: PartyPeriod) -> (NaiveDate, NaiveDate) { pub fn party_span(&self, period: PartyPeriod) -> (NaiveDate, NaiveDate) {
let last_posted = |within: Option<i32>| {
self.entries
.iter()
.filter(|e| e.status == EntryStatus::Posted)
.filter(|e| within.is_none_or(|y| e.date.year() == y))
.map(|e| e.date)
.max()
.map_or(self.today, |d| d.max(self.today))
};
let year = |y: i32| { let year = |y: i32| {
( (
NaiveDate::from_ymd_opt(y, 1, 1).expect("valid"), NaiveDate::from_ymd_opt(y, 1, 1).expect("valid"),
NaiveDate::from_ymd_opt(y, 12, 31) NaiveDate::from_ymd_opt(y, 12, 31)
.expect("valid") .expect("valid")
.min(self.today), .min(last_posted(Some(y))),
) )
}; };
match period { match period {
@@ -1346,7 +1364,7 @@ impl Books {
.min() .min()
.unwrap_or(self.today) .unwrap_or(self.today)
.min(self.today); .min(self.today);
(first, self.today) (first, last_posted(None))
} }
} }
} }
@@ -1419,29 +1437,7 @@ impl Books {
let mut by: BTreeMap<AccountId, Decimal> = BTreeMap::new(); let mut by: BTreeMap<AccountId, Decimal> = BTreeMap::new();
for e in &posted { for e in &posted {
let flow = ledger_core::parties::entry_flow(e, &money); let flow = ledger_core::parties::entry_flow(e, &money);
let share = if l.entry_parties(e.id()).len() == 1 { let parts = self.party_what_for(e, party, &flow);
flow.net
} else {
self.party_share(e, party)
};
// A member's own capital lines are what their share was
// for, when they account for it exactly.
let own: Vec<(AccountId, Decimal)> = self
.member(party)
.map(|m| {
flow.what_for
.iter()
.copied()
.filter(|(a, _)| *a == m.capital || Some(*a) == m.distributions)
.collect()
})
.unwrap_or_default();
let parts =
if !own.is_empty() && own.iter().map(|(_, v)| *v).sum::<Decimal>() == share {
own
} else {
share_of_what_for(&flow, share)
};
for (account, amount) in parts { for (account, amount) in parts {
*by.entry(account).or_default() += amount; *by.entry(account).or_default() += amount;
} }
@@ -1457,8 +1453,9 @@ impl Books {
} }
} }
/// ledger_core::parties::activity_with over `span`, with the books' /// ledger_core::parties::activity_with over `span`, with
/// cash accounts and members: the one source of a party's money. /// Books::money_cash_accounts and the members: the one source of a
/// party's money, the server's own (books.party, books.parties).
pub fn activity( pub fn activity(
&self, &self,
party: PartyId, party: PartyId,
@@ -1468,11 +1465,84 @@ impl Books {
&self.ledger, &self.ledger,
party, party,
span, span,
&self.cash_accounts, &self.money_cash_accounts,
&self.members, &self.members,
) )
} }
/// One entry's what-for, for one party (see [`PartyFlow`] INVARIANT
/// 3): its fraction of the entry's entry_flow what_for, summing to its
/// money share exactly (zero when the entry moves no money, like a
/// bill or a contribution of property).
///
/// ```text
/// The fraction: 1 for an entry with one party; else its resolved
/// share over what the shares split (the net, or the entry's total
/// debits when the net is zero).
/// A member's own capital and distribution lines are theirs in full;
/// another member's are never theirs. Every other line is scaled by
/// the fraction (to the cent, toward zero), and the first scaled line
/// takes the leftover so the parts sum to the money share.
/// ```
pub fn party_what_for(
&self,
e: &ledger_core::JournalEntry,
party: PartyId,
flow: &ledger_core::parties::EntryFlow,
) -> Vec<(AccountId, Decimal)> {
use rust_decimal::RoundingStrategy::ToZero;
let parties = self.ledger.entry_parties(e.id()).len();
if parties <= 1 {
return flow.what_for.clone();
}
let resolved = self.party_share(e, party);
let target = if flow.net.is_zero() {
e.lines()
.iter()
.filter(|l| l.side == Side::Debit)
.map(|l| l.amount.amount)
.sum::<Decimal>()
} else {
flow.net
};
if target.is_zero() {
return Vec::new();
}
let fraction = resolved / target;
let money_share = if flow.net.is_zero() {
Decimal::ZERO
} else {
resolved
};
let me = self.member(party).copied();
let member_line = |a: AccountId| {
self.members
.iter()
.any(|m| m.capital == a || m.distributions == Some(a))
};
let mut own: Vec<(AccountId, Decimal)> = Vec::new();
let mut scaled: Vec<(AccountId, Decimal)> = Vec::new();
for &(a, v) in &flow.what_for {
if me.is_some_and(|m| m.capital == a || m.distributions == Some(a)) {
own.push((a, v));
} else if !member_line(a) {
scaled.push((a, (v * fraction).round_dp_with_strategy(2, ToZero)));
}
}
let have: Decimal = own.iter().chain(&scaled).map(|(_, v)| *v).sum();
let left = money_share - have;
if !left.is_zero() {
match scaled.first_mut() {
Some(first) => first.1 += left,
None => own.push((e.lines()[0].account, left)),
}
}
own.into_iter()
.chain(scaled)
.filter(|(_, v)| !v.is_zero())
.collect()
}
/// A party's resolved share of an entry's flow (its entry_flow net /// A party's resolved share of an entry's flow (its entry_flow net
/// for a sole party; zero when the shares don't resolve or it isn't /// for a sole party; zero when the shares don't resolve or it isn't
/// named). /// named).
@@ -1499,7 +1569,8 @@ impl Books {
.iter() .iter()
.position(|x| x.capital_account == m.capital)?; .position(|x| x.capital_account == m.capital)?;
let detail = let detail =
ledger_core::k1::k1_detail(l, span.0, span.1, &members, &self.k1_cash_accounts).ok()?; ledger_core::k1::k1_detail(l, span.0, span.1, &members, &self.money_cash_accounts)
.ok()?;
let row = detail.rows.get(i)?; let row = detail.rows.get(i)?;
let lines: Vec<CapitalLine> = detail let lines: Vec<CapitalLine> = detail
.lines .lines
@@ -1605,18 +1676,21 @@ impl Books {
} }
/// A party's 1099 worksheet line for a calendar year and that year's /// A party's 1099 worksheet line for a calendar year and that year's
/// threshold: ledger-core's vendor_1099_report_with (cash and /// threshold: ledger-core's vendor_1099_report_with_members line for
/// financing accounts) line for its vendor record; None without a /// its vendor record, with Books::money_cash_accounts, the financing
/// vendor record in the report, or when the year's threshold isn't /// accounts and the members (the server's books.vendor_1099 and
/// known. /// books.party amount_1099); None without a vendor record in the
/// report, or when the year's threshold isn't known.
pub fn party_1099(&self, party: PartyId, year: i32) -> Option<(Vendor1099Line, Decimal)> { pub fn party_1099(&self, party: PartyId, year: i32) -> Option<(Vendor1099Line, Decimal)> {
let l = &self.ledger; let l = &self.ledger;
let vendor = l.get_party(party)?.vendor?; let vendor = l.get_party(party)?.vendor?;
let sources = ledger_core::PaymentAccounts { let sources = ledger_core::PaymentAccounts {
cash: self.cash_accounts.clone(), cash: self.money_cash_accounts.clone(),
financing: self.financing_accounts.clone(), financing: self.financing_accounts.clone(),
}; };
let r = ledger_core::vendor_1099_report_with(l, year, None, &sources).ok()?; let r =
ledger_core::vendor_1099_report_with_members(l, year, None, &sources, &self.members)
.ok()?;
let line = r.vendors.iter().find(|v| v.vendor_id == vendor)?.clone(); let line = r.vendors.iter().find(|v| v.vendor_id == vendor)?.clone();
Some((line, r.threshold)) Some((line, r.threshold))
} }
@@ -1816,26 +1890,8 @@ fn origin(events: &[usize], journal: &[JournalLine], closing: bool) -> &'static
} }
/// Cash accounts: from `[books] cash_accounts`, else asset accounts whose /// Cash accounts: from `[books] cash_accounts`, else asset accounts whose
/// names say they hold cash. /// names say they hold cash (ledger_store::reconcilable::cash_accounts,
/// as the server's digest reads them).
fn cash_accounts(ledger: &Ledger, settings: &BooksSettings) -> Vec<AccountId> { fn cash_accounts(ledger: &Ledger, settings: &BooksSettings) -> Vec<AccountId> {
if !settings.cash_accounts.is_empty() { ledger_store::reconcilable::cash_accounts(ledger, &settings.cash_accounts)
return settings
.cash_accounts
.iter()
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
.collect();
}
ledger
.chart()
.all()
.filter(|a| {
let n = a.name.to_lowercase();
a.account_type == AccountType::Asset
&& !a.placeholder
&& ["cash", "bank", "checking", "savings", "money market"]
.iter()
.any(|w| n.contains(w))
})
.map(|a| a.id)
.collect()
} }
+10
View File
@@ -282,6 +282,16 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
also: account.map(Route::Account), also: account.map(Route::Account),
}) })
} }
"statement.voided" => Some(Event {
icon: icon::PROHIBIT,
text: format!(
"Voided the statement recorded on {}: {}",
attachment_name(b, att),
s(p, "reason")
),
route: Some(Route::Document(att)),
also: None,
}),
"reconciliation.completed" => { "reconciliation.completed" => {
let account = p.get("account_id").and_then(|v| v.as_u64()); let account = p.get("account_id").and_then(|v| v.as_u64());
let balance = s(p, "statement_balance") let balance = s(p, "statement_balance")
+46
View File
@@ -603,3 +603,49 @@ fn books_show_opens_a_reconciliation() {
h.state_mut().show(ViewerTarget::Reconciliation { id: 999 }); h.state_mut().show(ViewerTarget::Reconciliation { id: 999 });
assert_eq!(h.state_mut().route(), &Route::Reconciliation(r)); 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()));
}
+81 -5
View File
@@ -362,11 +362,14 @@ fn each_partys_totals_are_the_books_numbers() {
assert!(out > Decimal::ZERO); assert!(out > Decimal::ZERO);
assert_eq!(info(f.irs).paid_ytd, out); 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); let pat = info(f.pat);
assert_eq!(pat.payroll_runs.len(), 2); assert_eq!(pat.payroll_runs.len(), 2);
assert!(pat.entries.is_empty()); assert_eq!(pat.entries.len(), 1);
assert_eq!(pat.paid_ytd, Decimal::ZERO); assert_eq!(pat.paid_ytd, cents(79_350));
// Every entry's party is the one whose list holds it. // Every entry's party is the one whose list holds it.
for e in &b.entries { 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 /// Without [books] cash_accounts the capital statement reads money as
/// core does (every asset account but receivables: Books:: /// 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 /// contributions are $600.00 cash, none property. The Cash page keeps its
/// name-based guess ("Bluevine" names no cash word: no cash accounts). /// name-based guess ("Bluevine" names no cash word: no cash accounts).
#[test] #[test]
@@ -1446,7 +1449,7 @@ fn without_cash_accounts_the_capital_statement_reads_money_as_core_does() {
write_member_toml(&m.dir.0, ""); write_member_toml(&m.dir.0, "");
let b = load_with_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; 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!( assert!(
b.cash_accounts.is_empty(), b.cash_accounts.is_empty(),
"the Cash page's guess is unchanged" "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) (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))
);
}
+224 -77
View File
@@ -54,7 +54,7 @@ use rust_decimal::Decimal;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
/// The agent operating contract, delivered via initialize. /// The agent operating contract, delivered via initialize.
const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- Attachments: file a file once and link it wherever it is evidence (books.link_attachment, or link_to on books.attach_receipt and books.file_document): an entry (a file linked to an entry counts as its receipt), an account, an asset, a party, an invoice, a bill, a period, a payroll run, a schedule, a reconciliation, or the company. books.attachments lists a subject's files; books.unlink_attachment takes a link off with a reason. File documents under their kind (statement, contract, photo, warranty, import_source, formation, legal, tax, accounting, other). File a bank export before importing it and pass source_attachment_id to books.import_bank_csv.\n- Reconciliations: every month, for each bank, cash, and card account, file the statement (books.file_document category statement), record what it says (books.record_statement), mark the lines it shows (books.mark_cleared), preview books.reconcile with statement_attachment, and confirm with confirmed=true once the difference is exactly zero: that records the reconciliation with its statement. Never confirm by forcing the numbers; find the difference. books.void_reconciliation (with a reason) undoes a mistake. books.digest lists unreconciled_accounts: accounts not reconciled through the end of last month.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA. Every asset gets a tag for the label on the thing itself (books.register_asset generates the next one, like FL-0001, unless the human has their own); record the manufacturer's serial number too, change either with books.label_asset, and print label sheets with books.asset_labels (save the PDF; print at 100% on Avery 5160/8160). Asset tools take the tag in place of the id. Record every asset's federal tax basis beside its book value (books.set_asset_tax_basis, or tax_basis_rule on books.register_asset): cost for a purchase; for a member's contribution, the member's adjusted basis (what they paid, less depreciation they took) with rule personal_use_conversion (their personal property: tax basis is the lesser of value and their basis) or carryover (their business property). Book value stays fair market value. books.export fixed_asset_register is the register for the CPA; books.show asset opens one in the viewer.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.\n- The viewer: the human usually has the read-only viewer open beside you (`just gui`). When they ask to see something, and after you post, call books.show so it's on their screen.\n- Parties: keep the directory current. Every counterparty (vendor, contractor, landlord, customer, payout platfo Line truncated const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- Attachments: file a file once and link it wherever it is evidence (books.link_attachment, or link_to on books.attach_receipt and books.file_document): an entry (a file linked to an entry counts as its receipt), an account, an asset, a party, an invoice, a bill, a period, a payroll run, a schedule, a reconciliation, or the company. books.attachments lists a subject's files; books.unlink_attachment takes a link off with a reason. File documents under their kind (statement, contract, photo, warranty, import_source, formation, legal, tax, accounting, other). File a bank export before importing it and pass source_attachment_id to books.import_bank_csv.\n- Reconciliations: every month, for each bank, cash, and card account, file the statement (books.file_document category statement), record what it says (books.record_statement), mark the lines it shows (books.mark_cleared), preview books.reconcile with statement_attachment, and confirm with confirmed=true once the difference is exactly zero: that records the reconciliation with its statement. Never confirm by forcing the numbers; find the difference. books.void_reconciliation (with a reason) undoes a mistake; books.void_statement (with a reason) a mis-recorded statement, to record it again. books.digest lists unreconciled_accounts: accounts not reconciled through the end of last month.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA. Every asset gets a tag for the label on the thing itself (books.register_asset generates the next one, like FL-0001, unless the human has their own); record the manufacturer's serial number too, change either with books.label_asset, and print label sheets with books.asset_labels (save the PDF; print at 100% on Avery 5160/8160). Asset tools take the tag in place of the id. Record every asset's federal tax basis beside its book value (books.set_asset_tax_basis, or tax_basis_rule on books.register_asset): cost for a purchase; for a member's contribution, the member's adjusted basis (what they paid, less depreciation they took) with rule personal_use_conversion (their personal property: tax basis is the lesser of value and their basis) or carryover (their business property). Book value stays fair market value. books.export fixed_asset_register is the register for the CPA; books.show asset opens one in the viewer.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.\n- The viewer: the human usually has the read-only viewer open beside you (`just gui`). When they ask to see something, and after you post, call books.show so it's on their screen.\n- Parties: keep the directory Line truncated
/// Max receipt/document upload: 25 MB. /// Max receipt/document upload: 25 MB.
const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024; const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
@@ -461,13 +461,17 @@ impl BooksServer {
}) })
} }
/// Cash and card accounts due for reconciliation: the `[books] /// Cash and card accounts due for reconciliation
/// cash_accounts` and every liability with a recorded statement, with /// (ledger_store::reconcilable, the viewer's Needs-attention rule):
/// posted activity through the end of last month, not reconciled /// the cash accounts (`[books] cash_accounts`, else assets named for
/// through it. /// cash), the card accounts (liabilities named "card", before their
/// first statement), and every other liability with a recorded
/// statement, with posted activity through the end of last month, not
/// reconciled through it.
/// ///
/// ```text /// ```text
/// GOAL: the monthly close's reconciliation step can't be forgotten. /// GOAL: the monthly close's reconciliation step can't be forgotten,
/// and the digest and the viewer agree on what is due.
/// SOURCE: docs/parties-and-attachments.md "Reconciliations" (Needs /// SOURCE: docs/parties-and-attachments.md "Reconciliations" (Needs
/// attention: an account with activity not reconciled through /// attention: an account with activity not reconciled through
/// last month's end). /// last month's end).
@@ -477,40 +481,17 @@ impl BooksServer {
store: &Store, store: &Store,
today: NaiveDate, today: NaiveDate,
) -> Result<Vec<UnreconciledAccountOut>, String> { ) -> Result<Vec<UnreconciledAccountOut>, String> {
use ledger_store::reconcilable;
let ledger = store.ledger(); let ledger = store.ledger();
let due = chrono::Datelike::with_day(&today, 1) let cash = reconcilable::cash_accounts(ledger, &self.settings().roles.cash);
.and_then(|d| d.pred_opt()) let statements = store.statements(None).map_err(|e| e.to_string())?;
.expect("a month before today"); let reconciliations = store.reconciliations(None).map_err(|e| e.to_string())?;
let mut accounts: Vec<AccountId> = self
.settings()
.roles
.cash
.iter()
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
.collect();
for st in store.statements(None).map_err(|e| e.to_string())? {
if ledger
.chart()
.get(st.account_id)
.is_some_and(|a| a.account_type == AccountType::Liability)
{
accounts.push(st.account_id);
}
}
accounts.sort_unstable();
accounts.dedup();
let mut out = Vec::new(); let mut out = Vec::new();
for id in accounts { for id in reconcilable::reconcilable_accounts(ledger, &cash, &statements) {
let active = ledger let Some(due) = reconcilable::reconciliation_due(ledger, &reconciliations, id, today)
.posted_entries() else {
.any(|e| e.date() <= due && e.lines().iter().any(|l| l.account == id));
if !active {
continue; continue;
} };
let through = reconciled_through(store, id)?;
if through.is_some_and(|t| t >= due) {
continue;
}
let (code, name) = code_name(store, id); let (code, name) = code_name(store, id);
out.push(UnreconciledAccountOut { out.push(UnreconciledAccountOut {
next_step: format!( next_step: format!(
@@ -518,7 +499,8 @@ impl BooksServer {
), ),
code, code,
name, name,
reconciled_through: through.map(|d| d.to_string()), reconciled_through: reconcilable::reconciled_through(&reconciliations, id)
.map(|d| d.to_string()),
due_through: due.to_string(), due_through: due.to_string(),
}); });
} }
@@ -1565,6 +1547,19 @@ fn party_activity(
}) })
} }
/// The `party_required` refusal for an engine-posted entry (payroll, a
/// remittance, a disposal) that names no counterparty under `[books]
/// parties = "require"`: `what` says what to pass.
fn party_required(what: &str) -> String {
serde_json::json!({
"code": "party_required",
"message": format!(
"these books require a counterparty on every entry ([books] parties = \"require\"): {what} (add the party with books.add_party first if it's new)"
),
})
.to_string()
}
/// The chart's ids for these account codes (codes it lacks are skipped). /// The chart's ids for these account codes (codes it lacks are skipped).
fn cash_ids(ledger: &Ledger, codes: &[String]) -> Vec<AccountId> { fn cash_ids(ledger: &Ledger, codes: &[String]) -> Vec<AccountId> {
codes codes
@@ -1650,6 +1645,58 @@ fn check_shares_resolve(
ledger_core::shares::resolve(entry, shares, &money, &members).map(|_| ()) ledger_core::shares::resolve(entry, shares, &money, &members).map(|_| ())
} }
/// Fix a posted entry's derived split among several parties: journal the
/// exact amounts they resolve to now, so a later change to
/// `[[company.members]]` (a new income split, a member archived) never
/// re-splits posted history.
///
/// ```text
/// GOAL: posted is forever, the split between its parties included.
/// SOURCE: AGENTS.md ("Posted is forever"); docs/parties-and-attachments.md
/// "Shares"; ledger_core::shares INVARIANT 10 (derived several:
/// the members' capital lines, else their income shares, both read
/// from the settings).
/// INVARIANTS:
/// 1. Only a posted entry naming two or more parties with derived shares
/// changes: its shares become Amount(|part|) for each party, in
/// order, journaled as entry.parties_set (the reversal chain too), so
/// each party's resolved part is exactly what it was.
/// 2. Nothing changes when the shares don't resolve, or a part is zero
/// (an amount must be positive): the entry keeps its derived shares.
/// ```
fn fix_derived_shares(store: &mut Store, settings: &Settings, entry_id: u64) -> Result<(), String> {
let ledger = store.ledger();
let Some(entry) = ledger.get_entry(entry_id) else {
return Ok(());
};
let shares = ledger.entry_parties(entry_id);
if entry.status() != ledger_core::EntryStatus::Posted
|| shares.len() < 2
|| !shares
.iter()
.any(|s| matches!(s.share, ledger_core::Share::Derived))
{
return Ok(());
}
let (money, members) = share_context(ledger, settings);
let Ok(parts) = ledger_core::shares::resolve(entry, shares, &money, &members) else {
return Ok(());
};
if parts.iter().any(|(_, v)| v.is_zero()) {
return Ok(());
}
let fixed: Vec<ledger_core::PartyShare> = parts
.into_iter()
.map(|(party, v)| ledger_core::PartyShare {
party,
share: ledger_core::Share::Amount(v.abs()),
})
.collect();
store
.set_entry_parties(entry_id, &fixed)
.map_err(|e| e.to_string())
}
/// An entry's parties for output, each share resolved, and why they don't /// An entry's parties for output, each share resolved, and why they don't
/// resolve when they don't. /// resolve when they don't.
fn entry_parties_out( fn entry_parties_out(
@@ -2482,6 +2529,7 @@ impl BooksServer {
.to_tool_error()); .to_tool_error());
} }
store.post_entry(p.entry_id).map_err(|e| e.to_string())?; store.post_entry(p.entry_id).map_err(|e| e.to_string())?;
fix_derived_shares(&mut store, &self.settings(), p.entry_id)?;
let mut posted = out; let mut posted = out;
posted.status = "posted".to_string(); posted.status = "posted".to_string();
Ok(Json(posted)) Ok(Json(posted))
@@ -2957,6 +3005,10 @@ impl BooksServer {
.map(|p| p.label().to_string()), .map(|p| p.label().to_string()),
parties, parties,
parties_error, parties_error,
no_party_reason: store
.ledger()
.entry_no_party(p.entry_id)
.map(str::to_string),
})) }))
} }
@@ -4971,7 +5023,7 @@ impl BooksServer {
#[tool( #[tool(
name = "books.dispose_asset", name = "books.dispose_asset",
description = "Dispose of a fixed asset (sell or retire). Without confirmed=true returns a preview — book value, gain/loss, the auto-posted final depreciation, and the disposal entry's lines — and posts nothing. With confirmed=true posts the disposal entry (plus any missing final-period depreciation) after enforcing the receipt policy: file the bill of sale with books.file_document first and pass its attachment ids as receipt_attachment_ids." description = "Dispose of a fixed asset (sell or retire). Without confirmed=true returns a preview — book value, gain/loss, the auto-posted final depreciation, and the disposal entry's lines — and posts nothing. With confirmed=true posts the disposal entry (plus any missing final-period depreciation) after enforcing the receipt policy: file the bill of sale with books.file_document first and pass its attachment ids as receipt_attachment_ids. Name the buyer (buyer: a party id or name): it is the entry's party, and [books] parties = \"require\" refuses a sale without one; a retirement with no proceeds and no buyer is marked as having no counterparty."
)] )]
async fn dispose_asset( async fn dispose_asset(
&self, &self,
@@ -4985,6 +5037,11 @@ impl BooksServer {
let proceeds_account = resolve_account(&store, &p.proceeds_account)?; let proceeds_account = resolve_account(&store, &p.proceeds_account)?;
let gain_loss_account = resolve_account(&store, &p.gain_loss_account)?; let gain_loss_account = resolve_account(&store, &p.gain_loss_account)?;
let asset_id = resolve_asset(&store, p.asset_id, p.tag.as_deref())?; let asset_id = resolve_asset(&store, p.asset_id, p.tag.as_deref())?;
let buyer = p
.buyer
.as_deref()
.map(|r| resolve_party(&store, r))
.transpose()?;
let asset = store let asset = store
.ledger() .ledger()
.get_asset(asset_id) .get_asset(asset_id)
@@ -5076,6 +5133,14 @@ impl BooksServer {
for aid in &attachment_ids { for aid in &attachment_ids {
store.check_linkable(*aid).map_err(|e| e.to_string())?; store.check_linkable(*aid).map_err(|e| e.to_string())?;
} }
if buyer.is_none()
&& !proceeds.amount.is_zero()
&& self.settings().parties == ledger_config::PartyPolicy::Require
{
return Err(party_required(
"pass buyer: the party the asset was sold to",
));
}
let entry_id = store let entry_id = store
.dispose_asset( .dispose_asset(
asset_id, asset_id,
@@ -5090,6 +5155,14 @@ impl BooksServer {
.link_receipt(*aid, entry_id) .link_receipt(*aid, entry_id)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
} }
match buyer {
Some(b) => store.set_entry_party(entry_id, Some(b)),
None if proceeds.amount.is_zero() => {
store.set_entry_no_party(entry_id, "asset retired with no proceeds")
}
None => Ok(()),
}
.map_err(|e| e.to_string())?;
let mut posted = out; let mut posted = out;
posted.status = "posted".to_string(); posted.status = "posted".to_string();
posted.entry_id = entry_id; posted.entry_id = entry_id;
@@ -5235,7 +5308,8 @@ impl BooksServer {
/// SCENARIOS: /// SCENARIOS:
/// 1. IF statement_attachment is given: the account, date, and /// 1. IF statement_attachment is given: the account, date, and
/// balance are the statement's; any given that disagree: SHOULD /// balance are the statement's; any given that disagree: SHOULD
/// refuse. /// refuse. IF its attachment is withdrawn (the statement retired)
/// or its statement voided: SHOULD refuse, preview or not.
/// 2. IF it isn't: account, statement_date, and statement_balance are /// 2. IF it isn't: account, statement_date, and statement_balance are
/// required, and confirmed=true SHOULD refuse. /// required, and confirmed=true SHOULD refuse.
/// 3. IF confirmed=true and problems is not empty: SHOULD refuse, /// 3. IF confirmed=true and problems is not empty: SHOULD refuse,
@@ -5265,7 +5339,11 @@ impl BooksServer {
.transpose()?; .transpose()?;
let (account, date, balance) = match p.statement_attachment { let (account, date, balance) = match p.statement_attachment {
Some(att) => { Some(att) => {
find_attachment(&store, att)?; if find_attachment(&store, att)?.withdrawn_at.is_some() {
return Err(format!(
"attachment {att} is withdrawn, so its statement is retired: file the statement again and record it (books.record_statement)"
));
}
let st = store let st = store
.statement(att) .statement(att)
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
@@ -5481,6 +5559,38 @@ impl BooksServer {
Ok(Json(ReconciliationsOut { reconciliations })) Ok(Json(ReconciliationsOut { reconciliations }))
} }
/// ```text
/// GOAL: correct a mis-recorded statement on the record.
/// SOURCE: Store::void_statement.
/// SCENARIOS:
/// 1. IF the attachment records no statement, a reconciliation not
/// voided rests on it, or the reason is blank: SHOULD refuse,
/// journaling nothing.
/// 2. Otherwise: journaled as `statement.voided`; books.statements no
/// longer lists it, and the same attachment can record the
/// statement again (books.record_statement).
/// ```
#[tool(
name = "books.void_statement",
description = "Void a recorded statement that was recorded wrong (a mistyped balance or period), with the reason recorded in the journal, so the same filed statement can be recorded again with books.record_statement. Refused while a reconciliation not voided rests on it (books.void_reconciliation first). Nothing is erased: the journal keeps what it said. Journaled as statement.voided. Returns the statement as it was recorded."
)]
async fn void_statement(
&self,
params: Parameters<VoidStatementParams>,
) -> Result<Json<StatementOut>, String> {
let p = params.0;
let mut store = self.lock()?;
let st = store
.statement(p.attachment_id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("attachment {} records no statement", p.attachment_id))?;
let out = statement_out(&store, &st)?;
store
.void_statement(p.attachment_id, &p.reason)
.map_err(|e| e.to_string())?;
Ok(Json(out))
}
/// ```text /// ```text
/// GOAL: undo a mistaken reconciliation on the record, to redo it. /// GOAL: undo a mistaken reconciliation on the record, to redo it.
/// SOURCE: Store::void_reconciliation. /// SOURCE: Store::void_reconciliation.
@@ -6356,7 +6466,7 @@ impl BooksServer {
#[tool( #[tool(
name = "books.set_entry_party", name = "books.set_entry_party",
description = "Tag an entry (draft or posted) with the party it was with (id or name), or pass party null (or omit it) to clear the tag. A party with a vendor record also tags the entry's vendor, so a payment to a contractor or landlord counts in books.vendor_1099; a party without one, or clearing, clears the vendor tag. For an entry with several counterparties pass parties instead: [{party, amount?|percent?}], each share an exact amount (cents), a percent (summing to 100; leftover cents to the first party), or neither (derived: a sole party gets all; members named together get what their capital or distributions lines say, else their [[company.members]] share). Name both members on a joint member transaction (a reimbursement on 2100, a joint contribution) and let the shares derive; give amounts when a split is uneven. Refused: a party twice, mixed kinds of share, two parties with vendor records, and, once the entry has balanced lines, shares that don't add up to what it moved. Metadata only: amounts never change. An entry and its reversal carry the same parties: tagging either tags both, so each party's part nets to zero. Journaled as entry.party_set (one party) or entry.parties_set (parties). Returns the entry (as books.get_entry), each party's share resolved." description = "Tag an entry (draft or posted) with the party it was with (id or name), or pass party null (or omit it) to clear the tag. A party with a vendor record also tags the entry's vendor, so a payment to a contractor or landlord counts in books.vendor_1099; a party without one, or clearing, clears the vendor tag. For an entry with several counterparties pass parties instead: [{party, amount?|percent?}], each share an exact amount (cents), a percent (summing to 100; leftover cents to the first party), or neither (derived: a sole party gets all; members named together get what their capital or distributions lines say, else their [[company.members]] share; once the entry is posted, a derived split among several parties is recorded as the exact amounts it resolves to, so a later change to [[company.members]] never re-splits posted history). Name both members on a joint member transaction (a reimbursement on 2100, a joint contribution) and let the shares derive; give amounts when a split is uneven. Refused: a party twice, mixed kinds of share, two parties with vendor records, and, once the entry has balanced lines, shares that don't add up to what it moved. Metadata only: amounts never change. An entry and its reversal carry the same parties: tagging either tags both, so each party's part nets to zero. Journaled as entry.party_set (one party) or entry.parties_set (parties). Returns the entry (as books.get_entry), each party's share resolved."
)] )]
async fn set_entry_party( async fn set_entry_party(
&self, &self,
@@ -6418,6 +6528,7 @@ impl BooksServer {
store store
.set_entry_parties(p.entry_id, &sh) .set_entry_parties(p.entry_id, &sh)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
fix_derived_shares(&mut store, &settings, p.entry_id)?;
} }
(party, None, None) => { (party, None, None) => {
store store
@@ -6705,6 +6816,15 @@ impl BooksServer {
}) })
}) })
.collect::<Result<_, String>>()?; .collect::<Result<_, String>>()?;
if self.settings().parties == ledger_config::PartyPolicy::Require
&& lines
.iter()
.any(|l| l.employee.is_none() && !l.net_pay().is_zero())
{
return Err(party_required(
"name each payroll line's employee party (employee), so the entry names who was paid",
));
}
let run_id = store let run_id = store
.run_payroll(parse_date(&p.date)?, lines, cash) .run_payroll(parse_date(&p.date)?, lines, cash)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -6724,7 +6844,7 @@ impl BooksServer {
#[tool( #[tool(
name = "books.remit_payroll_taxes", name = "books.remit_payroll_taxes",
description = "Remit all outstanding payroll tax payables (federal/state withholding, SS, Medicare, other deductions) to cash in one balanced entry." description = "Remit all outstanding payroll tax payables (federal/state withholding, SS, Medicare, other deductions) to cash in one balanced entry. Name the tax agency paid (agency: a party id or name, e.g. the IRS): it is the entry's party, and [books] parties = \"require\" refuses without it."
)] )]
async fn remit_payroll_taxes( async fn remit_payroll_taxes(
&self, &self,
@@ -6733,6 +6853,14 @@ impl BooksServer {
let p = params.0; let p = params.0;
let mut store = self.lock()?; let mut store = self.lock()?;
let cash = resolve_account(&store, &p.cash_account)?; let cash = resolve_account(&store, &p.cash_account)?;
let agency = p
.agency
.as_deref()
.map(|r| resolve_party(&store, r))
.transpose()?;
if agency.is_none() && self.settings().parties == ledger_config::PartyPolicy::Require {
return Err(party_required("pass agency: the tax agency paid"));
}
// Total before remitting, for the response. // Total before remitting, for the response.
let total: Decimal = store let total: Decimal = store
.ledger() .ledger()
@@ -6743,6 +6871,11 @@ impl BooksServer {
let entry_id = store let entry_id = store
.remit_payroll_taxes(parse_date(&p.date)?, cash) .remit_payroll_taxes(parse_date(&p.date)?, cash)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if let Some(a) = agency {
store
.set_entry_party(entry_id, Some(a))
.map_err(|e| e.to_string())?;
}
Ok(Json(RemitPayrollTaxesOut { Ok(Json(RemitPayrollTaxesOut {
entry_id, entry_id,
total: money(total), total: money(total),
@@ -6937,7 +7070,7 @@ impl BooksServer {
/// ``` /// ```
#[tool( #[tool(
name = "books.export_cpa_package", name = "books.export_cpa_package",
description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow, statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when there are fixed assets; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal as IIF (imports into QuickBooks Desktop); the 1099 worksheet as CSV; with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date); every receipt behind the year's entries (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault, so the package is saved, never returned: save_to (a new .zip path or an existing folder, outside the books dir) is required, and the output gives only its path; tell the human where it is and never open, attach, or copy it. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). include_tins: false builds it with last fours only, returned as base64 unless save_to is given. cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.", description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow, statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when there are fixed assets; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal as IIF (imports into QuickBooks Desktop); the 1099 worksheet as CSV; with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date); every receipt behind the year's entries, filed on them or linked to them (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault, so the package is saved, never returned: save_to (a new .zip path or an existing folder, outside the books dir) is required, and the output gives only its path; tell the human where it is and never open, attach, or copy it. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). include_tins: false builds it with last fours only, returned as base64 unless save_to is given. cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.",
annotations(read_only_hint = true) annotations(read_only_hint = true)
)] )]
async fn export_cpa_package( async fn export_cpa_package(
@@ -7252,8 +7385,11 @@ impl BooksServer {
format!("{e}\n").into_bytes(), format!("{e}\n").into_bytes(),
)), )),
} }
// The evidence: every current receipt on the year's posted entries, // The evidence: every current file behind the year's posted
// and the company's documents, with an index tying them together. // entries, filed on them or linked to them (a file linked to an
// entry counts as its receipt), one copy per entry with a row in
// the index; and the company's documents, the files behind no
// entry.
let mut receipt_count = 0; let mut receipt_count = 0;
if p.include_receipts { if p.include_receipts {
let clean = |s: &str| -> String { let clean = |s: &str| -> String {
@@ -7272,44 +7408,55 @@ impl BooksServer {
if a.withdrawn_at.is_some() { if a.withdrawn_at.is_some() {
continue; continue;
} }
let bytes = store.attachment_bytes(a.id).map_err(|e| e.to_string())?; let mut behind: Vec<u64> = a.entry_id.into_iter().collect();
match a.entry_id.and_then(|e| ledger.get_entry(e)) { for sub in store.attachment_links(a.id).map_err(|e| e.to_string())? {
Some(e) if let ledger_store::Subject::Entry(e) = sub
if e.status() == ledger_core::EntryStatus::Posted && !behind.contains(&e)
&& e.date() >= fy
&& e.date() <= as_of =>
{ {
let name = format!( behind.push(e);
"receipts/{}-entry-{}-{}",
e.date(),
e.id(),
clean(&a.file_name)
);
let amount: Decimal = e
.lines()
.iter()
.filter(|l| l.side == Side::Debit)
.map(|l| l.amount.amount)
.sum();
index.push_str(&format!(
"{},{},\"{}\",{},{},{}\n",
e.id(),
e.date(),
e.description().replace('"', "\"\""),
money(amount),
name,
a.blob_sha
));
files.push((name, bytes));
receipt_count += 1;
} }
None if a.entry_id.is_none() && a.category != "receipts" => { }
behind.sort_unstable();
let bytes = store.attachment_bytes(a.id).map_err(|e| e.to_string())?;
if behind.is_empty() {
if a.category != "receipts" {
files.push(( files.push((
format!("documents/{}/{}", clean(&a.category), clean(&a.file_name)), format!("documents/{}/{}", clean(&a.category), clean(&a.file_name)),
bytes, bytes,
)); ));
} }
_ => {} continue;
}
for e in behind.iter().filter_map(|&e| ledger.get_entry(e)) {
if e.status() != ledger_core::EntryStatus::Posted
|| e.date() < fy
|| e.date() > as_of
{
continue;
}
let name = format!(
"receipts/{}-entry-{}-{}",
e.date(),
e.id(),
clean(&a.file_name)
);
let amount: Decimal = e
.lines()
.iter()
.filter(|l| l.side == Side::Debit)
.map(|l| l.amount.amount)
.sum();
index.push_str(&format!(
"{},{},\"{}\",{},{},{}\n",
e.id(),
e.date(),
e.description().replace('"', "\"\""),
money(amount),
name,
a.blob_sha
));
files.push((name, bytes.clone()));
receipt_count += 1;
} }
} }
files.push(("receipts/index.csv".into(), index.into_bytes())); files.push(("receipts/index.csv".into(), index.into_bytes()));
+32 -6
View File
@@ -1463,6 +1463,9 @@ pub struct GetEntryOut {
pub parties: Vec<EntryPartyOut>, pub parties: Vec<EntryPartyOut>,
/// Why the shares don't resolve, when they don't. /// Why the shares don't resolve, when they don't.
pub parties_error: Option<String>, pub parties_error: Option<String>,
/// Why it has no counterparty, when it is marked so (a reversal
/// carries its original's mark).
pub no_party_reason: Option<String>,
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1989,6 +1992,12 @@ pub struct DisposeAssetParams {
/// sale) to link to the disposal entry on posting. Required when the /// sale) to link to the disposal entry on posting. Required when the
/// receipt policy applies. /// receipt policy applies.
pub receipt_attachment_ids: Option<Vec<u64>>, pub receipt_attachment_ids: Option<Vec<u64>>,
/// The buyer (party id or name), named on the disposal entry. Under
/// `[books] parties = "require"` a sale with proceeds needs one; a
/// retirement with no proceeds and no buyer is marked as having no
/// counterparty.
#[serde(default)]
pub buyer: Option<String>,
/// Confirm the disposal. Without confirmed=true returns a preview and /// Confirm the disposal. Without confirmed=true returns a preview and
/// posts nothing. /// posts nothing.
pub confirmed: Option<bool>, pub confirmed: Option<bool>,
@@ -2416,6 +2425,10 @@ pub struct RemitPayrollTaxesParams {
pub date: String, pub date: String,
/// Cash account id or code the remittance comes from. /// Cash account id or code the remittance comes from.
pub cash_account: String, pub cash_account: String,
/// The tax agency paid (party id or name, e.g. the IRS), named on the
/// remittance entry. Required under `[books] parties = "require"`.
#[serde(default)]
pub agency: Option<String>,
} }
/// Output of `books.remit_payroll_taxes`. /// Output of `books.remit_payroll_taxes`.
@@ -2530,8 +2543,10 @@ pub struct ExportCpaPackageParams {
/// `[books] investing_accounts`. /// `[books] investing_accounts`.
#[serde(default)] #[serde(default)]
pub investing_accounts: Vec<String>, pub investing_accounts: Vec<String>,
/// Include every receipt for the year's entries and the company's /// Include every receipt for the year's entries (every current file
/// documents (default true). /// filed on or linked to each posted entry, one copy per entry, each
/// with a row in receipts/index.csv) and the company's documents (the
/// files behind no entry) (default true).
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub include_receipts: bool, pub include_receipts: bool,
/// Carry every member's and 1099 payee's full TIN from the TIN vault /// Carry every member's and 1099 payee's full TIN from the TIN vault
@@ -2986,10 +3001,12 @@ pub struct ListPartiesOut {
/// liability (a card, a member paying for it, a loan; not payables or /// liability (a card, a member paying for it, a loan; not payables or
/// payroll withholdings), with cash that pays a liability down counting /// payroll withholdings), with cash that pays a liability down counting
/// once, as paid to that liability's creditor (a reimbursement to the /// once, as paid to that liability's creditor (a reimbursement to the
/// members, a card payment to the issuer), and cash raised by a liability /// members, a card payment to the issuer), a liability paid down by
/// counting as received from the lender. Positive is paid to the party, /// another counting the same way (a member paying the card from their own
/// negative received from it; a reversal counts against its original's /// funds, a loan paying it off), and cash raised by a liability counting
/// side. A transfer between the company's own cash accounts nets to zero. /// as received from the lender. Positive is paid to the party, negative
/// received from it; a reversal counts against its root original's side
/// (reversing the reversal counts on that side again). A transfer between the company's own cash accounts nets to zero.
/// A bill is not paid until its payment is. /// A bill is not paid until its payment is.
/// An entry naming several parties counts each one's share of that money /// An entry naming several parties counts each one's share of that money
/// (its amount, its percent, or its derived share: see /// (its amount, its percent, or its derived share: see
@@ -3271,6 +3288,15 @@ pub struct ReconciliationOut {
pub voided_reason: Option<String>, pub voided_reason: Option<String>,
} }
/// Parameters for `books.void_statement`.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct VoidStatementParams {
/// The attachment whose recorded statement to void.
pub attachment_id: u64,
/// Why (what was recorded wrong), recorded in the journal.
pub reason: String,
}
/// Parameters for `books.void_reconciliation`. /// Parameters for `books.void_reconciliation`.
#[derive(Debug, Clone, Deserialize, JsonSchema)] #[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct VoidReconciliationParams { pub struct VoidReconciliationParams {
+1 -1
View File
@@ -147,7 +147,7 @@ fn mcp_end_to_end_over_stdio() {
// Tool listing: the narrow tools; chart is read-only. // Tool listing: the narrow tools; chart is read-only.
let list = mcp.request("tools/list", json!({})); let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap(); 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(); let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
assert_eq!(chart["annotations"]["readOnlyHint"], true); assert_eq!(chart["annotations"]["readOnlyHint"], true);
assert!( assert!(
+170 -7
View File
@@ -1148,6 +1148,155 @@ fn void_reconciliation_lets_it_be_redone() {
assert_ne!(redo["reconciliation"]["reconciliation_id"], json!(rid)); 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 // ------------------------------------------------- import sources
#[test] #[test]
@@ -1243,9 +1392,12 @@ fn unreconciled(m: &mut Mcp) -> Vec<String> {
.collect() .collect()
} }
/// Cash accounts (`[books] cash_accounts`) and card accounts with /// Cash accounts (`[books] cash_accounts`), card accounts (liabilities
/// statements are listed until reconciled through the end of last month; /// named "card", before their first statement, so the first
/// accounts without activity by then aren't. /// 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] #[test]
fn the_digest_lists_accounts_not_reconciled_through_last_month() { fn the_digest_lists_accounts_not_reconciled_through_last_month() {
let mut m = books("digest"); let mut m = books("digest");
@@ -1263,10 +1415,15 @@ fn the_digest_lists_accounts_not_reconciled_through_last_month() {
json!({}), json!({}),
&[("6999", "debit", "40.00"), ("2050", "credit", "40.00")], &[("6999", "debit", "40.00"), ("2050", "credit", "40.00")],
); );
let owed = m.post(
&day,
json!({}),
&[("6999", "debit", "15.00"), ("2100", "credit", "15.00")],
);
assert_eq!( assert_eq!(
unreconciled(&mut m), unreconciled(&mut m),
["1000"], ["1000", "2050"],
"the card has no statement yet" "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 d = m.read_only("books.digest", json!({"since": "2020-01-01"}));
let row = &d["unreconciled_accounts"][0]; let row = &d["unreconciled_accounts"][0];
@@ -1278,15 +1435,21 @@ fn the_digest_lists_accounts_not_reconciled_through_last_month() {
.unwrap() .unwrap()
.contains("books.record_statement") .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"); let card_st = m.statement("2050", &start, &day, "40.00");
assert_eq!(unreconciled(&mut m), ["1000", "2050"]);
m.clear(card, 1); m.clear(card, 1);
m.ok( m.ok(
"books.reconcile", "books.reconcile",
json!({"statement_attachment": card_st, "confirmed": true}), json!({"statement_attachment": card_st, "confirmed": true}),
); );
assert_eq!(unreconciled(&mut m), ["1000"]); 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); m.clear(dep, 0);
let st = m.statement("1000", &start, &day, "100.00"); let st = m.statement("1000", &start, &day, "100.00");
m.ok( m.ok(
+5
View File
@@ -997,6 +997,11 @@ fn happy_path(name: &str) -> Mcp {
"books.void_reconciliation", "books.void_reconciliation",
json!({"reconciliation_id": rec, "reason": "redo after the March import"}), 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. // Periods.
let p = m.ok( 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", "books.post_entry",
json!({"entry_id": id, "confirmed": true}), 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"] { for who in ["Tobin Harlow", "Morgan Ellis"] {
let t = m.party(who, 2026)["totals"].clone(); let t = m.party(who, 2026)["totals"].clone();
assert_eq!(t["paid"], "50.00", "{who}'s half"); 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!({})); 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");
}
+1
View File
@@ -21,6 +21,7 @@
pub mod blob; pub mod blob;
pub mod error; pub mod error;
pub mod journal; pub mod journal;
pub mod reconcilable;
pub mod schema; pub mod schema;
pub mod snapshot; pub mod snapshot;
pub mod store; pub mod store;
+130
View File
@@ -0,0 +1,130 @@
//! Which accounts the monthly close reconciles, and when one is due: one
//! rule, read by the server's `books.digest` (`unreconciled_accounts`) and
//! the viewer's Needs attention and account pages alike.
//!
//! ```text
//! GOAL: the monthly close's reconciliation step can't be forgotten, and
//! the digest and the viewer never disagree on what is due.
//! SOURCE: docs/parties-and-attachments.md "Reconciliations" (Needs
//! attention: an account with activity not reconciled through last
//! month's end); CONVENTION (bank and card statements close
//! monthly); AGENTS.md's monthly close.
//! DEFINITIONS:
//! cash accounts: `[books] cash_accounts`, resolved; when none are set,
//! the asset accounts (not placeholders) whose names say they hold
//! cash ("cash", "bank", "checking", "savings", "money market").
//! card accounts: liability accounts whose names contain "card".
//! INVARIANTS:
//! 1. reconcilable_accounts: the cash accounts, then the card accounts,
//! then every other liability with a recorded statement, each once.
//! A card is expected before its first statement, so its first
//! reconciliation is asked for; a liability named otherwise (an Amex)
//! joins once its first statement is recorded.
//! 2. reconciliation_due(account) = Some(through), through the last day
//! of the month before today's, iff the account has a posted line
//! dated on or before it and no reconciliation not voided reaches it;
//! None otherwise (including activity only this month).
//! ```
use crate::{Reconciliation, Statement};
use chrono::{Datelike, NaiveDate};
use ledger_core::{AccountId, AccountType, Ledger};
/// The words an asset account's name uses to say it holds cash.
pub const CASH_WORDS: [&str; 5] = ["cash", "bank", "checking", "savings", "money market"];
/// The cash accounts: `configured` (account codes) resolved, else the
/// asset accounts whose names say they hold cash.
pub fn cash_accounts(ledger: &Ledger, configured: &[String]) -> Vec<AccountId> {
if !configured.is_empty() {
return configured
.iter()
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
.collect();
}
ledger
.chart()
.all()
.filter(|a| {
let n = a.name.to_lowercase();
a.account_type == AccountType::Asset
&& !a.placeholder
&& CASH_WORDS.iter().any(|w| n.contains(w))
})
.map(|a| a.id)
.collect()
}
/// The card accounts: liabilities whose names contain "card".
pub fn card_accounts(ledger: &Ledger) -> Vec<AccountId> {
ledger
.chart()
.all()
.filter(|a| {
a.account_type == AccountType::Liability && a.name.to_lowercase().contains("card")
})
.map(|a| a.id)
.collect()
}
/// The accounts the books reconcile monthly (INVARIANT 1).
pub fn reconcilable_accounts(
ledger: &Ledger,
cash: &[AccountId],
statements: &[Statement],
) -> Vec<AccountId> {
let mut out: Vec<AccountId> = Vec::new();
let with_statement = statements.iter().map(|s| s.account_id).filter(|&a| {
ledger
.chart()
.get(a)
.is_some_and(|x| x.account_type == AccountType::Liability)
});
for a in cash
.iter()
.copied()
.chain(card_accounts(ledger))
.chain(with_statement)
{
if !out.contains(&a) {
out.push(a);
}
}
out
}
/// The last day of the month before `today`'s.
pub fn last_month_end(today: NaiveDate) -> NaiveDate {
today
.with_day(1)
.and_then(|d| d.pred_opt())
.expect("a month before today")
}
/// The latest statement date `account` is reconciled through: its newest
/// reconciliation not voided.
pub fn reconciled_through(
reconciliations: &[Reconciliation],
account: AccountId,
) -> Option<NaiveDate> {
reconciliations
.iter()
.filter(|r| r.account_id == account && r.voided.is_none())
.map(|r| r.statement_date)
.max()
}
/// When `account` is due a reconciliation (INVARIANT 2).
pub fn reconciliation_due(
ledger: &Ledger,
reconciliations: &[Reconciliation],
account: AccountId,
today: NaiveDate,
) -> Option<NaiveDate> {
let through = last_month_end(today);
let active = ledger
.posted_entries()
.any(|e| e.date() <= through && e.lines().iter().any(|l| l.account == account));
(active && reconciled_through(reconciliations, account).is_none_or(|d| d < through))
.then_some(through)
}
+117 -10
View File
@@ -1473,12 +1473,39 @@ impl Store {
/// Mark a posted entry's line cleared (or uncleared) for bank /// Mark a posted entry's line cleared (or uncleared) for bank
/// reconciliation. Journaled as `line.cleared`. /// reconciliation. Journaled as `line.cleared`.
///
/// ```text
/// GOAL: a recorded reconciliation stays proof: the lines it cleared
/// stay cleared while it stands.
/// SOURCE: docs/parties-and-attachments.md "Reconciliations" (never
/// edited; void to redo).
/// SCENARIOS:
/// 1. IF the line is one a reconciliation not voided recorded as
/// cleared and `cleared` is false: SHOULD refuse
/// (ReconciliationRefused, naming it: void it first), journaling
/// nothing.
/// 2. Clearing a line dated on or before a reconciliation (an
/// outstanding check that clears next month) is allowed: the
/// later statement shows it.
/// 3. Otherwise as Ledger::mark_cleared.
/// ```
pub fn mark_cleared( pub fn mark_cleared(
&mut self, &mut self,
entry_id: EntryId, entry_id: EntryId,
line_index: usize, line_index: usize,
cleared: bool, cleared: bool,
) -> Result<(), StoreError> { ) -> Result<(), StoreError> {
if !cleared
&& let Some(r) = self
.reconciliations(None)?
.into_iter()
.find(|r| r.voided.is_none() && r.cleared.contains(&(entry_id, line_index)))
{
return Err(StoreError::ReconciliationRefused(format!(
"entry {entry_id} line {line_index} is cleared by reconciliation {} (account {} through {}): void that reconciliation (with a reason) to un-clear it",
r.id, r.account_id, r.statement_date
)));
}
let mut next = self.ledger.clone(); let mut next = self.ledger.clone();
next.mark_cleared(entry_id, line_index, cleared)?; next.mark_cleared(entry_id, line_index, cleared)?;
let line = self.append_journal( let line = self.append_journal(
@@ -2477,7 +2504,12 @@ impl Store {
/// 2. IF it is withdrawn: SHOULD refuse (AttachmentWithdrawn). /// 2. IF it is withdrawn: SHOULD refuse (AttachmentWithdrawn).
/// 3. IF it is linked (to any entry): SHOULD refuse (AttachmentLinked). /// 3. IF it is linked (to any entry): SHOULD refuse (AttachmentLinked).
/// 4. IF the entry is unknown: SHOULD refuse. /// 4. IF the entry is unknown: SHOULD refuse.
/// 5. Otherwise: SHOULD journal `receipt.linked`. /// 5. IF it is already linked to that entry (link_attachment): SHOULD
/// refuse (AlreadyLinked) BECAUSE filed on and linked to the same
/// entry is one receipt counted once, which neither unlink_receipt
/// nor unlink_attachment could then take off (link_attachment
/// SCENARIO 2, the other way round).
/// 6. Otherwise: SHOULD journal `receipt.linked`.
/// ``` /// ```
pub fn link_receipt( pub fn link_receipt(
&mut self, &mut self,
@@ -2488,6 +2520,10 @@ impl Store {
.get_entry(entry_id) .get_entry(entry_id)
.ok_or(LedgerError::UnknownEntry(entry_id))?; .ok_or(LedgerError::UnknownEntry(entry_id))?;
self.check_linkable(attachment_id)?; self.check_linkable(attachment_id)?;
let subject = Subject::Entry(entry_id);
if self.attachment_links(attachment_id)?.contains(&subject) {
return Err(StoreError::AlreadyLinked(attachment_id, subject.describe()));
}
let line = self.append_journal( let line = self.append_journal(
"receipt.linked", "receipt.linked",
json!({ "attachment_id": attachment_id, "entry_id": entry_id }), json!({ "attachment_id": attachment_id, "entry_id": entry_id }),
@@ -2763,10 +2799,12 @@ impl Store {
/// then `attachment.linked` to the account. /// then `attachment.linked` to the account.
/// SCENARIOS: /// SCENARIOS:
/// 1. IF the attachment is unknown or withdrawn, already records a /// 1. IF the attachment is unknown or withdrawn, already records a
/// statement, the account is unknown or not an asset or liability /// statement (not voided), the account is unknown or not an asset
/// (a bank, cash, or card account), the period runs backwards, or /// or liability (a bank, cash, or card account), the period runs
/// a balance has more than two decimal places: SHOULD refuse, /// backwards, or a balance has more than two decimal places:
/// journaling nothing. /// SHOULD refuse, journaling nothing.
/// 2. IF its statement was voided (void_statement): records it
/// afresh, the correction of a mis-recorded statement.
/// ``` /// ```
pub fn record_statement(&mut self, st: Statement) -> Result<(), StoreError> { pub fn record_statement(&mut self, st: Statement) -> Result<(), StoreError> {
let (_, withdrawn) = self.attachment_state(st.attachment_id)?; let (_, withdrawn) = self.attachment_state(st.attachment_id)?;
@@ -2818,7 +2856,57 @@ impl Store {
Ok(()) Ok(())
} }
/// The statement an attachment records, if any. /// Void a recorded statement, with the reason: what it says was
/// recorded wrong (a mistyped balance or period), or it shouldn't have
/// been recorded. Journaled as `statement.voided {attachment_id,
/// reason}`.
///
/// ```text
/// GOAL: a mis-recorded statement can be corrected on the record,
/// nothing erased (the journal keeps what it said and why it was
/// voided).
/// SOURCE: docs/parties-and-attachments.md "Reconciliations"; R-13a.
/// INVARIANTS:
/// 1. After it, the statement is no longer listed (statements,
/// statement), backs no reconciliation, and the same attachment
/// may record a statement again (record_statement).
/// SCENARIOS:
/// 1. IF the reason is blank: SHOULD refuse (ReasonRequired).
/// 2. IF the attachment records no statement: SHOULD refuse.
/// 3. IF a reconciliation not voided names it: SHOULD refuse
/// (ReconciliationRefused: void that reconciliation first).
/// 4. Every refusal journals nothing.
/// ```
pub fn void_statement(&mut self, attachment_id: u64, reason: &str) -> Result<(), StoreError> {
let reason = reason.trim();
if reason.is_empty() {
return Err(StoreError::ReasonRequired);
}
if self.statement(attachment_id)?.is_none() {
return Err(StoreError::InvalidStatement(format!(
"attachment {attachment_id} records no statement"
)));
}
if let Some(r) = self
.reconciliations(None)?
.into_iter()
.find(|r| r.voided.is_none() && r.attachment_id == attachment_id)
{
return Err(StoreError::ReconciliationRefused(format!(
"statement {attachment_id} backs reconciliation {}: void that reconciliation first",
r.id
)));
}
let line = self.append_journal(
"statement.voided",
json!({"attachment_id": attachment_id, "reason": reason}),
)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// The statement an attachment records, if any (not voided, its file
/// not withdrawn).
pub fn statement(&self, attachment_id: u64) -> Result<Option<Statement>, StoreError> { pub fn statement(&self, attachment_id: u64) -> Result<Option<Statement>, StoreError> {
Ok(self Ok(self
.statements(None)? .statements(None)?
@@ -2826,7 +2914,9 @@ impl Store {
.find(|s| s.attachment_id == attachment_id)) .find(|s| s.attachment_id == attachment_id))
} }
/// Recorded statements, optionally one account's, by period end. /// Recorded statements, optionally one account's, by period end: those
/// not voided whose file isn't withdrawn (a withdrawn file retires its
/// statement).
pub fn statements(&self, account: Option<AccountId>) -> Result<Vec<Statement>, StoreError> { pub fn statements(&self, account: Option<AccountId>) -> Result<Vec<Statement>, StoreError> {
query_statements(&self.conn, account) query_statements(&self.conn, account)
} }
@@ -2851,16 +2941,23 @@ impl Store {
/// 4. Journaled as `reconciliation.completed`; never edited (void and /// 4. Journaled as `reconciliation.completed`; never edited (void and
/// redo). /// redo).
/// SCENARIOS: /// SCENARIOS:
/// 1. IF the attachment records no statement: SHOULD refuse. /// 1. IF the attachment is withdrawn: SHOULD refuse
/// (AttachmentWithdrawn). IF it records no statement (or only a
/// voided one): SHOULD refuse.
/// 2. IF the books differ from the statement: SHOULD refuse with /// 2. IF the books differ from the statement: SHOULD refuse with
/// NotReconciled naming the difference. /// NotReconciled naming the difference.
/// 3. IF a reconciliation not voided is dated on or after it: SHOULD /// 3. IF a reconciliation not voided is dated on or after it: SHOULD
/// refuse. /// refuse.
/// 4. Every refusal comes before anything is journaled.
/// ``` /// ```
pub fn complete_reconciliation( pub fn complete_reconciliation(
&mut self, &mut self,
statement_attachment: u64, statement_attachment: u64,
) -> Result<Reconciliation, StoreError> { ) -> Result<Reconciliation, StoreError> {
let (_, withdrawn) = self.attachment_state(statement_attachment)?;
if withdrawn {
return Err(StoreError::AttachmentWithdrawn(statement_attachment));
}
let st = self.statement(statement_attachment)?.ok_or_else(|| { let st = self.statement(statement_attachment)?.ok_or_else(|| {
StoreError::InvalidStatement(format!( StoreError::InvalidStatement(format!(
"attachment {statement_attachment} records no statement (record_statement first)" "attachment {statement_attachment} records no statement (record_statement first)"
@@ -4029,6 +4126,7 @@ pub(crate) fn replay_line_into_ledger(
"attachment.linked" "attachment.linked"
| "attachment.unlinked" | "attachment.unlinked"
| "statement.recorded" | "statement.recorded"
| "statement.voided"
| "reconciliation.completed" | "reconciliation.completed"
| "reconciliation.voided" => { | "reconciliation.voided" => {
// Evidence, not accounting state: the projection records them. // Evidence, not accounting state: the projection records them.
@@ -4668,6 +4766,14 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
], ],
)?; )?;
} }
"statement.voided" => {
// The journal keeps what it said; the projection lists only
// statements that stand.
conn.execute(
"DELETE FROM statements WHERE attachment_id = ?",
params![get_u64(p, "attachment_id")? as i64],
)?;
}
"reconciliation.completed" => { "reconciliation.completed" => {
let id = get_u64(p, "id")? as i64; let id = get_u64(p, "id")? as i64;
conn.execute( conn.execute(
@@ -5326,13 +5432,14 @@ pub struct Reconciliation {
/// Recorded statements in the projection, optionally one account's, by /// Recorded statements in the projection, optionally one account's, by
/// period end (shared by [`Store::statements`] and the read-only /// period end (shared by [`Store::statements`] and the read-only
/// snapshot). /// snapshot): those not voided, leaving out any whose attachment is
/// withdrawn.
pub(crate) fn query_statements( pub(crate) fn query_statements(
conn: &Connection, conn: &Connection,
account: Option<AccountId>, account: Option<AccountId>,
) -> Result<Vec<Statement>, StoreError> { ) -> Result<Vec<Statement>, StoreError> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT attachment_id, account_id, period_start, period_end, opening_balance, closing_balance FROM statements ORDER BY period_end, attachment_id", "SELECT attachment_id, account_id, period_start, period_end, opening_balance, closing_balance FROM statements WHERE attachment_id NOT IN (SELECT id FROM attachments WHERE withdrawn_at IS NOT NULL) ORDER BY period_end, attachment_id",
)?; )?;
let rows = stmt.query_map([], |r| { let rows = stmt.query_map([], |r| {
Ok(( Ok((
+148
View File
@@ -1273,3 +1273,151 @@ fn everything_survives_replay_and_verifies() {
assert_eq!(view(&s, &subjects), before, "full replay"); assert_eq!(view(&s, &subjects), before, "full replay");
s.verify().unwrap(); s.verify().unwrap();
} }
// ------------------------------------------- withdrawn and voided statements
/// Oracle: complete_reconciliation SCENARIOS 1 and 4 (a withdrawn
/// statement file: AttachmentWithdrawn, before anything is journaled);
/// statements ("a withdrawn file retires its statement"). The statement's
/// account link is taken off and the file withdrawn; confirming then must
/// record no reconciliation, and the statement is no longer listed.
#[test]
fn a_withdrawn_statement_records_no_reconciliation() {
let dir = test_dir("withdrawn-statement");
let (mut s, fx) = fixture(&dir);
let a = jan_cash(&mut s, &fx, "jan.pdf");
clear_jan(&mut s, &fx);
s.unlink_attachment(a, Subject::Account(fx.cash), "filed wrong")
.unwrap();
s.withdraw_attachment(a, "filed wrong").unwrap();
let e = refused_nothing_journaled(&mut s, &dir, "withdrawn", |s| s.complete_reconciliation(a));
assert!(
matches!(e, StoreError::AttachmentWithdrawn(x) if x == a),
"{e:?}"
);
assert!(s.reconciliations(None).unwrap().is_empty());
assert_eq!(s.statement(a).unwrap(), None);
assert!(s.statements(Some(fx.cash)).unwrap().is_empty());
s.verify().unwrap();
}
/// Oracle: void_statement SCENARIOS 1–4 and INVARIANT 1; record_statement
/// SCENARIO 2 (a voided statement is recorded afresh on the same file);
/// the docs' `statement.voided {attachment_id, reason}`. A January
/// statement mistyped at 830.00 is voided and recorded again at 380.00,
/// then reconciled; voiding it while that reconciliation stands is
/// refused. Reopening and a full replay agree.
#[test]
fn a_mis_recorded_statement_is_voided_and_recorded_again() {
let dir = test_dir("void-statement");
let (mut s, fx) = fixture(&dir);
let a = doc(&mut s, "jan.pdf");
let typo = stmt(
a,
fx.cash,
date(2026, 1, 1),
date(2026, 1, 31),
"0.00",
"830.00",
);
s.record_statement(typo).unwrap();
for blank in ["", " "] {
let e = refused_nothing_journaled(&mut s, &dir, "blank", |s| s.void_statement(a, blank));
assert!(matches!(e, StoreError::ReasonRequired), "{e:?}");
}
let plain = doc(&mut s, "plain.pdf");
let e = refused_nothing_journaled(&mut s, &dir, "no statement", |s| {
s.void_statement(plain, "why")
});
assert!(matches!(e, StoreError::InvalidStatement(_)), "{e:?}");
s.void_statement(a, "closing balance mistyped").unwrap();
let (kind, payload) = journal(&dir).last().cloned().unwrap();
assert_eq!(kind, "statement.voided");
assert_eq!(
payload,
json!({"attachment_id": a, "reason": "closing balance mistyped"})
);
assert_eq!(s.statement(a).unwrap(), None);
assert!(s.statements(None).unwrap().is_empty());
let e = refused_nothing_journaled(&mut s, &dir, "voided twice", |s| {
s.void_statement(a, "again")
});
assert!(matches!(e, StoreError::InvalidStatement(_)), "{e:?}");
let right = stmt(
a,
fx.cash,
date(2026, 1, 1),
date(2026, 1, 31),
"0.00",
"380.00",
);
s.record_statement(right.clone()).unwrap();
assert_eq!(s.statements(None).unwrap(), vec![right.clone()]);
clear_jan(&mut s, &fx);
let rec = s.complete_reconciliation(a).unwrap();
let e = refused_nothing_journaled(&mut s, &dir, "backs a reconciliation", |s| {
s.void_statement(a, "why")
});
assert!(matches!(e, StoreError::ReconciliationRefused(_)), "{e:?}");
s.verify().unwrap();
drop(s);
delete_db(&dir);
let s = Store::open(&dir).unwrap();
assert_eq!(s.statements(None).unwrap(), vec![right]);
assert_eq!(s.reconciliations(None).unwrap(), vec![rec]);
s.verify().unwrap();
}
/// Oracle: mark_cleared SCENARIOS 1–3. A line January's reconciliation
/// recorded as cleared can't be un-cleared while it stands; once it is
/// voided it can. A line dated in January but cleared later (an
/// outstanding item) may still be cleared.
#[test]
fn a_reconciled_line_stays_cleared_until_the_reconciliation_is_voided() {
let dir = test_dir("reconciled-line");
let (mut s, fx) = fixture(&dir);
let a = jan_cash(&mut s, &fx, "jan.pdf");
clear_jan(&mut s, &fx);
let rec = s.complete_reconciliation(a).unwrap();
let e = refused_nothing_journaled(&mut s, &dir, "un-clear", |s| {
s.mark_cleared(fx.e2, 1, false)
});
assert!(matches!(e, StoreError::ReconciliationRefused(_)), "{e:?}");
assert!(s.ledger().is_cleared(fx.e2, 1));
// An outstanding January item cleared afterwards: allowed.
let late = post(
&mut s,
date(2026, 1, 30),
"Check 101",
fx.expense,
fx.cash,
"10.00",
);
s.mark_cleared(late, 1, true).unwrap();
s.mark_cleared(late, 1, false).unwrap();
s.void_reconciliation(rec.id, "redo").unwrap();
s.mark_cleared(fx.e2, 1, false).unwrap();
assert!(!s.ledger().is_cleared(fx.e2, 1));
s.verify().unwrap();
}
/// Oracle: link_receipt SCENARIO 5 (already linked to that entry: refuse,
/// AlreadyLinked, journaling nothing), the mirror of link_attachment
/// SCENARIO 2. A file linked to an entry can't then be filed on it too.
#[test]
fn a_file_linked_to_an_entry_cannot_also_be_filed_on_it() {
let dir = test_dir("linked-then-filed");
let (mut s, fx) = fixture(&dir);
let (a, _, _) = s
.attach_receipt(None, "r.pdf", "application/pdf", b"%PDF r", None)
.unwrap();
s.link_attachment(a, Subject::Entry(fx.e2)).unwrap();
let e = refused_nothing_journaled(&mut s, &dir, "filed on too", |s| s.link_receipt(a, fx.e2));
assert!(
matches!(e, StoreError::AlreadyLinked(x, _) if x == a),
"{e:?}"
);
// Another entry is fine: filed on one, linked to another.
s.link_receipt(a, fx.e3).unwrap();
s.verify().unwrap();
}
+73 -28
View File
@@ -138,8 +138,9 @@ below.
preview/confirm pattern as post. preview/confirm pattern as post.
- `books.get_entry { entry_id }` → full entry with lines, receipts, and - `books.get_entry { entry_id }` → full entry with lines, receipts, and
its `vendor`, `party_id`, and `party` (the party's label, when it names its `vendor`, `party_id`, and `party` (the party's label, when it names
exactly one) tags, and `parties` / `parties_error` as in the exactly one) tags, `parties` / `parties_error` as in the `post_entry`
`post_entry` preview. preview, and `no_party_reason` when it is marked as having no
counterparty.
- `books.search_entries { account?, date_from?, date_to?, text?, limit?, offset? }` - `books.search_entries { account?, date_from?, date_to?, text?, limit?, offset? }`
→ entries with lines, newest first. `limit` caps at 100. → entries with lines, newest first. `limit` caps at 100.
- `books.trial_balance { as_of? }` → `as_of`, lines, totals, `balanced` - `books.trial_balance { as_of? }` → `as_of`, lines, totals, `balanced`
@@ -165,10 +166,13 @@ below.
ask the human, then update it upstream (pull request or issue at ask the human, then update it upstream (pull request or issue at
github.com/swaits/wanderbooks). `unreconciled_accounts` lists the cash github.com/swaits/wanderbooks). `unreconciled_accounts` lists the cash
and card accounts due for reconciliation (`{ code, name, and card accounts due for reconciliation (`{ code, name,
reconciled_through?, due_through, next_step }`): every `[books] reconciled_through?, due_through, next_step }`): the cash accounts
cash_accounts` account and every liability with a recorded statement (`[books] cash_accounts`, else asset accounts named for cash: cash,
that has posted activity through the end of last month but no bank, checking, savings, money market), the card accounts (liabilities
reconciliation (not voided) through it. named "card", before their first statement), and every other liability
with a recorded statement, that have posted activity through the end of
last month but no reconciliation (not voided) through it. The viewer's
Needs attention uses the same rule (`ledger_store::reconcilable`).
## Tools — fixed assets ## Tools — fixed assets
@@ -197,11 +201,16 @@ so the lifetime total is exactly (cost − salvage).
→ per-asset `{ entry_id, amount, already_posted }`, plus `skipped` for → per-asset `{ entry_id, amount, already_posted }`, plus `skipped` for
assets outside their useful life. Idempotent per asset and period. assets outside their useful life. Idempotent per asset and period.
- `books.dispose_asset { asset_id | tag, date, proceeds, proceeds_account, - `books.dispose_asset { asset_id | tag, date, proceeds, proceeds_account,
gain_loss_account, confirmed?, receipt_attachment_ids? }` → without gain_loss_account, buyer?, confirmed?, receipt_attachment_ids? }` →
without
confirmation: `{ status: "preview", ... }` and posts nothing; with confirmation: `{ status: "preview", ... }` and posts nothing; with
`confirmed: true`: auto-posts missing final-period depreciation, then `confirmed: true`: auto-posts missing final-period depreciation, then
posts the balanced disposal entry. Subject to receipt policy (bill of posts the balanced disposal entry. Subject to receipt policy (bill of
sale); monthly depreciation is exempt as an allocation. sale); monthly depreciation is exempt as an allocation. `buyer` (a
party) is the disposal entry's party; under `[books] parties =
"require"` a sale with proceeds without one is refused before anything
posts (`party_required`); a retirement with no proceeds and no buyer is
marked as having no counterparty.
- `books.export { report: "fixed_asset_register", format: json | csv, - `books.export { report: "fixed_asset_register", format: json | csv,
as_of? }`: every asset registered by the date (tag, serial, cost, as_of? }`: every asset registered by the date (tag, serial, cost,
depreciation by schedule, book value, disposal), totals, and the depreciation by schedule, book value, disposal), totals, and the
@@ -447,12 +456,19 @@ include vendor and customer when it has those records.
payables and payroll withholding roles (`books.roles`). A liability payables and payroll withholding roles (`books.roles`). A liability
counts as how the company paid or was paid: a card, a member paying for counts as how the company paid or was paid: a card, a member paying for
it, a loan. it, a loan.
- `paid` / `received`: each entry's money lines, credits minus debits; a - `paid` / `received`: each entry's money lines, credits minus debits
positive net was paid to the party, a negative one received from it; a (`ledger_core::parties::entry_flow`), with a liability paid down
reversal counts against its original's side. A bill isn't paid until its counting once, as paid to its creditor: by cash (a reimbursement to the
payment is; an entry only moving money between money accounts nets to members, a card payment) or by another liability (a member paying the
zero. An entry naming several parties counts this party's resolved card from their own funds, a loan paying it off); cash raised by a
share only (an entry whose shares don't resolve counts for none). liability is received from the lender. A positive net was paid to the
party, a negative one received from it; a reversal counts against its
root original's side (reversing the reversal counts on that side
again). A bill isn't paid until its payment is; a transfer between the
company's own cash accounts nets to zero. An entry naming several
parties counts this party's resolved share only (an entry whose shares
don't resolve counts for none). The year is the whole calendar year;
the viewer's party page and directory show these same numbers.
- `invoiced` / `billed`: its invoices' and bills' totals dated in the year. - `invoiced` / `billed`: its invoices' and bills' totals dated in the year.
`owed` / `receivable`: its open bills' and invoices' balances now (every `owed` / `receivable`: its open bills' and invoices' balances now (every
year). year).
@@ -565,7 +581,8 @@ the receipt policy counts it.
exists. `link_to` as in "Tools — attachments". exists. `link_to` as in "Tools — attachments".
- `books.link_receipt { receipt_id, entry_id }` → link a previously filed - `books.link_receipt { receipt_id, entry_id }` → link a previously filed
(unlinked, not withdrawn) receipt to its entry. A receipt linked elsewhere (unlinked, not withdrawn) receipt to its entry. A receipt linked elsewhere
must be unlinked first. must be unlinked first; one already linked to that entry
(`books.link_attachment`) is refused, as filing it there twice.
- `books.unlink_receipt { attachment_id, reason }` → `{ ok, entry_id, - `books.unlink_receipt { attachment_id, reason }` → `{ ok, entry_id,
remaining_receipts }`. Detaches a receipt from its entry; it stays filed, remaining_receipts }`. Detaches a receipt from its entry; it stays filed,
unlinked. Journaled as `receipt.unlinked` with the reason. Subject to the unlinked. Journaled as `receipt.unlinked` with the reason. Subject to the
@@ -782,10 +799,17 @@ R-FUTURE-3 and rides the engine roadmap, not the MCP layer.
Records what a filed statement says (balances as printed, in the Records what a filed statement says (balances as printed, in the
account's normal direction, cents at most) and links it to the account. account's normal direction, cents at most) and links it to the account.
Journaled as `statement.recorded` then `attachment.linked`. Refused for Journaled as `statement.recorded` then `attachment.linked`. Refused for
an unknown or withdrawn attachment, one already recording a statement, an unknown or withdrawn attachment, one already recording a statement
an account that isn't an asset or liability, or a period that runs (not voided), an account that isn't an asset or liability, or a period
backwards. that runs backwards.
- `books.statements { account? }` → `{ statements }`, by statement date. - `books.statements { account? }` → `{ statements }`, by statement date:
those not voided whose file isn't withdrawn (withdrawing a statement's
file retires it).
- `books.void_statement { attachment_id, reason }` → the statement as it
was recorded. For a statement recorded wrong: journaled as
`statement.voided`; it is no longer listed, backs no reconciliation, and
the same attachment can record it again. Refused while a reconciliation
not voided rests on it.
- `books.reconcile { statement_attachment? | account, statement_date, - `books.reconcile { statement_attachment? | account, statement_date,
statement_balance, confirmed? }` → `{ status: "preview" | "reconciled", statement_balance, confirmed? }` → `{ status: "preview" | "reconciled",
account_code, statement_date, statement_attachment?, book_balance, account_code, statement_date, statement_attachment?, book_balance,
@@ -796,8 +820,9 @@ R-FUTURE-3 and rides the engine roadmap, not the MCP layer.
account, date, and balance are the recorded statement's (any given must account, date, and balance are the recorded statement's (any given must
agree). Without `confirmed: true` it is a preview that records nothing; agree). Without `confirmed: true` it is a preview that records nothing;
`problems` lists why a confirmation would be refused. Confirmed, it `problems` lists why a confirmation would be refused. Confirmed, it
needs a recorded statement, a difference of exactly zero, and a date needs a recorded statement (its file not withdrawn: refused before
after the account's last reconciliation not voided; it then journals anything is journaled), a difference of exactly zero, and a date after
the account's last reconciliation not voided; it then journals
`reconciliation.completed` (the cleared lines, the statement) and links `reconciliation.completed` (the cleared lines, the statement) and links
the statement to it, returning `reconciliation`. the statement to it, returning `reconciliation`.
- `books.reconciliations { account? }` → `{ reconciliations: [{ - `books.reconciliations { account? }` → `{ reconciliations: [{
@@ -807,6 +832,10 @@ R-FUTURE-3 and rides the engine roadmap, not the MCP layer.
- `books.void_reconciliation { reconciliation_id, reason }` → the - `books.void_reconciliation { reconciliation_id, reason }` → the
reconciliation, voided. Journaled as `reconciliation.voided`; the reconciliation, voided. Journaled as `reconciliation.voided`; the
account can be reconciled through that date again. account can be reconciled through that date again.
- `books.mark_cleared` refuses to un-clear a line a reconciliation not
voided recorded as cleared: void the reconciliation first. Clearing a
line dated on or before a reconciliation (an outstanding check that
clears on a later statement) is allowed.
## Tools — A/R and A/P ## Tools — A/R and A/P
@@ -851,9 +880,15 @@ entries.
`employee` names the employee's party (id or name; it must have the `employee` names the employee's party (id or name; it must have the
employee role, else nothing is written); new runs should name it, and employee role, else nothing is written); new runs should name it, and
`employee_name` then defaults to the party's name. A line needs one or `employee_name` then defaults to the party's name. A line needs one or
the other. the other. When every line with net pay names its party, the entry
- `books.remit_payroll_taxes { date, cash_account }` → `{ entry_id, total }`. names those parties, each with its net pay, so each employee's
Clears every payroll payable to cash in one entry. `books.party` counts it; under `[books] parties = "require"` a line
with net pay and no party is refused before anything posts
(`party_required`).
- `books.remit_payroll_taxes { date, cash_account, agency? }` → `{
entry_id, total }`. Clears every payroll payable to cash in one entry,
naming `agency` (the tax agency's party) on it; required under
`[books] parties = "require"` (`party_required`, nothing posted).
- `books.list_payroll_runs` → `{ runs: [{ id, date, employee_count, - `books.list_payroll_runs` → `{ runs: [{ id, date, employee_count,
total_gross, entry_id }] }`. total_gross, entry_id }] }`.
@@ -874,6 +909,12 @@ entries.
- `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?, - `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?,
investing_accounts?, include_receipts?, include_tins?, save_to? }` → investing_accounts?, include_receipts?, include_tins?, save_to? }` →
`{ file_name, content_base64?, saved_to?, tins_included, tin_gaps, note }`. `{ file_name, content_base64?, saved_to?, tins_included, tin_gaps, note }`.
**Receipts** (`include_receipts`, default true): `receipts/` holds every
current file behind each posted entry of the fiscal year to date, filed
on it or linked to it (`books.link_attachment`; a linked file counts as
the entry's receipt), one copy per entry, each with a row in
`receipts/index.csv`; `documents/<category>/` holds the files behind no
entry, receipts excepted.
**TINs** (`include_tins`, default true; DECISION, the owner, 2026-10-15): **TINs** (`include_tins`, default true; DECISION, the owner, 2026-10-15):
the K-1 worksheet and the 1099 worksheet carry every member's and the K-1 worksheet and the 1099 worksheet carry every member's and
payee's full TIN from the vault, so the zip is written to `save_to` (a payee's full TIN from the vault, so the zip is written to `save_to` (a
@@ -892,10 +933,14 @@ entries.
contributions_cash, contributions_property, net_income, distributions, contributions_cash, contributions_property, net_income, distributions,
other, ending_capital` for the fiscal year to date: the statement of other, ending_capital` for the fiscal year to date: the statement of
members' equity's figures (`ledger_core::k1`), contributions split into members' equity's figures (`ledger_core::k1`), contributions split into
property (what a contribution debits to asset accounts other than cash, property (what an entry crediting a member's capital debits to asset
receivables, and prepaid accounts: a prepaid schedule's account, or one accounts other than cash and receivables: equipment, software, or a
named "prepaid") and cash (the rest, including a service the members prepaid service contract the members owned and handed over straight to
paid ahead). Property in an entry crediting several members is shared capital, e.g. AppleCare+ contributed with a laptop) and cash (the rest:
deposits, and what the members paid for the company, including a
service they paid a vendor ahead for, booked through Due to
Shareholders and then converted to capital; DECISION, the owner,
2026-10-15). Property in an entry crediting several members is shared
in proportion to their credits. An entry and its reversal both in the in proportion to their credits. An entry and its reversal both in the
year cancel: neither is a contribution or a distribution. A member's year cancel: neither is a contribution or a distribution. A member's
party is the party with the member's name (not a display name) and the party is the party with the member's name (not a display name) and the
+44 -5
View File
@@ -109,6 +109,16 @@ derived. Spec: `ledger_core::shares`.
member gets what their own lines say (and those must account for all member gets what their own lines say (and those must account for all
it moved); otherwise each gets their income share, rescaled to the it moved); otherwise each gets their income share, rescaled to the
members named, rounded as percents are. members named, rounded as percents are.
- **Posted is forever, the split included:** when an entry naming
several parties with derived shares posts (or a posted entry is tagged
so), the server records the exact amounts they resolve to
(`entry.parties_set` with amounts), so a later change to
`[[company.members]]` (a new income split, a member archived) never
re-splits posted history. A draft's derived shares follow the settings
until it posts. An archived member stays a member for their history
(`Company::party_members` falls back to the archived party of that
name). Entries posted with derived shares before this rule keep them
until re-tagged with `books.set_entry_party`.
- **Refused** (journaling nothing): a party named twice, an unknown - **Refused** (journaling nothing): a party named twice, an unknown
party, mixed kinds of share, percents not summing to 100, an amount party, mixed kinds of share, percents not summing to 100, an amount
finer than a cent, and two parties with vendor records. A 1099 counts finer than a cent, and two parties with vendor records. A 1099 counts
@@ -126,6 +136,14 @@ derived. Spec: `ledger_core::shares`.
every share, so a reversal subtracts each party's part; re-tagging every share, so a reversal subtracts each party's part; re-tagging
either one (parties, shares, or vendor) re-tags the other, so the pair either one (parties, shares, or vendor) re-tags the other, so the pair
always nets to zero. always nets to zero.
A reversal counts on its root original's side, so reversing the
reversal reinstates the payment as paid (never a negative received).
- **One number everywhere:** `books.party`, `books.parties`, and the
viewer's party page and directory read cash as the server does
(`[books] cash_accounts`, else every asset account but receivables)
and count the whole calendar year, so a posted entry dated later this
year counts in both. The viewer shows the year up to the later of today
and its last posted entry.
- **For the agent:** a joint member transaction (a reimbursement on Due - **For the agent:** a joint member transaction (a reimbursement on Due
to Shareholders, a contribution or distribution from joint funds) to Shareholders, a contribution or distribution from joint funds)
names both members with no shares and lets them derive; an uneven names both members with no shares and lets them derive; an uneven
@@ -315,17 +333,26 @@ statement_balance, cleared: [(entry_id, line)], attachment_id}`. It
refuses unless the difference is exactly zero and a statement for that refuses unless the difference is exactly zero and a statement for that
account and date is attached (the receipt rule's twin). A reconciliation account and date is attached (the receipt rule's twin). A reconciliation
is never edited; a later one supersedes it; `reconciliation.voided is never edited; a later one supersedes it; `reconciliation.voided
{id, reason}` exists for mistakes. Viewer: per-account reconciliation {id, reason}` exists for mistakes. While it stands, the lines it
recorded as cleared can't be un-cleared (void it first), and nothing is
journaled when it is refused (a withdrawn statement file included). A
statement recorded wrong is voided (`statement.voided {attachment_id,
reason}`, refused while a reconciliation rests on it) and recorded again
on the same file; withdrawing a statement's file retires its statement. Viewer: per-account reconciliation
history with its statement; Needs-attention: an account with activity not history with its statement; Needs-attention: an account with activity not
reconciled through last month's end. reconciled through last month's end.
Tools (built; `docs/mcp-interface.md` "Tools — bank reconciliation"): Tools (built; `docs/mcp-interface.md` "Tools — bank reconciliation"):
`books.record_statement`, `books.statements`, `books.reconcile` (preview `books.record_statement`, `books.statements`, `books.reconcile` (preview
by default; `statement_attachment` + `confirmed: true` records it), by default; `statement_attachment` + `confirmed: true` records it),
`books.reconciliations`, `books.void_reconciliation`; `books.show `books.reconciliations`, `books.void_reconciliation`,
reconciliation`; `books.digest` `unreconciled_accounts` (the `[books] `books.void_statement`; `books.show
cash_accounts` and liabilities with statements, not reconciled through reconciliation`; `books.digest` `unreconciled_accounts` and the
last month's end). The statement of a reconciliation not voided can't be viewer's Needs attention, one rule (`ledger_store::reconcilable`): the
cash accounts (`[books] cash_accounts`, else assets named for cash), the
card accounts (liabilities named "card", before their first statement),
and other liabilities with statements, not reconciled through last
month's end). The statement of a reconciliation not voided can't be
unlinked from it. unlinked from it.
### Import sources ### Import sources
@@ -378,6 +405,18 @@ without forcing one style. `[books] parties = "off" | "suggest" |
- `require`: posting is refused until the entry names a party or is - `require`: posting is refused until the entry names a party or is
marked as having none (transfers, depreciation, amortization, closes marked as having none (transfers, depreciation, amortization, closes
are exempt by kind). are exempt by kind).
The engine's own posting paths name their counterparty or are refused
first, so nothing posts around the rule: `books.run_payroll` names each
line's employee party on the entry (each with their net pay) and, under
`require`, refuses a line without one; `books.remit_payroll_taxes`
takes `agency` (required under `require`); `books.dispose_asset` takes
`buyer` (required under `require` for a sale with proceeds; a
retirement with no proceeds and no buyer is marked as having none).
- **Marks follow the chain:** a reversal carries its original's parties,
shares, vendor tag, and no-party mark; marking or tagging any entry of a
reversal chain marks or tags them all. Tagging a vendor (or any party)
clears a no-party mark, so an entry never names a party and carries the
mark at once.
Also: memorized matches (journaled rules like description contains Also: memorized matches (journaled rules like description contains
"OPENROUTER" → OpenRouter, applied by bank-CSV import and new drafts, "OPENROUTER" → OpenRouter, applied by bank-CSV import and new drafts,
+6 -2
View File
@@ -238,8 +238,12 @@ closed period).
both in a statement's span are neither contributions nor both in a statement's span are neither contributions nor
distributions (`reports::cancelled_in_span`; the K-1 worksheet reads distributions (`reports::cancelled_in_span`; the K-1 worksheet reads
the same). The K-1 worksheet's contributed property (Item L) is what the same). The K-1 worksheet's contributed property (Item L) is what
the member owned and handed over, with its own basis (§723): never a the member owned and handed over, with its own basis (§723): a prepaid
prepaid service they paid for the company (cash), and shared among service contract credited straight to capital is property (a contract
handed over), while a prepaid service they paid a vendor for on the
company's behalf, booked through Due to Shareholders (Dr prepaid / Cr
2100, then Dr 2100 / Cr capital), is cash (DECISION, the owner,
2026-10-15); and it is shared among
members credited in one entry in proportion to their credits members credited in one entry in proportion to their credits
(`k1` INVARIANTS 3, 4, 6). (`k1` INVARIANTS 3, 4, 6).