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 })
|
||||
}
|
||||
Reference in new issue
Block a user