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:
52 files changed
+7535
-560
No files matched your search
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
|
||||
+142
-98
@@ -67,13 +67,16 @@ pub struct EntryInfo {
|
||||
/// One party an entry names, with its share.
|
||||
///
|
||||
/// ```text
|
||||
/// SOURCE: ledger_core::shares (resolve), with Books::money_cash_accounts
|
||||
/// and Books::members, as the server's books.get_entry resolves
|
||||
/// them.
|
||||
/// SOURCE: ledger_core::shares (resolve), with Books::money and
|
||||
/// Books::members, as the server's books.get_entry resolves them.
|
||||
/// INVARIANTS:
|
||||
/// 1. resolved: the party's part from ledger_core::shares::resolve, None
|
||||
/// when the entry's shares don't resolve; over an entry's parties the
|
||||
/// resolved parts sum to what it moved exactly.
|
||||
/// 1. resolved: the party's part from ledger_core::shares::resolve
|
||||
/// (signed as a line on its own account: positive paid to it,
|
||||
/// negative received from it), None when the entry's shares don't
|
||||
/// resolve; over an entry's parties the resolved parts sum to the
|
||||
/// target exactly.
|
||||
/// 2. direction, cash, in_kind: the target's direction and the part's
|
||||
/// money and property, None exactly when resolved is.
|
||||
/// ```
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EntryParty {
|
||||
@@ -81,6 +84,9 @@ pub struct EntryParty {
|
||||
pub label: String,
|
||||
pub share: ledger_core::Share,
|
||||
pub resolved: Option<Decimal>,
|
||||
pub direction: Option<ledger_core::shares::Direction>,
|
||||
pub cash: Option<Decimal>,
|
||||
pub in_kind: Option<Decimal>,
|
||||
}
|
||||
|
||||
/// One fixed asset's trail through the journal and the entries.
|
||||
@@ -133,13 +139,14 @@ impl AssetTrail {
|
||||
/// year 1099s are counted in, and books.party's year: a posted entry,
|
||||
/// invoice, or bill dated later this year counts).
|
||||
/// Money: Books::activity (ledger_core::parties::activity_with with
|
||||
/// Books::money_cash_accounts and Books::members: a party's share of
|
||||
/// an entry naming several; the same numbers books.party reports).
|
||||
/// Books::money and Books::members: a party's share of an entry naming
|
||||
/// several; the same numbers books.party reports).
|
||||
/// INVARIANTS:
|
||||
/// 1. entries: every entry that is the party's, drafts included, in
|
||||
/// Books::entries order (newest first).
|
||||
/// 2. paid_ytd = activity's paid − received over the year, exactly.
|
||||
/// Positive: paid to them, net; negative: received from them, net.
|
||||
/// 2. paid_ytd = activity's paid − received over the year, exactly, on
|
||||
/// basis cash (books.party's default). Positive: paid to them, net;
|
||||
/// negative: received from them, net.
|
||||
/// 3. invoiced_ytd = Σ Invoice::total() over its customer record's
|
||||
/// invoices dated in the year; billed_ytd the same for its vendor
|
||||
/// record's bills.
|
||||
@@ -242,6 +249,11 @@ pub struct Books {
|
||||
pub members: Vec<ledger_core::PartyMember>,
|
||||
/// `[books] financing_accounts`, resolved (empty when not set).
|
||||
pub financing_accounts: Vec<AccountId>,
|
||||
/// The money accounts every party number reads
|
||||
/// (ledger_core::parties::money_accounts over money_cash_accounts and
|
||||
/// financing_accounts, with the cards), exactly as the server's
|
||||
/// books.party and books.get_entry read them.
|
||||
pub money: ledger_core::parties::MoneyAccounts,
|
||||
/// The cash accounts every number the server also reports reads
|
||||
/// money with: `[books] cash_accounts`, resolved; when that is empty,
|
||||
/// core's fallback (ledger_core::parties::money_accounts: every asset
|
||||
@@ -301,26 +313,26 @@ pub enum PartyPeriod {
|
||||
/// ```text
|
||||
/// GOAL: answer "how much did we spend with them, and how much did we
|
||||
/// get back?" (owner, 2026-10-15) with the books' own numbers.
|
||||
/// SOURCE: ledger_core::parties::activity_with (paid, received) and
|
||||
/// ledger_core::parties::entry_flow (what for), with
|
||||
/// Books::money_cash_accounts and Books::members, as PartyInfo
|
||||
/// uses them.
|
||||
/// SOURCE: ledger_core::parties::activity_with (paid, received, in kind)
|
||||
/// and ledger_core::parties::flow (what for), with Books::money
|
||||
/// and Books::members, as PartyInfo uses them; rules.md B-35.
|
||||
/// INVARIANTS:
|
||||
/// 1. paid, received: Books::activity(party, span)'s, exactly.
|
||||
/// 1. paid, received: Books::activity(party, span)'s, exactly: basis
|
||||
/// cash (money), books.party's default. paid_in_kind and
|
||||
/// received_in_kind: its property given and taken in kind, so basis
|
||||
/// all is paid + paid_in_kind and received + received_in_kind.
|
||||
/// 2. net() = paid − received.
|
||||
/// 3. what_for: over the party's posted entries dated in the span, the
|
||||
/// sum by account of each entry_flow's what_for (a liability paid
|
||||
/// down, like a reimbursement, appears as what the money paid for),
|
||||
/// for an entry naming several parties this party's part of it: a
|
||||
/// member's own capital and distributions lines when they come to
|
||||
/// its resolved share exactly, else the what_for apportioned to the
|
||||
/// share (share_of_what_for; nothing when the shares don't resolve),
|
||||
/// the accounts whose sum is zero left out, largest magnitude first
|
||||
/// (then by account id). Positive: what money paid for; negative:
|
||||
/// where money received came from (or, on payables and
|
||||
/// receivables, what is still owed).
|
||||
/// 4. Σ what_for = net(), exactly: each entry_flow's what_for sums to
|
||||
/// its net (a reversal reverses both).
|
||||
/// sum by account of each entry's breakdown for the party
|
||||
/// (Books::party_what_for: parties::flow's what_for, its share of it
|
||||
/// for an entry naming several, made to sum to the party's cash part
|
||||
/// of the entry), the accounts whose sum is zero left out, largest
|
||||
/// magnitude first (then by account id). Positive: what money paid
|
||||
/// for; negative: where money received came from (or, on payables
|
||||
/// and receivables, what is still owed).
|
||||
/// 4. Σ what_for = net(), exactly: each entry's breakdown sums to the
|
||||
/// party's cash part of it, and activity sums those parts (a
|
||||
/// reversal's are its original's negated).
|
||||
/// 5. entries: how many of the party's entries are posted and dated
|
||||
/// in the span.
|
||||
/// ```
|
||||
@@ -328,6 +340,8 @@ pub enum PartyPeriod {
|
||||
pub struct PartyFlow {
|
||||
pub paid: Decimal,
|
||||
pub received: Decimal,
|
||||
pub paid_in_kind: Decimal,
|
||||
pub received_in_kind: Decimal,
|
||||
pub what_for: Vec<(AccountId, Decimal)>,
|
||||
pub entries: usize,
|
||||
}
|
||||
@@ -571,17 +585,22 @@ impl Books {
|
||||
let money_cash_accounts = {
|
||||
let configured = codes(&settings.cash_accounts);
|
||||
if configured.is_empty() {
|
||||
let mut all: Vec<AccountId> = ledger_core::parties::money_accounts(&ledger, &[])
|
||||
.cash
|
||||
.into_iter()
|
||||
.collect();
|
||||
let mut all: Vec<AccountId> =
|
||||
ledger_core::parties::money_accounts(&ledger, &[], &[])
|
||||
.cash
|
||||
.into_iter()
|
||||
.collect();
|
||||
all.sort_unstable();
|
||||
all
|
||||
} else {
|
||||
configured
|
||||
}
|
||||
};
|
||||
let share_money = ledger_core::parties::money_accounts(&ledger, &money_cash_accounts);
|
||||
let share_money = ledger_core::parties::money_accounts(
|
||||
&ledger,
|
||||
&money_cash_accounts,
|
||||
&financing_accounts,
|
||||
);
|
||||
let investing_accounts = codes(&settings.investing_accounts);
|
||||
let card_accounts = ledger_store::reconcilable::card_accounts(&ledger);
|
||||
|
||||
@@ -675,7 +694,8 @@ impl Books {
|
||||
// vendor tags, bills, invoices, their payments, reversals).
|
||||
let party_id = ledger.entry_party(id);
|
||||
let shares = ledger.entry_parties(id);
|
||||
let resolved = ledger_core::shares::resolve(e, shares, &share_money, &members).ok();
|
||||
let resolved =
|
||||
ledger_core::shares::resolve(&ledger, id, shares, &share_money, &members).ok();
|
||||
let parties: Vec<EntryParty> = shares
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -686,7 +706,10 @@ impl Books {
|
||||
.map(|p| p.label().to_string())
|
||||
.unwrap_or_else(|| format!("party {}", s.party)),
|
||||
share: s.share,
|
||||
resolved: resolved.as_ref().map(|r| r[i].1),
|
||||
resolved: resolved.as_ref().map(|r| r.parts[i].amount),
|
||||
direction: resolved.as_ref().map(|r| r.target.direction),
|
||||
cash: resolved.as_ref().map(|r| r.parts[i].cash),
|
||||
in_kind: resolved.as_ref().map(|r| r.parts[i].in_kind),
|
||||
})
|
||||
.collect();
|
||||
let party = if parties.is_empty() {
|
||||
@@ -766,6 +789,7 @@ impl Books {
|
||||
cash_accounts,
|
||||
members,
|
||||
financing_accounts,
|
||||
money: share_money,
|
||||
money_cash_accounts,
|
||||
investing_accounts,
|
||||
card_accounts,
|
||||
@@ -1505,17 +1529,14 @@ impl Books {
|
||||
.collect();
|
||||
let mut what_for = Vec::new();
|
||||
if with_breakdown {
|
||||
let money = ledger_core::parties::money_accounts(l, &self.cash_accounts);
|
||||
let mut by: BTreeMap<AccountId, Decimal> = BTreeMap::new();
|
||||
for e in &posted {
|
||||
// A posted entry's flow computes (its lines are in a line's
|
||||
// range); one that doesn't, activity counts as unresolved
|
||||
// and counts nothing, so neither does what it was for.
|
||||
let Ok(flow) = ledger_core::parties::entry_flow(e, &money) else {
|
||||
// An entry activity counts as unresolved counts nothing, so
|
||||
// neither does what it was for.
|
||||
if a.unresolved.contains(&e.id()) {
|
||||
continue;
|
||||
};
|
||||
let parts = self.party_what_for(e, party, &flow);
|
||||
for (account, amount) in parts {
|
||||
}
|
||||
for (account, amount) in self.party_what_for(e, party) {
|
||||
*by.entry(account).or_default() += amount;
|
||||
}
|
||||
}
|
||||
@@ -1525,26 +1546,22 @@ impl Books {
|
||||
PartyFlow {
|
||||
paid: a.paid,
|
||||
received: a.received,
|
||||
paid_in_kind: a.paid_in_kind,
|
||||
received_in_kind: a.received_in_kind,
|
||||
what_for,
|
||||
entries: posted.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// ledger_core::parties::activity_with over `span`, with
|
||||
/// Books::money_cash_accounts and the members: the one source of a
|
||||
/// party's money, the server's own (books.party, books.parties).
|
||||
/// ledger_core::parties::activity_with over `span`, with Books::money
|
||||
/// and the members: the one source of a party's money, the server's own
|
||||
/// (books.party, books.parties).
|
||||
pub fn activity(
|
||||
&self,
|
||||
party: PartyId,
|
||||
span: (NaiveDate, NaiveDate),
|
||||
) -> ledger_core::parties::Activity {
|
||||
ledger_core::parties::activity_with(
|
||||
&self.ledger,
|
||||
party,
|
||||
span,
|
||||
&self.money_cash_accounts,
|
||||
&self.members,
|
||||
)
|
||||
ledger_core::parties::activity_with(&self.ledger, party, span, &self.money, &self.members)
|
||||
}
|
||||
|
||||
/// The books dir: the folder holding `books-data/` (and `vault/`).
|
||||
@@ -1629,51 +1646,86 @@ impl Books {
|
||||
}
|
||||
|
||||
/// One entry's what-for, for one party (see [`PartyFlow`] INVARIANT
|
||||
/// 3): its fraction of the entry's entry_flow what_for, summing to its
|
||||
/// money share exactly (zero when the entry moves no money, like a
|
||||
/// bill or a contribution of property).
|
||||
/// 3): its share of the entry's money breakdown (ledger_core::parties::
|
||||
/// flow's what_for: a reversal's is its original's negated), summing
|
||||
/// to its cash part of the entry exactly (nothing counted when the
|
||||
/// entry's shares don't resolve).
|
||||
///
|
||||
/// ```text
|
||||
/// The fraction: 1 for an entry with one party; else its resolved
|
||||
/// share over what the shares split (the net, or the entry's total
|
||||
/// debits when the net is zero).
|
||||
/// A member's own capital and distribution lines are theirs in full;
|
||||
/// another member's are never theirs. Every other line is scaled by
|
||||
/// the fraction (to the cent, toward zero), and the first scaled line
|
||||
/// takes the leftover so the parts sum to the money share.
|
||||
/// The fraction: the party's resolved part over the target (the sum of
|
||||
/// every party's part): 1 for an entry with one party.
|
||||
/// A member's own capital and distribution lines are theirs in full
|
||||
/// when the entry names several; another member's are never theirs.
|
||||
/// Every other line is scaled by the fraction (to the cent, toward
|
||||
/// zero), and the first scaled line takes what is left over so the
|
||||
/// breakdown sums to the party's cash part: a bill (no cash part)
|
||||
/// sums to zero, and a member's entry that counts only what moved
|
||||
/// their capital (a reimbursement with a distribution) leaves out the
|
||||
/// money that wasn't theirs.
|
||||
/// What moved in kind isn't what money paid for: when the party's part
|
||||
/// has property in it (rules.md B-35), the property lines (asset
|
||||
/// accounts neither cash nor receivables, the K-1 worksheet's test)
|
||||
/// stay out, so a property contribution, and its reversal, show
|
||||
/// nothing, and a distribution of money and equipment shows the
|
||||
/// money alone.
|
||||
/// ```
|
||||
pub fn party_what_for(
|
||||
&self,
|
||||
e: &ledger_core::JournalEntry,
|
||||
party: PartyId,
|
||||
flow: &ledger_core::parties::EntryFlow,
|
||||
) -> Vec<(AccountId, Decimal)> {
|
||||
use rust_decimal::RoundingStrategy::ToZero;
|
||||
let parties = self.ledger.entry_parties(e.id()).len();
|
||||
if parties <= 1 {
|
||||
return flow.what_for.clone();
|
||||
}
|
||||
let resolved = self.party_share(e, party);
|
||||
// Posted lines are in a line's range, so these sums and products
|
||||
// compute; one past exact arithmetic counts nothing, as an entry
|
||||
// whose shares don't resolve.
|
||||
let target = if flow.net.is_zero() {
|
||||
match ledger_config::ReceiptPolicy::entry_debits(e) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return Vec::new(),
|
||||
}
|
||||
} else {
|
||||
flow.net
|
||||
};
|
||||
let Some(fraction) = resolved.checked_div(target) else {
|
||||
let Some(info) = self
|
||||
.entry_index
|
||||
.get(&e.id())
|
||||
.and_then(|&i| self.entries.get(i))
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let money_share = if flow.net.is_zero() {
|
||||
Decimal::ZERO
|
||||
let Some(me) = info.parties.iter().find(|p| p.id == party) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let (Some(resolved), Some(cash)) = (me.resolved, me.cash) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let in_kind = me.in_kind.is_some_and(|k| !k.is_zero());
|
||||
let receivables = self.ledger.role_account(&ledger_core::RECEIVABLES);
|
||||
let property = |a: AccountId| {
|
||||
!self.money.cash.contains(&a)
|
||||
&& Some(a) != receivables
|
||||
&& self
|
||||
.ledger
|
||||
.chart()
|
||||
.get(a)
|
||||
.is_some_and(|x| x.account_type == AccountType::Asset)
|
||||
};
|
||||
let shares = self.ledger.entry_parties(e.id());
|
||||
// A posted entry's flow computes (its lines are in a line's range);
|
||||
// one past exact arithmetic counts nothing.
|
||||
let Ok(flow) = ledger_core::parties::flow(&self.ledger, e.id(), shares, &self.money) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(target) = info
|
||||
.parties
|
||||
.iter()
|
||||
.try_fold(Decimal::ZERO, |t, p| t.checked_add(p.resolved?))
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let fraction = if info.parties.len() <= 1 {
|
||||
Decimal::ONE
|
||||
} else {
|
||||
resolved
|
||||
match resolved.checked_div(target) {
|
||||
Some(f) => f,
|
||||
None => return Vec::new(),
|
||||
}
|
||||
};
|
||||
let several = info.parties.len() > 1;
|
||||
let me = if several {
|
||||
self.member(party).copied()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let me = self.member(party).copied();
|
||||
let member_line = |a: AccountId| {
|
||||
self.members
|
||||
.iter()
|
||||
@@ -1682,9 +1734,12 @@ impl Books {
|
||||
let mut own: Vec<(AccountId, Decimal)> = Vec::new();
|
||||
let mut scaled: Vec<(AccountId, Decimal)> = Vec::new();
|
||||
for &(a, v) in &flow.what_for {
|
||||
if in_kind && property(a) {
|
||||
continue;
|
||||
}
|
||||
if me.is_some_and(|m| m.capital == a || m.distributions == Some(a)) {
|
||||
own.push((a, v));
|
||||
} else if !member_line(a) {
|
||||
} else if !several || !member_line(a) {
|
||||
let Some(part) = v.checked_mul(fraction) else {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -1695,12 +1750,12 @@ impl Books {
|
||||
.iter()
|
||||
.chain(&scaled)
|
||||
.try_fold(Decimal::ZERO, |t, (_, v)| t.checked_add(*v))
|
||||
.and_then(|have| money_share.checked_sub(have))
|
||||
.and_then(|have| cash.checked_sub(have))
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !left.is_zero() {
|
||||
match scaled.first_mut() {
|
||||
match scaled.first_mut().or(own.first_mut()) {
|
||||
Some(first) => first.1 += left,
|
||||
None => own.push((e.lines()[0].account, left)),
|
||||
}
|
||||
@@ -1711,17 +1766,6 @@ impl Books {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A party's resolved share of an entry's flow (its entry_flow net
|
||||
/// for a sole party; zero when the shares don't resolve or it isn't
|
||||
/// named).
|
||||
pub fn party_share(&self, e: &ledger_core::JournalEntry, party: PartyId) -> Decimal {
|
||||
self.entries
|
||||
.get(self.entry_index.get(&e.id()).copied().unwrap_or(usize::MAX))
|
||||
.and_then(|info| info.parties.iter().find(|p| p.id == party))
|
||||
.and_then(|p| p.resolved)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A member's capital statement over `span` (see
|
||||
/// [`CapitalStatement`]); None when the party isn't a member.
|
||||
pub fn capital_statement(
|
||||
|
||||
@@ -94,8 +94,9 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
|
||||
};
|
||||
ui.add_space(4.0);
|
||||
let mut text = format!("{} {}", super::parties::party_icon(party), party.label());
|
||||
if several {
|
||||
text.push_str(&format!(" · {}", share_text(share)));
|
||||
let what = share_text(share, several, e.reversal_of);
|
||||
if !what.is_empty() {
|
||||
text.push_str(&format!(" · {what}"));
|
||||
}
|
||||
if widgets::link(ui, RichText::new(text).color(p.accent))
|
||||
.on_hover_text(format!(
|
||||
@@ -511,23 +512,71 @@ pub fn render(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>, wid
|
||||
}
|
||||
}
|
||||
|
||||
/// One party's share of an entry, in words: "$50.00 (derived)", "$70.00",
|
||||
/// "$33.34 (33.333%)", or the share alone when it doesn't resolve.
|
||||
pub fn share_text(s: &crate::books::EntryParty) -> String {
|
||||
let how = match s.share {
|
||||
ledger_core::Share::Amount(_) => None,
|
||||
ledger_core::Share::Percent(p) => Some(format!("{}%", p.normalize())),
|
||||
ledger_core::Share::Derived => Some("derived".to_string()),
|
||||
/// A party's part of an entry in words (rules.md B-35): which way it
|
||||
/// went and how much ("paid $64.00", "received $740.00, in kind"), what of
|
||||
/// it is still owed, the share it came from when the entry names several
|
||||
/// ("(50%)", "(derived)"), and a reversal said as one ("paid $1,480.00
|
||||
/// (reversal of #12)"), so a property contribution and its reversal never
|
||||
/// read alike. A part that moved nothing with the party (a bill, a close)
|
||||
/// says so. Empty for a sole party whose part doesn't resolve.
|
||||
pub fn share_text(
|
||||
s: &crate::books::EntryParty,
|
||||
several: bool,
|
||||
reversal_of: Option<ledger_core::EntryId>,
|
||||
) -> String {
|
||||
use ledger_core::shares::Direction;
|
||||
let how = if several {
|
||||
match s.share {
|
||||
ledger_core::Share::Amount(_) => None,
|
||||
ledger_core::Share::Percent(p) => Some(format!("{}%", p.normalize())),
|
||||
ledger_core::Share::Derived => Some("derived".to_string()),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match (s.resolved, how) {
|
||||
(Some(v), Some(h)) => format!("{} ({h})", fmt::dollars(v.abs())),
|
||||
(Some(v), None) => fmt::dollars(v.abs()),
|
||||
(None, Some(h)) => h,
|
||||
(None, None) => match s.share {
|
||||
let mut t = match (s.resolved, s.direction) {
|
||||
(Some(v), Some(d)) => {
|
||||
let amount = fmt::dollars(v.abs());
|
||||
let mut t = match d {
|
||||
Direction::Paid => format!("paid {amount}"),
|
||||
Direction::Received => format!("received {amount}"),
|
||||
Direction::Neither => format!("{amount}, no money moved"),
|
||||
};
|
||||
let cash = s.cash.unwrap_or_default();
|
||||
let in_kind = s.in_kind.unwrap_or_default();
|
||||
if !in_kind.is_zero() {
|
||||
if in_kind == v {
|
||||
t.push_str(", in kind");
|
||||
} else {
|
||||
t.push_str(&format!(", {} in kind", fmt::dollars(in_kind.abs())));
|
||||
}
|
||||
}
|
||||
let owed = v - cash - in_kind;
|
||||
if d != Direction::Neither && !owed.is_zero() {
|
||||
t.push_str(&format!(", {} still owed", fmt::dollars(owed.abs())));
|
||||
}
|
||||
t
|
||||
}
|
||||
_ if several => match s.share {
|
||||
ledger_core::Share::Amount(a) => fmt::dollars(a),
|
||||
_ => String::new(),
|
||||
},
|
||||
_ => String::new(),
|
||||
};
|
||||
let mut notes: Vec<String> = how.into_iter().collect();
|
||||
if s.resolved.is_some()
|
||||
&& let Some(o) = reversal_of
|
||||
{
|
||||
notes.push(format!("reversal of #{o}"));
|
||||
}
|
||||
if !notes.is_empty() {
|
||||
if t.is_empty() {
|
||||
t = notes.join(", ");
|
||||
} else {
|
||||
t.push_str(&format!(" ({})", notes.join(", ")));
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -483,6 +483,21 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
||||
};
|
||||
ev(icon::USER_GEAR, text, pid.map(Route::Party))
|
||||
}
|
||||
"party.creditor_set" => {
|
||||
let pid = p.get("id").and_then(|v| v.as_u64());
|
||||
let name = party_name(b, pid);
|
||||
let account = p
|
||||
.get("account_id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|a| b.account_name(a))
|
||||
.unwrap_or_else(|| "an account".to_string());
|
||||
let text = if p.get("on").and_then(|v| v.as_bool()).unwrap_or(true) {
|
||||
format!("{name} is now owed on {account}")
|
||||
} else {
|
||||
format!("{name} is no longer owed on {account}")
|
||||
};
|
||||
ev(icon::USER_GEAR, text, pid.map(Route::Party))
|
||||
}
|
||||
"party.w9_set" => {
|
||||
let pid = p.get("id").and_then(|v| v.as_u64());
|
||||
let name = party_name(b, pid);
|
||||
|
||||
@@ -564,7 +564,7 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(format!(
|
||||
"{period_label}: Paid out is what each party's posted entries moved out of cash, cards, and what's owed back to whoever paid for the company; Received in is what came back in; Net is paid out less received (a plus means more came in). Entries counts the posted ones in the period. The 1099 amount, on each party's page, is the 1099 worksheet's."
|
||||
"{period_label}, cash basis: Paid out is the money each party's posted entries paid it, from cash, a card, or a member or lender paying for the company; Received in is the money that came in from it; Net is paid out less received (a plus means more came in). Property contributed or distributed in kind is on each party's page. Entries counts the posted ones in the period. The 1099 amount, on each party's page, is the 1099 worksheet's."
|
||||
))
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.faint),
|
||||
@@ -577,6 +577,21 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The party's totals with property in kind (basis all, rules.md B-35
|
||||
/// item 4), when it gave or took any in the period: "With property
|
||||
/// contributed or distributed: paid $X, received $Y". None when nothing
|
||||
/// moved in kind (the cash totals are then the whole story).
|
||||
pub fn in_kind_text(flow: &crate::books::PartyFlow) -> Option<String> {
|
||||
if flow.paid_in_kind.is_zero() && flow.received_in_kind.is_zero() {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"With property contributed or distributed: paid {}, received {}",
|
||||
fmt::dollars(flow.paid + flow.paid_in_kind),
|
||||
fmt::dollars(flow.received + flow.received_in_kind)
|
||||
))
|
||||
}
|
||||
|
||||
/// A number tile on a party's page: label, amount, how it's computed,
|
||||
/// and the line under it.
|
||||
/// (label, value, how, sub); the value None when it can't be computed
|
||||
@@ -673,14 +688,14 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
|
||||
tiles.push((
|
||||
"Paid out".into(),
|
||||
Some(flow.paid),
|
||||
format!("{label}, {span_words}: what this party's posted entries moved out of cash, cards, and what's owed back to whoever paid for the company (net of refunds and reversals of payments)."),
|
||||
Some((label.clone(), p.muted)),
|
||||
format!("{label}, {span_words}, cash basis: the money this party's posted entries paid it, from cash, a card, or a member or lender paying for the company (net of reversals of payments; a refund is received in). A bill counts once it is paid."),
|
||||
Some((format!("{label} · cash basis"), p.muted)),
|
||||
));
|
||||
tiles.push((
|
||||
"Received in".into(),
|
||||
Some(flow.received),
|
||||
format!("{label}, {span_words}: what this party's posted entries brought into cash and those accounts (net of reversals)."),
|
||||
Some((label.clone(), p.muted)),
|
||||
format!("{label}, {span_words}, cash basis: the money this party's posted entries brought in (net of reversals)."),
|
||||
Some((format!("{label} · cash basis"), p.muted)),
|
||||
));
|
||||
let net = flow.net();
|
||||
tiles.push((
|
||||
@@ -771,6 +786,21 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
|
||||
r.context_menu(|ui| widgets::copy_item(ui, "Copy amount", &v.to_string()));
|
||||
}
|
||||
});
|
||||
// Basis all (rules.md B-35 item 4): property in kind, when any.
|
||||
if let Some(text) = in_kind_text(&flow) {
|
||||
ui.add_space(6.0);
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(text)
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
)
|
||||
.wrap(),
|
||||
)
|
||||
.on_hover_text(
|
||||
"Basis all (books.party basis \"all\"): the money above plus property contributed or distributed in kind, as the statement of members' equity counts it.",
|
||||
);
|
||||
}
|
||||
|
||||
// A member's page is their capital account statement too.
|
||||
if let Some(st) = b.capital_statement(id, span) {
|
||||
@@ -939,6 +969,23 @@ fn facts_card(ui: &mut Ui, b: &Books, party: &Party, go: &mut Option<Route>) {
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
if !party.creditor_of.is_empty() {
|
||||
k(ui, "Owed on");
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
for &a in &party.creditor_of {
|
||||
if widgets::link(ui, RichText::new(b.account_name(a)).color(p.accent))
|
||||
.on_hover_text(
|
||||
"What the company owes this party itself on (its card, its loan, what the members advanced): a charge or fee credited to it never counts as paying them.",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
*go = Some(Route::Account(a));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
if let Some(v) = party.vendor {
|
||||
k(ui, "Vendor record");
|
||||
if widgets::link(ui, RichText::new(format!("vendor {v}")).color(p.accent))
|
||||
@@ -1229,7 +1276,7 @@ fn entries_card(ui: &mut Ui, b: &Books, info: &PartyInfo, go: &mut Option<Route>
|
||||
.collect();
|
||||
sub = format!(
|
||||
"Their share, {} of {}, with {} · {sub}",
|
||||
super::entry::share_text(mine),
|
||||
super::entry::share_text(mine, true, e.reversal_of),
|
||||
fmt::dollars_or(e.amount),
|
||||
others.join(" & ")
|
||||
);
|
||||
|
||||
@@ -172,17 +172,16 @@ fn the_last_months_of_a_span() {
|
||||
fn a_partys_part_of_a_split_entry_is_its_fraction_of_what_was_bought() {
|
||||
let f = fixture("what-for");
|
||||
let e = f.books.ledger.get_entry(f.entry).unwrap();
|
||||
let money = money_accounts(&f.books.ledger, &[f.cash]);
|
||||
let money = money_accounts(&f.books.ledger, &[f.cash], &[]);
|
||||
let flow = entry_flow(e, &money).unwrap();
|
||||
let dec = |c: i64| Decimal::new(c, 2);
|
||||
assert_eq!(
|
||||
f.books.party_what_for(e, f.sam, &flow),
|
||||
vec![(f.rent, dec(4_200)), (f.fees, dec(1_800))]
|
||||
);
|
||||
assert_eq!(
|
||||
f.books.party_what_for(e, f.riley, &flow),
|
||||
vec![(f.rent, dec(2_800)), (f.fees, dec(1_200))]
|
||||
);
|
||||
let sam = f.books.party_what_for(e, f.sam);
|
||||
let riley = f.books.party_what_for(e, f.riley);
|
||||
assert_eq!(sam, vec![(f.rent, dec(4_200)), (f.fees, dec(1_800))]);
|
||||
assert_eq!(riley, vec![(f.rent, dec(2_800)), (f.fees, dec(1_200))]);
|
||||
// Together, the entry's whole breakdown.
|
||||
let total: Decimal = sam.iter().chain(&riley).map(|(_, v)| *v).sum();
|
||||
assert_eq!(total, flow.net);
|
||||
}
|
||||
|
||||
/// share_of_what_for doc: the whole what-for for the whole net; nothing
|
||||
|
||||
@@ -8,7 +8,7 @@ use egui_kittest::Harness;
|
||||
use ledger_core::arap::DocumentLine;
|
||||
use ledger_core::{
|
||||
AccountType, Currency, EntryId, Form1099, Money, PartyField, PartyId, PayrollLine, Role, Side,
|
||||
TinFacts, TinType, W4Facts, W9Facts, vendor_1099_report,
|
||||
TinFacts, TinType, W4Facts, W9Facts,
|
||||
};
|
||||
use ledger_gui::books::Books;
|
||||
use ledger_gui::nav::{Route, Section};
|
||||
@@ -258,6 +258,9 @@ fn fixture(tag: &str) -> Fixture {
|
||||
let e = pay(&mut s, d(2026, 1, 20), "Old Co", cash, 12_300);
|
||||
s.set_entry_party(e, Some(old)).unwrap();
|
||||
s.update_party(old, PartyField::Archived(true)).unwrap();
|
||||
// What the members advanced is money (rules.md B-35 item 5): Due to
|
||||
// Shareholders is a financing account.
|
||||
journal_settings(&mut s, "[books]\nfinancing_accounts = [\"2100\"]\n");
|
||||
drop(s);
|
||||
let oracle = Store::open(&data).unwrap().ledger().clone();
|
||||
Fixture {
|
||||
@@ -297,8 +300,20 @@ fn each_partys_totals_are_the_books_numbers() {
|
||||
assert_eq!(sam.open_bills, bill.balance().ok());
|
||||
assert_eq!(sam.open_bills, Some(cents(30_000)));
|
||||
assert_eq!(sam.bills, vec![1]);
|
||||
// The 1099 amount is ledger-core's worksheet line, exactly.
|
||||
let r = vendor_1099_report(o, 2026, None).unwrap();
|
||||
// The 1099 amount is ledger-core's worksheet line, exactly, over the
|
||||
// viewer's payment accounts (every asset account, no cash accounts
|
||||
// being set; the financing account).
|
||||
let owed = o.chart().find_by_code("2100").unwrap().id;
|
||||
let r = ledger_core::vendor_1099_report_with(
|
||||
o,
|
||||
2026,
|
||||
None,
|
||||
&ledger_core::PaymentAccounts {
|
||||
cash: vec![],
|
||||
financing: vec![owed],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let line = r
|
||||
.vendors
|
||||
.iter()
|
||||
@@ -306,8 +321,9 @@ fn each_partys_totals_are_the_books_numbers() {
|
||||
.unwrap();
|
||||
assert_eq!(sam.form_1099.as_ref(), Some(line));
|
||||
assert_eq!(sam.threshold_1099, Some(r.threshold));
|
||||
// (Cash only: card and owner-paid aren't in it.)
|
||||
assert_eq!(line.paid, cents(150_000));
|
||||
// (Cash and what the owner paid for the company: the card isn't in
|
||||
// it.)
|
||||
assert_eq!(line.paid, cents(180_000));
|
||||
// Every entry tagged, drafts included, newest first.
|
||||
assert!(sam.entries.contains(&f.bill_entry));
|
||||
assert!(sam.entries.contains(&f.reversal));
|
||||
@@ -561,6 +577,57 @@ fn journal_text_for_party_events() {
|
||||
}
|
||||
}
|
||||
|
||||
/// party.creditor_set and the journal feature it declares read as words
|
||||
/// in the journal, and the party's page lists what it is owed on.
|
||||
#[test]
|
||||
fn journal_text_for_a_creditor() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let f = fixture("creditor");
|
||||
let mut s = Store::open(&f.dir.0.join("books-data")).unwrap();
|
||||
let bank = s
|
||||
.add_party("Harbor Bank", &[Role::FinancialInstitution])
|
||||
.unwrap();
|
||||
let card = s.ledger().chart().find_by_code("2050").unwrap().id;
|
||||
s.set_party_creditor(bank, card, true).unwrap();
|
||||
s.set_party_creditor(bank, card, false).unwrap();
|
||||
s.set_party_creditor(bank, card, true).unwrap();
|
||||
drop(s);
|
||||
let b = load(&f.dir.0);
|
||||
let texts: Vec<(String, Option<Route>)> = b
|
||||
.journal
|
||||
.iter()
|
||||
.filter(|l| l.line_type == "party.creditor_set" || l.line_type == "journal.format_set")
|
||||
.filter_map(|l| describe(&b, l))
|
||||
.map(|e| (e.text, e.route))
|
||||
.collect();
|
||||
assert!(
|
||||
texts.contains(&(
|
||||
"Harbor Bank is now owed on Business Card".to_string(),
|
||||
Some(Route::Party(bank))
|
||||
)),
|
||||
"{texts:#?}"
|
||||
);
|
||||
assert!(
|
||||
texts.contains(&(
|
||||
"Harbor Bank is no longer owed on Business Card".to_string(),
|
||||
Some(Route::Party(bank))
|
||||
)),
|
||||
"{texts:#?}"
|
||||
);
|
||||
assert!(
|
||||
texts
|
||||
.iter()
|
||||
.any(|(t, _)| t == "Journal format 2 declared, with party_creditors"),
|
||||
"{texts:#?}"
|
||||
);
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(1440.0, 1400.0))
|
||||
.build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, Route::Party(bank)));
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Owed on");
|
||||
h.get_by_label("Business Card");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn w9_text_never_shows_more_than_the_last_four() {
|
||||
let f = fixture("w9text");
|
||||
@@ -787,7 +854,7 @@ fn party_money_by_period_and_what_for() {
|
||||
for period in b.party_periods(Some(p.id)) {
|
||||
let span = b.party_span(period);
|
||||
let fl = b.party_flow(p.id, span, true);
|
||||
let a = ledger_core::parties::activity(o, p.id, span, &b.cash_accounts);
|
||||
let a = ledger_core::parties::activity(o, p.id, span, &b.money);
|
||||
assert_eq!((fl.paid, fl.received), (a.paid, a.received));
|
||||
assert_eq!(
|
||||
fl.what_for.iter().map(|x| x.1).sum::<Decimal>(),
|
||||
@@ -824,7 +891,7 @@ fn party_money_by_period_and_what_for() {
|
||||
let (line, _) = b.party_1099(f.sam, 2025).unwrap();
|
||||
assert_eq!(line.paid, cents(50_000));
|
||||
let (line, _) = b.party_1099(f.sam, 2026).unwrap();
|
||||
assert_eq!(line.paid, cents(150_000));
|
||||
assert_eq!(line.paid, cents(180_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1156,9 +1223,19 @@ fn entries_list_every_party_with_its_resolved_share() {
|
||||
vec![(m.tobin, Some(cents(5_000))), (m.mo, Some(cents(5_000)))]
|
||||
);
|
||||
assert_eq!(e.party.as_deref(), Some("Tobin Harlow & Morgan Ellis"));
|
||||
// Property: net zero, shares of the total.
|
||||
// Property (rules.md B-35): what moved each member's capital, signed
|
||||
// as their own account shows it: received, in kind (before P3 it read
|
||||
// +600.00, the sign of a payment).
|
||||
let e = b.entry(m.property).unwrap();
|
||||
assert_eq!(e.parties[1].resolved, Some(cents(60_000)));
|
||||
assert_eq!(e.parties[1].resolved, Some(cents(-60_000)));
|
||||
assert_eq!(
|
||||
e.parties[1].direction,
|
||||
Some(ledger_core::shares::Direction::Received)
|
||||
);
|
||||
assert_eq!(
|
||||
(e.parties[1].cash, e.parties[1].in_kind),
|
||||
(Some(Decimal::ZERO), Some(cents(-60_000)))
|
||||
);
|
||||
// The journal says who, for parties_set.
|
||||
let line = b
|
||||
.journal
|
||||
@@ -1257,6 +1334,185 @@ fn a_members_page_is_their_capital_statement() {
|
||||
h.get_by_label("Contributions of property (at fair market value)");
|
||||
}
|
||||
|
||||
/// member_books with the laptop's contribution reversed and made again
|
||||
/// (after the closed first half), so the pages have a reversal to say:
|
||||
/// the books and the reversal's id.
|
||||
fn reversed_laptop(tag: &str) -> (MemberBooks, EntryId) {
|
||||
let m = member_books(tag);
|
||||
let mut s = Store::open(&m.dir.0.join("books-data")).unwrap();
|
||||
let undo = s
|
||||
.reverse_entry(m.property, d(2026, 7, 2), "Reversed")
|
||||
.unwrap();
|
||||
drop(s);
|
||||
let again = post_more(
|
||||
&m,
|
||||
d(2026, 7, 3),
|
||||
&[
|
||||
("1500", Side::Debit, 120_000),
|
||||
("3000", Side::Credit, 60_000),
|
||||
("3001", Side::Credit, 60_000),
|
||||
],
|
||||
);
|
||||
let mut s = Store::open(&m.dir.0.join("books-data")).unwrap();
|
||||
s.set_entry_parties(
|
||||
again,
|
||||
&[
|
||||
ledger_core::PartyShare::whole(m.tobin),
|
||||
ledger_core::PartyShare::whole(m.mo),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
drop(s);
|
||||
(m, undo)
|
||||
}
|
||||
|
||||
/// A harness showing `route` over the books in `dir`.
|
||||
fn show(dir: &Path, route: Route) -> Harness<'static, App> {
|
||||
let b = load(dir);
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(1440.0, 1400.0))
|
||||
.build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, route));
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
/// The viewer shows the tools' numbers (rules.md B-35): a member's party
|
||||
/// flow is books.party's activity on both bases, labelled cash basis, with
|
||||
/// the in-kind line under it; the entry page says each part in words, a
|
||||
/// property contribution "received $600.00, in kind" and its reversal
|
||||
/// "paid $600.00, in kind (reversal of #n)".
|
||||
#[test]
|
||||
fn the_party_page_shows_the_basis_and_in_kind() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let (m, undo) = reversed_laptop("basis");
|
||||
let b = load(&m.dir.0);
|
||||
let span = b.party_span(ledger_gui::books::PartyPeriod::ThisYear);
|
||||
for who in [m.tobin, m.mo] {
|
||||
let flow = b.party_flow(who, span, true);
|
||||
let server =
|
||||
ledger_core::parties::activity_with(&b.ledger, who, span, &b.money, &b.members);
|
||||
assert_eq!(
|
||||
(flow.paid, flow.received),
|
||||
server.totals(ledger_core::parties::Basis::Cash)
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
flow.paid + flow.paid_in_kind,
|
||||
flow.received + flow.received_in_kind
|
||||
),
|
||||
server.totals(ledger_core::parties::Basis::All)
|
||||
);
|
||||
// Cash: half the reimbursement and their distribution; in kind:
|
||||
// the laptop (600.00 − 600.00 + 600.00).
|
||||
assert_eq!((flow.paid, flow.received), (cents(25_000), Decimal::ZERO));
|
||||
assert_eq!(
|
||||
(flow.paid_in_kind, flow.received_in_kind),
|
||||
(Decimal::ZERO, cents(60_000))
|
||||
);
|
||||
assert_eq!(
|
||||
flow.what_for.iter().map(|x| x.1).sum::<Decimal>(),
|
||||
flow.net()
|
||||
);
|
||||
// What for is what the money paid for: the laptop, contributed in
|
||||
// kind, reversed and contributed again, isn't in it (it read
|
||||
// "Equipment 600.00" and "+400.00" of capital).
|
||||
let equip = b.ledger.chart().find_by_code("1500").unwrap().id;
|
||||
let due = b.ledger.chart().find_by_code("2100").unwrap().id;
|
||||
let capital = b.member(who).unwrap().capital;
|
||||
assert_eq!(
|
||||
flow.what_for,
|
||||
vec![(capital, cents(20_000)), (due, cents(5_000))],
|
||||
"no {equip} line"
|
||||
);
|
||||
}
|
||||
let flow = b.party_flow(m.tobin, span, true);
|
||||
assert_eq!(
|
||||
ledger_gui::pages::parties::in_kind_text(&flow).as_deref(),
|
||||
Some("With property contributed or distributed: paid $250.00, received $600.00")
|
||||
);
|
||||
let h = show(&m.dir.0, Route::Party(m.tobin));
|
||||
h.get_by_label("With property contributed or distributed: paid $250.00, received $600.00");
|
||||
assert!(h.query_all_by_label_contains("cash basis").next().is_some());
|
||||
let h = show(&m.dir.0, Route::Entry(m.property));
|
||||
h.get_by_label_contains("Tobin Harlow · received $600.00, in kind (derived)");
|
||||
let h = show(&m.dir.0, Route::Entry(undo));
|
||||
h.get_by_label_contains(&format!(
|
||||
"Morgan Ellis · paid $600.00, in kind (derived, reversal of #{})",
|
||||
m.property
|
||||
));
|
||||
}
|
||||
|
||||
/// The viewer's what-for reads a reversal as parties::flow does, the
|
||||
/// opposite of its original (rules.md B-35 item 3; design §1.4): a member
|
||||
/// paying the card from their own funds (Dr card / Cr 2100), tagged with
|
||||
/// the issuer, then reversed, is nothing on the issuer's page, paid or
|
||||
/// what for. Its lines alone read as a second payment.
|
||||
#[test]
|
||||
fn a_reversed_member_card_payment_is_nothing_on_the_issuers_page() {
|
||||
let m = member_books("card-reversed");
|
||||
let mut s = Store::open(&m.dir.0.join("books-data")).unwrap();
|
||||
s.add_account("2050", "Business Card", AccountType::Liability, None, false)
|
||||
.unwrap();
|
||||
let issuer = s
|
||||
.add_party("Harbor Bank", &[Role::FinancialInstitution])
|
||||
.unwrap();
|
||||
drop(s);
|
||||
member_settings(
|
||||
&m.dir.0,
|
||||
"cash_accounts = [\"1000\"]\nfinancing_accounts = [\"2100\"]\n",
|
||||
);
|
||||
let paid = post_more(
|
||||
&m,
|
||||
d(2026, 7, 10),
|
||||
&[
|
||||
("2050", Side::Debit, 10_000),
|
||||
("2100", Side::Credit, 10_000),
|
||||
],
|
||||
);
|
||||
let mut s = Store::open(&m.dir.0.join("books-data")).unwrap();
|
||||
s.set_entry_party(paid, Some(issuer)).unwrap();
|
||||
drop(s);
|
||||
let b = load(&m.dir.0);
|
||||
let span = (d(2026, 1, 1), d(2026, 12, 31));
|
||||
let flow = b.party_flow(issuer, span, true);
|
||||
let card = b.ledger.chart().find_by_code("2050").unwrap().id;
|
||||
assert_eq!((flow.paid, flow.received), (cents(10_000), Decimal::ZERO));
|
||||
assert_eq!(flow.what_for, vec![(card, cents(10_000))]);
|
||||
let mut s = Store::open(&m.dir.0.join("books-data")).unwrap();
|
||||
s.reverse_entry(paid, d(2026, 7, 11), "Reversed").unwrap();
|
||||
drop(s);
|
||||
let b = load(&m.dir.0);
|
||||
let flow = b.party_flow(issuer, span, true);
|
||||
assert_eq!((flow.paid, flow.received), (Decimal::ZERO, Decimal::ZERO));
|
||||
assert_eq!(flow.what_for, vec![], "the breakdown nets to nothing");
|
||||
}
|
||||
|
||||
/// Screenshots of the party pages and the entry pages B-35 changed, for
|
||||
/// eyes: set `SHOT_DIR`.
|
||||
#[test]
|
||||
fn party_basis_screenshots() {
|
||||
let Ok(out) = std::env::var("SHOT_DIR") else {
|
||||
return;
|
||||
};
|
||||
let out = PathBuf::from(out);
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
let (m, undo) = reversed_laptop("basis-shots");
|
||||
for (name, route) in [
|
||||
("party-member", Route::Party(m.tobin)),
|
||||
("parties", Route::Parties),
|
||||
("entry-property", Route::Entry(m.property)),
|
||||
("entry-reversal", Route::Entry(undo)),
|
||||
("entry-reimbursement", Route::Entry(m.reimburse)),
|
||||
] {
|
||||
let mut h = show(&m.dir.0, route);
|
||||
h.run_steps(3);
|
||||
h.render()
|
||||
.unwrap()
|
||||
.save(out.join(format!("{name}.png")))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// The viewer's statement is the K-1 worksheet's row, field for field,
|
||||
/// over any span (CapitalStatement INVARIANT 1): ledger_core::k1 called
|
||||
/// here with the members and cash accounts as the CPA package builds them.
|
||||
@@ -1286,7 +1542,7 @@ fn the_capital_statement_is_the_k1_worksheet_row() {
|
||||
}
|
||||
});
|
||||
let cash: Vec<_> = if fallback {
|
||||
let mut all: Vec<_> = ledger_core::parties::money_accounts(&b.ledger, &[])
|
||||
let mut all: Vec<_> = ledger_core::parties::money_accounts(&b.ledger, &[], &[])
|
||||
.cash
|
||||
.into_iter()
|
||||
.collect();
|
||||
@@ -1521,7 +1777,7 @@ fn party_money_is_the_servers_without_cash_accounts_and_after_today() {
|
||||
&oracle,
|
||||
apple,
|
||||
(d(2026, 1, 1), d(2026, 12, 31)),
|
||||
&[],
|
||||
&ledger_core::parties::money_accounts(&oracle, &[], &[]),
|
||||
&[],
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
@@ -4,8 +4,12 @@
|
||||
- Tag every entry with its party (books.draft_entry party, or books.set_entry_party; a party with a vendor record tags the vendor for 1099s too).
|
||||
- An entry with several counterparties names them all with parties ([{party, amount?|percent?}] on books.draft_entry or books.set_entry_party): there is no "joint" party. A joint member transaction (paid from or reimbursed to the members' joint funds through a payable to members, a contribution from joint funds, a distribution to both) names both members with no shares, and their shares derive (from their capital or distribution lines on the entry, else their member shares in the settings); when the split is uneven, or a party isn't a member, give each party an exact amount (or a percent).
|
||||
- The post_entry preview lists each party's resolved share (parties) and refuses to post (party_shares, parties_error) until the shares add up to what the entry moved; show the split to the human before confirming.
|
||||
- A share's resolved amount is signed as the party's own account would show it: positive, the company paid it or gave it value; negative, received from it. Each part says its direction (paid, received, or neither: a bill, an invoice, a fee on its own card, a distribution still owed) and what of it was money (cash) and what property (in_kind); a reversal is exactly the opposite of what it reverses. A members' entry that moves their capital counts what moved it: a property contribution is received, in kind.
|
||||
- Party totals (books.party, books.parties) take a basis: cash, the default, counts money; all adds property contributed or distributed in kind (for a member, the statement of members' equity). Say which basis you report. It is not the books' accounting method.
|
||||
- Money is the cash accounts, the cards (a card account's name says "card"; a gift card the company sold is deferred revenue, not a card) and the financing accounts setting ([books] financing_accounts: what the members advanced, loans): list the members' payable and every loan there, and any card whose account name doesn't say "card". Any other liability (an accrual, deferred revenue, distributions payable) counts once, when it is settled.
|
||||
- Name whose each card, loan and advance is: books.update_party creditor_of, the issuer for its card, the lender for its loan, each member for what the members advanced. Then a fee on the card, interest on the loan, or a distribution credited to what the members are owed never reads as a payment to them; it counts when paid. A distribution declared to distributions payable counts when it is paid, too. Record a reimbursement and a distribution as two entries, and a member's cash either as a loan (through the members' financing, tagged with the payee it paid) or as capital, not both.
|
||||
- Every post_entry preview says where the entry stands (party_policy, party_missing, party_suggestions): under the `[books] parties` setting "suggest" (the default), when party_missing, name the likely party to the human and tag it (or add it) before confirming; under "require", posting is refused (party_required) until the entry names a party or is marked as having none (books.set_entry_party with party null and no_party_reason, or draft_entry no_party_reason: only for an entry with truly no counterparty, like a transfer between the company's own accounts); under "off", don't raise it.
|
||||
- Record W-9 facts (books.set_w9) for contractors and landlords before paying them, and W-4 facts (books.set_w4) for employees before their first payroll; name the employee party on every books.run_payroll line.
|
||||
- Never put a full TIN or SSN anywhere in the books, only the last four digits: the engine refuses any nine-digit number in a party field. The full TIN goes only to the encrypted TIN vault, and every member's and every 1099 payee's belongs there: books.set_tin (party, tin, tin_type: ssn, ein, itin; any role, so each member's SSN for their K-1) and books.set_w9 with tin and tin_type for a payee's W-9, and the signed form with w9_upload_id (books.begin_upload purpose w9), or w9_content_base64 with w9_sha256 for one of at most 256 KiB (never books.file_document or books.attach_receipt: the form shows the TIN).
|
||||
- A full TIN lives in the vault: keep it out of descriptions, notes, file names and version-history messages. books.get_w9 hands the signed form over (run its download command to save it for the human); books.purge_tin (preview, then confirmed, with a reason) deletes a TIN no longer needed.
|
||||
- books.parties lists the directory, books.party shows one party with the year's totals, and books.show party opens it in the viewer. Archive (books.update_party archived) parties no longer dealt with; never delete history.
|
||||
- books.parties lists the directory (each party's TIN last four from its TIN or W-9 facts), books.party shows one party with the year's totals, and books.show party opens it in the viewer. Archive (books.update_party archived) parties no longer dealt with; never delete history.
|
||||
+103
-49
@@ -2026,7 +2026,7 @@ fn party_roles(p: &ledger_core::Party) -> Vec<String> {
|
||||
}
|
||||
|
||||
/// A party's facts for a tool's output.
|
||||
fn party_out(p: &ledger_core::Party) -> PartyOut {
|
||||
fn party_out(ledger: &Ledger, p: &ledger_core::Party) -> PartyOut {
|
||||
PartyOut {
|
||||
party_id: p.id,
|
||||
name: p.name.clone(),
|
||||
@@ -2059,6 +2059,11 @@ fn party_out(p: &ledger_core::Party) -> PartyOut {
|
||||
in_vault: t.vault_record.is_some(),
|
||||
purged: t.purged.clone(),
|
||||
}),
|
||||
creditor_of: p
|
||||
.creditor_of
|
||||
.iter()
|
||||
.filter_map(|a| ledger.chart().get(*a).map(|a| a.code.clone()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2122,34 +2127,45 @@ fn checked_sum(
|
||||
struct PartyActivity {
|
||||
/// The party's entries, drafts included.
|
||||
entries: std::collections::BTreeSet<u64>,
|
||||
/// Paid to it in the year.
|
||||
/// Paid to it in the year, on the basis asked.
|
||||
paid: Decimal,
|
||||
/// Received from it in the year.
|
||||
/// Received from it in the year, on the basis asked.
|
||||
received: Decimal,
|
||||
}
|
||||
|
||||
/// The party's entries and the year's money paid and received
|
||||
/// A party-totals `basis` from a tool: "cash" (the default) or "all"
|
||||
/// (rules.md B-35 item 4); any other word is refused.
|
||||
fn parse_basis(basis: Option<&str>) -> Result<ledger_core::parties::Basis, String> {
|
||||
match basis {
|
||||
None => Ok(ledger_core::parties::Basis::default()),
|
||||
Some(b) => ledger_core::parties::Basis::parse(b)
|
||||
.ok_or_else(|| format!("basis is \"cash\" or \"all\", not {b:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The party's entries and the year's paid and received on `basis`
|
||||
/// (`PartyTotalsOut`): ledger_core::parties::activity_with over the
|
||||
/// calendar year, with the `[books] cash_accounts` and the members (so an
|
||||
/// entry naming several parties counts this one's share).
|
||||
/// calendar year, with the money accounts of [`share_context`] and the
|
||||
/// members (so an entry naming several parties counts this one's part).
|
||||
fn party_activity(
|
||||
store: &Store,
|
||||
party: &ledger_core::Party,
|
||||
settings: &Settings,
|
||||
year: i32,
|
||||
basis: ledger_core::parties::Basis,
|
||||
) -> Result<PartyActivity, String> {
|
||||
let ledger = store.ledger();
|
||||
let cash = cash_ids(ledger, &settings.roles.cash);
|
||||
let span = (
|
||||
NaiveDate::from_ymd_opt(year, 1, 1).ok_or("year out of range")?,
|
||||
NaiveDate::from_ymd_opt(year, 12, 31).ok_or("year out of range")?,
|
||||
);
|
||||
let members = settings.company.party_members(ledger);
|
||||
let a = ledger_core::parties::activity_with(ledger, party.id, span, &cash, &members);
|
||||
let (money, members) = share_context(ledger, settings);
|
||||
let a = ledger_core::parties::activity_with(ledger, party.id, span, &money, &members);
|
||||
let (paid, received) = a.totals(basis);
|
||||
Ok(PartyActivity {
|
||||
entries: a.entries,
|
||||
paid: a.paid,
|
||||
received: a.received,
|
||||
paid,
|
||||
received,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2175,7 +2191,8 @@ fn cash_ids(ledger: &Ledger, codes: &[String]) -> Vec<AccountId> {
|
||||
}
|
||||
|
||||
/// What party shares resolve with ([`ledger_core::shares::resolve`]): the
|
||||
/// money accounts over the `[books] cash_accounts`, and the members.
|
||||
/// money accounts over the `[books] cash_accounts` and
|
||||
/// `financing_accounts` (with the cards), and the members.
|
||||
fn share_context(
|
||||
ledger: &Ledger,
|
||||
settings: &Settings,
|
||||
@@ -2184,7 +2201,11 @@ fn share_context(
|
||||
Vec<ledger_core::PartyMember>,
|
||||
) {
|
||||
(
|
||||
ledger_core::parties::money_accounts(ledger, &cash_ids(ledger, &settings.roles.cash)),
|
||||
ledger_core::parties::money_accounts(
|
||||
ledger,
|
||||
&cash_ids(ledger, &settings.roles.cash),
|
||||
&cash_ids(ledger, &settings.roles.financing),
|
||||
),
|
||||
settings.company.party_members(ledger),
|
||||
)
|
||||
}
|
||||
@@ -2252,7 +2273,7 @@ fn check_shares_resolve(
|
||||
return Ok(());
|
||||
}
|
||||
let (money, members) = share_context(ledger, settings);
|
||||
ledger_core::shares::resolve(entry, shares, &money, &members).map(|_| ())
|
||||
ledger_core::shares::resolve(ledger, entry_id, shares, &money, &members).map(|_| ())
|
||||
}
|
||||
|
||||
/// Fix a posted entry's derived split among several parties: journal the
|
||||
@@ -2289,17 +2310,19 @@ fn fix_derived_shares(store: &mut Store, settings: &Settings, entry_id: u64) ->
|
||||
return Ok(());
|
||||
}
|
||||
let (money, members) = share_context(ledger, settings);
|
||||
let Ok(parts) = ledger_core::shares::resolve(entry, shares, &money, &members) else {
|
||||
let Ok(resolved) = ledger_core::shares::resolve(ledger, entry_id, shares, &money, &members)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if parts.iter().any(|(_, v)| v.is_zero()) {
|
||||
if resolved.parts.iter().any(|p| p.amount.is_zero()) {
|
||||
return Ok(());
|
||||
}
|
||||
let fixed: Vec<ledger_core::PartyShare> = parts
|
||||
.into_iter()
|
||||
.map(|(party, v)| ledger_core::PartyShare {
|
||||
party,
|
||||
share: ledger_core::Share::Amount(v.abs()),
|
||||
let fixed: Vec<ledger_core::PartyShare> = resolved
|
||||
.parts
|
||||
.iter()
|
||||
.map(|p| ledger_core::PartyShare {
|
||||
party: p.party,
|
||||
share: ledger_core::Share::Amount(p.amount.abs()),
|
||||
})
|
||||
.collect();
|
||||
store
|
||||
@@ -2317,10 +2340,11 @@ fn entry_parties_out(
|
||||
let ledger = store.ledger();
|
||||
let shares = ledger.entry_parties(entry.id());
|
||||
let (accounts, members) = share_context(ledger, settings);
|
||||
let (resolved, error) = match ledger_core::shares::resolve(entry, shares, &accounts, &members) {
|
||||
Ok(r) => (Some(r), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
};
|
||||
let (resolved, error) =
|
||||
match ledger_core::shares::resolve(ledger, entry.id(), shares, &accounts, &members) {
|
||||
Ok(r) => (Some(r), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
};
|
||||
let out = shares
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -2341,7 +2365,12 @@ fn entry_parties_out(
|
||||
share: kind.to_string(),
|
||||
amount,
|
||||
percent,
|
||||
resolved: resolved.as_ref().map(|r| money(r[i].1)),
|
||||
resolved: resolved.as_ref().map(|r| money(r.parts[i].amount)),
|
||||
direction: resolved
|
||||
.as_ref()
|
||||
.map(|r| r.target.direction.key().to_string()),
|
||||
cash: resolved.as_ref().map(|r| money(r.parts[i].cash)),
|
||||
in_kind: resolved.as_ref().map(|r| money(r.parts[i].in_kind)),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -6906,13 +6935,13 @@ impl BooksServer {
|
||||
.add_party(&p.name, &roles)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let party = store.ledger().get_party(id).expect("just added");
|
||||
Ok(Json(party_out(party)))
|
||||
Ok(Json(party_out(store.ledger(), party)))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.update_party",
|
||||
title = "Update a party",
|
||||
description = "Change a party's facts: name, display_name, website, address, notes, archived. Give only the fields to change; an empty string clears display_name, website, address, or notes. Every field is checked before any is written, so a bad one (a blank or taken name, a full TIN, over 500 characters) changes nothing; each is journaled as its own party.updated. Archive a party you no longer deal with (its history stays; it then goes by id). Returns the party.",
|
||||
description = "Change a party's facts: name, display_name, website, address, notes, archived, creditor_of. Give only the fields to change; \"\" clears display_name, website, address or notes. creditor_of replaces the list of liabilities the company owes the party itself (its card, its loan, what the members advanced): a credit to one never counts as paying it. Every field is checked before any is written, so a bad one (a blank or taken name, a full TIN, over 500 characters, not a liability) changes nothing; each is journaled as party.updated or party.creditor_set. Archive a party you no longer deal with (its history stays; it then goes by id). Returns the party.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -6946,24 +6975,46 @@ impl BooksServer {
|
||||
if let Some(a) = p.archived {
|
||||
fields.push(F::Archived(a));
|
||||
}
|
||||
if fields.is_empty() {
|
||||
// creditor_of replaces the list: each account added or dropped is
|
||||
// one change.
|
||||
let mut creditor: Vec<(AccountId, bool)> = Vec::new();
|
||||
if let Some(codes) = &p.creditor_of {
|
||||
let want: std::collections::BTreeSet<AccountId> = codes
|
||||
.iter()
|
||||
.map(|c| resolve_account(&store, c.trim()))
|
||||
.collect::<Result<_, _>>()?;
|
||||
let has = &store.ledger().get_party(id).expect("resolved").creditor_of;
|
||||
creditor.extend(want.difference(has).map(|a| (*a, true)));
|
||||
creditor.extend(has.difference(&want).map(|a| (*a, false)));
|
||||
}
|
||||
if fields.is_empty() && p.creditor_of.is_none() {
|
||||
return Err(
|
||||
"give at least one field to change: name, display_name, website, address, notes, archived"
|
||||
"give at least one field to change: name, display_name, website, address, notes, archived, creditor_of"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
// Every field, in order, before any is written (INVARIANT 2).
|
||||
// Every change, in order, before any is written (INVARIANT 2).
|
||||
let mut rehearsal = store.ledger().clone();
|
||||
for f in &fields {
|
||||
rehearsal
|
||||
.update_party(id, f.clone())
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
for (a, on) in &creditor {
|
||||
rehearsal
|
||||
.set_party_creditor(id, *a, *on)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
for f in fields {
|
||||
store.update_party(id, f).map_err(|e| e.to_string())?;
|
||||
}
|
||||
for (a, on) in creditor {
|
||||
store
|
||||
.set_party_creditor(id, a, on)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let party = store.ledger().get_party(id).expect("resolved");
|
||||
Ok(Json(party_out(party)))
|
||||
Ok(Json(party_out(store.ledger(), party)))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
@@ -7030,7 +7081,7 @@ impl BooksServer {
|
||||
store.set_party_role(id, r, on).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let party = store.ledger().get_party(id).expect("resolved");
|
||||
Ok(Json(party_out(party)))
|
||||
Ok(Json(party_out(store.ledger(), party)))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
@@ -7272,7 +7323,7 @@ impl BooksServer {
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let party = store.ledger().get_party(id).expect("resolved");
|
||||
Ok(Json(party_out(party)))
|
||||
Ok(Json(party_out(store.ledger(), party)))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
@@ -7359,7 +7410,7 @@ impl BooksServer {
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let party = store.ledger().get_party(id).expect("resolved");
|
||||
Ok(Json(party_out(party)))
|
||||
Ok(Json(party_out(store.ledger(), party)))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
@@ -7494,11 +7545,7 @@ impl BooksServer {
|
||||
name: party.name.clone(),
|
||||
tin_in_vault: held_tin,
|
||||
w9_file_in_vault: held_w9,
|
||||
tin_last4: party
|
||||
.w9
|
||||
.as_ref()
|
||||
.map(|f| f.tin_last4.clone())
|
||||
.or_else(|| party.tin.as_ref().map(|t| t.last4.clone())),
|
||||
tin_last4: party.tin_last4().map(str::to_string),
|
||||
journaled: false,
|
||||
note: format!(
|
||||
"Confirming deletes {}{}; backups made before keep their copies. Make sure the CPA has what they need first.",
|
||||
@@ -7578,14 +7625,14 @@ impl BooksServer {
|
||||
let id = resolve_party(&store, &p.party)?;
|
||||
store.set_party_w4(id, facts).map_err(|e| e.to_string())?;
|
||||
let party = store.ledger().get_party(id).expect("resolved");
|
||||
Ok(Json(party_out(party)))
|
||||
Ok(Json(party_out(store.ledger(), party)))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.parties",
|
||||
title = "List parties",
|
||||
description = described(
|
||||
"The party directory, by id: each party's name, label, roles (vendor and customer included), vendor and customer record ids, whether W-9 facts (with the TIN's last four) and W-4 facts are on file, archived, and what was paid to and received from it in the year (defined as in books.party). Filter by role; archived parties only with include_archived.",
|
||||
"The party directory, by id: each party's name, label, roles (vendor and customer included), vendor and customer record ids, whether W-9 and W-4 facts are on file, the TIN's last four when the books know it (TIN or W-9 facts), archived, and what was paid to and received from it in the year on the basis asked: cash by default (money), or all (adds property contributed or distributed in kind); defined as in books.party, the basis echoed. Filter by role; archived parties only with include_archived.",
|
||||
ledger_core::calendar::PeriodKind::CalendarYear
|
||||
),
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
@@ -7601,6 +7648,7 @@ impl BooksServer {
|
||||
.map(|r| parse_roles(&[r.to_string()]).map(|v| v[0]))
|
||||
.transpose()?;
|
||||
let year = p.year.unwrap_or_else(|| self.periods().calendar_year());
|
||||
let basis = parse_basis(p.basis.as_deref())?;
|
||||
let settings = self.settings();
|
||||
let store = self.lock()?;
|
||||
let mut parties = Vec::new();
|
||||
@@ -7611,7 +7659,7 @@ impl BooksServer {
|
||||
if role.is_some_and(|r| !party.all_roles().contains(&r)) {
|
||||
continue;
|
||||
}
|
||||
let act = party_activity(&store, party, &settings, year)?;
|
||||
let act = party_activity(&store, party, &settings, year, basis)?;
|
||||
parties.push(PartySummaryOut {
|
||||
party_id: party.id,
|
||||
name: party.name.clone(),
|
||||
@@ -7620,21 +7668,25 @@ impl BooksServer {
|
||||
vendor_id: party.vendor,
|
||||
customer_id: party.customer,
|
||||
w9_on_file: party.w9.is_some(),
|
||||
tin_last4: party.w9.as_ref().map(|w| w.tin_last4.clone()),
|
||||
tin_last4: party.tin_last4().map(str::to_string),
|
||||
w4_on_file: party.w4.is_some(),
|
||||
archived: party.archived,
|
||||
paid: money(act.paid),
|
||||
received: money(act.received),
|
||||
});
|
||||
}
|
||||
Ok(Json(ListPartiesOut { year, parties }))
|
||||
Ok(Json(ListPartiesOut {
|
||||
year,
|
||||
basis: basis.key().to_string(),
|
||||
parties,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.party",
|
||||
title = "Get a party",
|
||||
description = described(
|
||||
"One party, by id or name: its facts and roles, vendor and customer record ids, the year's totals (paid to it, received from it, invoiced, billed, the 1099 amount for a vendor, payroll gross and net), what it is owed and owes now (open bills and invoices), every entry it is on (tagged with it or its vendor record, its invoices and their payments, and their reversals), its invoices and bills, and the payroll lines naming it. Totals come from the journal: the output's schema says exactly how.",
|
||||
"One party, by id or name: its facts and roles, vendor and customer record ids, the year's totals (paid to it and received from it on the basis asked, cash by default or all with property in kind, the basis echoed; invoiced, billed, the 1099 amount for a vendor, payroll gross and net), what it is owed and owes now (open bills and invoices), every entry it is on (tagged with it or its vendor record, its invoices and their payments, and their reversals), its invoices and bills, and the payroll lines naming it. Totals come from the journal: the output's schema says exactly how.",
|
||||
ledger_core::calendar::PeriodKind::CalendarYear
|
||||
),
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
@@ -7642,12 +7694,13 @@ impl BooksServer {
|
||||
async fn party(&self, params: Parameters<PartyParams>) -> Result<Json<PartyDetailOut>, String> {
|
||||
let p = params.0;
|
||||
let year = p.year.unwrap_or_else(|| self.periods().calendar_year());
|
||||
let basis = parse_basis(p.basis.as_deref())?;
|
||||
let settings = self.settings();
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
let id = resolve_party(&store, &p.party)?;
|
||||
let party = ledger.get_party(id).expect("resolved");
|
||||
let act = party_activity(&store, party, &settings, year)?;
|
||||
let act = party_activity(&store, party, &settings, year, basis)?;
|
||||
let in_year = |d: NaiveDate| chrono::Datelike::year(&d) == year;
|
||||
let mut entries: Vec<PartyEntryOut> = act
|
||||
.entries
|
||||
@@ -7704,6 +7757,7 @@ impl BooksServer {
|
||||
}
|
||||
}
|
||||
let totals = PartyTotalsOut {
|
||||
basis: basis.key().to_string(),
|
||||
paid: money(act.paid),
|
||||
received: money(act.received),
|
||||
invoiced: money(checked_sum(
|
||||
@@ -7734,7 +7788,7 @@ impl BooksServer {
|
||||
net_pay: money(net),
|
||||
};
|
||||
Ok(Json(PartyDetailOut {
|
||||
party: party_out(party),
|
||||
party: party_out(ledger, party),
|
||||
year,
|
||||
totals,
|
||||
entries,
|
||||
@@ -7769,7 +7823,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.set_entry_party",
|
||||
title = "Tag an entry's parties",
|
||||
description = "Tag an entry (draft or posted) with the party it was with (id or name), or pass party null (or omit it) to clear the tag. A party with a vendor record also tags the entry's vendor, so a payment to a contractor or landlord counts in books.vendor_1099; a party without one, or clearing, clears the vendor tag. For an entry with several counterparties pass parties instead: [{party, amount?|percent?}], each share an exact amount (cents), a percent (summing to 100; leftover cents to the first party), or neither (derived: a sole party gets all; members named together get what their capital or distributions lines say, else their [[company.members]] share; once the entry is posted, a derived split among several parties is recorded as the exact amounts it resolves to, so a later change to [[company.members]] never re-splits posted history). Name both members on a joint member transaction (a reimbursement on 2100, a joint contribution) and let the shares derive; give amounts when a split is uneven. Refused: a party twice, mixed kinds of share, two parties with vendor records, and, once the entry has balanced lines, shares that don't add up to what it moved. Metadata only: amounts never change. An entry and its reversal carry the same parties: tagging either tags both, so each party's part nets to zero. Journaled as entry.party_set (one party) or entry.parties_set (parties). Returns the entry (as books.get_entry), each party's share resolved.",
|
||||
description = "Tag an entry (draft or posted) with the party it was with (id or name), or pass party null (or omit it) to clear the tag. A party with a vendor record also tags the entry's vendor, so a payment to a contractor or landlord counts in books.vendor_1099; a party without one, or clearing, clears the vendor tag. For an entry with several counterparties pass parties instead: [{party, amount?|percent?}], each share an exact amount (cents), a percent (summing to 100; leftover cents to the first party), or neither (derived: a sole party gets all; members named together get what their capital or distributions lines say, else their [[company.members]] share; once the entry is posted, a derived split among several parties is recorded as the exact amounts it resolves to, so a later change to [[company.members]] never re-splits posted history). Name both members on a joint member transaction (a reimbursement on 2100, a joint contribution) and let the shares derive; give amounts when a split is uneven. Refused: a party twice, mixed kinds of share, two parties with vendor records, and, once the entry has balanced lines, shares that don't add up to what it moved. Metadata only: amounts never change. An entry and its reversal carry the same parties: tagging either tags both, so each party's part nets to zero. Journaled as entry.party_set (one party) or entry.parties_set (parties). Returns the entry (as books.get_entry), each party's share resolved, with its direction and what of it was money or property in kind.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
|
||||
+103
-27
@@ -1256,11 +1256,33 @@ pub struct EntryPartyOut {
|
||||
pub amount: Option<String>,
|
||||
/// The percent given, for a percent share, decimal string.
|
||||
pub percent: Option<String>,
|
||||
/// Its exact part of what the entry moved (positive: paid to it;
|
||||
/// negative: received from it; for an entry moving no money, its part
|
||||
/// of the total), decimal string. Null when the shares don't resolve
|
||||
/// (see `parties_error`).
|
||||
/// Its exact part of what the entry moved, signed as a line on the
|
||||
/// party's own account would be (debit-positive): positive, the
|
||||
/// company paid it or gave it value; negative, received from it. With
|
||||
/// direction "neither", its part of the entry's movement of its own
|
||||
/// accounts (a bill is negative, owed to it; an invoice positive), else
|
||||
/// of the entry's total. A reversal's is exactly its original's
|
||||
/// negated. A member's entry is what moved their capital (a property
|
||||
/// contribution is negative, received, in kind). Decimal string. Null
|
||||
/// when the shares don't resolve (see `parties_error`).
|
||||
pub resolved: Option<String>,
|
||||
/// "paid", "received" or "neither": neither is a promise not yet
|
||||
/// settled (a bill, an invoice, a fee charged on its own card, a
|
||||
/// distribution declared and not yet paid) or an entry that moved
|
||||
/// nothing with anyone (a close), counted in no party total. Null
|
||||
/// exactly when `resolved` is.
|
||||
pub direction: Option<String>,
|
||||
/// What of `resolved` moved as money (cash, a card, the members'
|
||||
/// financing, a loan; on a member's contribution, what the K-1
|
||||
/// worksheet counts as cash; of a distribution, the money it paid):
|
||||
/// what the party's totals count on basis "cash". "0.00" for direction
|
||||
/// neither. Decimal string, null when `resolved` is.
|
||||
pub cash: Option<String>,
|
||||
/// What of `resolved` moved as property (contributed or distributed in
|
||||
/// kind): basis "all" adds it. resolved − cash − in_kind is a promise
|
||||
/// not yet paid, counted on neither basis. Decimal string, null when
|
||||
/// `resolved` is.
|
||||
pub in_kind: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.draft_entry`.
|
||||
@@ -3000,6 +3022,14 @@ pub struct UpdatePartyParams {
|
||||
/// resolves by name, and its name is free for another party). False:
|
||||
/// dealt with again (refused while another party has its name).
|
||||
pub archived: Option<bool>,
|
||||
/// The liability accounts (codes or ids) the company owes this party
|
||||
/// itself on: the card it issued, the loan it made, what the members
|
||||
/// advanced (name each member). Replaces its list; [] clears it. A
|
||||
/// credit to one never counts as paying the party (a fee on its card,
|
||||
/// interest on its loan, a distribution still owed: a promise, counted
|
||||
/// when paid). Liabilities only, not placeholders, payables or payroll
|
||||
/// withholdings.
|
||||
pub creditor_of: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.set_party_roles`. Give at least one role to add
|
||||
@@ -3243,6 +3273,10 @@ pub struct ListPartiesParams {
|
||||
/// so year to date).
|
||||
#[schemars(description = YEAR_DOC)]
|
||||
pub year: Option<i32>,
|
||||
/// What `paid` and `received` count: "cash" (the default: money) or
|
||||
/// "all" (money and property contributed or distributed in kind). Not
|
||||
/// the books' accounting method. See `books.party` totals.
|
||||
pub basis: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.party`.
|
||||
@@ -3254,6 +3288,10 @@ pub struct PartyParams {
|
||||
/// date).
|
||||
#[schemars(description = YEAR_DOC)]
|
||||
pub year: Option<i32>,
|
||||
/// What `paid` and `received` count: "cash" (the default: money) or
|
||||
/// "all" (money and property contributed or distributed in kind). Not
|
||||
/// the books' accounting method. See the totals' schema.
|
||||
pub basis: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.set_entry_party`.
|
||||
@@ -3345,6 +3383,10 @@ pub struct PartyOut {
|
||||
pub w4: Option<W4Out>,
|
||||
/// Taxpayer-ID facts (any role), if on file: type and last four.
|
||||
pub tin: Option<TinOut>,
|
||||
/// The liability accounts the company owes this party itself on (its
|
||||
/// card, its loan, what the members advanced), by code: a credit to
|
||||
/// one never counts as paying it.
|
||||
pub creditor_of: Vec<String>,
|
||||
}
|
||||
|
||||
/// One party in `books.parties`.
|
||||
@@ -3364,16 +3406,19 @@ pub struct PartySummaryOut {
|
||||
pub customer_id: Option<u64>,
|
||||
/// W-9 facts are on file.
|
||||
pub w9_on_file: bool,
|
||||
/// The W-9's TIN last four digits, when on file.
|
||||
/// The TIN's last four digits when the books know it: from its TIN
|
||||
/// facts (`books.set_tin`; a member's SSN for the K-1) or its W-9
|
||||
/// facts, which never disagree. Null when neither is on file. Whether
|
||||
/// the vault still holds the full number: `books.party` `tin.in_vault`.
|
||||
pub tin_last4: Option<String>,
|
||||
/// W-4 facts are on file.
|
||||
pub w4_on_file: bool,
|
||||
/// No longer dealt with.
|
||||
pub archived: bool,
|
||||
/// Paid to the party in the year, decimal string (as `books.party`
|
||||
/// totals).
|
||||
/// Paid to the party in the year on `basis`, decimal string (as
|
||||
/// `books.party` totals).
|
||||
pub paid: String,
|
||||
/// Received from the party in the year, decimal string (as
|
||||
/// Received from the party in the year on `basis`, decimal string (as
|
||||
/// `books.party` totals).
|
||||
pub received: String,
|
||||
}
|
||||
@@ -3383,6 +3428,8 @@ pub struct PartySummaryOut {
|
||||
pub struct ListPartiesOut {
|
||||
/// The year `paid` and `received` cover.
|
||||
pub year: i32,
|
||||
/// What `paid` and `received` count: "cash" or "all".
|
||||
pub basis: String,
|
||||
/// Parties, by id.
|
||||
pub parties: Vec<PartySummaryOut>,
|
||||
}
|
||||
@@ -3394,30 +3441,59 @@ pub struct ListPartiesOut {
|
||||
/// the payments received on them, and reversals of any of these. Only
|
||||
/// posted entries dated in the calendar year count.
|
||||
///
|
||||
/// Each entry's money is `ledger_core::parties::entry_flow`: what left or
|
||||
/// reached the `[books] cash_accounts` (every asset account but
|
||||
/// receivables when none are set), plus what the company paid through a
|
||||
/// liability (a card, a member paying for it, a loan; not payables or
|
||||
/// payroll withholdings), with cash that pays a liability down counting
|
||||
/// once, as paid to that liability's creditor (a reimbursement to the
|
||||
/// members, a card payment to the issuer), a liability paid down by
|
||||
/// another counting the same way (a member paying the card from their own
|
||||
/// funds, a loan paying it off), and cash raised by a liability counting
|
||||
/// as received from the lender. Positive is paid to the party, negative
|
||||
/// received from it; a reversal counts against its root original's side
|
||||
/// (reversing the reversal counts on that side again). A transfer between the company's own cash accounts nets to zero.
|
||||
/// A bill is not paid until its payment is.
|
||||
/// An entry naming several parties counts each one's share of that money
|
||||
/// (its amount, its percent, or its derived share: see
|
||||
/// `books.set_entry_party` `parties`); an entry whose shares don't resolve
|
||||
/// counts for none of them.
|
||||
/// `paid` and `received` add up each entry's part for the party, as
|
||||
/// `books.get_entry` shows it (`resolved`, `direction`, `cash`,
|
||||
/// `in_kind`): on `basis` "cash" (the default) what moved as money, on
|
||||
/// "all" that plus property contributed or distributed in kind. A
|
||||
/// reversal counts on its original's side and nets it to zero. (Not the
|
||||
/// books' accounting method.)
|
||||
///
|
||||
/// Money is what left or reached the `[books] cash_accounts` (every asset
|
||||
/// account but receivables when none are set), the cards (liabilities
|
||||
/// whose names say "card", not "gift card"), and the `[books]
|
||||
/// financing_accounts` (what the members
|
||||
/// advanced, loans): a card charge or a member paying for the company is
|
||||
/// paid to the payee then; cash that pays one of these down is paid to
|
||||
/// its creditor (a reimbursement to the members, a card payment to the
|
||||
/// issuer); cash it raises is received from the lender. Any other
|
||||
/// liability is a promise (a bill, an accrual, deferred revenue): counted
|
||||
/// when it is settled, never also when it is booked. A credit to what the
|
||||
/// company owes a party itself (`books.party` `creditor_of`: a fee on its
|
||||
/// own card, interest on its own loan, a distribution still owed to a
|
||||
/// member) never counts as paying it.
|
||||
///
|
||||
/// An entry naming only members that moves equity counts what moved their
|
||||
/// capital: a contribution received, the property in it in kind (asset
|
||||
/// accounts that are neither cash nor receivables) and the rest cash, as
|
||||
/// on the K-1 worksheet; a distribution paid, the money it paid as cash
|
||||
/// and the property it gave in kind, the rest (declared to distributions
|
||||
/// payable, or credited to what the members advanced once they are named
|
||||
/// its creditors) still owed, counted when it is paid. So for a member,
|
||||
/// basis "all" over those entries and those payments is the statement of
|
||||
/// members' equity's contributions and distributions, and basis "cash"
|
||||
/// its cash contributions as the K-1 worksheet has them, but for a
|
||||
/// distribution declared and not yet paid, which is in the statement only. Known limits (record them apart): an entry
|
||||
/// that both repays a member and distributes to them counts the
|
||||
/// distribution only; a member's money that goes through the members'
|
||||
/// financing account both ways under their own name (lent, then converted
|
||||
/// to capital) counts both times, as does a distribution declared through
|
||||
/// it before the members are named its creditors; an expense a member paid
|
||||
/// booked straight to capital and tagged with the vendor isn't paid to the
|
||||
/// vendor; capital moved between members counts for neither.
|
||||
///
|
||||
/// An entry naming several parties counts each one's share (its amount,
|
||||
/// its percent, or its derived share: see `books.set_entry_party`
|
||||
/// `parties`); an entry whose shares don't resolve counts for none of
|
||||
/// them. A transfer between the company's own cash accounts nets to zero.
|
||||
/// (`ledger_core::parties::activity_with`, with the `[[company.members]]`,
|
||||
/// computes it; the viewer shows the same numbers.)
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct PartyTotalsOut {
|
||||
/// Paid to the party, decimal string.
|
||||
/// What `paid` and `received` count: "cash" or "all".
|
||||
pub basis: String,
|
||||
/// Paid to the party on `basis`, decimal string.
|
||||
pub paid: String,
|
||||
/// Received from the party, decimal string.
|
||||
/// Received from the party on `basis`, decimal string.
|
||||
pub received: String,
|
||||
/// Its customer record's invoices dated in the year, total, decimal
|
||||
/// string.
|
||||
|
||||
@@ -1027,14 +1027,31 @@ fn party_totals_for_a_customer_a_member_and_a_prepaid() {
|
||||
assert_eq!(t["received"], "2000.00", "a contribution");
|
||||
assert_eq!(t["paid"], "500.00", "a distribution");
|
||||
|
||||
// A prepaid bought with a member's card: an asset, paid by a liability.
|
||||
m.add_party("Insurer", &[]);
|
||||
m.post(
|
||||
"2026-04-01",
|
||||
json!({"party": "Insurer"}),
|
||||
&[("1300", "debit", "1200.00"), ("2100", "credit", "1200.00")],
|
||||
// A prepaid bought with a member's card: an asset, paid through what
|
||||
// the members advanced. That is money when it is a financing account
|
||||
// (rules.md B-35 item 5, P3 defect 24); unlisted, Due to Members is a
|
||||
// promise like any other liability, counted when the member is paid
|
||||
// back.
|
||||
let prepaid = |m: &mut Mcp| {
|
||||
m.add_party("Insurer", &[]);
|
||||
m.post(
|
||||
"2026-04-01",
|
||||
json!({"party": "Insurer"}),
|
||||
&[("1300", "debit", "1200.00"), ("2100", "credit", "1200.00")],
|
||||
);
|
||||
m.party("Insurer", 2026)["totals"].clone()
|
||||
};
|
||||
let t = prepaid(&mut m);
|
||||
assert_eq!(
|
||||
(t["paid"].clone(), t["received"].clone()),
|
||||
(json!("0.00"), json!("0.00"))
|
||||
);
|
||||
let t = m.party("Insurer", 2026)["totals"].clone();
|
||||
let dir = test_dir("totals-financing");
|
||||
let toml = books_toml(&dir, "Spec Co", "financing_accounts = [\"2100\"]\n");
|
||||
let mut f = Mcp::start(&dir, &toml);
|
||||
f.accounts(CHART);
|
||||
f.books_dir = Some(dir);
|
||||
let t = prepaid(&mut f);
|
||||
assert_eq!(t["paid"], "1200.00");
|
||||
assert_eq!(t["received"], "0.00");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
//! P3 defects 14 and 24 through the MCP, over stdio against the real
|
||||
//! binary: each party's part of an entry (`books.get_entry` `resolved`,
|
||||
//! `direction`, `cash`, `in_kind`), party totals on a basis
|
||||
//! (`books.party`, `books.parties` `basis`), and a party named the
|
||||
//! creditor of a liability (`books.update_party` `creditor_of`).
|
||||
//!
|
||||
//! Expected behavior comes only from the tool descriptions and parameter /
|
||||
//! result doc comments in `src/server.rs` and `src/types.rs` (as
|
||||
//! `tools/list` serves them) and rules.md B-35. The harness is the minimal
|
||||
//! subset of `tests/spec_mcp.rs` (copied from `tests/spec_parties.rs`):
|
||||
//! every success validates against the tool's `outputSchema`, and every
|
||||
//! refusal leaves `journal.jsonl` byte-identical.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(180);
|
||||
|
||||
// ---------------------------------------------------------------- harness
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Out {
|
||||
Ok(Value),
|
||||
ToolErr(String),
|
||||
RpcErr(Value),
|
||||
}
|
||||
|
||||
impl Out {
|
||||
fn refused(&self) -> bool {
|
||||
!matches!(self, Out::Ok(_))
|
||||
}
|
||||
fn err_text(&self) -> String {
|
||||
match self {
|
||||
Out::Ok(v) => panic!("expected a refusal, got {v}"),
|
||||
Out::ToolErr(t) => t.clone(),
|
||||
Out::RpcErr(e) => e.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Mcp {
|
||||
stdin: ChildStdin,
|
||||
rx: Receiver<String>,
|
||||
child: Child,
|
||||
next_id: u64,
|
||||
data: PathBuf,
|
||||
validators: HashMap<String, jsonschema::Validator>,
|
||||
/// The books dir when the server owns it: removed after the server
|
||||
/// stops (fields drop after `Drop::drop`).
|
||||
books_dir: Option<TestDir>,
|
||||
}
|
||||
|
||||
impl Drop for Mcp {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
static DIR_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = std::path::Path;
|
||||
fn deref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<std::path::Path> for TestDir {
|
||||
fn as_ref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ledger-mcp-spec-party-totals-{}-{name}-{n}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
}
|
||||
|
||||
/// `wanderbooks.toml` with a company name and extra `[books]` TOML. The
|
||||
/// receipt threshold is high so ordinary test entries need no receipt.
|
||||
fn books_toml(dir: &Path, company: &str, books_extra: &str) -> PathBuf {
|
||||
let path = dir.join("wanderbooks.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"[company]\nname = \"{company}\"\nfiscal_year_start = \"01-01\"\n\
|
||||
[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n{books_extra}"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
fn start(dir: &Path, toml: &Path) -> Self {
|
||||
let run = dir.join("run");
|
||||
std::fs::create_dir_all(&run).unwrap();
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
|
||||
.arg("--data-dir")
|
||||
.arg(dir.join("books-data"))
|
||||
.arg("--books-toml")
|
||||
.arg(toml)
|
||||
.env("XDG_RUNTIME_DIR", &run)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn ledger-mcp");
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
let (tx, rx) = channel();
|
||||
std::thread::spawn(move || {
|
||||
for line in stdout.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut mcp = Self {
|
||||
books_dir: None,
|
||||
stdin,
|
||||
rx,
|
||||
child,
|
||||
next_id: 1,
|
||||
data: dir.join("books-data"),
|
||||
validators: HashMap::new(),
|
||||
};
|
||||
let init = mcp.rpc(
|
||||
"initialize",
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "spec-party-totals", "version": "0.1.0"},
|
||||
}),
|
||||
);
|
||||
assert!(init.get("result").is_some(), "initialize failed: {init}");
|
||||
mcp.send_line(
|
||||
&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}).to_string(),
|
||||
);
|
||||
let list = mcp.rpc("tools/list", json!({}));
|
||||
for tool in list["result"]["tools"].as_array().expect("tools") {
|
||||
let name = tool["name"].as_str().unwrap().to_string();
|
||||
let validator = jsonschema::validator_for(&tool["outputSchema"])
|
||||
.unwrap_or_else(|e| panic!("{name}'s outputSchema is not valid: {e}"));
|
||||
mcp.validators.insert(name, validator);
|
||||
}
|
||||
mcp
|
||||
}
|
||||
|
||||
fn journal(&self) -> Vec<u8> {
|
||||
std::fs::read(self.data.join("journal.jsonl")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The journal's line types, in order.
|
||||
fn journal_types(&self) -> Vec<String> {
|
||||
String::from_utf8(self.journal())
|
||||
.unwrap()
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| {
|
||||
let v: Value = serde_json::from_str(l).unwrap();
|
||||
v["type"]
|
||||
.as_str()
|
||||
.or_else(|| v["line_type"].as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn send_line(&mut self, line: &str) {
|
||||
writeln!(self.stdin, "{line}").unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
}
|
||||
|
||||
fn recv(&mut self) -> Option<Value> {
|
||||
loop {
|
||||
match self.rx.recv_timeout(TIMEOUT) {
|
||||
Ok(line) => {
|
||||
let v: Value = serde_json::from_str(&line)
|
||||
.unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}"));
|
||||
if v.get("id").is_none() && v.get("method").is_some() {
|
||||
continue;
|
||||
}
|
||||
return Some(v);
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rpc(&mut self, method: &str, params: Value) -> Value {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
|
||||
self.send_line(&msg.to_string());
|
||||
let resp = self
|
||||
.recv()
|
||||
.unwrap_or_else(|| panic!("no response to {method} (id {id})"));
|
||||
assert_eq!(resp["id"], json!(id), "response id mismatch: {resp}");
|
||||
resp
|
||||
}
|
||||
|
||||
/// Call a tool; a refusal must leave the journal unchanged, a success
|
||||
/// must match the outputSchema.
|
||||
fn tool(&mut self, name: &str, args: Value) -> Out {
|
||||
let before = self.journal();
|
||||
let resp = self.rpc("tools/call", json!({"name": name, "arguments": args}));
|
||||
let out = if let Some(err) = resp.get("error") {
|
||||
Out::RpcErr(err.clone())
|
||||
} else {
|
||||
let result = &resp["result"];
|
||||
if result.get("isError") == Some(&json!(true)) {
|
||||
Out::ToolErr(
|
||||
result["content"]
|
||||
.as_array()
|
||||
.and_then(|c| c.iter().find_map(|b| b["text"].as_str()))
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
)
|
||||
} else {
|
||||
let sc = result["structuredContent"].clone();
|
||||
let v = self
|
||||
.validators
|
||||
.get(name)
|
||||
.unwrap_or_else(|| panic!("{name} is not in tools/list"));
|
||||
let errors: Vec<String> = v.iter_errors(&sc).map(|e| e.to_string()).collect();
|
||||
assert!(
|
||||
errors.is_empty(),
|
||||
"{name} violates its outputSchema: {errors:?}\n{sc}"
|
||||
);
|
||||
Out::Ok(sc)
|
||||
}
|
||||
};
|
||||
if out.refused() {
|
||||
assert!(
|
||||
self.journal() == before,
|
||||
"refused {name} {args} changed the journal ({out:?})"
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn ok(&mut self, name: &str, args: Value) -> Value {
|
||||
let a = args.to_string();
|
||||
match self.tool(name, args) {
|
||||
Out::Ok(v) => v,
|
||||
other => panic!("{name} {a}: expected success, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `tool`, expecting a refusal that says why.
|
||||
fn refuse(&mut self, name: &str, args: Value) -> String {
|
||||
let a = args.to_string();
|
||||
let out = self.tool(name, args);
|
||||
assert!(out.refused(), "{name} {a} should be refused, got {out:?}");
|
||||
let why = out.err_text();
|
||||
assert!(!why.trim().is_empty(), "{name} {a}: a refusal says why");
|
||||
why
|
||||
}
|
||||
|
||||
/// `ok`, and the journal must not change (read-only tools).
|
||||
fn read_only(&mut self, name: &str, args: Value) -> Value {
|
||||
let before = self.journal();
|
||||
let v = self.ok(name, args);
|
||||
assert!(
|
||||
self.journal() == before,
|
||||
"read-only {name} changed the journal"
|
||||
);
|
||||
v
|
||||
}
|
||||
|
||||
fn accounts(&mut self, accts: &[(&str, &str, &str)]) {
|
||||
for (code, name, ty) in accts {
|
||||
self.ok(
|
||||
"books.add_account",
|
||||
json!({"code": code, "name": name, "account_type": ty}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draft an entry with `args` extra (party, vendor) and lines; the id.
|
||||
fn draft(&mut self, date: &str, extra: Value, lines: &[(&str, &str, &str)]) -> u64 {
|
||||
let mut args = json!({"date": date, "description": format!("entry {date}")});
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
args[k] = v.clone();
|
||||
}
|
||||
let id = self.ok("books.draft_entry", args)["entry_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
for (account, side, amount) in lines {
|
||||
self.ok(
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
|
||||
);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// `draft`, then post it (high receipt threshold: no receipt needed).
|
||||
fn post(&mut self, date: &str, extra: Value, lines: &[(&str, &str, &str)]) -> u64 {
|
||||
let id = self.draft(date, extra, lines);
|
||||
self.ok(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
fn add_party(&mut self, name: &str, roles: &[&str]) -> u64 {
|
||||
self.ok("books.add_party", json!({"name": name, "roles": roles}))["party_id"]
|
||||
.as_u64()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// How many journal lines have this type.
|
||||
fn count(&self, line_type: &str) -> usize {
|
||||
self.journal_types()
|
||||
.iter()
|
||||
.filter(|t| t.as_str() == line_type)
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
const CHART: &[(&str, &str, &str)] = &[
|
||||
("1000", "Checking", "asset"),
|
||||
("1300", "Prepaid Expenses", "asset"),
|
||||
("1500", "Equipment", "asset"),
|
||||
("2050", "Business Card", "liability"),
|
||||
("2100", "Due to Members", "liability"),
|
||||
("3000", "Capital — Tobin Harlow", "equity"),
|
||||
("3001", "Capital — Morgan Ellis", "equity"),
|
||||
("6050", "Domain & Hosting", "expense"),
|
||||
("6400", "Bank Fees", "expense"),
|
||||
];
|
||||
|
||||
/// Fernhill Labs LLC: two members 50/50, cash 1000, the members'
|
||||
/// financing 2100; the chart; both members and the card's issuer in the
|
||||
/// directory.
|
||||
fn books(name: &str) -> Mcp {
|
||||
let dir = test_dir(name);
|
||||
let toml = books_toml(
|
||||
&dir,
|
||||
"Fernhill Labs LLC",
|
||||
"financing_accounts = [\"2100\"]\n\
|
||||
[[company.members]]\nname = \"Tobin Harlow\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
|
||||
[[company.members]]\nname = \"Morgan Ellis\"\nshare = \"50%\"\ncapital_account = \"3001\"\n",
|
||||
);
|
||||
let mut m = Mcp::start(&dir, &toml);
|
||||
m.accounts(CHART);
|
||||
m.add_party("Tobin Harlow", &["member"]);
|
||||
m.add_party("Morgan Ellis", &["member"]);
|
||||
m.add_party("Harbor Bank", &["financial_institution"]);
|
||||
m.books_dir = Some(dir);
|
||||
m
|
||||
}
|
||||
|
||||
/// A party's part of an entry as books.get_entry gives it: (resolved,
|
||||
/// direction, cash, in_kind).
|
||||
fn part(m: &mut Mcp, entry: u64, party: &str) -> (Value, Value, Value, Value) {
|
||||
let e = m.read_only("books.get_entry", json!({"entry_id": entry}));
|
||||
let p = e["parties"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|p| p["party"] == party)
|
||||
.unwrap_or_else(|| panic!("{party} on entry {entry}: {e}"))
|
||||
.clone();
|
||||
(
|
||||
p["resolved"].clone(),
|
||||
p["direction"].clone(),
|
||||
p["cash"].clone(),
|
||||
p["in_kind"].clone(),
|
||||
)
|
||||
}
|
||||
|
||||
/// (paid, received, basis) from books.party.
|
||||
fn totals(m: &mut Mcp, party: &str, basis: Option<&str>) -> (Value, Value, Value) {
|
||||
let mut args = json!({"party": party, "year": 2026});
|
||||
if let Some(b) = basis {
|
||||
args["basis"] = json!(b);
|
||||
}
|
||||
let p = m.read_only("books.party", args);
|
||||
let t = &p["totals"];
|
||||
(t["paid"].clone(), t["received"].clone(), t["basis"].clone())
|
||||
}
|
||||
|
||||
fn reverse(m: &mut Mcp, entry: u64, date: &str) -> u64 {
|
||||
m.ok(
|
||||
"books.reverse_entry",
|
||||
json!({"entry_id": entry, "date": date, "description": "reversed", "confirmed": true}),
|
||||
)["reversal_id"]
|
||||
.as_u64()
|
||||
.unwrap_or_else(|| panic!("a reversal of {entry}"))
|
||||
}
|
||||
|
||||
/// Defect 14 over the MCP (rules.md B-35): `resolved` is signed as a line
|
||||
/// on the party's own account, with its direction and its money and
|
||||
/// property; a reversal is the exact opposite; party totals take a basis,
|
||||
/// cash by default and echoed, all adding property in kind, so that a
|
||||
/// member's basis-all received is the statement of members' equity's
|
||||
/// contributions; any other basis is refused.
|
||||
#[test]
|
||||
fn defect14_party_totals_take_a_basis() {
|
||||
let mut m = books("defect14");
|
||||
let tobin = json!({"parties": [{"party": "Tobin Harlow"}]});
|
||||
let both = json!({"parties": [{"party": "Tobin Harlow"}, {"party": "Morgan Ellis"}]});
|
||||
// Cash in, 400.00 each.
|
||||
let cash_in = m.post(
|
||||
"2026-10-05",
|
||||
tobin.clone(),
|
||||
&[("1000", "debit", "400.00"), ("3000", "credit", "400.00")],
|
||||
);
|
||||
m.post(
|
||||
"2026-10-05",
|
||||
json!({"parties": [{"party": "Morgan Ellis"}]}),
|
||||
&[("1000", "debit", "400.00"), ("3001", "credit", "400.00")],
|
||||
);
|
||||
// A laptop Tobin contributes, then its reversal.
|
||||
let laptop = m.post(
|
||||
"2026-10-12",
|
||||
tobin.clone(),
|
||||
&[("1500", "debit", "1480.00"), ("3000", "credit", "1480.00")],
|
||||
);
|
||||
let undo = reverse(&mut m, laptop, "2026-10-13");
|
||||
// Devices from both, derived from the capital lines; one with its
|
||||
// service contract (prepaid: property).
|
||||
m.post(
|
||||
"2026-10-14",
|
||||
both.clone(),
|
||||
&[
|
||||
("1500", "debit", "1480.00"),
|
||||
("3000", "credit", "740.00"),
|
||||
("3001", "credit", "740.00"),
|
||||
],
|
||||
);
|
||||
m.post(
|
||||
"2026-10-19",
|
||||
both.clone(),
|
||||
&[
|
||||
("1500", "debit", "1010.00"),
|
||||
("1300", "debit", "30.00"),
|
||||
("3000", "credit", "520.00"),
|
||||
("3001", "credit", "520.00"),
|
||||
],
|
||||
);
|
||||
// A reimbursement to both (money, paid).
|
||||
let reimburse = m.post(
|
||||
"2026-10-20",
|
||||
json!({"parties": [{"party": "Tobin Harlow", "amount": "93.25"}, {"party": "Morgan Ellis", "amount": "93.25"}]}),
|
||||
&[("2100", "debit", "186.50"), ("1000", "credit", "186.50")],
|
||||
);
|
||||
assert_eq!(
|
||||
part(&mut m, cash_in, "Tobin Harlow"),
|
||||
(
|
||||
json!("-400.00"),
|
||||
json!("received"),
|
||||
json!("-400.00"),
|
||||
json!("0.00")
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
part(&mut m, laptop, "Tobin Harlow"),
|
||||
(
|
||||
json!("-1480.00"),
|
||||
json!("received"),
|
||||
json!("0.00"),
|
||||
json!("-1480.00")
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
part(&mut m, undo, "Tobin Harlow"),
|
||||
(
|
||||
json!("1480.00"),
|
||||
json!("paid"),
|
||||
json!("0.00"),
|
||||
json!("1480.00")
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
part(&mut m, reimburse, "Morgan Ellis"),
|
||||
(json!("93.25"), json!("paid"), json!("93.25"), json!("0.00"))
|
||||
);
|
||||
// Totals: cash by default, labelled; all adds 740.00 + 520.00.
|
||||
let cash = (json!("93.25"), json!("400.00"), json!("cash"));
|
||||
assert_eq!(totals(&mut m, "Tobin Harlow", None), cash);
|
||||
assert_eq!(totals(&mut m, "Tobin Harlow", Some("cash")), cash);
|
||||
let all = totals(&mut m, "Tobin Harlow", Some("all"));
|
||||
assert_eq!(all, (json!("93.25"), json!("1660.00"), json!("all")));
|
||||
// The statement of members' equity, the same dates: contributions
|
||||
// 1,660.00 each (the laptop and its reversal cancel), no
|
||||
// distributions; basis all's received is exactly that.
|
||||
let me = m.read_only(
|
||||
"books.export",
|
||||
json!({"report": "members_equity", "format": "json", "start": "2026-01-01", "end": "2026-12-31"}),
|
||||
);
|
||||
let me: Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap();
|
||||
for who in ["Tobin Harlow", "Morgan Ellis"] {
|
||||
let col = me["members"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|c| c["name"] == who)
|
||||
.unwrap_or_else(|| panic!("{who} in {me}"))
|
||||
.clone();
|
||||
assert_eq!(col["contributions"], totals(&mut m, who, Some("all")).1);
|
||||
assert_eq!(col["contributions"], "1660.00");
|
||||
assert_eq!(col["distributions"], "0.00");
|
||||
}
|
||||
// books.parties echoes the basis.
|
||||
let list = m.read_only("books.parties", json!({"year": 2026, "basis": "all"}));
|
||||
assert_eq!(list["basis"], "all");
|
||||
let row = list["parties"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|p| p["name"] == "Morgan Ellis")
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(
|
||||
(row["paid"].clone(), row["received"].clone()),
|
||||
(json!("93.25"), json!("1660.00"))
|
||||
);
|
||||
assert_eq!(m.read_only("books.parties", json!({}))["basis"], "cash");
|
||||
// Any other basis is refused, by both tools.
|
||||
for (tool, args) in [
|
||||
(
|
||||
"books.party",
|
||||
json!({"party": "Tobin Harlow", "basis": "accrual"}),
|
||||
),
|
||||
("books.parties", json!({"basis": "accrual"})),
|
||||
] {
|
||||
let why = m.refuse(tool, args);
|
||||
assert!(why.contains("\"cash\" or \"all\""), "{tool}: {why}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Defect 24 over the MCP: `books.update_party` `creditor_of` names the
|
||||
/// liabilities the company owes a party itself on (journaled as
|
||||
/// party.creditor_set, the journal's feature declared first), replaces
|
||||
/// the list, refuses what isn't a liability with nothing journaled; and a
|
||||
/// fee on the issuer's own card then reads neither, so the issuer is paid
|
||||
/// what the card payment paid, once.
|
||||
#[test]
|
||||
fn defect24_creditor_of_round_trips_and_a_card_fee_is_not_a_payment() {
|
||||
let mut m = books("defect24");
|
||||
let issuer = json!({"parties": [{"party": "Harbor Bank"}]});
|
||||
let fee = m.post(
|
||||
"2026-05-01",
|
||||
issuer.clone(),
|
||||
&[("6400", "debit", "25.00"), ("2050", "credit", "25.00")],
|
||||
);
|
||||
m.post(
|
||||
"2026-05-20",
|
||||
issuer.clone(),
|
||||
&[("2050", "debit", "125.00"), ("1000", "credit", "125.00")],
|
||||
);
|
||||
assert_eq!(totals(&mut m, "Harbor Bank", None).0, json!("150.00"));
|
||||
// Not a liability: refused, nothing journaled (the tool checks).
|
||||
let why = m.refuse(
|
||||
"books.update_party",
|
||||
json!({"party": "Harbor Bank", "creditor_of": ["1000"]}),
|
||||
);
|
||||
assert!(why.contains("liability"), "{why}");
|
||||
assert_eq!(m.count("party.creditor_set"), 0);
|
||||
let before = m.count("journal.format_set");
|
||||
let p = m.ok(
|
||||
"books.update_party",
|
||||
json!({"party": "Harbor Bank", "creditor_of": ["2050"]}),
|
||||
);
|
||||
assert_eq!(p["creditor_of"], json!(["2050"]));
|
||||
assert_eq!(m.count("party.creditor_set"), 1);
|
||||
assert_eq!(
|
||||
m.count("journal.format_set"),
|
||||
before + 1,
|
||||
"the feature declared once"
|
||||
);
|
||||
// The same list again changes nothing.
|
||||
let journal = m.journal();
|
||||
m.ok(
|
||||
"books.update_party",
|
||||
json!({"party": "Harbor Bank", "creditor_of": ["2050"]}),
|
||||
);
|
||||
assert!(m.journal() == journal);
|
||||
assert_eq!(
|
||||
part(&mut m, fee, "Harbor Bank"),
|
||||
(
|
||||
json!("-25.00"),
|
||||
json!("neither"),
|
||||
json!("0.00"),
|
||||
json!("0.00")
|
||||
)
|
||||
);
|
||||
assert_eq!(totals(&mut m, "Harbor Bank", None).0, json!("125.00"));
|
||||
assert_eq!(
|
||||
totals(&mut m, "Harbor Bank", Some("all")).0,
|
||||
json!("125.00")
|
||||
);
|
||||
// Both members are owed what the members advanced; then one isn't.
|
||||
for who in ["Tobin Harlow", "Morgan Ellis"] {
|
||||
let p = m.ok(
|
||||
"books.update_party",
|
||||
json!({"party": who, "creditor_of": ["2100"]}),
|
||||
);
|
||||
assert_eq!(p["creditor_of"], json!(["2100"]));
|
||||
}
|
||||
let p = m.ok(
|
||||
"books.update_party",
|
||||
json!({"party": "Morgan Ellis", "creditor_of": []}),
|
||||
);
|
||||
assert_eq!(p["creditor_of"], json!([]));
|
||||
assert_eq!(m.count("party.creditor_set"), 4);
|
||||
assert_eq!(m.count("journal.format_set"), before + 1);
|
||||
let p = m.read_only("books.party", json!({"party": "Tobin Harlow"}));
|
||||
assert_eq!(p["party"]["creditor_of"], json!(["2100"]));
|
||||
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||||
}
|
||||
|
||||
/// What `books.post_entry` previews is what it posts: each party's
|
||||
/// `resolved`, `direction`, `cash` and `in_kind` in the preview are
|
||||
/// `books.get_entry`'s once confirmed (a derived split is then journaled as
|
||||
/// the amounts it resolved to, the same parts), for a joint contribution
|
||||
/// of money and property and for a distribution partly still owed.
|
||||
#[test]
|
||||
fn the_post_preview_shows_the_parts_it_posts() {
|
||||
let mut m = books("preview");
|
||||
m.accounts(&[("2150", "Distributions Payable", "liability")]);
|
||||
let both = json!({"parties": [{"party": "Tobin Harlow"}, {"party": "Morgan Ellis"}]});
|
||||
let tobin = json!({"parties": [{"party": "Tobin Harlow"}]});
|
||||
let parts = |v: &Value| -> Vec<Value> {
|
||||
v["parties"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| {
|
||||
json!([
|
||||
p["party"],
|
||||
p["resolved"],
|
||||
p["direction"],
|
||||
p["cash"],
|
||||
p["in_kind"]
|
||||
])
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let joint = m.draft(
|
||||
"2026-03-02",
|
||||
both,
|
||||
&[
|
||||
("1000", "debit", "610.00"),
|
||||
("1500", "debit", "390.00"),
|
||||
("3000", "credit", "500.00"),
|
||||
("3001", "credit", "500.00"),
|
||||
],
|
||||
);
|
||||
let owed = m.draft(
|
||||
"2026-03-03",
|
||||
tobin,
|
||||
&[
|
||||
("3000", "debit", "300.00"),
|
||||
("1000", "credit", "120.00"),
|
||||
("2150", "credit", "180.00"),
|
||||
],
|
||||
);
|
||||
for (id, want) in [
|
||||
(
|
||||
joint,
|
||||
vec![
|
||||
json!(["Tobin Harlow", "-500.00", "received", "-305.00", "-195.00"]),
|
||||
json!(["Morgan Ellis", "-500.00", "received", "-305.00", "-195.00"]),
|
||||
],
|
||||
),
|
||||
(
|
||||
owed,
|
||||
vec![json!(["Tobin Harlow", "300.00", "paid", "120.00", "0.00"])],
|
||||
),
|
||||
] {
|
||||
let preview = m.read_only("books.post_entry", json!({"entry_id": id}));
|
||||
assert_eq!(preview["status"], "preview");
|
||||
assert_eq!(parts(&preview), want, "entry {id}'s preview");
|
||||
m.ok(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
);
|
||||
let posted = m.read_only("books.get_entry", json!({"entry_id": id}));
|
||||
assert_eq!(parts(&posted), want, "entry {id} posted");
|
||||
}
|
||||
let (paid, received, _) = totals(&mut m, "Tobin Harlow", None);
|
||||
assert_eq!((paid, received), (json!("120.00"), json!("305.00")));
|
||||
}
|
||||
@@ -2219,3 +2219,61 @@ fn idempotent_tools_change_nothing_when_repeated() {
|
||||
.collect();
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
|
||||
// ------------------------------------------------- defect 11 (P3)
|
||||
|
||||
/// Defect 11 (audit F28): `books.parties` showed a member's TIN as
|
||||
/// missing (`tin_last4: null`) because it read W-9 facts only, while
|
||||
/// `books.party` showed the TIN facts' last four. The directory's last
|
||||
/// four is the TIN facts', else the W-9 facts' (they never disagree:
|
||||
/// parties INVARIANT 2a), null with neither, and kept after a purge
|
||||
/// (the facts keep the last four).
|
||||
#[test]
|
||||
fn defect11_the_directory_shows_a_members_tin_last4() {
|
||||
let mut m = books("defect11");
|
||||
m.add_party("Mo", &["member"]);
|
||||
m.add_party("Nobody In Particular", &[]);
|
||||
m.ok(
|
||||
"books.set_tin",
|
||||
json!({"party": "Mo", "tin": MEMBER_SSN, "tin_type": "ssn"}),
|
||||
);
|
||||
assert!(
|
||||
!m.w9(
|
||||
"Acme Design",
|
||||
json!({"tin_last4": "6789", "kept_at": "binder"})
|
||||
)
|
||||
.refused()
|
||||
);
|
||||
let row = |m: &mut Mcp, name: &str| -> Value {
|
||||
let list = m.read_only("books.parties", json!({"year": 2026}));
|
||||
list["parties"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|p| p["name"] == name)
|
||||
.unwrap_or_else(|| panic!("{name} in {list}"))
|
||||
.clone()
|
||||
};
|
||||
let mo = row(&mut m, "Mo");
|
||||
assert_eq!(
|
||||
(mo["tin_last4"].clone(), mo["w9_on_file"].clone()),
|
||||
(json!("4915"), json!(false))
|
||||
);
|
||||
assert_eq!(
|
||||
m.party("Mo", 2026)["party"]["tin"]["last4"],
|
||||
mo["tin_last4"]
|
||||
);
|
||||
let acme = row(&mut m, "Acme Design");
|
||||
assert_eq!(
|
||||
(acme["tin_last4"].clone(), acme["w9_on_file"].clone()),
|
||||
(json!("6789"), json!(true))
|
||||
);
|
||||
assert!(row(&mut m, "Nobody In Particular")["tin_last4"].is_null());
|
||||
m.ok(
|
||||
"books.purge_tin",
|
||||
json!({"party": "Mo", "reason": "left the company", "confirmed": true}),
|
||||
);
|
||||
assert_eq!(row(&mut m, "Mo")["tin_last4"], "4915");
|
||||
let list = m.read_only("books.parties", json!({}));
|
||||
assert!(!holds_tin(list.to_string().as_bytes(), MEMBER_SSN_DIGITS));
|
||||
}
|
||||
@@ -263,7 +263,11 @@ pub fn build_package<B: PackageBooks + ?Sized>(
|
||||
}
|
||||
None => {
|
||||
cells.push(
|
||||
facts.as_ref().map(|f| f.last4.clone()).unwrap_or_default(),
|
||||
party
|
||||
.and_then(|id| ledger.get_party(id))
|
||||
.and_then(|p| p.tin_last4())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
);
|
||||
cells.push(
|
||||
facts
|
||||
@@ -396,11 +400,10 @@ pub fn build_package<B: PackageBooks + ?Sized>(
|
||||
// The payee's full TIN from the source (INVARIANT 2).
|
||||
if let Some(tins) = tins {
|
||||
let party = ledger.party_of_vendor(v.vendor_id);
|
||||
let last4 = party.and_then(|id| ledger.get_party(id)).and_then(|p| {
|
||||
p.w9.as_ref()
|
||||
.map(|f| f.tin_last4.clone())
|
||||
.or_else(|| p.tin.as_ref().map(|t| t.last4.clone()))
|
||||
});
|
||||
let last4 = party
|
||||
.and_then(|id| ledger.get_party(id))
|
||||
.and_then(|p| p.tin_last4())
|
||||
.map(str::to_string);
|
||||
let got = match party {
|
||||
None => Err("TIN not in the vault: no party for this vendor".into()),
|
||||
Some(id) => tins(id),
|
||||
|
||||
@@ -173,11 +173,10 @@ pub fn vendor_1099_worksheet(
|
||||
let mut lines = Vec::new();
|
||||
for v in r.vendors {
|
||||
let party_id = ledger.party_of_vendor(v.vendor_id);
|
||||
let tin_last4 = party_id.and_then(|id| ledger.get_party(id)).and_then(|p| {
|
||||
p.w9.as_ref()
|
||||
.map(|f| f.tin_last4.clone())
|
||||
.or_else(|| p.tin.as_ref().map(|t| t.last4.clone()))
|
||||
});
|
||||
let tin_last4 = party_id
|
||||
.and_then(|id| ledger.get_party(id))
|
||||
.and_then(|p| p.tin_last4())
|
||||
.map(str::to_string);
|
||||
let mut line = Vendor1099WorksheetLine {
|
||||
vendor_id: v.vendor_id,
|
||||
name: v.name,
|
||||
|
||||
@@ -43,6 +43,7 @@ pub const KNOWN_FEATURES: &[&str] = &[
|
||||
MEMBERS_CUMULATIVE,
|
||||
FEATURE_ACCOUNT_RENAMED,
|
||||
FEATURE_REQUIRE_TAX_CATEGORY,
|
||||
PARTY_CREDITORS,
|
||||
];
|
||||
|
||||
/// Feature: `period.closed` closes to members with `"rounding":
|
||||
@@ -70,6 +71,10 @@ pub(crate) fn setting_feature(key: &str) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The feature `party.creditor_set` lines need: a party named the
|
||||
/// creditor of a liability (rules.md B-35, P3 defect 24).
|
||||
pub const PARTY_CREDITORS: &str = "party_creditors";
|
||||
|
||||
/// The line type that declares the journal format.
|
||||
pub const FORMAT_SET: &str = "journal.format_set";
|
||||
|
||||
@@ -119,6 +124,7 @@ pub const EVENT_TYPES: &[&str] = &[
|
||||
"open_item.raised",
|
||||
"open_item.resolved",
|
||||
"party.added",
|
||||
"party.creditor_set",
|
||||
"party.role_set",
|
||||
"party.tin_set",
|
||||
"party.updated",
|
||||
|
||||
@@ -55,16 +55,11 @@ pub fn cash_accounts(ledger: &Ledger, configured: &[String]) -> Vec<AccountId> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The card accounts: liabilities whose names contain "card".
|
||||
/// The card accounts: liabilities whose names contain "card"
|
||||
/// (`ledger_core::parties::card_accounts`, the same rule party money
|
||||
/// reads, so the two never disagree on what a card is).
|
||||
pub fn card_accounts(ledger: &Ledger) -> Vec<AccountId> {
|
||||
ledger
|
||||
.chart()
|
||||
.all()
|
||||
.filter(|a| {
|
||||
a.account_type == AccountType::Liability && a.name.to_lowercase().contains("card")
|
||||
})
|
||||
.map(|a| a.id)
|
||||
.collect()
|
||||
ledger_core::parties::card_accounts(ledger)
|
||||
}
|
||||
|
||||
/// The accounts the books reconcile monthly (INVARIANT 1).
|
||||
|
||||
@@ -1238,6 +1238,30 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Name a party the creditor of a liability account the company owes
|
||||
/// it itself on, or stop (`on` false): ledger_core's
|
||||
/// `Ledger::set_party_creditor`. Journaled as `party.creditor_set
|
||||
/// {id, account_id, on}`, after the journal declares the feature it
|
||||
/// needs ([`crate::format::PARTY_CREDITORS`]) the first time, so an
|
||||
/// older wanderbooks stops there and says it is too old instead of
|
||||
/// misreading the books.
|
||||
pub fn set_party_creditor(
|
||||
&mut self,
|
||||
id: ledger_core::PartyId,
|
||||
account: AccountId,
|
||||
on: bool,
|
||||
) -> Result<(), StoreError> {
|
||||
self.ledger.check_set_party_creditor(id, account, on)?;
|
||||
self.ensure_feature(crate::format::PARTY_CREDITORS)?;
|
||||
let line = self.append_journal(
|
||||
"party.creditor_set",
|
||||
json!({"id": id, "account_id": account, "on": on}),
|
||||
)?;
|
||||
self.ledger.set_party_creditor(id, account, on)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record or clear a party's W-9 facts. Journaled as `party.w9_set`.
|
||||
pub fn set_party_w9(
|
||||
&mut self,
|
||||
@@ -4844,6 +4868,13 @@ pub(crate) fn replay_line_into_ledger(
|
||||
.ok_or_else(|| StoreError::JournalCorrupt("party.role_set on".into()))?;
|
||||
ledger.set_party_role(get_u64(p, "id")?, role, on)?;
|
||||
}
|
||||
"party.creditor_set" => {
|
||||
let on = p
|
||||
.get("on")
|
||||
.and_then(|v| v.as_bool())
|
||||
.ok_or_else(|| StoreError::JournalCorrupt("party.creditor_set on".into()))?;
|
||||
ledger.set_party_creditor(get_u64(p, "id")?, get_u64(p, "account_id")?, on)?;
|
||||
}
|
||||
"party.w9_set" => {
|
||||
let facts: Option<ledger_core::W9Facts> = serde_json::from_value(p["facts"].clone())
|
||||
.map_err(|_| StoreError::JournalCorrupt("party.w9_set facts".into()))?;
|
||||
@@ -5569,8 +5600,8 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
||||
)?;
|
||||
}
|
||||
"role.assigned" | "party.added" | "party.updated" | "party.role_set" | "party.w9_set"
|
||||
| "party.w4_set" | "party.tin_set" | "entry.party_set" | "entry.parties_set"
|
||||
| "entry.no_party" => {
|
||||
| "party.w4_set" | "party.tin_set" | "party.creditor_set" | "entry.party_set"
|
||||
| "entry.parties_set" | "entry.no_party" => {
|
||||
// Control roles and the party directory live in the ledger
|
||||
// (rebuilt by replay); the events mirror below keeps the
|
||||
// history.
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! A party named the creditor of a liability (rules.md B-35, P3 defect
|
||||
//! 24), in the store: journaled as `party.creditor_set {id, account_id,
|
||||
//! on}`, after the journal declares the feature it needs
|
||||
//! (`party_creditors`) the first time, and never otherwise.
|
||||
//!
|
||||
//! Oracles: `Store::set_party_creditor`'s doc, `format::KNOWN_FEATURES`
|
||||
//! and `PARTY_CREDITORS`, `Store::ensure_feature`'s doc (nothing journaled
|
||||
//! when the feature is in force; books that never use it never change),
|
||||
//! and `Ledger::set_party_creditor`'s SCENARIOS. Expected values by hand.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{AccountType, Currency, Money, Side};
|
||||
use ledger_store::format::{FORMAT_SET, KNOWN_FEATURES, PARTY_CREDITORS};
|
||||
use ledger_store::journal::read_and_verify_journal;
|
||||
use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Snapshot, Store};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = Path;
|
||||
fn deref(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
let dir = std::env::temp_dir().join(format!("wb-creditors-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
}
|
||||
|
||||
fn journal(dir: &Path) -> Vec<(String, Value)> {
|
||||
read_and_verify_journal(&dir.join(JOURNAL_FILE))
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|l| (l.line_type, l.payload))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn bytes(dir: &Path) -> Vec<u8> {
|
||||
std::fs::read(dir.join(JOURNAL_FILE)).unwrap()
|
||||
}
|
||||
|
||||
/// Books with a card, the members' financing account, a member, and a
|
||||
/// posted entry.
|
||||
fn books(dir: &Path) -> (Store, u64, u64, u64, u64) {
|
||||
let mut s = Store::create(dir, Currency::USD).unwrap();
|
||||
let cash = s
|
||||
.add_account("1000", "Checking", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let card = s
|
||||
.add_account("2050", "Business Card", AccountType::Liability, None, false)
|
||||
.unwrap();
|
||||
let due = s
|
||||
.add_account(
|
||||
"2100",
|
||||
"Due to Members",
|
||||
AccountType::Liability,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let capital = s
|
||||
.add_account(
|
||||
"3000",
|
||||
"Capital — Tobin Harlow",
|
||||
AccountType::Equity,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let e = s
|
||||
.create_entry(NaiveDate::from_ymd_opt(2026, 1, 2).unwrap(), "Contribution")
|
||||
.unwrap();
|
||||
let m = Money::new(Decimal::from(500), Currency::USD);
|
||||
s.add_line(e, cash, Side::Debit, m).unwrap();
|
||||
s.add_line(e, capital, Side::Credit, m).unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
let issuer = s
|
||||
.add_party("Harbor Bank", &[ledger_core::Role::FinancialInstitution])
|
||||
.unwrap();
|
||||
let tobin = s
|
||||
.add_party("Tobin Harlow", &[ledger_core::Role::Member])
|
||||
.unwrap();
|
||||
(s, card, due, issuer, tobin)
|
||||
}
|
||||
|
||||
/// The feature is one this version reads.
|
||||
#[test]
|
||||
fn party_creditors_is_a_known_feature() {
|
||||
assert_eq!(PARTY_CREDITORS, "party_creditors");
|
||||
assert!(KNOWN_FEATURES.contains(&PARTY_CREDITORS));
|
||||
}
|
||||
|
||||
/// Store::set_party_creditor doc: the first one declares the feature (one
|
||||
/// journal.format_set keeping the format, at least 2, and every feature
|
||||
/// already declared), then journals party.creditor_set; later ones journal
|
||||
/// one line each; the books replay to the same directory, through the
|
||||
/// store and the viewer's snapshot alike.
|
||||
#[test]
|
||||
fn naming_a_creditor_declares_its_feature_once() {
|
||||
let dir = test_dir("declare");
|
||||
let (mut s, card, due, issuer, tobin) = books(&dir);
|
||||
let before = journal(&dir).len();
|
||||
s.set_party_creditor(issuer, card, true).unwrap();
|
||||
let j = journal(&dir);
|
||||
assert_eq!(j.len(), before + 2);
|
||||
assert_eq!(j[before].0, FORMAT_SET);
|
||||
assert_eq!(
|
||||
j[before].1,
|
||||
json!({"format": 2, "features": ["party_creditors"]})
|
||||
);
|
||||
assert_eq!(
|
||||
j[before + 1],
|
||||
(
|
||||
"party.creditor_set".to_string(),
|
||||
json!({"id": issuer, "account_id": card, "on": true})
|
||||
)
|
||||
);
|
||||
assert_eq!(s.journal_format().features, vec!["party_creditors"]);
|
||||
s.set_party_creditor(tobin, due, true).unwrap();
|
||||
s.set_party_creditor(tobin, due, false).unwrap();
|
||||
let j = journal(&dir);
|
||||
assert_eq!(j.len(), before + 4, "the feature is declared once");
|
||||
assert_eq!(
|
||||
j[before + 3].1,
|
||||
json!({"id": tobin, "account_id": due, "on": false})
|
||||
);
|
||||
s.set_party_creditor(tobin, due, true).unwrap();
|
||||
let live = s.ledger().clone();
|
||||
s.verify().unwrap();
|
||||
drop(s);
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert_eq!(s.ledger().creditors_of(card), vec![issuer]);
|
||||
assert_eq!(s.ledger().creditors_of(due), vec![tobin]);
|
||||
assert!(*s.ledger() == live);
|
||||
drop(s);
|
||||
let snap = Snapshot::load(&dir).unwrap();
|
||||
assert!(*snap.ledger() == live);
|
||||
assert_eq!(snap.journal_format().features, vec!["party_creditors"]);
|
||||
// Rebuilt from the journal alone.
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}")));
|
||||
}
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert!(*s.ledger() == live);
|
||||
}
|
||||
|
||||
/// A refusal journals nothing: not the line, and not the feature either.
|
||||
#[test]
|
||||
fn a_refused_creditor_journals_nothing() {
|
||||
let dir = test_dir("refused");
|
||||
let (mut s, _, _, issuer, _) = books(&dir);
|
||||
let cash = s.ledger().chart().find_by_code("1000").unwrap().id;
|
||||
let was = bytes(&dir);
|
||||
for (party, account) in [(issuer, cash), (999, cash), (issuer, 999)] {
|
||||
assert!(s.set_party_creditor(party, account, true).is_err());
|
||||
assert_eq!(bytes(&dir), was);
|
||||
}
|
||||
assert!(s.journal_format().features.is_empty());
|
||||
}
|
||||
|
||||
/// Books that never name a creditor never declare the feature: opening,
|
||||
/// verifying and rebuilding them leaves the journal byte-identical, in the
|
||||
/// format it was in.
|
||||
#[test]
|
||||
fn books_without_creditors_are_never_changed_by_the_feature() {
|
||||
let dir = test_dir("untouched");
|
||||
let (s, ..) = books(&dir);
|
||||
let format = s.journal_format().clone();
|
||||
drop(s);
|
||||
let was = bytes(&dir);
|
||||
let mut s = Store::open(&dir).unwrap();
|
||||
s.verify().unwrap();
|
||||
s.rebuild().unwrap();
|
||||
assert_eq!(s.journal_format(), &format);
|
||||
drop(s);
|
||||
assert_eq!(bytes(&dir), was);
|
||||
assert!(
|
||||
journal(&dir)
|
||||
.iter()
|
||||
.all(|(t, p)| t != FORMAT_SET || p["features"] == json!([]))
|
||||
);
|
||||
}
|
||||
|
||||
/// A line that can't replay is refused on open, with the database and
|
||||
/// without it: `on` not a bool, an unknown account, an account that isn't
|
||||
/// a liability.
|
||||
#[test]
|
||||
fn corrupt_creditor_lines_are_refused_on_open() {
|
||||
for (name, payload) in [
|
||||
(
|
||||
"on-not-bool",
|
||||
json!({"id": 1, "account_id": 2, "on": "yes"}),
|
||||
),
|
||||
(
|
||||
"unknown-account",
|
||||
json!({"id": 1, "account_id": 99, "on": true}),
|
||||
),
|
||||
(
|
||||
"not-a-liability",
|
||||
json!({"id": 1, "account_id": 1, "on": true}),
|
||||
),
|
||||
] {
|
||||
let dir = test_dir(name);
|
||||
let (mut s, card, ..) = books(&dir);
|
||||
// Declare the feature properly first, so only the line is wrong.
|
||||
let issuer = s.ledger().party_by_name("Harbor Bank").unwrap().id;
|
||||
s.set_party_creditor(issuer, card, true).unwrap();
|
||||
drop(s);
|
||||
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
|
||||
w.append("party.creditor_set", payload.clone()).unwrap();
|
||||
drop(w);
|
||||
assert!(Store::open(&dir).is_err(), "{name}: {payload} opened");
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}")));
|
||||
}
|
||||
assert!(
|
||||
Store::open(&dir).is_err(),
|
||||
"{name}: {payload} opened without the db"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1940,7 +1940,7 @@ fn every_figure_of(l: &ledger_core::Ledger) -> (usize, usize) {
|
||||
.map(|_| ()),
|
||||
);
|
||||
}
|
||||
let money = ledger_core::parties::money_accounts(l, &cash);
|
||||
let money = ledger_core::parties::money_accounts(l, &cash, &[]);
|
||||
let party_members: Vec<ledger_core::PartyMember> = l
|
||||
.parties()
|
||||
.map(|p| ledger_core::PartyMember {
|
||||
@@ -1953,14 +1953,15 @@ fn every_figure_of(l: &ledger_core::Ledger) -> (usize, usize) {
|
||||
for id in 1..l.next_entry_id() {
|
||||
let Some(e) = l.get_entry(id) else { continue };
|
||||
tally(ledger_core::parties::entry_flow(e, &money).map(|_| ()));
|
||||
tally(ledger_core::shares::share_target(e, &money).map(|_| ()));
|
||||
let _ = ledger_core::shares::resolve(e, l.entry_parties(id), &money, &party_members);
|
||||
tally(ledger_core::shares::target(l, id, l.entry_parties(id), &money).map(|_| ()));
|
||||
tally(ledger_core::parties::flow(l, id, l.entry_parties(id), &money).map(|_| ()));
|
||||
let _ = ledger_core::shares::resolve(l, id, l.entry_parties(id), &money, &party_members);
|
||||
let _ = e.validate(l.chart());
|
||||
}
|
||||
for p in l.parties() {
|
||||
for (start, end) in spans {
|
||||
let _ =
|
||||
ledger_core::parties::activity_with(l, p.id, (start, end), &cash, &party_members);
|
||||
ledger_core::parties::activity_with(l, p.id, (start, end), &money, &party_members);
|
||||
}
|
||||
}
|
||||
for inv in l.invoices() {
|
||||
|
||||
Reference in new issue
Block a user