P3: party totals by what moved the money, once (defects 14, 11, 24)

Each party's part of an entry is signed as its own account would show it,
with a direction and its cash and in-kind amounts; a reversal is its
original negated. Totals on a cash or all basis: for a member, all equals
the members' equity statement and cash the K-1's cash. Money is cash,
cards and the financing accounts, and a liability credit never pays its
own creditor (creditor_of), so accruals and declared distributions count
once. tin_last4 from the TIN or W-9 facts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-21 12:12:26 -06:00
1 parent 9ab003d0d1
commit c2ceabbae9
52 files changed
+7535 -560

No files matched your search

+10 -11
View File
@@ -223,10 +223,11 @@ fn vendor_part(payment: Decimal, share: Decimal, target: Decimal) -> Result<Deci
/// their share; DECISION, owner, 2026-10-15).
/// DEFINITIONS:
/// payment(e): credits minus debits to `sources` on entry e.
/// money: crate::parties::money_accounts(ledger, sources.cash), what the
/// shares resolve against (the MCP server checks them with the same).
/// target(e): crate::shares::share_target(e, money): the entry_flow net,
/// else the total debits.
/// money: crate::parties::money_accounts(ledger, sources.cash,
/// sources.financing), what the shares resolve against (the MCP server
/// checks them with the same).
/// target(e): crate::shares::target(e, money).amount: what the shares
/// split (rules.md B-35); a reversal's is its original's negated.
/// INVARIANTS:
/// 1. Only posted entries dated in `year` (and, given `through`, on or
/// before it) and tagged with a vendor (Ledger::entry_vendor) count,
@@ -277,7 +278,7 @@ pub fn vendor_1099_report_with_members(
Some(t) => t,
None => default_threshold(year)?,
};
let money = crate::parties::money_accounts(ledger, &sources.cash);
let money = crate::parties::money_accounts(ledger, &sources.cash, &sources.financing);
let mut paid: std::collections::HashMap<VendorId, Decimal> = Default::default();
let mut unresolved = Vec::new();
for entry in ledger.posted_entries() {
@@ -293,12 +294,10 @@ pub fn vendor_1099_report_with_members(
cash
} else {
let party = ledger.party_of_vendor(vendor);
match crate::shares::resolve(entry, shares, &money, members) {
Ok(parts) => {
match parts.into_iter().find(|(p, _)| Some(*p) == party) {
Some((_, share)) => {
vendor_part(cash, share, crate::shares::share_target(entry, &money)?)?
}
match crate::shares::resolve(ledger, entry.id(), shares, &money, members) {
Ok(resolved) => {
match resolved.parts.iter().find(|p| Some(p.party) == party) {
Some(share) => vendor_part(cash, share.amount, resolved.target.amount)?,
// Tagged by hand with a vendor none of the parties
// is: nothing to scale by, so the whole payment.
None => {
+1 -1
View File
@@ -281,7 +281,7 @@ pub fn k1_detail(
/// largest remainder. Each part is at most its own credit.
/// Refused when whole × credit is beyond exact arithmetic (credits near
/// the line limit; P0 review round 3, S1).
fn share_property(
pub(crate) fn share_property(
pool: Decimal,
credits: &[Decimal],
places: u32,
+222 -1
View File
@@ -36,7 +36,10 @@
//! to zero, and a reversal's parts are the original's negated (B-14);
//! * what a member or lender paid or received for the company, with no
//! cash account moving, is split among the kinds it settled, netting to
//! zero, and mirrored by a reversal (B-14 rule 11).
//! zero, and mirrored by a reversal (B-14 rule 11);
//! * a party's part of an entry is signed as a line on the party's own
//! account would be, a reversal's is its original's negated, and what of
//! it counts as money or as property never exceeds it (B-35).
use crate::{AccountType, Side};
use rust_decimal::Decimal;
@@ -642,6 +645,224 @@ pub fn exchanged<T: Amount>(debits: T, credits: T, inflow: T, outflow: T) -> Opt
Some(d.min(c).max(T::ZERO))
}
/// Which way an entry moved value with its parties (rules.md B-35).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Direction {
/// The company paid the party, or gave it value: a line on the
/// party's own account would be a debit.
Paid,
/// The company received from the party: a line on its own account
/// would be a credit.
Received,
/// Neither: nothing moved with the party yet (a bill, an invoice, a
/// fee charged on its own card, a distribution still owed), or the
/// entry moved nothing with anyone (a close, a reclassification).
/// Counted in no total.
Neither,
}
impl Direction {
/// The other way: paid and received swap; neither stays neither.
pub fn flip(self) -> Self {
match self {
Direction::Paid => Direction::Received,
Direction::Received => Direction::Paid,
Direction::Neither => Direction::Neither,
}
}
/// The direction of a signed amount: paid above zero, received below,
/// neither at zero.
pub fn of<T: Amount>(amount: T) -> Self {
if amount > T::ZERO {
Direction::Paid
} else if amount < T::ZERO {
Direction::Received
} else {
Direction::Neither
}
}
/// Its wire name: `paid`, `received`, or `neither`.
pub fn key(self) -> &'static str {
match self {
Direction::Paid => "paid",
Direction::Received => "received",
Direction::Neither => "neither",
}
}
}
/// What an entry's parties split, and what of it counts in their totals
/// (rules.md B-35).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PartyTarget<T> {
/// The whole, signed as a line on the parties' own account would be
/// (debit-positive): positive, the company paid or gave; negative, it
/// received.
pub amount: T,
/// Which way value moved.
pub direction: Direction,
/// The part of `amount` that moved as money (of a member's
/// contribution, what the K-1 worksheet counts as cash; of a
/// distribution, the money it paid): counted on both bases.
pub cash: T,
/// The part of `amount` that moved as property: counted on basis
/// "all" only.
pub in_kind: T,
}
/// The magnitude of `a`, or `None` past the type's range.
fn magnitude<T: Amount>(a: T) -> Option<T> {
if a < T::ZERO {
a.checked_neg()
} else {
Some(a)
}
}
/// The magnitude `m` with the sign of `like`, never a negative zero (a
/// zero `m` is the plain zero, whatever sign arithmetic left on it).
fn with_sign<T: Amount>(like: T, m: T) -> Option<T> {
if m == T::ZERO {
Some(T::ZERO)
} else if like < T::ZERO {
m.checked_neg()
} else {
Some(m)
}
}
/// The smaller of `a` and `b`.
fn least<T: Amount>(a: T, b: T) -> T {
if a < b { a } else { b }
}
/// What an entry's parties split and what of it counts (rules.md B-35
/// item 3, every case but a close and a reversal, which the caller
/// handles: a close is its parties' own-account movement with direction
/// neither, a reversal is [`reverse_target`] of its original's).
///
/// ```text
/// GOAL: one signed whole for an entry's parties, debit-positive as a line
/// on their own account, with its direction and what of it moved as
/// money or as property, so a property contribution and its
/// reversal never read alike, and a promise is never counted as if
/// it were paid.
/// SOURCE: rules.md B-35 (B-2 signs; DECISION, owner, 2026-10-20: a
/// member's entry counts what moved their capital, money is cash,
/// cards, the members' financing and loans, and a liability credit
/// never counts as paying that liability's own creditor; Helvering
/// v. Price: a promise to pay is not payment).
/// INPUTS (each signed debit-positive over the entry's lines):
/// capital: Some((e, property)) when every party named is a member: e
/// the movement of equity other than retained earnings; property what
/// property accounts took in (their debits, when e < 0) or gave up
/// (their credits, when e > 0).
/// net: the money paid (+) or received (−) (parties::entry_flow, a
/// named party's own liabilities not counted as money).
/// own: the movement of the parties' own accounts (payables,
/// receivables, liabilities owed to a party named).
/// debits: the entry's total debits.
/// INVARIANTS:
/// 1. capital with e < 0 (a contribution): amount = e; |in_kind| =
/// min(|e|, property⁺); cash the rest (what the K-1 worksheet counts
/// as cash: money, or what the member paid for the company).
/// 2. capital with e > 0 (a distribution): amount = e; cash = min(e,
/// net⁺), the money it paid; in_kind = min(e − cash, property⁺), the
/// property it gave; the rest (credited to a liability that isn't
/// money for the entry: a distribution declared, still owed) counts
/// as nothing until it is paid.
/// 3. In 1 and 2, direction is the sign of e, or neither when nothing
/// counts.
/// 4. Else net ≠ 0: amount = cash = net, in_kind = 0, direction its
/// sign.
/// 5. Else amount = own when it isn't zero, else debits; neither.
/// 6. cash and in_kind each have amount's sign or are zero (never a
/// negative zero), |cash| + |in_kind| ≤ |amount|, and direction is
/// neither exactly when both are zero.
/// 7. Odd, but for the debits fallback and a member's entry whose cash
/// isn't exactly its money (an expense booked straight to capital, a
/// debt converted, a distribution still owed): negating e, net and
/// own (property comes from the other side of the lines, so it keeps
/// its size) gives reverse_target of the result.
/// ```
///
/// `None` past the type's range.
pub fn party_target<T: Amount>(
capital: Option<(T, T)>,
net: T,
own: T,
debits: T,
) -> Option<PartyTarget<T>> {
if let Some((e, property)) = capital
&& e != T::ZERO
{
let whole = magnitude(e)?;
let property = if property > T::ZERO {
property
} else {
T::ZERO
};
let (cash, in_kind) = if e < T::ZERO {
let in_kind = least(property, whole);
(whole.checked_sub(in_kind)?, in_kind)
} else {
let paid = if net > T::ZERO {
least(net, whole)
} else {
T::ZERO
};
(paid, least(property, whole.checked_sub(paid)?))
};
let counted = cash.checked_add(in_kind)?;
return Some(PartyTarget {
amount: e,
direction: if counted == T::ZERO {
Direction::Neither
} else {
Direction::of(e)
},
cash: with_sign(e, cash)?,
in_kind: with_sign(e, in_kind)?,
});
}
if net != T::ZERO {
return Some(PartyTarget {
amount: net,
direction: Direction::of(net),
cash: net,
in_kind: T::ZERO,
});
}
Some(PartyTarget {
amount: if own != T::ZERO { own } else { debits },
direction: Direction::Neither,
cash: T::ZERO,
in_kind: T::ZERO,
})
}
/// A reversal's target from its original's (rules.md B-35 item 3; B-9: a
/// correction is the opposite entry): every amount negated (never to a
/// negative zero), paid and received swapped. An involution. `None` past
/// the type's range.
pub fn reverse_target<T: Amount>(t: PartyTarget<T>) -> Option<PartyTarget<T>> {
let neg = |a: T| {
if a == T::ZERO {
Some(T::ZERO)
} else {
a.checked_neg()
}
};
Some(PartyTarget {
amount: neg(t.amount)?,
direction: t.direction.flip(),
cash: neg(t.cash)?,
in_kind: neg(t.in_kind)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
+99
View File
@@ -2358,6 +2358,14 @@ impl Ledger {
.collect()
}
/// Whether `entry` is a period's closing entry ([`Self::closing_entry_ids`]
/// without building the set: O(periods)).
pub fn is_closing_entry(&self, entry: EntryId) -> bool {
self.periods
.values()
.any(|p| p.closing_entry == Some(entry))
}
/// All periods, in creation order.
pub fn list_periods(&self) -> Vec<&Period> {
let mut ids: Vec<PeriodId> = self.periods.keys().copied().collect();
@@ -3272,6 +3280,7 @@ impl Ledger {
tin: None,
vendor: None,
customer: None,
creditor_of: std::collections::BTreeSet::new(),
},
);
id
@@ -3397,6 +3406,7 @@ impl Ledger {
tin: None,
vendor: None,
customer: None,
creditor_of: std::collections::BTreeSet::new(),
},
);
Ok(id)
@@ -3653,6 +3663,95 @@ impl Ledger {
Ok(())
}
/// Name a party the creditor of a liability account the company owes
/// it itself on (`on`), or stop (`!on`): the card it issued, the loan
/// it made, what the members advanced (each member).
///
/// ```text
/// GOAL: the books know whose each card, loan and advance is, so a
/// credit to it never counts as paying that party (a fee on its
/// own card, interest on its own loan, a distribution still owed
/// to a member: a promise, not a payment).
/// SOURCE: crate::parties INVARIANT 5; rules.md B-35 (DECISION, owner,
/// 2026-10-20: "a liability credit never counts as paying that
/// liability's own creditor").
/// SCENARIOS:
/// 1. IF the party or the account is unknown: SHOULD refuse.
/// 2. IF the account isn't a liability, is a placeholder, or is
/// payables or a payroll withholding (owed to many, through their
/// own records): SHOULD refuse, changing nothing.
/// 3. Naming it twice, or stopping one never named, changes nothing.
/// 4. Several parties may be one account's creditors (both members,
/// for what the members advanced).
/// ```
pub fn set_party_creditor(
&mut self,
id: crate::PartyId,
account: AccountId,
on: bool,
) -> Result<(), LedgerError> {
self.check_set_party_creditor(id, account, on)?;
let p = self.parties.get_mut(&id).expect("checked");
if on {
p.creditor_of.insert(account);
} else {
p.creditor_of.remove(&account);
}
Ok(())
}
/// The validation behind [`Self::set_party_creditor`].
pub fn check_set_party_creditor(
&self,
id: crate::PartyId,
account: AccountId,
on: bool,
) -> Result<(), LedgerError> {
if !self.parties.contains_key(&id) {
return Err(LedgerError::UnknownParty(id));
}
let a = self
.chart
.get(account)
.ok_or(LedgerError::UnknownAccount(account))?;
if !on {
return Ok(());
}
if a.account_type != AccountType::Liability {
return Err(LedgerError::InvalidParty(format!(
"account {} isn't a liability: a party is the creditor only of what the company owes it (a card, a loan, what the members advanced)",
a.code
)));
}
if a.placeholder {
return Err(LedgerError::InvalidParty(format!(
"account {} is a placeholder: name the creditor of the account under it that is posted to",
a.code
)));
}
let many = PAYROLL[2..]
.iter()
.chain([&PAYABLES])
.any(|c| self.role_account(c) == Some(account));
if many {
return Err(LedgerError::InvalidParty(format!(
"account {} is owed to many parties through their own records (bills, payroll): it has no one creditor",
a.code
)));
}
Ok(())
}
/// The parties named the creditor of `account`, by id
/// ([`Self::set_party_creditor`]).
pub fn creditors_of(&self, account: AccountId) -> Vec<crate::PartyId> {
self.parties
.values()
.filter(|p| p.creditor_of.contains(&account))
.map(|p| p.id)
.collect()
}
/// Record (or clear) a party's W-4 facts.
pub fn set_party_w4(
&mut self,
+341 -77
View File
@@ -26,6 +26,12 @@
//! 3. Names are unique, ignoring case and surrounding space, among parties
//! not archived.
//! 4. Every vendor and customer record links to exactly one party.
//! 5. A party's `creditor_of` names only liability accounts that are
//! not placeholders, payables or payroll withholdings: what the
//! company owes that party itself on (a card's issuer, a loan's
//! lender, a member for what the members advanced). A credit to one
//! never counts as paying that party (rules.md B-35; DECISION, owner,
//! 2026-10-20).
//! ```
use chrono::NaiveDate;
@@ -247,6 +253,11 @@ pub struct Party {
/// Its customer record, if the company invoices it.
#[serde(default)]
pub customer: Option<crate::CustomerId>,
/// The liability accounts the company owes this party itself on: the
/// card it issued, the loan it made, what the members advanced (each
/// member). INVARIANT 5.
#[serde(default)]
pub creditor_of: BTreeSet<crate::AccountId>,
}
impl Party {
@@ -255,6 +266,43 @@ impl Party {
self.display_name.as_deref().unwrap_or(&self.name)
}
/// The last four digits of its TIN, when the books know them: its TIN
/// facts' (`books.set_tin`), else its W-9 facts'.
///
/// ```text
/// GOAL: one answer to "which TIN is on file for them?" wherever the
/// books show a last four (defect 11: the directory showed a
/// member's as missing because it read W-9 facts only).
/// SOURCE: rules.md B-26 (Treas. Reg. §301.6109-4(a): a truncated TIN
/// shows its last four); INVARIANT 2a (DECISION, owner,
/// 2026-10-15: the two never disagree, so either answers).
/// SCENARIOS:
/// 1. A member with TIN facts and no W-9: the TIN facts' last four.
/// 2. A contractor with W-9 facts only: the W-9's.
/// 3. Both on file: the same four (2a), the TIN facts'.
/// 4. Neither: None.
/// 5. A TIN purged from the vault keeps its facts' last four
/// (books.purge_tin drops only the vault record): still shown.
/// 6. NEVER more than four digits: facts whose last four isn't
/// exactly four digits (built by hand, not through the checking
/// setters) show nothing from that source.
/// ```
pub fn tin_last4(&self) -> Option<&str> {
// Exactly four digits or nothing, however the facts were built
// (the setters check it; this never shows more, B-26).
let four = |s: &str| s.len() == 4 && s.chars().all(|c| c.is_ascii_digit());
self.tin
.as_ref()
.map(|t| t.last4.as_str())
.filter(|s| four(s))
.or_else(|| {
self.w9
.as_ref()
.map(|w| w.tin_last4.as_str())
.filter(|s| four(s))
})
}
/// Its roles: the ones set by hand, plus vendor and customer when it
/// has those records.
pub fn all_roles(&self) -> BTreeSet<Role> {
@@ -522,26 +570,91 @@ pub enum PartyField {
Archived(bool),
}
/// Which of a party's totals to report (rules.md B-35 item 4). Not the
/// books' accounting method (`books.accounting_basis`): party totals count
/// what moved, whichever method the books keep.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Basis {
/// Money: through the company's cash accounts, a card, the members'
/// financing or a loan; on a member's capital, what the K-1 counts as
/// cash. The default.
#[default]
Cash,
/// Money and property contributed or distributed in kind: for a
/// member, what the statement of members' equity counts.
All,
}
impl Basis {
/// Every basis, the default first.
pub const ALL: [Basis; 2] = [Basis::Cash, Basis::All];
/// Its wire name: `cash` or `all`.
pub fn key(self) -> &'static str {
match self {
Basis::Cash => "cash",
Basis::All => "all",
}
}
/// The basis named `key` (surrounding space ignored); None for any
/// other word.
pub fn parse(key: &str) -> Option<Basis> {
Basis::ALL.into_iter().find(|b| b.key() == key.trim())
}
}
/// What the books did with one party over a span of dates
/// ([`activity`]).
/// ([`activity_with`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Activity {
/// The party's entries, drafts included, by id.
pub entries: BTreeSet<crate::EntryId>,
/// Paid to it in the span (net of refunds and reversals of payments).
/// Paid to it in money in the span (basis cash), net of reversals of
/// payments (a refund recorded as its own entry is received from it).
pub paid: rust_decimal::Decimal,
/// Received from it in the span (net of reversals of receipts).
/// Received from it in money in the span (basis cash), net of
/// reversals of receipts.
pub received: rust_decimal::Decimal,
/// Posted entries in the span naming it among several parties whose
/// shares don't resolve against the entry and the members given
/// ([`crate::shares::resolve`]): counted in neither total.
/// Given to it as property in the span (a distribution in kind), net
/// of reversals: basis all adds it to `paid`.
pub paid_in_kind: rust_decimal::Decimal,
/// Received from it as property in the span (a contribution in kind),
/// net of reversals: basis all adds it to `received`.
pub received_in_kind: rust_decimal::Decimal,
/// Posted entries in the span naming it whose shares don't resolve
/// against the entry and the members given ([`crate::shares::resolve`]),
/// or whose part is past exact arithmetic: counted in no total.
pub unresolved: BTreeSet<crate::EntryId>,
}
/// The accounts [`entry_flow`] reads money from: the cash accounts (every
/// asset account but receivables when `cash` is empty), and the
/// liabilities other than payables and the payroll withholdings (cards,
/// what members paid for the company and are owed back, loans).
impl Activity {
/// (paid, received) on `basis`: cash is `paid` and `received`; all
/// adds what moved in kind. [`activity_with`] admits an entry only when
/// every one of these sums stays exact, so this never overflows for an
/// activity it made.
pub fn totals(&self, basis: Basis) -> (rust_decimal::Decimal, rust_decimal::Decimal) {
match basis {
Basis::Cash => (self.paid, self.received),
Basis::All => (
self.paid
.checked_add(self.paid_in_kind)
.expect("activity_with keeps the all-basis sums exact"),
self.received
.checked_add(self.received_in_kind)
.expect("activity_with keeps the all-basis sums exact"),
),
}
}
}
/// The accounts [`entry_flow`] reads money from (rules.md B-35, defect
/// 24): the cash accounts (every asset account but receivables when none
/// are given), and the money liabilities: the cards ([`card_accounts`]),
/// what the members advanced and loans (`books.financing_accounts`).
/// Never payables, the payroll withholdings, or any other liability (an
/// accrued expense, deferred revenue): an accrual is a promise, counted
/// when it is settled, never also when it is booked.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MoneyAccounts {
/// Cash: bank, savings, cash on hand.
@@ -550,21 +663,80 @@ pub struct MoneyAccounts {
pub liabilities: std::collections::HashSet<crate::AccountId>,
}
/// The [`MoneyAccounts`] of `ledger`, with `cash` the books' cash accounts.
pub fn money_accounts(ledger: &crate::Ledger, cash: &[crate::AccountId]) -> MoneyAccounts {
impl MoneyAccounts {
/// These accounts less every liability owed to one of `parties`
/// ([`Party::creditor_of`]): for an entry naming them, a credit to
/// what the company owes a party itself never counts as paying it (a
/// fee charged on its own card, interest added to its own loan,
/// equipment bought on its own note, a distribution still owed to a
/// member), and a debit to it is what the money paid for (rules.md
/// B-35; DECISION, owner, 2026-10-20).
pub fn for_parties(
&self,
ledger: &crate::Ledger,
parties: &[PartyId],
) -> std::borrow::Cow<'_, MoneyAccounts> {
let owed: Vec<crate::AccountId> = parties
.iter()
.filter_map(|p| ledger.get_party(*p))
.flat_map(|p| p.creditor_of.iter().copied())
.filter(|a| self.liabilities.contains(a))
.collect();
if owed.is_empty() {
return std::borrow::Cow::Borrowed(self);
}
let mut out = self.clone();
for a in owed {
out.liabilities.remove(&a);
}
std::borrow::Cow::Owned(out)
}
}
/// The company's cards: the liability accounts whose names say "card"
/// (ignoring case), but not "gift card" (gift cards the company sold are
/// deferred revenue it owes its customers, a promise, not a card it pays
/// with), the rule the monthly reconciliation reminders read
/// (`ledger_store::reconcilable`), so the two never disagree on what a
/// card is. CONVENTION until payment instruments name the card accounts
/// (plan/served.md P3 defect 12).
pub fn card_accounts(ledger: &crate::Ledger) -> Vec<crate::AccountId> {
ledger
.chart()
.all()
.filter(|a| {
let name = a.name.to_lowercase();
a.account_type == crate::AccountType::Liability
&& name.contains("card")
&& !name.contains("gift")
})
.map(|a| a.id)
.collect()
}
/// The [`MoneyAccounts`] of `ledger`: `cash` the books' cash accounts
/// (every asset account but receivables when empty), `financing` the
/// books' financing liabilities (`books.financing_accounts`: what the
/// members advanced, loans), with the cards ([`card_accounts`]).
pub fn money_accounts(
ledger: &crate::Ledger,
cash: &[crate::AccountId],
financing: &[crate::AccountId],
) -> MoneyAccounts {
use crate::AccountType;
let not_money: std::collections::HashSet<crate::AccountId> = crate::PAYROLL[2..]
.iter()
.chain([&crate::PAYABLES, &crate::RECEIVABLES])
.filter_map(|c| ledger.role_account(c))
.collect();
let cards = card_accounts(ledger);
let mut out = MoneyAccounts::default();
for a in ledger.chart().all().filter(|a| !not_money.contains(&a.id)) {
match a.account_type {
AccountType::Asset if cash.is_empty() || cash.contains(&a.id) => {
out.cash.insert(a.id);
}
AccountType::Liability => {
AccountType::Liability if cards.contains(&a.id) || financing.contains(&a.id) => {
out.liabilities.insert(a.id);
}
_ => {}
@@ -573,6 +745,28 @@ pub fn money_accounts(ledger: &crate::Ledger, cash: &[crate::AccountId]) -> Mone
out
}
/// The original a reversal chain starts from, and how many reversals lie
/// between it and `entry` (zero when `entry` reverses nothing): followed
/// up `reversal_of`, O(depth). Ids strictly fall along the chain (a
/// reversal is made after its original), so the walk always ends.
pub fn root_original<'a>(
ledger: &'a crate::Ledger,
entry: &'a crate::JournalEntry,
) -> (&'a crate::JournalEntry, usize) {
let mut root = entry;
let mut depth = 0;
while let Some(o) = root.reversal_of() {
match ledger.get_entry(o) {
Some(orig) if orig.id() < root.id() => {
root = orig;
depth += 1;
}
_ => break,
}
}
(root, depth)
}
/// What one entry moved with its counterparty ([`entry_flow`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct EntryFlow {
@@ -630,6 +824,12 @@ pub struct EntryFlow {
/// 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.
/// 8. An accrual through a liability that isn't money (Dr expense / Cr
/// accrued expenses): zero; its settlement (Dr accrued / Cr 1000) is
/// the payment (defect 24: counted once, not twice).
/// Line-level: not odd for one liability paying down another (a
/// reversal of SCENARIO 7 reads as another payment, `across` again), so a
/// party's figures read a reversal through [`flow`], never its lines.
/// ```
pub fn entry_flow(
entry: &crate::JournalEntry,
@@ -743,64 +943,119 @@ pub fn entry_flow(
})
}
/// One party's entries, and the money paid to and received from it in
/// The money an entry moved with the parties it names, and what for,
/// read as party figures read it: a reversal's is exactly its original's
/// negated (B-9), never re-read from its own lines; and a liability owed
/// to a party named is not money for the entry
/// ([`MoneyAccounts::for_parties`]).
///
/// ```text
/// GOAL: what a party's entry paid for, right for reversals too.
/// SOURCE: rules.md B-9 ("a mistake is corrected by an opposite entry"),
/// B-35.
/// INVARIANTS:
/// 1. An entry reversing none: entry_flow of its lines, with
/// money.for_parties(the parties of `shares`).
/// 2. flow(reversal) = −flow(its original): net and every what_for
/// negated, accounts in the same order; a reversal of a reversal
/// gives the original's back. Found up reversal_of (root_original),
/// O(depth).
/// SCENARIOS:
/// 1. A member pays the card from their own funds (Dr 2050 / Cr 2100,
/// tagged with the issuer): +100.00, for 2050. Its reversal (Dr 2100
/// / Cr 2050) is −100.00, though entry_flow reads its lines as
/// another +100.00 (the across term isn't odd).
/// ```
pub fn flow(
ledger: &crate::Ledger,
id: crate::EntryId,
shares: &[crate::PartyShare],
money: &MoneyAccounts,
) -> Result<EntryFlow, crate::LedgerError> {
let entry = ledger
.get_entry(id)
.ok_or(crate::LedgerError::UnknownEntry(id))?;
let (root, depth) = root_original(ledger, entry);
let named: Vec<PartyId> = shares.iter().map(|s| s.party).collect();
let f = entry_flow(root, &money.for_parties(ledger, &named))?;
if depth % 2 == 0 {
return Ok(f);
}
// Every figure a flow holds is nonzero or a plain zero, and a
// Decimal's negation is exact.
let neg = |v: rust_decimal::Decimal| {
if v.is_zero() {
rust_decimal::Decimal::ZERO
} else {
-v
}
};
Ok(EntryFlow {
net: neg(f.net),
what_for: f.what_for.into_iter().map(|(a, v)| (a, neg(v))).collect(),
})
}
/// One party's entries, and what was paid to and received from it in
/// `start..=end`, with no members ([`activity_with`] with none: an entry
/// naming several parties then needs explicit shares to count).
pub fn activity(
ledger: &crate::Ledger,
party: PartyId,
span: (NaiveDate, NaiveDate),
cash: &[crate::AccountId],
money: &MoneyAccounts,
) -> Activity {
activity_with(ledger, party, span, cash, &[])
activity_with(ledger, party, span, money, &[])
}
/// One party's entries, and its share of the money paid to and received
/// from the entries' parties in `start..=end`.
/// One party's entries, and its part of what was paid to and received
/// from the entries' parties in `start..=end`, on both bases.
///
/// ```text
/// GOAL: one answer to "what did we pay them this year?" for the MCP
/// server and the viewer alike, right when an entry was with
/// several parties.
/// SOURCE: docs/parties-and-attachments.md (Parties, Shares); DECISION
/// (owner, 2026-10-15).
/// GOAL: one answer to "what did we pay them this year, and what did we
/// get from them?" for the MCP server and the viewer alike, right
/// when an entry was with several parties, for property as well as
/// money, and for reversals.
/// SOURCE: docs/parties-and-attachments.md (Parties, Shares); rules.md
/// B-35 (DECISION, owner, 2026-10-15 and 2026-10-20).
/// INVARIANTS:
/// 1. entries: every entry Ledger::entry_parties names the party in
/// (hand tags, vendor tags, bills and their payments, invoices and
/// their payments, and reversals of any of these carry it).
/// 2. Each posted entry dated in the span whose entry_flow net is not
/// zero counts the party's resolved share of it
/// (crate::shares::resolve with `members`; the whole net for a sole
/// party): positive is a payment, negative a receipt. A reversal
/// counts on its root original's side (the first entry of
/// Ledger::reversal_chain), so a reversal subtracts and a reversal
/// of that reversal adds back, on the same side.
/// 3. An entry moving no money (net zero, like property contributed)
/// counts nothing. An entry whose shares don't resolve counts
/// nothing and is listed in `unresolved`.
/// 4. Over all the parties an entry names, the shares counted sum to its
/// net exactly (crate::shares INVARIANT 6).
/// 5. A card charge counts as paid (the company paid them, by card); a
/// bill doesn't until it is paid. Not the 1099 amount: that is
/// 2. Each posted entry dated in the span counts the party's part of it
/// (crate::shares::resolve with `money` and `members`): its cash in
/// paid or received, its in_kind in paid_in_kind or received_in_kind,
/// by the direction of the entry's root original (Target::counts_as),
/// so a reversal subtracts on its original's side, and a reversal of
/// that reversal adds back there.
/// 3. An entry whose target is direction neither at its root (a bill,
/// an invoice, a close, an accrual, a fee on the party's own card),
/// or whose part counts nothing (a distribution still owed), counts
/// nothing and is never unresolved: its target is found before its
/// split.
/// 4. An entry whose target or split is refused (shares that don't fit,
/// a sum past exact arithmetic), or whose part would take any total,
/// on either basis, past exact arithmetic, counts nothing and is
/// listed in `unresolved`.
/// 5. Over all the parties an entry names, the parts counted sum to its
/// target's cash and in_kind exactly (crate::shares INVARIANT 6).
/// 6. A card charge counts as paid (the company paid them, by card); a
/// bill doesn't until it is paid; a member's property contribution is
/// received in kind. Not the 1099 amount: that is
/// form1099::vendor_1099_report_with.
/// ```
pub fn activity_with(
ledger: &crate::Ledger,
party: PartyId,
(start, end): (NaiveDate, NaiveDate),
cash: &[crate::AccountId],
money: &MoneyAccounts,
members: &[crate::PartyMember],
) -> Activity {
use rust_decimal::Decimal;
use crate::kernel::Direction;
let entries: BTreeSet<crate::EntryId> = (0..ledger.next_entry_id())
.filter(|&id| ledger.entry_names_party(id, party))
.collect();
let money = money_accounts(ledger, cash);
// A posted entry's lines are in a line's range, so its flow computes;
// one that doesn't counts nothing and is listed as unresolved.
let net_out = |e: &crate::JournalEntry| entry_flow(e, &money).map(|f| f.net);
let (mut paid, mut received) = (Decimal::ZERO, Decimal::ZERO);
let mut unresolved = BTreeSet::new();
let mut a = Activity::default();
for &id in &entries {
let Some(e) = ledger.get_entry(id) else {
continue;
@@ -808,43 +1063,52 @@ pub fn activity_with(
if e.status() != crate::EntryStatus::Posted || e.date() < start || e.date() > end {
continue;
}
let Ok(net) = net_out(e) else {
unresolved.insert(id);
let shares = ledger.entry_parties(id);
// The target first: an entry that counts nothing is never
// unresolved for shares that don't fit it (rules.md B-35 item 4).
let Ok(t) = crate::shares::target(ledger, id, shares, money) else {
a.unresolved.insert(id);
continue;
};
if net.is_zero() {
if t.counts_as == Direction::Neither || (t.cash.is_zero() && t.in_kind.is_zero()) {
continue;
}
let share = match crate::shares::resolve(e, ledger.entry_parties(id), &money, members) {
Ok(parts) => parts
.into_iter()
.find(|(p, _)| *p == party)
.map(|(_, v)| v)
.unwrap_or_default(),
Err(_) => {
unresolved.insert(id);
continue;
let Ok(parts) = crate::shares::split(e, shares, &t, members) else {
a.unresolved.insert(id);
continue;
};
let Some(p) = parts.iter().find(|p| p.party == party) else {
continue;
};
// Paid adds, received subtracts (a receipt is negative), each on
// the root original's side; every total, the all-basis sums
// included, stays exact or the entry counts nothing.
let next = match t.counts_as {
Direction::Paid => a
.paid
.checked_add(p.cash)
.zip(a.paid_in_kind.checked_add(p.in_kind))
.map(|(c, k)| (c, k, a.received, a.received_in_kind)),
_ => a
.received
.checked_sub(p.cash)
.zip(a.received_in_kind.checked_sub(p.in_kind))
.map(|(c, k)| (a.paid, a.paid_in_kind, c, k)),
};
match next.filter(|(pc, pk, rc, rk)| {
pc.checked_add(*pk).is_some() && rc.checked_add(*rk).is_some()
}) {
Some((pc, pk, rc, rk)) => {
a.paid = pc;
a.paid_in_kind = pk;
a.received = rc;
a.received_in_kind = rk;
}
None => {
a.unresolved.insert(id);
}
};
let side = match e.reversal_of() {
Some(_) => ledger
.reversal_chain(id)
.first()
.and_then(|&root| ledger.get_entry(root))
.map_or(Ok(net), net_out)
.unwrap_or(net),
None => net,
};
if side > Decimal::ZERO {
paid += share;
} else {
received -= share;
}
}
Activity {
entries,
paid,
received,
unresolved,
}
a.entries = entries;
a
}
+138
View File
@@ -1091,6 +1091,144 @@ fn constructive_mirrors() {
}
}
// ---------------------------------------------------------------------------
// A party's part of an entry (rules.md B-35)
// ---------------------------------------------------------------------------
/// The inputs `kernel::party_target` reads, any in range: a capital
/// movement (when every party named is a member) with the property beside
/// it, the money net, the own-account movement, and the debits (a
/// magnitude).
fn any_party_inputs() -> (Option<(i64, i64)>, i64, i64, i64) {
let capital = if kani::any() {
let property = any_amount();
kani::assume(property >= 0);
Some((any_amount(), property))
} else {
None
};
let debits = any_amount();
kani::assume(debits >= 0);
(capital, any_amount(), any_amount(), debits)
}
/// B-35 items 1-4: the direction is the sign of what counts (paid above
/// zero, received below), neither exactly when nothing counts; cash and
/// in kind each carry the whole's sign and together never exceed it; a
/// money target is all cash; a contribution counts all of itself; a
/// distribution's cash is never more than the money it paid, nor its in
/// kind more than the property it gave (the rest is still owed).
#[kani::proof]
fn party_target_direction_is_its_sign() {
let (capital, net, own, debits) = any_party_inputs();
let t = kernel::party_target(capital, net, own, debits).expect("in range");
let sign = |a: i64| a.signum();
assert!(t.cash == 0 || sign(t.cash) == sign(t.amount));
assert!(t.in_kind == 0 || sign(t.in_kind) == sign(t.amount));
assert!(t.cash.abs() + t.in_kind.abs() <= t.amount.abs());
match t.direction {
kernel::Direction::Neither => {
assert!(t.cash == 0 && t.in_kind == 0);
}
kernel::Direction::Paid => {
assert!(t.amount > 0 && t.cash + t.in_kind > 0);
}
kernel::Direction::Received => {
assert!(t.amount < 0 && t.cash + t.in_kind < 0);
}
}
let capital_case = matches!(capital, Some((e, _)) if e != 0);
if !capital_case && net != 0 {
assert!(t.amount == net && t.cash == net && t.in_kind == 0);
}
if let Some((e, property)) = capital {
if e < 0 {
// A contribution counts all of itself, money or property.
assert_eq!(t.cash + t.in_kind, e);
assert!(-t.in_kind <= property);
}
if e > 0 {
// A distribution: what it paid, then what it gave; no more.
assert!(t.cash <= net.max(0) && t.in_kind <= property);
}
}
kani::cover!(
t.direction == kernel::Direction::Paid && t.in_kind != 0 && t.cash != 0,
"a distribution partly in kind"
);
kani::cover!(
capital_case && t.direction == kernel::Direction::Neither,
"a distribution all still owed"
);
kani::cover!(
t.direction == kernel::Direction::Paid && t.cash + t.in_kind < t.amount,
"a distribution partly still owed"
);
}
/// B-35 item 3 and B-9: the target is odd. Negating the lines negates the
/// capital movement, the money net and the own-account movement, and the
/// property comes from the other side, so it keeps its size: the result
/// is the original's reverse_target, whenever it isn't the debits fallback
/// (a sign means nothing there) and a member's entry counts exactly its
/// money as cash (not an expense booked straight to capital, a debt
/// converted, or a distribution still owed). The reversal arm, not the
/// lines, gives every reversal its target, so these exceptions never reach
/// one.
#[kani::proof]
fn party_target_is_odd() {
let (capital, net, own, debits) = any_party_inputs();
if let Some((e, property)) = capital
&& e != 0
{
// The money the member's entry moved their way, signed as the
// distribution side reads it, is exactly what isn't property.
let side_net = if e > 0 { net } else { -net };
let whole = e.abs();
kani::assume(side_net.max(0).min(whole) == whole - property.min(whole));
}
let t = kernel::party_target(capital, net, own, debits).expect("in range");
let negated = capital.map(|(e, p)| (-e, p));
let n = kernel::party_target(negated, -net, -own, debits).expect("in range");
let fallback = !matches!(capital, Some((e, _)) if e != 0) && net == 0 && own == 0;
if !fallback {
assert_eq!(Some(n), kernel::reverse_target(t));
}
kani::cover!(!fallback && capital.is_some(), "a member's entry, negated");
kani::cover!(
matches!(capital, Some((e, p)) if e > 0 && p > 0 && net > 0 && net < e),
"a distribution of money and property, negated"
);
}
/// B-9: reversing a reversal gives the original back, and reverse_target
/// never panics: past the range it refuses.
#[kani::proof]
fn reverse_target_is_an_involution() {
let direction = match kani::any::<u8>() {
0 => kernel::Direction::Paid,
1 => kernel::Direction::Received,
_ => kernel::Direction::Neither,
};
let t = kernel::PartyTarget {
amount: kani::any::<i64>(),
direction,
cash: kani::any::<i64>(),
in_kind: kani::any::<i64>(),
};
match kernel::reverse_target(t) {
Some(r) => {
assert_eq!(r.direction, t.direction.flip());
assert_eq!(r.amount, -t.amount);
assert_eq!(kernel::reverse_target(r), Some(t));
}
None => {
assert!(t.amount == i64::MIN || t.cash == i64::MIN || t.in_kind == i64::MIN);
}
}
kani::cover!(t.amount == i64::MIN, "the range's edge");
}
// ---------------------------------------------------------------------------
// The checker itself
// ---------------------------------------------------------------------------
+308 -52
View File
@@ -9,22 +9,58 @@
//! itself ([`Share::Derived`]).
//!
//! ```text
//! GOAL: "what did we pay each of them?" right when one entry was with
//! several parties, with apportioning the human controls exactly.
//! GOAL: "what did we pay each of them, and what did we get from each?"
//! right when one entry was with several parties, with apportioning
//! the human controls exactly, every part signed as the party's own
//! account would show it, and a reversal always the opposite of what
//! it reverses.
//! SOURCE: DECISION (owner, 2026-10-15): no "joint" party; a joint
//! transaction is with both members, each with their share, and
//! shares can be apportioned explicitly ("do you allow for
//! controlled apportioning?" Yes). docs/parties-and-attachments.md
//! "Shares".
//! "Shares". rules.md B-35 (the sign, direction and money of a
//! part; DECISION, owner, 2026-10-20: a member's entry counts what
//! moved their capital).
//! DEFINITIONS:
//! flow: crate::parties::entry_flow(entry, money).
//! target: flow.net when it is not zero, else the entry's total debits
//! (a member contributing property moves no money but is still all
//! theirs). magnitude = |target|; sign = the sign of target (positive
//! when net is zero).
//! A member: a PartyMember (party, income share, capital account,
//! distributions account), passed in by the caller from the company's
//! settings ([[company.members]]); core doesn't read the settings.
//! target (target(), rules.md B-35 item 3): what the shares split,
//! signed debit-positive as a line on the parties' own account would
//! be (positive: the company paid or gave; negative: it received),
//! with its direction (paid, received, neither) and what of it moved
//! as money (cash) and as property (in_kind), in this order of cases:
//! 1. A period's closing entry: the parties' own-account movement (the
//! members' equity, when every party named is a member), else its
//! total debits; neither: a close moves nothing with anyone.
//! 2. A reversal: its original's target negated, paid and received
//! swapped (kernel::reverse_target), from its root original down;
//! never read from its own lines (parties::entry_flow isn't odd
//! for one liability paying down another).
//! 3. Every party named has the member role in the directory and the
//! entry moves equity other than retained earnings (E): E, received
//! when a contribution (E < 0), paid when a distribution (E > 0).
//! Of a contribution, in_kind is the property it brought in (asset
//! accounts neither cash nor receivables: the K-1 worksheet's test,
//! k1 INVARIANT 3), at most |E|, and cash the rest (what the K-1
//! counts as cash: money, or what a member paid for the company).
//! Of a distribution, cash is the money it paid (case 4's net, at
//! most E) and in_kind the property it gave up, at most what is
//! left; the rest, credited to a liability that isn't money for the
//! entry (distributions payable, or what the members are owed when
//! they are named its creditors), is still owed and counts as
//! nothing until it is paid (kernel::party_target INVARIANT 2).
//! 4. Money moved (parties::entry_flow net, a liability owed to a
//! party named not counted as money: MoneyAccounts::for_parties):
//! the net, paid (+) or received (−), all of it cash.
//! 5. Otherwise the movement of the parties' own accounts (payables,
//! receivables, liabilities owed to a party named: a bill −, an
//! invoice +, a fee on the issuer's own card −), else the total
//! debits; neither.
//! magnitude = |target.amount|.
//! A member (PartyMember): a party, income share, capital account,
//! distributions account, passed in by the caller from the company's
//! settings ([[company.members]]); core doesn't read the settings. Only
//! a derived split among several members reads them (INVARIANT 10),
//! and the server journals that split as amounts once the entry is
//! posted, so posted history never depends on today's settings.
//! INVARIANTS (of an entry's shares, checked by check_shares, journaled):
//! 1. No party appears twice; every party is in the directory.
//! 2. All shares are one kind: all Amount, all Percent, or all Derived.
@@ -33,14 +69,16 @@
//! 100.
//! 5. At most one party has a vendor record: a 1099 counts a payment
//! toward a single vendor.
//! INVARIANTS (of resolve, against the entry's lines and the members):
//! 6. Resolved amounts sum to target exactly, and each has target's sign.
//! INVARIANTS (of split and resolve, against the entry's lines, the
//! target and the members):
//! 6. The parts sum to target.amount exactly, and each has its sign (a
//! zero part is a plain zero, never "-0").
//! 7. Amount: each party gets sign × its amount; refused unless the
//! amounts sum to magnitude.
//! 8. Percent: each party gets magnitude × percent / 100 rounded toward
//! zero to the cent; the cents left over (fewer than one per party)
//! go to the first party in order. Then the sign.
//! 9. Derived, one party: it gets target.
//! 9. Derived, one party: it gets target.amount.
//! 10. Derived, several parties: every one must be a member (else
//! refused: give amounts or percents). If the entry has a line on any
//! named member's capital or distributions account, each member gets
@@ -48,21 +86,49 @@
//! to magnitude (else refused). Otherwise each gets their income share
//! of magnitude, the shares rescaled to the members named, rounded as
//! in 8 (remainder to the first).
//! 11. A reversal's parts are its original's negated: its target is the
//! original's negated, and its lines are the original's with every
//! side flipped (Ledger::reverse_entry), so every magnitude above is
//! the same.
//! 12. Each part's cash and in_kind have its sign, |cash| + |in_kind| ≤
//! |part|, and over the parts they sum to the target's cash and
//! in_kind exactly: in_kind is shared by each part's size, cash by
//! what is left of each (k1's share_property: in proportion, to the
//! cent, leftover cents by largest remainder, ties to the party
//! listed first in the company's members), so a member's in-kind part
//! of a joint contribution is the property the K-1 worksheet counts
//! for them.
//! SCENARIOS:
//! 1. Reimbursing both members $100.00 (Dr 2100 / Cr 1000), both named,
//! derived, 50/50: $50.00 each.
//! derived, 50/50: $50.00 each, paid, cash.
//! 2. A joint contribution (Dr 1000 300 / Cr 3000 200 / Cr 3001 100):
//! derived from the capital lines, Tobin −200.00, Morgan −100.00
//! (received).
//! derived from the capital lines, Tobin −200.00, Morgan −100.00,
//! received, cash.
//! 3. A property contribution (Dr Equipment 1000 / Cr 3000 500 / Cr 3001
//! 500): net is zero; target is the total, 500.00 each.
//! 500): −500.00 each, received, in kind; its reversal +500.00 each,
//! paid, in kind.
//! 4. $100.00 split 1/3 each three ways by percent 33.33, 33.33, 33.34:
//! 33.33, 33.33, 33.34. By 33.333/33.333/33.334: 33.34, 33.33, 33.33
//! (the leftover cent to the first).
//! 5. Amounts of $60.00 and $30.00 on a $100.00 payment: refused.
//! 6. A bill for $80.00 tagged with its vendor (Dr expense / Cr
//! payables): −80.00, neither; its reversal +80.00, neither.
//! 7. A joint contribution of cash and property (Dr 1000 600, Dr 1500
//! 400 / Cr 3000 500, Cr 3001 500): −500.00 each, received, of it
//! −300.00 cash and −200.00 in kind (the K-1 worksheet's SCENARIO 3).
//! 8. A distribution declared to one member (Dr 3000 200 / Cr
//! Distributions Payable 200): +200.00, neither, nothing counted; its
//! payment (Dr Distributions Payable 200 / Cr 1000 200) is the 200.00
//! paid, once.
//! 9. Equipment carried at 1,000 less 300 depreciated, and 100 in cash,
//! distributed to one member (Dr 3000 800, Dr accumulated
//! depreciation 300 / Cr equipment 1000, Cr 1000 100): +800.00, paid,
//! of it 100.00 cash and 700.00 in kind.
//! ```
use crate::{AccountId, JournalEntry, PartyId, Side};
pub use crate::kernel::Direction;
use crate::kernel::{self, PartyTarget};
use crate::{AccountId, AccountType, EntryId, JournalEntry, Ledger, LedgerError, PartyId, Side};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
@@ -202,28 +268,6 @@ pub fn check_shares_journaled(
Ok(())
}
/// What the shares are split over: the entry's flow net, else its total
/// debits (the module's DEFINITIONS).
/// Refused (TooLargeToCompute), never a panic, when a sum is past exact
/// arithmetic: a draft's lines and an entry's shares are as journaled, of
/// any size (rules.md B-28).
pub fn share_target(
entry: &JournalEntry,
money: &crate::parties::MoneyAccounts,
) -> Result<Decimal, crate::LedgerError> {
let net = crate::parties::entry_flow(entry, money)?.net;
if net.is_zero() {
entry
.lines()
.iter()
.filter(|l| l.side == Side::Debit)
.try_fold(Decimal::ZERO, |s, l| s.checked_add(l.amount.amount))
.ok_or_else(|| too_large(format!("entry {}'s debits", entry.id())))
} else {
Ok(net)
}
}
/// The refusal of a sum past exact arithmetic.
fn too_large(what: String) -> crate::LedgerError {
crate::LedgerError::TooLargeToCompute(what)
@@ -259,23 +303,182 @@ fn split_first(magnitude: Decimal, weights: &[Decimal]) -> Option<Vec<Decimal>>
Some(parts)
}
/// Each party's exact part of what `entry` moved (the module's INVARIANTS
/// 6–10), in the shares' order. Refused, with why, when the shares can't
/// resolve against these lines and members.
pub fn resolve(
/// What an entry's shares split, and what of it counts (the module's
/// DEFINITIONS: target; rules.md B-35).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Target {
/// The whole, signed debit-positive as a line on the parties' own
/// account would be.
pub amount: Decimal,
/// Which way value moved with the parties.
pub direction: Direction,
/// What of `amount` moved as money: counted on both bases.
pub cash: Decimal,
/// What of `amount` moved as property: counted on basis "all".
pub in_kind: Decimal,
/// Which side the parts count on in a party's totals: the direction
/// of the entry's root original (itself, unless it is a reversal), so
/// a reversal nets what it reverses (B-35 item 4).
pub counts_as: Direction,
}
/// One party's part of an entry (the module's INVARIANTS 6-12).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Part {
/// The party.
pub party: PartyId,
/// Its part of the target, with the target's sign.
pub amount: Decimal,
/// What of it moved as money.
pub cash: Decimal,
/// What of it moved as property.
pub in_kind: Decimal,
}
/// An entry's target and each party's part of it ([`resolve`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Resolved {
/// What the shares split.
pub target: Target,
/// Each party's part, in the shares' order.
pub parts: Vec<Part>,
}
impl Resolved {
/// The part of `party`, when the entry names it.
pub fn part(&self, party: PartyId) -> Option<&Part> {
self.parts.iter().find(|p| p.party == party)
}
}
/// What entry `id`'s shares split (the module's DEFINITIONS: target).
/// `shares` are passed, not read from the ledger, so a proposed set can be
/// checked; they cover the whole reversal chain (Ledger::set_entry_parties
/// INVARIANT 5). Refused (TooLargeToCompute), never a panic, when a sum is
/// past exact arithmetic: a draft's lines are as journaled, of any size
/// (rules.md B-28).
pub fn target(
ledger: &Ledger,
id: EntryId,
shares: &[PartyShare],
money: &crate::parties::MoneyAccounts,
) -> Result<Target, LedgerError> {
let entry = ledger.get_entry(id).ok_or(LedgerError::UnknownEntry(id))?;
let (root, depth) = crate::parties::root_original(ledger, entry);
let base = original_target(ledger, root, shares, money)?;
let t = if depth % 2 == 1 {
kernel::reverse_target(base)
.ok_or_else(|| too_large(format!("entry {id}'s reversal target")))?
} else {
base
};
Ok(Target {
amount: t.amount,
direction: t.direction,
cash: t.cash,
in_kind: t.in_kind,
counts_as: base.direction,
})
}
/// The target of an entry that reverses none (the module's DEFINITIONS:
/// target, cases 1 and 3-5).
fn original_target(
ledger: &Ledger,
entry: &JournalEntry,
shares: &[PartyShare],
money: &crate::parties::MoneyAccounts,
) -> Result<PartyTarget<Decimal>, LedgerError> {
let past_exact = || too_large(format!("entry {}'s target", entry.id()));
let named: Vec<PartyId> = shares.iter().map(|s| s.party).collect();
let money = money.for_parties(ledger, &named);
let parties: Vec<&crate::Party> = named.iter().filter_map(|p| ledger.get_party(*p)).collect();
let members_only = !named.is_empty()
&& parties.len() == named.len()
&& parties
.iter()
.all(|p| p.roles.contains(&crate::Role::Member));
let owed = |a: AccountId| parties.iter().any(|p| p.creditor_of.contains(&a));
let retained = ledger.role_account(&crate::RETAINED_EARNINGS);
let receivables = ledger.role_account(&crate::RECEIVABLES);
let payables = ledger.role_account(&crate::PAYABLES);
let kind = |a: AccountId| ledger.chart().get(a).map(|x| x.account_type);
// Property: the K-1 worksheet's test (k1 INVARIANT 3).
let property = |a: AccountId| {
kind(a) == Some(AccountType::Asset) && !money.cash.contains(&a) && Some(a) != receivables
};
let add = |t: &mut Decimal, v: Decimal| -> Result<(), LedgerError> {
*t = t.checked_add(v).ok_or_else(past_exact)?;
Ok(())
};
let zero = Decimal::ZERO;
let (mut equity, mut prop_in, mut prop_out, mut own, mut debits) =
(zero, zero, zero, zero, zero);
for l in entry.lines() {
let v = l.amount.amount;
let signed = kernel::signed(l.side, v).ok_or_else(past_exact)?;
let k = kind(l.account);
if k == Some(AccountType::Equity) && Some(l.account) != retained {
add(&mut equity, signed)?;
}
if property(l.account) {
match l.side {
Side::Debit => add(&mut prop_in, v)?,
Side::Credit => add(&mut prop_out, v)?,
}
}
let theirs = k == Some(AccountType::Liability) && owed(l.account);
if theirs || Some(l.account) == payables || Some(l.account) == receivables {
add(&mut own, signed)?;
}
if l.side == Side::Debit {
add(&mut debits, v)?;
}
}
let property = if equity < zero { prop_in } else { prop_out };
let capital = members_only.then_some((equity, property));
if ledger.is_closing_entry(entry.id()) {
let t = kernel::party_target(capital, zero, own, debits).ok_or_else(past_exact)?;
return Ok(PartyTarget {
amount: t.amount,
direction: Direction::Neither,
cash: zero,
in_kind: zero,
});
}
let net = crate::parties::entry_flow(entry, &money)?.net;
kernel::party_target(capital, net, own, debits).ok_or_else(past_exact)
}
/// Split `whole` (at most the sum of `sizes`) over `sizes` in proportion,
/// to the cent, leftover cents by largest remainder (k1's
/// share_property), ties to the earlier in `order` (each part's rank).
/// None past exact arithmetic.
fn share_by(whole: Decimal, sizes: &[Decimal], order: &[usize]) -> Option<Vec<Decimal>> {
let ranked: Vec<Decimal> = order.iter().map(|&i| sizes[i]).collect();
let shared = crate::k1::share_property(whole, &ranked, 2).ok()?;
let mut out = vec![Decimal::ZERO; sizes.len()];
for (k, &i) in order.iter().enumerate() {
out[i] = shared[k];
}
Some(out)
}
/// Each party's part of `target` (the module's INVARIANTS 6-12), in the
/// shares' order. Refused, with why, when the shares can't resolve against
/// these lines and members.
pub fn split(
entry: &JournalEntry,
shares: &[PartyShare],
target: &Target,
members: &[PartyMember],
) -> Result<Vec<(PartyId, Decimal)>, String> {
let target = share_target(entry, money).map_err(|e| e.to_string())?;
) -> Result<Vec<Part>, String> {
let past_exact = || too_large(format!("entry {}'s shares", entry.id())).to_string();
let magnitude = target.abs();
let signed = |v: Decimal| if target.is_sign_negative() { -v } else { v };
let magnitude = target.amount.abs();
let Some(first) = shares.first() else {
return Ok(Vec::new());
};
let parts: Vec<Decimal> = match first.share {
let sizes: Vec<Decimal> = match first.share {
Share::Amount(_) => {
let amounts: Vec<Decimal> = shares
.iter()
@@ -355,9 +558,62 @@ pub fn resolve(
}
}
};
// Ties go to the party listed first in the company's members, as on
// the K-1 worksheet; other parties after them, in the shares' order.
let mut order: Vec<usize> = (0..shares.len()).collect();
order.sort_by_key(|&i| {
members
.iter()
.position(|m| m.party == shares[i].party)
.unwrap_or(members.len() + i)
});
let (cash, in_kind) = (target.cash.abs(), target.in_kind.abs());
let in_kind_sizes = if in_kind.is_zero() {
vec![Decimal::ZERO; sizes.len()]
} else {
share_by(in_kind, &sizes, &order).ok_or_else(past_exact)?
};
let left: Vec<Decimal> = sizes
.iter()
.zip(&in_kind_sizes)
.map(|(s, k)| s.checked_sub(*k))
.collect::<Option<_>>()
.ok_or_else(past_exact)?;
let cash_sizes = if cash == checked_sum(left.iter().copied()).ok_or_else(past_exact)? {
left
} else if cash.is_zero() {
vec![Decimal::ZERO; sizes.len()]
} else {
share_by(cash, &left, &order).ok_or_else(past_exact)?
};
let negative = target.amount < Decimal::ZERO;
let signed = |v: Decimal| if negative && !v.is_zero() { -v } else { v };
Ok(shares
.iter()
.zip(parts)
.map(|(s, v)| (s.party, signed(v)))
.enumerate()
.map(|(i, s)| Part {
party: s.party,
amount: signed(sizes[i]),
cash: signed(cash_sizes[i]),
in_kind: signed(in_kind_sizes[i]),
})
.collect())
}
/// Entry `id`'s target and each party's exact part of it (the module's
/// INVARIANTS 6-12), in the shares' order. Refused, with why, when the
/// shares can't resolve against these lines and members.
pub fn resolve(
ledger: &Ledger,
id: EntryId,
shares: &[PartyShare],
money: &crate::parties::MoneyAccounts,
members: &[PartyMember],
) -> Result<Resolved, String> {
let entry = ledger
.get_entry(id)
.ok_or_else(|| LedgerError::UnknownEntry(id).to_string())?;
let target = target(ledger, id, shares, money).map_err(|e| e.to_string())?;
let parts = split(entry, shares, &target, members)?;
Ok(Resolved { target, parts })
}
+491 -36
View File
@@ -5,13 +5,15 @@
//! Each example is a setup, events and expected values worked by hand from
//! its cited source (`bookspec/README.md` gives the shape). Every example
//! must load, whatever it asks or whichever appendix it is in: its shape,
//! its accounts, fixed assets, members and payees, every event (an entry,
//! a reversal, a close or a bill, posting entry #N as event N) and every
//! change that posts no entry (a rename, a policy). The queries the core
//! answers (`cash_flow`, `trial_balance`, `profit_and_loss`,
//! `vendor_1099_paid`, `entry`, `balances`, `members_equity`, `period`,
//! `day`, `calendar_year`, `account_names`, `uncategorized_accounts`) are
//! then checked value by value, and the commands it tries
//! its accounts, fixed assets, parties, members and payees, every event
//! (an entry, a reversal, a close or a bill, posting entry #N as event N,
//! with the parties and payee it is tagged with) and every change that
//! posts no entry (a rename, a policy). The queries the core answers
//! (`cash_flow`, `trial_balance`, `profit_and_loss`, `vendor_1099_paid`,
//! `entry`, `balances`, `members_equity`, `k1`, `party_part`,
//! `party_totals`, `party_tin_last4`, `period`, `day`, `calendar_year`,
//! `account_names`, `uncategorized_accounts`) are then checked value by
//! value, and the commands it tries
//! (`rename_account`, `add_account`, `set_tax_category`) accepted or
//! refused on a copy of the books, each after the event it names (`after`)
//! or the last; the formats appendix's (`iif_lines`, `iif_changes`) are
@@ -24,11 +26,13 @@ use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use ledger_core::calendar::{PeriodDefaults, period_defaults};
use ledger_core::parties::{Basis, MoneyAccounts};
use ledger_core::settings::Company;
use ledger_core::{
AccountType, CashFlowParams, CashFlowSection, CashFlowStatement, CloseTo, Currency,
DocumentLine, EntryId, Form1099, Ledger, LedgerError, MemberCapital, MemberClose, Money,
NewAsset, PaymentAccounts, ReconcilingKind, Side, VendorId, cash_flow, members_equity,
NewAsset, PartyId, PartyMember, PartyShare, PaymentAccounts, ReconcilingKind, Role, Share,
Side, TinFacts, TinType, VendorId, W9Facts, cash_flow, members_equity,
profit_and_loss_for_period, trial_balance_as_of, vendor_1099_report_with_members,
};
use rust_decimal::Decimal;
@@ -71,6 +75,10 @@ struct Setup {
investing_accounts: Vec<String>,
#[serde(default)]
fixed_assets: Vec<FixedAsset>,
/// The company's directory: parties other than a payee's vendor
/// record, and the payees' when the example names their roles.
#[serde(default)]
parties: Vec<PartySpec>,
#[serde(default)]
members: Vec<MemberSpec>,
#[serde(default)]
@@ -124,6 +132,8 @@ struct FixedAsset {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct MemberSpec {
/// The member's name: a party's in `setup.parties` (with the member
/// role) when the example asks about the member as a party.
name: String,
capital_account: String,
/// Their share of income, an exact decimal fraction; every member has
@@ -131,9 +141,14 @@ struct MemberSpec {
/// needs them.
#[serde(default)]
share: Option<String>,
/// Their distributions account (rules.md B-13), when they have one.
#[serde(default)]
distributions_account: Option<String>,
}
/// A payee marked for an information return (rules.md B-19).
/// A payee marked for an information return from the beginning (rules.md
/// B-19): a vendor record, the party's of that name in `setup.parties`
/// when there is one with the vendor role.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct PayeeSpec {
@@ -141,6 +156,43 @@ struct PayeeSpec {
form: String,
}
/// One party in the directory (rules.md B-26, B-35).
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct PartySpec {
name: String,
/// Role keys (`member`, `vendor`, `customer`, `contractor`,
/// `financial_institution`, ...); `vendor` and `customer` give the
/// party its record (one of them at most).
roles: Vec<String>,
/// The liabilities, by code, the company owes the party itself
/// (rules.md B-35 item 6).
#[serde(default)]
creditor_of: Vec<String>,
/// Its TIN facts' last four digits (an SSN's).
#[serde(default)]
tin_last4: Option<String>,
/// Its W-9 facts' last four digits (a 1099 role's).
#[serde(default)]
w9_tin_last4: Option<String>,
/// The vault's copy of the TIN purged, the facts kept (needs
/// `tin_last4`).
#[serde(default)]
tin_purged: bool,
}
/// A party named on an entry, with its share: an `amount`, a `percent`,
/// or neither (derived from the entry: rules.md B-35).
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ShareSpec {
party: String,
#[serde(default)]
amount: Option<String>,
#[serde(default)]
percent: Option<String>,
}
/// One event: an entry (`lines`), a reversal (`reverses`), a close
/// (`close`) or a bill (`bill`), exactly one of them.
#[derive(Deserialize)]
@@ -158,6 +210,10 @@ struct Event {
/// The payee (in `setup.payees`) the entry paid.
#[serde(default)]
payee: Option<String>,
/// The parties (in `setup.parties`) the entry is with, and their
/// shares.
#[serde(default)]
parties: Vec<ShareSpec>,
}
/// A bill from `vendor` for `amount`, dated and due on the event's date:
@@ -224,8 +280,13 @@ struct Expect {
from: Option<String>,
to: Option<String>,
as_of: Option<String>,
/// The entry an `entry` query asks about.
/// The entry an `entry` or `party_part` query asks about.
entry: Option<EntryId>,
/// The party a `party_part`, `party_totals` or `party_tin_last4`
/// query asks about (in `setup.parties`).
party: Option<String>,
/// `party_totals`' basis: `"cash"` or `"all"` (rules.md B-35 item 4).
basis: Option<String>,
/// The books after event N, not after the last.
after: Option<usize>,
/// The account a `rename_account` or `set_tax_category` command
@@ -412,15 +473,103 @@ struct Books<'a> {
currency: Currency,
/// The members, with their shares (zero when the example gives none).
members: Vec<MemberCapital>,
/// The members who are parties (`setup.parties` with the member
/// role), as the settings give them to party shares and the 1099.
party_members: Vec<PartyMember>,
/// The directory's parties, by name.
parties: BTreeMap<String, PartyId>,
/// The payees' vendor records, by name.
payees: BTreeMap<String, VendorId>,
/// The vendors bills came from, by name (a payee's record if one).
vendors: BTreeMap<String, VendorId>,
}
/// The example's directory (`setup.parties`), by name: each party with
/// its roles (a vendor or customer with its record), the liabilities it
/// is the creditor of, and its TIN and W-9 facts' last four.
fn add_parties(ex: &Example, l: &mut Ledger) -> BTreeMap<String, PartyId> {
let mut out = BTreeMap::new();
for p in &ex.setup.parties {
let what = format!("{} party {}", ex.id, p.name);
let roles: Vec<Role> = p
.roles
.iter()
.map(|r| Role::parse(r).unwrap_or_else(|| panic!("{what}: role {r:?}")))
.collect();
let record = |r: Role| roles.contains(&r);
let others: Vec<Role> = roles
.iter()
.copied()
.filter(|r| !matches!(r, Role::Vendor | Role::Customer))
.collect();
let id = match (record(Role::Vendor), record(Role::Customer)) {
(true, true) => panic!("{what}: a vendor or a customer, not both"),
(true, false) => {
let v = l
.add_vendor(&p.name)
.unwrap_or_else(|e| panic!("{what}: {e}"));
l.party_of_vendor(v).expect("a vendor's party")
}
(false, true) => {
let c = l
.add_customer(&p.name)
.unwrap_or_else(|e| panic!("{what}: {e}"));
l.party_of_customer(c).expect("a customer's party")
}
(false, false) => l
.add_party(&p.name, &others)
.unwrap_or_else(|e| panic!("{what}: {e}")),
};
if record(Role::Vendor) || record(Role::Customer) {
for r in &others {
l.set_party_role(id, *r, true)
.unwrap_or_else(|e| panic!("{what}: {e}"));
}
}
for code in &p.creditor_of {
let account = l
.chart()
.find_by_code(code)
.unwrap_or_else(|| panic!("{what}: no account {code}"))
.id;
l.set_party_creditor(id, account, true)
.unwrap_or_else(|e| panic!("{what}: creditor of {code}: {e}"));
}
assert!(
!p.tin_purged || p.tin_last4.is_some(),
"{what}: tin_purged needs tin_last4"
);
if let Some(last4) = &p.tin_last4 {
let facts = TinFacts {
tin_type: TinType::Ssn,
last4: last4.clone(),
vault_record: (!p.tin_purged).then(|| "0".repeat(64)),
purged: p.tin_purged.then(|| "purged: retention ended".into()),
};
l.set_party_tin(id, Some(facts))
.unwrap_or_else(|e| panic!("{what}: {e}"));
}
if let Some(last4) = &p.w9_tin_last4 {
let facts = W9Facts {
received: date("2026-01-01", &what),
entity_type: "individual/sole proprietor".into(),
exempt_payee_code: None,
fatca_code: None,
tin_last4: last4.clone(),
kept_at: "wanderbooks vault".into(),
vault_record: None,
};
l.set_party_w9(id, Some(facts))
.unwrap_or_else(|e| panic!("{what}: {e}"));
}
assert!(out.insert(p.name.clone(), id).is_none(), "{what}: twice");
}
out
}
impl<'a> Books<'a> {
/// The books an example sets up, before any event: its shape checked,
/// its accounts, fixed assets, members and payees.
/// its accounts, fixed assets, parties, members and payees.
fn new(ex: &'a Example) -> Self {
let s = &ex.setup;
let currency = Currency::new(&s.currency).unwrap();
@@ -508,7 +657,7 @@ impl<'a> Books<'a> {
.map(|m| MemberCapital {
name: m.name.clone(),
capital_account: id(&l, &m.capital_account),
distribution_account: None,
distribution_account: m.distributions_account.as_deref().map(|c| id(&l, c)),
share: m.share.as_deref().map_or(Decimal::ZERO, |x| {
let share = exact(x, &format!("{} {} share", ex.id, m.name));
assert!(share > Decimal::ZERO, "{}: {}'s share", ex.id, m.name);
@@ -520,14 +669,36 @@ impl<'a> Books<'a> {
let total: Decimal = members.iter().map(|m| m.share).sum();
assert_eq!(total, Decimal::ONE, "{}: the shares sum to 1", ex.id);
}
let parties = add_parties(ex, &mut l);
// A member who is a party (by name, with the member role), as the
// settings' members are (settings::Company::party_members).
let party_members: Vec<PartyMember> = members
.iter()
.filter_map(|m| {
let p = l.party_by_name(&m.name)?;
p.roles.contains(&Role::Member).then_some(PartyMember {
party: p.id,
share: m.share,
capital: m.capital_account,
distributions: m.distribution_account,
})
})
.collect();
let mut payees = BTreeMap::new();
for p in &s.payees {
let form = Form1099::parse(&p.form)
.filter(|_| p.form.starts_with("1099-"))
.unwrap_or_else(|| panic!("{}: payee {}: form {:?}", ex.id, p.name, p.form));
let v = l
.add_vendor(&p.name)
// A payee in the directory with the vendor role is that
// party's vendor record.
let v = match parties.get(&p.name).and_then(|&id| l.get_party(id)?.vendor) {
Some(v) => v,
None => l
.add_vendor(&p.name)
.unwrap_or_else(|e| panic!("{}: payee {}: {e}", ex.id, p.name)),
};
l.set_vendor_1099(v, Some(form), false)
.unwrap_or_else(|e| panic!("{}: payee {}: {e}", ex.id, p.name));
l.set_vendor_1099(v, Some(form), false).unwrap();
assert!(
payees.insert(p.name.clone(), v).is_none(),
"{}: payee {} twice",
@@ -540,6 +711,8 @@ impl<'a> Books<'a> {
l,
currency,
members,
party_members,
parties,
vendors: payees.clone(),
payees,
}
@@ -561,6 +734,10 @@ impl<'a> Books<'a> {
e.payee.is_none() || (e.reverses.is_none() && e.close.is_none() && e.bill.is_none()),
"{ex} event {n}: a reversal takes its original's payee, a bill names its vendor, and a close has none"
);
assert!(
e.parties.is_empty() || e.reverses.is_none(),
"{ex} event {n}: a reversal takes its original's parties"
);
let posted = match (&e.close, e.reverses, e.lines.is_empty()) {
(None, None, true) if e.bill.is_some() => {
let b = e.bill.as_ref().unwrap();
@@ -674,6 +851,29 @@ impl<'a> Books<'a> {
_ => panic!("{ex} event {n}: an event is one of lines, reverses, close or bill"),
};
assert_eq!(posted, n, "{ex}: event {n} must be entry #{n}");
if !e.parties.is_empty() {
let shares: Vec<PartyShare> = e
.parties
.iter()
.map(|sh| {
let party = *self.parties.get(&sh.party).unwrap_or_else(|| {
panic!("{ex} event {n}: party {:?} not in setup", sh.party)
});
let share = match (&sh.amount, &sh.percent) {
(None, None) => Share::Derived,
(Some(a), None) => Share::Amount(exact(a, ex)),
(None, Some(p)) => Share::Percent(exact(p, ex)),
_ => panic!("{ex} event {n}: a share is an amount or a percent"),
};
PartyShare { party, share }
})
.collect();
self.l
.set_entry_parties(n, &shares)
.unwrap_or_else(|err| panic!("{ex} event {n}: {err}"));
}
// The payee after the parties: a payee's party must be among
// several named (the vendor tag follows the parties).
if let Some(p) = &e.payee {
let v = *self
.payees
@@ -973,21 +1173,19 @@ fn check_profit_and_loss(ex: &Example, l: &Ledger, x: &Expect) {
}
}
/// Check one `vendor_1099_paid` expectation: each payee's payments out of
/// the cash (and financing) accounts from January 1 through `to`, a
/// payment card's left out, every payee listed (rules.md B-19, B-33).
fn check_vendor_1099_paid(
ex: &Example,
l: &Ledger,
payees: &BTreeMap<String, VendorId>,
x: &Expect,
) {
/// The 1099 report a `vendor_1099_paid` expectation asks for: the
/// calendar year of `to`, from January 1 through `to`, out of the cash
/// and financing accounts, an entry's shares resolved with the members
/// who are parties (rules.md B-19, B-33).
fn report_1099(b: &Books<'_>, x: &Expect) -> (NaiveDate, NaiveDate, ledger_core::Vendor1099Report) {
let (ex, l) = (b.ex, &b.l);
// A calendar-year count, never the fiscal default (rules.md B-30):
// both dates given.
assert!(
x.from.is_some() && x.to.is_some(),
"{}: vendor_1099_paid gives from and to",
ex.id
"{}: {} gives from and to",
ex.id,
x.query
);
let (from, to) = x.period(ex);
let year = chrono::Datelike::year(&to);
@@ -999,28 +1197,221 @@ fn check_vendor_1099_paid(
);
assert!(
!ex.setup.cash_accounts.is_empty(),
"{}: vendor_1099_paid needs cash_accounts (what payees are paid from)",
ex.id
"{}: {} needs cash_accounts (what payees are paid from)",
ex.id,
x.query
);
let sources = PaymentAccounts {
cash: codes(l, &ex.setup.cash_accounts, &ex.id),
financing: codes(l, &ex.setup.financing_accounts, &ex.id),
};
let r = vendor_1099_report_with_members(l, year, Some(to), None, &sources, &[])
let r = vendor_1099_report_with_members(l, year, Some(to), None, &sources, &b.party_members)
.unwrap_or_else(|e| panic!("{}: {e}", ex.id));
exactly(ex, x, &payees.keys().cloned().collect());
(from, to, r)
}
/// Check one `vendor_1099_paid` expectation: each payee's payments that
/// count toward its 1099 ([`report_1099`]), a payment card's left out,
/// every payee listed (rules.md B-19, B-33).
fn check_vendor_1099_paid(b: &Books<'_>, x: &Expect) {
let ex = b.ex;
let (from, to, r) = report_1099(b, x);
exactly(ex, x, &b.payees.keys().cloned().collect());
for (name, want) in x.figures(&ex.id) {
let want = exact(want, &format!("{} {name}", ex.id));
let got: Decimal = r
.vendors
.iter()
.filter(|v| v.vendor_id == payees[name])
.filter(|v| v.vendor_id == b.payees[name])
.map(|v| v.paid)
.sum();
assert_eq!(got, want, "{} 1099 paid [{from} .. {to}]: {name}", ex.id);
}
}
/// The money party shares resolve against: the cash accounts (every
/// asset account but receivables when none are named), the cards and the
/// financing accounts (rules.md B-35 item 5).
fn party_money(ex: &Example, l: &Ledger) -> MoneyAccounts {
ledger_core::parties::money_accounts(
l,
&codes(l, &ex.setup.cash_accounts, &ex.id),
&codes(l, &ex.setup.financing_accounts, &ex.id),
)
}
/// The party an expectation names.
fn expect_party(b: &Books<'_>, x: &Expect) -> PartyId {
let name = x
.party
.as_deref()
.unwrap_or_else(|| panic!("{}: {} names its party", b.ex.id, x.query));
*b.parties
.get(name)
.unwrap_or_else(|| panic!("{}: party {name:?} not in setup.parties", b.ex.id))
}
/// Check one `party_part` expectation: entry N's part for the party
/// (rules.md B-35): `resolved`, signed as a line on the party's own
/// account (debit-positive: paid or given positive, received negative),
/// the entry's `direction` (`paid`, `received` or `neither`), and what
/// of it moved as money (`cash`) and as property (`in_kind`); every one
/// given.
fn check_party_part(b: &Books<'_>, x: &Expect) {
let (ex, l) = (b.ex, &b.l);
assert!(
x.from.is_none() && x.to.is_none() && x.as_of.is_none(),
"{}: party_part asks about one entry, no dates",
ex.id
);
let n = x
.entry
.unwrap_or_else(|| panic!("{}: a party_part query names its entry", ex.id));
let party = expect_party(b, x);
let r = ledger_core::shares::resolve(
l,
n,
l.entry_parties(n),
&party_money(ex, l),
&b.party_members,
)
.unwrap_or_else(|why| panic!("{}: entry {n}'s shares: {why}", ex.id));
let part = r
.part(party)
.unwrap_or_else(|| panic!("{}: entry {n} doesn't name {:?}", ex.id, x.party));
let keys = ["resolved", "direction", "cash", "in_kind"];
exactly(ex, x, &keys.iter().map(|k| k.to_string()).collect());
let what = |k: &str| format!("{} entry {n} {:?}: {k}", ex.id, x.party);
for (key, want) in x.figures(&ex.id) {
if key == "direction" {
assert_eq!(r.target.direction.key(), want, "{}", what(key));
continue;
}
let got = match key.as_str() {
"resolved" => part.amount,
"cash" => part.cash,
_ => part.in_kind,
};
assert_eq!(got, exact(want, &what(key)), "{}", what(key));
}
}
/// Check one `party_totals` expectation: what was `paid` to and
/// `received` from the party from `from` through `to` on `basis` (`cash`:
/// money; `all`: money and property in kind), each entry's part on its
/// root original's side (rules.md B-35 item 4); both given.
fn check_party_totals(b: &Books<'_>, x: &Expect) {
let (ex, l) = (b.ex, &b.l);
assert!(
x.from.is_some() && x.to.is_some(),
"{}: party_totals gives from and to",
ex.id
);
let (from, to) = x.period(ex);
let basis = x
.basis
.as_deref()
.map(|k| Basis::parse(k).unwrap_or_else(|| panic!("{}: basis {k:?}", ex.id)))
.unwrap_or_else(|| panic!("{}: party_totals names its basis", ex.id));
let party = expect_party(b, x);
let a = ledger_core::parties::activity_with(
l,
party,
(from, to),
&party_money(ex, l),
&b.party_members,
);
assert!(
a.unresolved.is_empty(),
"{}: {:?}'s entries {:?} don't resolve",
ex.id,
x.party,
a.unresolved
);
let (paid, received) = a.totals(basis);
exactly(ex, x, &["paid".to_string(), "received".to_string()].into());
let f = x.figures(&ex.id);
let what = |k: &str| {
format!(
"{} {:?} [{from} .. {to}] {}: {k}",
ex.id,
x.party,
basis.key()
)
};
assert_eq!(paid, exact(&f["paid"], &what("paid")), "{}", what("paid"));
assert_eq!(
received,
exact(&f["received"], &what("received")),
"{}",
what("received")
);
}
/// Check one `party_tin_last4` expectation: the last four of the party's
/// TIN the books keep (its TIN facts', else its W-9's; `""` for none:
/// rules.md B-26).
fn check_party_tin_last4(b: &Books<'_>, x: &Expect) {
let ex = b.ex;
assert!(
x.from.is_none() && x.to.is_none() && x.as_of.is_none(),
"{}: party_tin_last4 takes no dates",
ex.id
);
let party = expect_party(b, x);
exactly(ex, x, &["tin_last4".to_string()].into());
let got =
b.l.get_party(party)
.and_then(|p| p.tin_last4())
.unwrap_or_default();
assert_eq!(
got,
x.figures(&ex.id)["tin_last4"],
"{}: {:?}'s TIN last four",
ex.id,
x.party
);
}
/// Check one `k1` expectation: the K-1 worksheet over the period (rules.md
/// B-13; Schedule K-1 Item L), `"<member>.<column>"` for the columns
/// `beginning`, `contributions_cash`, `contributions_property`,
/// `net_income`, `distributions`, `other` and `ending`; the figures named
/// are the example's choice. It needs `cash_accounts` (what is money
/// rather than property).
fn check_k1(ex: &Example, l: &Ledger, members: &[MemberCapital], x: &Expect) {
let (from, to) = x.period(ex);
assert!(
!members.is_empty() && members.iter().all(|m| m.share > Decimal::ZERO),
"{}: k1 needs the members and their shares",
ex.id
);
let cash = codes(l, &ex.setup.cash_accounts, &ex.id);
let rows = ledger_core::k1::k1_worksheet(l, from, to, members, &cash)
.unwrap_or_else(|e| panic!("{}: {e}", ex.id));
let mut got = BTreeMap::new();
for r in &rows {
for (column, v) in [
("beginning", r.beginning),
("contributions_cash", r.contributions_cash),
("contributions_property", r.contributions_property),
("net_income", r.net_income),
("distributions", r.distributions),
("other", r.other),
("ending", r.ending),
] {
got.insert(format!("{}.{column}", r.name), v);
}
}
for (key, want) in x.figures(&ex.id) {
let want = exact(want, &format!("{} {key}", ex.id));
let got = got
.get(key)
.unwrap_or_else(|| panic!("{}: k1 has no {key}", ex.id));
assert_eq!(*got, want, "{} K-1 [{from} .. {to}]: {key}", ex.id);
}
}
/// The formats appendix's queries, and the test (another crate's) that
/// checks them against the engine.
const FORMAT_QUERIES: [(&str, &str); 2] = [
@@ -1374,7 +1765,13 @@ fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> {
queries.join(" and ")
);
};
only(x.entry.is_some(), "entry", &["entry"]);
only(x.entry.is_some(), "entry", &["entry", "party_part"]);
only(
x.party.is_some(),
"party",
&["party_part", "party_totals", "party_tin_last4"],
);
only(x.basis.is_some(), "basis", &["party_totals"]);
only(
x.code.is_some(),
"code",
@@ -1419,10 +1816,14 @@ fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> {
"cash_flow" => check_cash_flow(ex, l, &b.members, x),
"trial_balance" => check_trial_balance(ex, l, x),
"profit_and_loss" => check_profit_and_loss(ex, l, x),
"vendor_1099_paid" => check_vendor_1099_paid(ex, l, &b.payees, x),
"vendor_1099_paid" => check_vendor_1099_paid(b, x),
"entry" => check_entry(ex, l, x),
"balances" => check_balances(ex, l, x),
"members_equity" => check_members_equity(ex, l, &b.members, x),
"k1" => check_k1(ex, l, &b.members, x),
"party_part" => check_party_part(b, x),
"party_totals" => check_party_totals(b, x),
"party_tin_last4" => check_party_tin_last4(b, x),
q => {
let &(_, by) = FORMAT_QUERIES
.iter()
@@ -1508,8 +1909,8 @@ fn every_bookspec_example_holds() {
}
// Each query the examples use ran: the cash flow's six examples at
// least, the as-of and 1099 examples', the members closes', the
// formats appendix's, and P3's default period, rename and tax
// category examples'.
// formats appendix's, and P3's default period, rename, tax category
// and parties examples'.
for (q, at_least) in [
("cash_flow", 6),
("trial_balance", 2),
@@ -1517,7 +1918,11 @@ fn every_bookspec_example_holds() {
("vendor_1099_paid", 3),
("entry", 8),
("balances", 4),
("members_equity", 5),
("members_equity", 10),
("k1", 2),
("party_part", 27),
("party_totals", 21),
("party_tin_last4", 5),
("iif_lines", 1),
("iif_changes", 1),
("period", 6),
@@ -1842,3 +2247,53 @@ fn a_command_with_values_is_refused() {
)
});
}
/// An event's parties must be in the setup's directory.
#[test]
#[should_panic(expected = "not in setup")]
fn a_party_not_in_the_setup_is_refused() {
run_edited("p3-party-signs-and-reversals.toml", |t| {
t.replacen(
"parties = [{ party = \"Tobin Harlow\" }]",
"parties = [{ party = \"Tobin Harlo\" }]",
1,
)
});
}
/// A party's part is checked for its direction as well as its figures:
/// a contribution read as paid fails.
#[test]
#[should_panic(expected = "entry 2 Some(\"Tobin Harlow\"): direction")]
fn a_party_parts_direction_is_checked() {
run_edited("p3-party-signs-and-reversals.toml", |t| {
t.replacen(
"resolved = \"-400.00\", direction = \"received\"",
"resolved = \"-400.00\", direction = \"paid\"",
1,
)
});
}
/// A reversal takes its original's parties: naming them again is
/// refused, never read as a second tag.
#[test]
#[should_panic(expected = "takes its original's parties")]
fn a_reversal_naming_parties_is_refused() {
run_edited("p3-party-signs-and-reversals.toml", |t| {
t.replacen(
"reverses = 4\n",
"reverses = 4\nparties = [{ party = \"Tobin Harlow\" }]\n",
1,
)
});
}
/// Party totals name their basis: cash and all differ for property.
#[test]
#[should_panic(expected = "party_totals names its basis")]
fn party_totals_without_a_basis_are_refused() {
run_edited("p3-party-signs-and-reversals.toml", |t| {
t.replacen("basis = \"all\"\n", "", 1)
});
}
+33
View File
@@ -315,4 +315,37 @@ proptest! {
prop_assert_eq!(fi.into_iter().map(dec).collect::<Vec<_>>(), fd);
prop_assert_eq!(ti.into_iter().map(dec).collect::<Vec<_>>(), td);
}
/// rules.md B-35: a party's target, and a reversal's, are the same on
/// i64 cents and on Decimal.
#[test]
fn party_target_agrees(
capital in prop::option::of((arb_cents(), 0i64..MAX_CENTS)),
net in arb_cents(),
own in arb_cents(),
debits in 0i64..MAX_CENTS,
) {
let as_dec = |t: kernel::PartyTarget<i64>| kernel::PartyTarget {
amount: dec(t.amount),
direction: t.direction,
cash: dec(t.cash),
in_kind: dec(t.in_kind),
};
let ti = kernel::party_target(capital, net, own, debits);
let td = kernel::party_target(
capital.map(|(e, p)| (dec(e), dec(p))),
dec(net),
dec(own),
dec(debits),
);
prop_assert_eq!(ti.map(as_dec), td);
// Never a negative zero on Decimal (party_target INVARIANT 6).
for t in td.iter().chain(td.and_then(kernel::reverse_target).iter()) {
for v in [t.amount, t.cash, t.in_kind] {
prop_assert!(!(v.is_zero() && v.is_sign_negative()), "{t:?}");
}
}
let ri = ti.and_then(kernel::reverse_target);
prop_assert_eq!(ri.map(as_dec), td.and_then(kernel::reverse_target));
}
}
+2 -3
View File
@@ -561,9 +561,8 @@ fn replayed_bases_and_shares_past_the_limit_are_accepted() {
));
b.ledger.replay_set_entry_parties(e, &shares).unwrap();
assert_eq!(b.ledger.entry_parties(e), &shares);
let money = ledger_core::parties::money_accounts(&b.ledger, &[b.cash]);
let why = ledger_core::shares::resolve(b.ledger.get_entry(e).unwrap(), &shares, &money, &[])
.unwrap_err();
let money = ledger_core::parties::money_accounts(&b.ledger, &[b.cash], &[]);
let why = ledger_core::shares::resolve(&b.ledger, e, &shares, &money, &[]).unwrap_err();
assert!(why.contains("too large to compute"), "{why}");
// Replay still checks what every version checked.
assert_eq!(
+137 -51
View File
@@ -132,6 +132,7 @@ fn bare_party(name: &str) -> Party {
tin: None,
vendor: None,
customer: None,
creditor_of: BTreeSet::new(),
}
}
@@ -1401,14 +1402,14 @@ fn entry_flow_takes_across_in_order_until_used_up() {
use ledger_core::parties::{entry_flow, money_accounts};
use ledger_core::{AccountType, Currency, Ledger, Money, Side};
let mut l = Ledger::new(Currency::USD);
let mut acct = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
let bank = acct("1000", AccountType::Asset);
let card_a = acct("2050", AccountType::Liability);
let card_b = acct("2060", AccountType::Liability);
let members = acct("2100", AccountType::Liability);
let capital = acct("3000", AccountType::Equity);
let fees = acct("6000", AccountType::Expense);
let money = money_accounts(&l, &[bank]);
let mut acct = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
let bank = acct("1000", "Bank", AccountType::Asset);
let card_a = acct("2050", "Card A", AccountType::Liability);
let card_b = acct("2060", "Card B", AccountType::Liability);
let members = acct("2100", "Due to Members", AccountType::Liability);
let capital = acct("3000", "Capital", AccountType::Equity);
let fees = acct("6000", "Fees", AccountType::Expense);
let money = money_accounts(&l, &[bank], &[members]);
let e = l.create_entry(NaiveDate::from_ymd_opt(2026, 5, 1).unwrap(), "x");
for (a, side, amt) in [
(card_a, Side::Debit, "30.00"),
@@ -1437,11 +1438,15 @@ fn entry_flow_takes_across_in_order_until_used_up() {
);
}
/// Oracle: shares::share_target: an entry moving no money splits over its
/// total debits, whatever its credits (a draft not yet balanced).
/// Oracle: shares::target DEFINITIONS, case 3 (rules.md B-35): members
/// named on an entry moving no money split what moved equity, E (here
/// −4.00 of a draft not yet balanced), received, in kind (the equipment's
/// 10.00, at most |E|); case 5: with a party named who isn't a member,
/// the total debits, direction neither, counting nothing.
#[test]
fn a_share_target_without_money_is_the_debits() {
fn a_share_target_without_money_is_the_capital_or_the_debits() {
use ledger_core::parties::money_accounts;
use ledger_core::shares::{Direction, Target, target};
use ledger_core::{AccountType, Currency, Ledger, Money, Side};
let mut l = Ledger::new(Currency::USD);
let bank = l
@@ -1453,7 +1458,9 @@ fn a_share_target_without_money_is_the_debits() {
let capital = l
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let money = money_accounts(&l, &[bank]);
let member = l.add_party("Tobin Harlow", &[Role::Member]).unwrap();
let vendor = l.add_party("Riley Chen", &[Role::Contractor]).unwrap();
let money = money_accounts(&l, &[bank], &[]);
let e = l.create_entry(NaiveDate::from_ymd_opt(2026, 5, 1).unwrap(), "x");
l.add_line(e, equip, Side::Debit, Money::new(d("10.00"), Currency::USD))
.unwrap();
@@ -1465,8 +1472,25 @@ fn a_share_target_without_money_is_the_debits() {
)
.unwrap();
assert_eq!(
ledger_core::shares::share_target(l.get_entry(e).unwrap(), &money),
Ok(d("10.00"))
target(&l, e, &[PartyShare::whole(member)], &money),
Ok(Target {
amount: d("-4.00"),
direction: Direction::Received,
cash: Decimal::ZERO,
in_kind: d("-4.00"),
counts_as: Direction::Received,
})
);
let both = [PartyShare::whole(member), PartyShare::whole(vendor)];
assert_eq!(
target(&l, e, &both, &money),
Ok(Target {
amount: d("10.00"),
direction: Direction::Neither,
cash: Decimal::ZERO,
in_kind: Decimal::ZERO,
counts_as: Direction::Neither,
})
);
}
@@ -1484,7 +1508,7 @@ fn derived_member_shares_net_their_capital_lines() {
let cap_b = acct("3001", AccountType::Equity);
let a = l.add_party("A", &[Role::Member]).unwrap();
let b = l.add_party("B", &[Role::Member]).unwrap();
let money = money_accounts(&l, &[bank]);
let money = money_accounts(&l, &[bank], &[]);
let member = |party, capital| ledger_core::PartyMember {
party,
share: d("0.5"),
@@ -1506,9 +1530,13 @@ fn derived_member_shares_net_their_capital_lines() {
s.share = ledger_core::Share::Derived;
s
});
let r = ledger_core::shares::resolve(&l, e, &shares, &money, &members).unwrap();
assert_eq!(
ledger_core::shares::resolve(l.get_entry(e).unwrap(), &shares, &money, &members),
Ok(vec![(a, d("-70.00")), (b, d("-30.00"))])
r.parts
.iter()
.map(|p| (p.party, p.amount))
.collect::<Vec<_>>(),
vec![(a, d("-70.00")), (b, d("-30.00"))]
);
}
@@ -1520,16 +1548,17 @@ fn entry_flow_scenarios() {
use ledger_core::{AccountType, Currency, Ledger, Money, Side};
use rust_decimal::Decimal;
let mut l = Ledger::new(Currency::USD);
let mut acct = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
let bank = acct("1000", AccountType::Asset);
let savings = acct("1010", AccountType::Asset);
let equip = acct("1500", AccountType::Asset);
let card = acct("2050", AccountType::Liability);
let members = acct("2100", AccountType::Liability);
let loan = acct("2500", AccountType::Liability);
let capital = acct("3000", AccountType::Equity);
let exp = acct("6000", AccountType::Expense);
let money = money_accounts(&l, &[bank, savings]);
let mut acct = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
let bank = acct("1000", "Bank", AccountType::Asset);
let savings = acct("1010", "Savings", AccountType::Asset);
let equip = acct("1500", "Equipment", AccountType::Asset);
let card = acct("2050", "Business Card", AccountType::Liability);
let members = acct("2100", "Due to Members", AccountType::Liability);
let loan = acct("2500", "Loan", AccountType::Liability);
let accrued = acct("2200", "Accrued Expenses", AccountType::Liability);
let capital = acct("3000", "Capital", AccountType::Equity);
let exp = acct("6000", "Expenses", AccountType::Expense);
let money = money_accounts(&l, &[bank, savings], &[members, loan]);
let d = chrono::NaiveDate::from_ymd_opt(2026, 5, 1).unwrap();
let mut case = |lines: &[(u64, Side, &str)]| {
let e = l.create_entry(d, "x");
@@ -1619,6 +1648,13 @@ fn entry_flow_scenarios() {
(card, C, "50.00"),
]);
assert_eq!(f.net, dec("350.00"));
// 8 (defect 24). An accrual through a liability that isn't money
// moves none; its settlement is the payment, counted once.
let f = case(&[(exp, D, "40.00"), (accrued, C, "40.00")]);
assert_eq!(f.net, Decimal::ZERO);
let f = case(&[(accrued, D, "40.00"), (bank, C, "40.00")]);
assert_eq!(f.net, dec("40.00"));
assert_eq!(f.what_for, vec![(accrued, dec("40.00"))]);
}
// ------------------------------------------------------- party shares
@@ -1729,25 +1765,33 @@ mod shares {
.collect()
}
/// The money accounts: the bank, and 2100 as the members' financing
/// (`books.financing_accounts`).
fn money(b: &Books) -> ledger_core::parties::MoneyAccounts {
money_accounts(&b.l, &[b.bank], &[b.due])
}
fn resolved(b: &Books, e: EntryId) -> Result<Vec<(PartyId, Decimal)>, String> {
let money = money_accounts(&b.l, &[b.bank]);
resolve(
b.l.get_entry(e).unwrap(),
b.l.entry_parties(e),
&money,
&b.members,
)
resolve(&b.l, e, b.l.entry_parties(e), &money(b), &b.members)
.map(|r| r.parts.iter().map(|p| (p.party, p.amount)).collect())
}
fn year() -> (chrono::NaiveDate, chrono::NaiveDate) {
(date(2026, 1, 1), date(2026, 12, 31))
}
/// (paid, received) on basis cash.
fn paid_received(b: &Books, p: PartyId) -> (Decimal, Decimal) {
let a = activity_with(&b.l, p, year(), &[b.bank], &b.members);
let a = activity_with(&b.l, p, year(), &money(b), &b.members);
(a.paid, a.received)
}
/// (paid, received) on basis all.
fn paid_received_all(b: &Books, p: PartyId) -> (Decimal, Decimal) {
activity_with(&b.l, p, year(), &money(b), &b.members)
.totals(ledger_core::parties::Basis::All)
}
/// SCENARIO 1: a reimbursement to both members, derived, 50/50.
#[test]
fn reimbursing_both_members_splits_by_member_share() {
@@ -1793,8 +1837,11 @@ mod shares {
assert_eq!(paid_received(&b, b.mo), (Decimal::ZERO, d("100.00")));
}
/// SCENARIO 3: property contributed moves no money; the shares are of
/// the total, and activity counts nothing.
/// SCENARIO 3 (defect 14, rules.md B-35): property contributed moves
/// no money; each member's part is what moved their capital, signed
/// as their own account shows it: received, in kind. Basis cash
/// counts nothing; basis all counts it. (Before P3 this read +500.00
/// each, the sign of a payment.)
#[test]
fn property_contribution_resolves_against_the_total() {
let mut b = books();
@@ -1810,14 +1857,52 @@ mod shares {
.unwrap();
assert_eq!(
resolved(&b, e).unwrap(),
vec![(b.tobin, d("500.00")), (b.mo, d("500.00"))]
vec![(b.tobin, d("-500.00")), (b.mo, d("-500.00"))]
);
let r = resolve(&b.l, e, b.l.entry_parties(e), &money(&b), &b.members).unwrap();
assert_eq!(r.target.direction, ledger_core::shares::Direction::Received);
assert!(
r.parts
.iter()
.all(|p| p.cash.is_zero() && p.in_kind == d("-500.00"))
);
assert_eq!(paid_received(&b, b.tobin), (Decimal::ZERO, Decimal::ZERO));
assert_eq!(paid_received_all(&b, b.tobin), (Decimal::ZERO, d("500.00")));
assert_eq!(paid_received_all(&b, b.mo), (Decimal::ZERO, d("500.00")));
}
/// Capital lines that don't account for the whole flow are refused.
/// INVARIANT 10: capital lines that don't account for the whole
/// movement of equity (here another equity account's line) are
/// refused, and the entry is listed as unresolved.
#[test]
fn capital_lines_short_of_the_flow_are_refused() {
fn capital_lines_short_of_the_target_are_refused() {
let mut b = books();
let other =
b.l.add_account("3500", "Opening Equity", AccountType::Equity, None, false)
.unwrap();
let e = post(
&mut b.l,
&[
(b.bank, Side::Debit, "300.00"),
(b.cap_s, Side::Credit, "200.00"),
(other, Side::Credit, "100.00"),
],
);
b.l.set_entry_parties(e, &derived(&[b.tobin, b.mo]))
.unwrap();
assert!(resolved(&b, e).is_err());
let a = activity_with(&b.l, b.tobin, year(), &money(&b), &b.members);
assert!(a.unresolved.contains(&e));
assert_eq!((a.paid, a.received), (Decimal::ZERO, Decimal::ZERO));
}
/// rules.md B-35 item 3 (capital first): a members' entry counts what
/// moved their capital, not other money on it: 300.00 in, 200.00 of it
/// Tobin's capital and 100.00 an expense refunded, is Tobin's 200.00
/// received (cash) and nothing of Morgan's. (Before P3 the 300.00 net
/// was the target and the capital lines fell short of it: refused.)
#[test]
fn a_members_entry_counts_what_moved_their_capital() {
let mut b = books();
let e = post(
&mut b.l,
@@ -1829,10 +1914,12 @@ mod shares {
);
b.l.set_entry_parties(e, &derived(&[b.tobin, b.mo]))
.unwrap();
assert!(resolved(&b, e).is_err());
let a = activity_with(&b.l, b.tobin, year(), &[b.bank], &b.members);
assert!(a.unresolved.contains(&e));
assert_eq!((a.paid, a.received), (Decimal::ZERO, Decimal::ZERO));
assert_eq!(
resolved(&b, e).unwrap(),
vec![(b.tobin, d("-200.00")), (b.mo, Decimal::ZERO)]
);
assert_eq!(paid_received(&b, b.tobin), (Decimal::ZERO, d("200.00")));
assert_eq!(paid_received(&b, b.mo), (Decimal::ZERO, Decimal::ZERO));
}
/// SCENARIO 4: percent rounding, remainder to the first party.
@@ -1907,8 +1994,7 @@ mod shares {
// With no members passed (plain activity), two members can't derive.
b.l.set_entry_parties(e, &derived(&[b.tobin, b.mo]))
.unwrap();
let money = money_accounts(&b.l, &[b.bank]);
assert!(resolve(b.l.get_entry(e).unwrap(), b.l.entry_parties(e), &money, &[]).is_err());
assert!(resolve(&b.l, e, b.l.entry_parties(e), &money(&b), &[]).is_err());
// A sole party needs no members.
b.l.set_entry_parties(e, &derived(&[b.acme])).unwrap();
assert_eq!(resolved(&b, e).unwrap(), vec![(b.acme, d("10.00"))]);
@@ -2147,7 +2233,7 @@ mod shares {
],
);
b.l.set_entry_party(e, Some(b.acme)).unwrap();
let a = ledger_core::parties::activity(&b.l, b.acme, year(), &[b.bank]);
let a = ledger_core::parties::activity(&b.l, b.acme, year(), &money(&b));
assert_eq!(a.paid, d("12.34"));
assert!(a.entries.contains(&e));
}
@@ -2168,14 +2254,14 @@ mod shares {
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]);
let act = ledger_core::parties::activity(&b.l, b.acme, year(), &money(&b));
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]);
let act = ledger_core::parties::activity(&b.l, b.acme, year(), &money(&b));
assert_eq!((act.paid, act.received), (Decimal::ZERO, Decimal::ZERO));
}
@@ -2255,8 +2341,8 @@ mod shares {
b.l.set_entry_parties(e, &derived(&[b.tobin, b.mo])).unwrap();
let r = resolved(&b, e).unwrap();
prop_assert_eq!(r, vec![(b.tobin, -a), (b.mo, -c)]);
let sa = activity_with(&b.l, b.tobin, year(), &[b.bank], &b.members);
let sk = activity_with(&b.l, b.mo, year(), &[b.bank], &b.members);
let sa = activity_with(&b.l, b.tobin, year(), &money(&b), &b.members);
let sk = activity_with(&b.l, b.mo, year(), &money(&b), &b.members);
prop_assert_eq!(sa.received + sk.received, total);
}
}
@@ -0,0 +1,8 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 816160434c103e96360af20c33f0f16533f84b4c6599ea517ce6b1569139411f # shrinks to lines = [(0, Debit, 1), (1, Credit, 1)], who = Vendor, owed = []
cc 38876dbe146abc3025abf7a96f9addc1de590a95de893435df024c709cb0672e # shrinks to steps = [(true, 0, false)]
File diff suppressed because it is too large. Load diff
@@ -199,7 +199,16 @@ fn a_draft_of_decimals_largest_is_refused_where_summed() {
let q = l
.add_party("Beta", &[ledger_core::Role::Contractor])
.unwrap();
let money = ledger_core::parties::money_accounts(&l, &[cash]);
let capital = l
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let member = l
.add_party("Tobin Harlow", &[ledger_core::Role::Member])
.unwrap();
let money = ledger_core::parties::money_accounts(&l, &[cash], &[]);
let resolve = |l: &Ledger, e, shares: &[PartyShare]| {
ledger_core::shares::resolve(l, e, shares, &money, &[])
};
for (dr, cr) in [(rent, cash), (rent, card), (card, cash), (cash, card)] {
let e = l.create_entry(d(2026, 3, 1), "huge");
for side in [Side::Debit, Side::Debit] {
@@ -211,12 +220,31 @@ fn a_draft_of_decimals_largest_is_refused_where_summed() {
.unwrap();
let entry = l.get_entry(e).unwrap();
assert!(too_large(ledger_core::parties::entry_flow(entry, &money)));
assert!(too_large(ledger_core::shares::share_target(entry, &money)));
let why =
ledger_core::shares::resolve(entry, &[PartyShare::whole(p)], &money, &[]).unwrap_err();
assert!(too_large(ledger_core::shares::target(
&l,
e,
&[PartyShare::whole(p)],
&money
)));
let why = resolve(&l, e, &[PartyShare::whole(p)]).unwrap_err();
assert!(why.contains("too large to compute"), "{why}");
assert_eq!(l.post_entry(e).unwrap_err(), LedgerError::AmountOutOfRange);
}
// A member's capital moved past exact arithmetic (rules.md B-35, case
// 3, B-28): the target is refused, never a panic.
let e = l.create_entry(d(2026, 3, 1), "huge capital");
for _ in 0..2 {
l.replay_add_line(e, cash, Side::Debit, usd(Decimal::MAX))
.unwrap();
l.replay_add_line(e, capital, Side::Credit, usd(Decimal::MAX))
.unwrap();
}
assert!(too_large(ledger_core::shares::target(
&l,
e,
&[PartyShare::whole(member)],
&money
)));
// One line of Decimal's largest flows, and its share resolves whole.
let e = l.create_entry(d(2026, 3, 1), "one huge");
l.replay_add_line(e, rent, Side::Debit, usd(Decimal::MAX))
@@ -228,16 +256,17 @@ fn a_draft_of_decimals_largest_is_refused_where_summed() {
ledger_core::parties::entry_flow(entry, &money).unwrap().net,
Decimal::MAX
);
let r = resolve(&l, e, &[PartyShare::whole(p)]).unwrap();
assert_eq!(
ledger_core::shares::resolve(entry, &[PartyShare::whole(p)], &money, &[]).unwrap(),
[(p, Decimal::MAX)]
(r.parts[0].party, r.parts[0].amount, r.parts[0].cash),
(p, Decimal::MAX, Decimal::MAX)
);
// A percent of it: the product is past exact arithmetic.
let halves = [p, q].map(|party| PartyShare {
party,
share: ledger_core::shares::Share::Percent(Decimal::from(50)),
});
let why = ledger_core::shares::resolve(entry, &halves, &money, &[]).unwrap_err();
let why = resolve(&l, e, &halves).unwrap_err();
assert!(why.contains("too large to compute"), "{why}");
}