Shared parties with exact shares, member capital statements, TINs for any party, K-1 worksheet
Entries may name several parties, each with an exact amount, a percent, or a share derived from the members' capital lines or [[company.members]] shares (entry.parties_set); shares always sum exactly. The 1099 worksheet counts a vendor's share. A member's party page is their partner capital statement, from the same ledger_core::k1 source as the CPA package. books.set_tin stores any party's TIN (members' SSNs for K-1s) in the vault (party.tin_set, last four in the books). The CPA package carries a K-1 member worksheet (k1-members.csv, with a members' total that ties to Schedule L) and the 1099 worksheet with full TINs, saved outside the books only. files_jointly_with notes spouses' two K-1s land on one 1040. Adversarial review fixes: reversed contributions no longer inflate K-1 contributions and distributions; a K-1 row never takes another party's TIN; TIN detection covers dot/slash/dash separators; refusals redact TINs; vendor tagging can't silently replace shared parties; reversals follow re-apportioning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
1 parent
ef00ccc242
commit
e1b2509c22
32 files changed
+7232
-375
No files matched your search
@@ -399,7 +399,7 @@ pub fn find(b: &Books) -> Vec<Attention> {
|
||||
e.status == EntryStatus::Posted
|
||||
&& e.date >= year_start
|
||||
&& e.date <= today
|
||||
&& e.party_id.is_none()
|
||||
&& e.parties.is_empty()
|
||||
&& b.ledger.entry_no_party(e.id).is_none()
|
||||
&& !matches!(e.origin, "Period close" | "Depreciation" | "Amortization")
|
||||
})
|
||||
|
||||
+422
-36
@@ -47,15 +47,37 @@ pub struct EntryInfo {
|
||||
/// entry doesn't touch cash.
|
||||
pub cash_effect: Decimal,
|
||||
/// The counterparty's name: its directory party's label (see
|
||||
/// [`PartyInfo`] for which entries are a party's), else its vendor's
|
||||
/// or invoice customer's name.
|
||||
/// [`PartyInfo`] for which entries are a party's; several parties'
|
||||
/// labels joined with " & "), else its vendor's or invoice customer's
|
||||
/// name.
|
||||
pub party: Option<String>,
|
||||
/// The directory party the entry is with (see [`PartyInfo`]).
|
||||
/// The directory party the entry is with, when it names exactly one
|
||||
/// (see [`PartyInfo`]).
|
||||
pub party_id: Option<PartyId>,
|
||||
/// Every party the entry names, in order, each with its share.
|
||||
pub parties: Vec<EntryParty>,
|
||||
/// Lower-cased text for search: description, accounts, vendor, amount.
|
||||
pub haystack: String,
|
||||
}
|
||||
|
||||
/// One party an entry names, with its share.
|
||||
///
|
||||
/// ```text
|
||||
/// SOURCE: ledger_core::shares (resolve), with Books::cash_accounts 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.
|
||||
/// ```
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EntryParty {
|
||||
pub id: PartyId,
|
||||
pub label: String,
|
||||
pub share: ledger_core::Share,
|
||||
pub resolved: Option<Decimal>,
|
||||
}
|
||||
|
||||
/// One fixed asset's trail through the journal and the entries.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct AssetTrail {
|
||||
@@ -99,12 +121,14 @@ impl AssetTrail {
|
||||
/// module's rule (every number from ledger-core; this indexes and
|
||||
/// sums); DECISION (the year and the payment accounts, below).
|
||||
/// DEFINITIONS:
|
||||
/// The party's entries: Ledger::entry_party names it (core tags hand
|
||||
/// tags, vendor tags, bills, invoices, their payments, reversals).
|
||||
/// The party's entries: Ledger::entry_parties names it, alone or among
|
||||
/// others (core tags hand tags, vendor tags, bills, invoices, their
|
||||
/// payments, reversals).
|
||||
/// The year: the calendar year of `today`, Jan 1 through today (the
|
||||
/// year 1099s are counted in).
|
||||
/// Money: ledger_core::parties::activity with Books::cash_accounts
|
||||
/// (the same numbers books.party reports).
|
||||
/// Money: Books::activity (ledger_core::parties::activity_with with
|
||||
/// Books::cash_accounts 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).
|
||||
@@ -125,7 +149,8 @@ impl AssetTrail {
|
||||
/// or (older runs, no party on the line) whose employee_name is the
|
||||
/// party's name ignoring case and surrounding space.
|
||||
/// 7. events: indexes into Books::journal of party.* events with its
|
||||
/// id, entry.party_set naming it, and its records' vendor.added,
|
||||
/// id, entry.party_set or entry.parties_set naming it, and its
|
||||
/// records' vendor.added,
|
||||
/// customer.added, vendor.1099_set, and entry.vendor_set events.
|
||||
/// ```
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -201,8 +226,24 @@ pub struct Books {
|
||||
pub entries: Vec<EntryInfo>,
|
||||
pub entry_index: HashMap<EntryId, usize>,
|
||||
pub cash_accounts: Vec<AccountId>,
|
||||
/// The `[[company.members]]` that are parties (role member, matched
|
||||
/// by name), with their capital accounts: what derived party shares
|
||||
/// and member capital statements read
|
||||
/// (ledger_config::Company::party_members).
|
||||
pub members: Vec<ledger_core::PartyMember>,
|
||||
/// `[books] financing_accounts`, resolved (empty when not set).
|
||||
pub financing_accounts: Vec<AccountId>,
|
||||
/// The cash accounts the member capital statement hands
|
||||
/// ledger_core::k1 to tell cash from property: `[books]
|
||||
/// cash_accounts`, resolved; when that is empty, core's fallback
|
||||
/// (ledger_core::parties::money_accounts: every asset account but
|
||||
/// receivables), so the viewer reads money as core does. Without the
|
||||
/// setting every contribution therefore reads as cash: property can't
|
||||
/// be told from cash without knowing which accounts hold cash.
|
||||
/// Kept apart from `cash_accounts`, whose name-based guess the Cash
|
||||
/// page, dashboard and charts keep (changing those is out of scope:
|
||||
/// "every asset" would put equipment on the Cash page).
|
||||
pub k1_cash_accounts: Vec<AccountId>,
|
||||
/// `[books] investing_accounts`, resolved (empty when not set).
|
||||
pub investing_accounts: Vec<AccountId>,
|
||||
pub card_accounts: Vec<AccountId>,
|
||||
@@ -249,16 +290,20 @@ 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 (paid, received) and
|
||||
/// SOURCE: ledger_core::parties::activity_with (paid, received) and
|
||||
/// ledger_core::parties::entry_flow (what for), with
|
||||
/// Books::cash_accounts, as PartyInfo uses them.
|
||||
/// Books::cash_accounts and Books::members, as PartyInfo uses
|
||||
/// them.
|
||||
/// INVARIANTS:
|
||||
/// 1. paid, received: activity(ledger, party, span, cash accounts)'s,
|
||||
/// exactly.
|
||||
/// 1. paid, received: Books::activity(party, span)'s, exactly.
|
||||
/// 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
|
||||
@@ -283,6 +328,102 @@ impl PartyFlow {
|
||||
}
|
||||
}
|
||||
|
||||
/// What moved a member's capital account in a span, by kind
|
||||
/// ([`Books::capital_statement`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CapitalKind {
|
||||
/// Cash put in, or paid for the company (ledger_core::k1's cash part).
|
||||
CashContribution,
|
||||
/// Property put in, at the value booked: fair market value
|
||||
/// (ledger_core::k1's property part).
|
||||
PropertyContribution,
|
||||
/// A period close allocating their share of net income (or loss).
|
||||
IncomeShare,
|
||||
/// Taken out.
|
||||
Distribution,
|
||||
}
|
||||
|
||||
/// One entry's effect on a member's capital, split by kind.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CapitalLine {
|
||||
pub entry: EntryId,
|
||||
pub date: NaiveDate,
|
||||
pub description: String,
|
||||
pub kind: CapitalKind,
|
||||
/// Signed: positive raises their capital, negative lowers it.
|
||||
pub amount: Decimal,
|
||||
}
|
||||
|
||||
/// A member's capital account statement over a span: the partner capital
|
||||
/// statement, read from ledger_core::k1 (the CPA package's K-1 worksheet).
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: a member's party page answers "what is my stake?" as a partner
|
||||
/// capital statement does (owner, 2026-10-15), with the same
|
||||
/// numbers the CPA's K-1 worksheet carries: one source of truth.
|
||||
/// SOURCE: ledger_core::k1::k1_detail (members_equity's roll-forward,
|
||||
/// split into cash and property); ledger_config::Company::members
|
||||
/// ([[company.members]], all of them, in order, as the CPA
|
||||
/// package builds its MemberCapital list); Books::k1_cash_accounts;
|
||||
/// Books::financing_accounts; DECISION (the due-to accounts, and
|
||||
/// the cash fallback, below).
|
||||
/// DEFINITIONS:
|
||||
/// row: k1_detail(span, members, k1_cash_accounts).rows[i], i this
|
||||
/// member's position in [[company.members]] (matched by capital
|
||||
/// account).
|
||||
/// INVARIANTS:
|
||||
/// 1. opening = row.beginning; contributions_cash =
|
||||
/// row.contributions_cash; contributions_property =
|
||||
/// row.contributions_property; income_share = row.net_income;
|
||||
/// distributions_taken = row.distributions; other = row.other;
|
||||
/// closing = row.ending. Opening and closing include the member's
|
||||
/// share of earnings not yet closed into capital (members_equity
|
||||
/// INVARIANT 3), and income_share is their share of the span's net
|
||||
/// income whether or not a period close has moved it yet.
|
||||
/// 2. closing = opening + contributions_cash + contributions_property +
|
||||
/// income_share − distributions_taken + other, exactly.
|
||||
/// 3. lines: k1_detail's lines for this member. Σ CashContribution =
|
||||
/// contributions_cash, Σ PropertyContribution =
|
||||
/// contributions_property, −Σ Distribution = distributions_taken;
|
||||
/// IncomeShare lines are the closes in the span (they need not sum to
|
||||
/// income_share: income not yet closed has no entry on capital).
|
||||
/// 4. share: the member's [[company.members]] share (a fraction).
|
||||
/// 5. due_to_members: the balance at span.1 of the liabilities the
|
||||
/// members advance: [books] financing_accounts (Books::
|
||||
/// financing_accounts, its liability accounts) when set; only when it
|
||||
/// is empty, the liability accounts whose name says "due to" or
|
||||
/// "owed to" (DECISION: a name is a guess; the setting is the books'
|
||||
/// word). owed_to_member: this member's part of it by the members'
|
||||
/// shares (ledger_core::periods::allocate over every member, so the
|
||||
/// parts sum to the balance).
|
||||
/// 6. None when the party isn't a member, a [[company.members]]
|
||||
/// account isn't in the chart, or k1_detail refuses (shares not
|
||||
/// summing to 1, accounts not distinct equity accounts).
|
||||
/// ```
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CapitalStatement {
|
||||
pub party: PartyId,
|
||||
pub capital: AccountId,
|
||||
pub distributions: Option<AccountId>,
|
||||
pub span: (NaiveDate, NaiveDate),
|
||||
pub share: Decimal,
|
||||
pub opening: Decimal,
|
||||
pub contributions_cash: Decimal,
|
||||
pub contributions_property: Decimal,
|
||||
pub income_share: Decimal,
|
||||
/// Taken out, as a positive amount.
|
||||
pub distributions_taken: Decimal,
|
||||
/// Any other change in their capital (members_equity's "other").
|
||||
pub other: Decimal,
|
||||
pub closing: Decimal,
|
||||
pub lines: Vec<CapitalLine>,
|
||||
pub due_to_members: Decimal,
|
||||
pub owed_to_member: Decimal,
|
||||
/// `[[company.members]] files_jointly_with`: the member they file a
|
||||
/// joint individual return with.
|
||||
pub files_jointly_with: Option<String>,
|
||||
}
|
||||
|
||||
/// One month of a party's money, for the out-versus-in chart.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PartyMonth {
|
||||
@@ -396,12 +537,27 @@ impl Books {
|
||||
)?;
|
||||
let receipt_threshold = policy.threshold();
|
||||
let cash_accounts = cash_accounts(&ledger, &settings);
|
||||
let members = settings.company.party_members(&ledger);
|
||||
let share_money = ledger_core::parties::money_accounts(&ledger, &cash_accounts);
|
||||
let codes = |cs: &[String]| -> Vec<AccountId> {
|
||||
cs.iter()
|
||||
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
|
||||
.collect()
|
||||
};
|
||||
let financing_accounts = codes(&settings.financing_accounts);
|
||||
let k1_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();
|
||||
all.sort_unstable();
|
||||
all
|
||||
} else {
|
||||
configured
|
||||
}
|
||||
};
|
||||
let investing_accounts = codes(&settings.investing_accounts);
|
||||
let card_accounts: Vec<AccountId> = ledger
|
||||
.chart()
|
||||
@@ -507,10 +663,32 @@ impl Books {
|
||||
// Core tags every entry with its counterparty (hand tags,
|
||||
// vendor tags, bills, invoices, their payments, reversals).
|
||||
let party_id = ledger.entry_party(id);
|
||||
let party = party_id
|
||||
.and_then(|p| ledger.get_party(p))
|
||||
.map(|p| p.label().to_string())
|
||||
.or_else(|| vendor.clone());
|
||||
let shares = ledger.entry_parties(id);
|
||||
let resolved = ledger_core::shares::resolve(e, shares, &share_money, &members).ok();
|
||||
let parties: Vec<EntryParty> = shares
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, s)| EntryParty {
|
||||
id: s.party,
|
||||
label: ledger
|
||||
.get_party(s.party)
|
||||
.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),
|
||||
})
|
||||
.collect();
|
||||
let party = if parties.is_empty() {
|
||||
vendor.clone()
|
||||
} else {
|
||||
Some(
|
||||
parties
|
||||
.iter()
|
||||
.map(|p| p.label.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" & "),
|
||||
)
|
||||
};
|
||||
let mut haystack = format!(
|
||||
"{} {} #{} {}",
|
||||
e.description(),
|
||||
@@ -527,11 +705,11 @@ impl Books {
|
||||
haystack.push(' ');
|
||||
haystack.push_str(v);
|
||||
}
|
||||
if let Some(p) = party_id.and_then(|p| ledger.get_party(p))
|
||||
&& p.display_name.is_some()
|
||||
{
|
||||
haystack.push(' ');
|
||||
haystack.push_str(&p.name);
|
||||
for p in parties.iter().filter_map(|x| ledger.get_party(x.id)) {
|
||||
if p.display_name.is_some() {
|
||||
haystack.push(' ');
|
||||
haystack.push_str(&p.name);
|
||||
}
|
||||
}
|
||||
entries.push(EntryInfo {
|
||||
id,
|
||||
@@ -550,6 +728,7 @@ impl Books {
|
||||
cash_effect,
|
||||
party,
|
||||
party_id,
|
||||
parties,
|
||||
haystack: haystack.to_lowercase(),
|
||||
});
|
||||
}
|
||||
@@ -574,7 +753,9 @@ impl Books {
|
||||
entries,
|
||||
entry_index,
|
||||
cash_accounts,
|
||||
members,
|
||||
financing_accounts,
|
||||
k1_cash_accounts,
|
||||
investing_accounts,
|
||||
card_accounts,
|
||||
fresh,
|
||||
@@ -796,12 +977,7 @@ impl Books {
|
||||
})
|
||||
.collect();
|
||||
for (id, info) in out.iter_mut() {
|
||||
let a = ledger_core::parties::activity(
|
||||
l,
|
||||
*id,
|
||||
(year_start, self.today),
|
||||
&self.cash_accounts,
|
||||
);
|
||||
let a = self.activity(*id, (year_start, self.today));
|
||||
info.entries = self
|
||||
.entries
|
||||
.iter()
|
||||
@@ -874,6 +1050,20 @@ impl Books {
|
||||
} else {
|
||||
match t {
|
||||
"entry.party_set" => u(p, "party_id"),
|
||||
"entry.parties_set" => {
|
||||
let named = p
|
||||
.get("parties")
|
||||
.and_then(|v| v.as_array())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|x| u(x, "party_id"));
|
||||
for pid in named {
|
||||
if let Some(info) = out.get_mut(&pid) {
|
||||
info.events.push(i);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
"vendor.added" => u(p, "id").and_then(|v| l.party_of_vendor(v)),
|
||||
"customer.added" => u(p, "id").and_then(|c| l.party_of_customer(c)),
|
||||
"vendor.1099_set" | "entry.vendor_set" => {
|
||||
@@ -1192,7 +1382,7 @@ impl Books {
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| e.status == EntryStatus::Posted && e.date <= self.today)
|
||||
.filter(|e| party.is_none_or(|p| e.party_id == Some(p)))
|
||||
.filter(|e| party.is_none_or(|p| e.parties.iter().any(|x| x.id == p)))
|
||||
.map(|e| e.date.year())
|
||||
.filter(|&x| x < y - 1)
|
||||
.collect();
|
||||
@@ -1214,7 +1404,7 @@ impl Books {
|
||||
with_breakdown: bool,
|
||||
) -> PartyFlow {
|
||||
let l = &self.ledger;
|
||||
let a = ledger_core::parties::activity(l, party, span, &self.cash_accounts);
|
||||
let a = self.activity(party, span);
|
||||
let posted: Vec<&ledger_core::JournalEntry> = a
|
||||
.entries
|
||||
.iter()
|
||||
@@ -1228,7 +1418,31 @@ impl Books {
|
||||
let money = ledger_core::parties::money_accounts(l, &self.cash_accounts);
|
||||
let mut by: BTreeMap<AccountId, Decimal> = BTreeMap::new();
|
||||
for e in &posted {
|
||||
for (account, amount) in ledger_core::parties::entry_flow(e, &money).what_for {
|
||||
let flow = ledger_core::parties::entry_flow(e, &money);
|
||||
let share = if l.entry_parties(e.id()).len() == 1 {
|
||||
flow.net
|
||||
} else {
|
||||
self.party_share(e, party)
|
||||
};
|
||||
// A member's own capital lines are what their share was
|
||||
// for, when they account for it exactly.
|
||||
let own: Vec<(AccountId, Decimal)> = self
|
||||
.member(party)
|
||||
.map(|m| {
|
||||
flow.what_for
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|(a, _)| *a == m.capital || Some(*a) == m.distributions)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let parts =
|
||||
if !own.is_empty() && own.iter().map(|(_, v)| *v).sum::<Decimal>() == share {
|
||||
own
|
||||
} else {
|
||||
share_of_what_for(&flow, share)
|
||||
};
|
||||
for (account, amount) in parts {
|
||||
*by.entry(account).or_default() += amount;
|
||||
}
|
||||
}
|
||||
@@ -1243,6 +1457,153 @@ impl Books {
|
||||
}
|
||||
}
|
||||
|
||||
/// ledger_core::parties::activity_with over `span`, with the books'
|
||||
/// cash accounts and members: the one source of a party's money.
|
||||
pub fn activity(
|
||||
&self,
|
||||
party: PartyId,
|
||||
span: (NaiveDate, NaiveDate),
|
||||
) -> ledger_core::parties::Activity {
|
||||
ledger_core::parties::activity_with(
|
||||
&self.ledger,
|
||||
party,
|
||||
span,
|
||||
&self.cash_accounts,
|
||||
&self.members,
|
||||
)
|
||||
}
|
||||
|
||||
/// 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(
|
||||
&self,
|
||||
party: PartyId,
|
||||
span: (NaiveDate, NaiveDate),
|
||||
) -> Option<CapitalStatement> {
|
||||
use ledger_core::k1::K1Kind;
|
||||
let m = *self.member(party)?;
|
||||
let l = &self.ledger;
|
||||
let members = self.member_capitals()?;
|
||||
let i = members
|
||||
.iter()
|
||||
.position(|x| x.capital_account == m.capital)?;
|
||||
let detail =
|
||||
ledger_core::k1::k1_detail(l, span.0, span.1, &members, &self.k1_cash_accounts).ok()?;
|
||||
let row = detail.rows.get(i)?;
|
||||
let lines: Vec<CapitalLine> = detail
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|x| x.member == i)
|
||||
.filter_map(|x| {
|
||||
let e = l.get_entry(x.entry)?;
|
||||
Some(CapitalLine {
|
||||
entry: x.entry,
|
||||
date: e.date(),
|
||||
description: e.description().to_string(),
|
||||
kind: match x.kind {
|
||||
K1Kind::CashContribution => CapitalKind::CashContribution,
|
||||
K1Kind::PropertyContribution => CapitalKind::PropertyContribution,
|
||||
K1Kind::Distribution => CapitalKind::Distribution,
|
||||
K1Kind::IncomeClose => CapitalKind::IncomeShare,
|
||||
},
|
||||
amount: x.amount,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let due_accounts: Vec<AccountId> = if self.financing_accounts.is_empty() {
|
||||
l.chart()
|
||||
.all()
|
||||
.filter(|a| {
|
||||
a.account_type == AccountType::Liability && {
|
||||
let n = a.name.to_lowercase();
|
||||
n.contains("due to") || n.contains("owed to")
|
||||
}
|
||||
})
|
||||
.map(|a| a.id)
|
||||
.collect()
|
||||
} else {
|
||||
self.financing_accounts
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&a| {
|
||||
l.chart()
|
||||
.get(a)
|
||||
.is_some_and(|a| a.account_type == AccountType::Liability)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let due_to_members: Decimal = due_accounts
|
||||
.iter()
|
||||
.map(|&a| self.balance_on(a, span.1))
|
||||
.sum();
|
||||
let shares: Vec<Decimal> = members.iter().map(|x| x.share).collect();
|
||||
let owed_to_member = ledger_core::periods::allocate(due_to_members, &shares)[i];
|
||||
Some(CapitalStatement {
|
||||
party,
|
||||
capital: m.capital,
|
||||
distributions: m.distributions,
|
||||
span,
|
||||
share: row.share,
|
||||
opening: row.beginning,
|
||||
contributions_cash: row.contributions_cash,
|
||||
contributions_property: row.contributions_property,
|
||||
income_share: row.net_income,
|
||||
distributions_taken: row.distributions,
|
||||
other: row.other,
|
||||
closing: row.ending,
|
||||
lines,
|
||||
due_to_members,
|
||||
owed_to_member,
|
||||
files_jointly_with: self
|
||||
.settings
|
||||
.company
|
||||
.members
|
||||
.get(i)
|
||||
.and_then(|x| x.files_jointly_with.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Every `[[company.members]]`, in order, as ledger_core's
|
||||
/// MemberCapital (the CPA package's list); None when an account isn't
|
||||
/// in the chart or there are no members.
|
||||
pub fn member_capitals(&self) -> Option<Vec<ledger_core::MemberCapital>> {
|
||||
let chart = self.ledger.chart();
|
||||
let members = &self.settings.company.members;
|
||||
if members.is_empty() {
|
||||
return None;
|
||||
}
|
||||
members
|
||||
.iter()
|
||||
.map(|m| {
|
||||
Some(ledger_core::MemberCapital {
|
||||
name: m.name.clone(),
|
||||
capital_account: chart.find_by_code(&m.capital_account)?.id,
|
||||
distribution_account: match &m.distribution_account {
|
||||
Some(c) => Some(chart.find_by_code(c)?.id),
|
||||
None => None,
|
||||
},
|
||||
share: m.share,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The member a party is, if it is one of `[[company.members]]`.
|
||||
pub fn member(&self, party: PartyId) -> Option<&ledger_core::PartyMember> {
|
||||
self.members.iter().find(|m| m.party == party)
|
||||
}
|
||||
|
||||
/// A party's 1099 worksheet line for a calendar year and that year's
|
||||
/// threshold: ledger-core's vendor_1099_report_with (cash and
|
||||
/// financing accounts) line for its vendor record; None without a
|
||||
@@ -1269,12 +1630,7 @@ impl Books {
|
||||
while m <= span.1 {
|
||||
let end = (m + chrono::Months::new(1) - chrono::Days::new(1)).min(span.1);
|
||||
let start = m.max(span.0);
|
||||
let a = ledger_core::parties::activity(
|
||||
&self.ledger,
|
||||
party,
|
||||
(start, end),
|
||||
&self.cash_accounts,
|
||||
);
|
||||
let a = self.activity(party, (start, end));
|
||||
out.push(PartyMonth {
|
||||
month: m,
|
||||
paid: a.paid,
|
||||
@@ -1291,6 +1647,36 @@ impl Books {
|
||||
}
|
||||
}
|
||||
|
||||
/// An entry flow's what_for apportioned to one party's `share` of its net:
|
||||
/// the whole of it when the share is the net; else each account's amount
|
||||
/// times share / net, rounded toward zero to the cent, the cents left over
|
||||
/// going to the first account, so the parts sum to `share` exactly.
|
||||
pub fn share_of_what_for(
|
||||
flow: &ledger_core::parties::EntryFlow,
|
||||
share: Decimal,
|
||||
) -> Vec<(AccountId, Decimal)> {
|
||||
use rust_decimal::RoundingStrategy::ToZero;
|
||||
if share == flow.net || flow.what_for.is_empty() {
|
||||
return flow.what_for.clone();
|
||||
}
|
||||
if flow.net.is_zero() || share.is_zero() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut parts: Vec<(AccountId, Decimal)> = flow
|
||||
.what_for
|
||||
.iter()
|
||||
.map(|(a, v)| {
|
||||
(
|
||||
*a,
|
||||
(*v * share / flow.net).round_dp_with_strategy(2, ToZero),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let left = share - parts.iter().map(|(_, v)| *v).sum::<Decimal>();
|
||||
parts[0].1 += left;
|
||||
parts
|
||||
}
|
||||
|
||||
/// Every registered asset's trail, from the journal (depreciation,
|
||||
/// disposal, its own events) and the entries (the purchase).
|
||||
fn asset_trails(ledger: &Ledger, journal: &[JournalLine]) -> HashMap<AssetId, AssetTrail> {
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
//! A member's capital account statement, on their party page: the partner
|
||||
//! capital statement (opening balance, contributions of cash and of
|
||||
//! property, share of net income, distributions, closing balance), their
|
||||
//! share, and their part of what the company owes the members jointly.
|
||||
//! Every number is [`crate::books::CapitalStatement`]'s (which reads
|
||||
//! ledger_core::k1, the CPA package's K-1 worksheet); this module only
|
||||
//! lays it out.
|
||||
|
||||
use crate::books::{Books, CapitalKind, CapitalStatement};
|
||||
use crate::fmt;
|
||||
use crate::nav::Route;
|
||||
use crate::theme::{self, pal};
|
||||
use crate::widgets;
|
||||
use egui::{Align, Layout, RichText, Sense, Ui, vec2};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
/// What a capital line is, in words.
|
||||
pub fn kind_label(k: CapitalKind) -> &'static str {
|
||||
match k {
|
||||
CapitalKind::CashContribution => "Contribution — cash",
|
||||
CapitalKind::PropertyContribution => "Contribution — property",
|
||||
CapitalKind::IncomeShare => "Share of net income",
|
||||
CapitalKind::Distribution => "Distribution",
|
||||
}
|
||||
}
|
||||
|
||||
/// A share as a percent: 0.5 → "50%", 0.3333 → "33.33%".
|
||||
pub fn percent(share: Decimal) -> String {
|
||||
format!("{}%", (share * Decimal::ONE_HUNDRED).normalize())
|
||||
}
|
||||
|
||||
/// One statement row: an indented label, an amount column, and an
|
||||
/// optional total column; `strong` for the balances.
|
||||
fn line(
|
||||
ui: &mut Ui,
|
||||
label: &str,
|
||||
indent: f32,
|
||||
amount: Option<String>,
|
||||
total: Option<String>,
|
||||
strong: bool,
|
||||
) {
|
||||
let p = pal(ui.ctx());
|
||||
let col = 130.0;
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(indent);
|
||||
let font = if strong {
|
||||
theme::semibold(14.0)
|
||||
} else {
|
||||
theme::regular(14.0)
|
||||
};
|
||||
ui.label(RichText::new(label).font(font));
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
let t = total.unwrap_or_default();
|
||||
widgets::amount_cell(ui, &t, col, p.text, strong, false);
|
||||
let a = amount.unwrap_or_default();
|
||||
widgets::amount_cell(ui, &a, col, p.text, false, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A short rule over the amount column (`double`: two, under a total).
|
||||
fn underline(ui: &mut Ui, total_col: bool, double: bool) {
|
||||
let p = pal(ui.ctx());
|
||||
let col = 130.0;
|
||||
let w = ui.available_width();
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(w, if double { 5.0 } else { 3.0 }), Sense::hover());
|
||||
let right = if total_col {
|
||||
rect.right()
|
||||
} else {
|
||||
rect.right() - col - ui.spacing().item_spacing.x
|
||||
};
|
||||
let left = right - col + 30.0;
|
||||
let stroke = egui::Stroke::new(1.0, p.text);
|
||||
let y = rect.top() + 1.0;
|
||||
ui.painter()
|
||||
.line_segment([egui::pos2(left, y), egui::pos2(right, y)], stroke);
|
||||
if double {
|
||||
ui.painter().line_segment(
|
||||
[egui::pos2(left, y + 3.0), egui::pos2(right, y + 3.0)],
|
||||
stroke,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The member's capital account card.
|
||||
pub fn capital_card(
|
||||
ui: &mut Ui,
|
||||
b: &Books,
|
||||
st: &CapitalStatement,
|
||||
name: &str,
|
||||
go: &mut Option<Route>,
|
||||
) {
|
||||
let p = pal(ui.ctx());
|
||||
let account = b.account_name(st.capital);
|
||||
let (open_account, ()) = widgets::titled_card(
|
||||
ui,
|
||||
"Capital account",
|
||||
|ui| {
|
||||
widgets::link(
|
||||
ui,
|
||||
RichText::new(&account)
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.accent),
|
||||
)
|
||||
.on_hover_text("Open the capital account's register")
|
||||
.clicked()
|
||||
},
|
||||
|ui| {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(RichText::new(name).font(theme::semibold(15.0)));
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"Statement of partner's capital · {} – {}",
|
||||
fmt::date(st.span.0),
|
||||
fmt::date(st.span.1)
|
||||
))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
if let Some(j) = &st.files_jointly_with {
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"Files a joint return with {j}: a K-1 each, both on one Form 1040"
|
||||
))
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
let amt = |d: Decimal| Some(fmt::accounting(d));
|
||||
line(
|
||||
ui,
|
||||
&format!("Capital, {}", fmt::date(st.span.0)),
|
||||
0.0,
|
||||
None,
|
||||
Some(format!("${}", fmt::accounting(st.opening))),
|
||||
true,
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new("Add")
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
line(
|
||||
ui,
|
||||
"Contributions of cash",
|
||||
16.0,
|
||||
amt(st.contributions_cash),
|
||||
None,
|
||||
false,
|
||||
);
|
||||
line(
|
||||
ui,
|
||||
"Contributions of property (at fair market value)",
|
||||
16.0,
|
||||
amt(st.contributions_property),
|
||||
None,
|
||||
false,
|
||||
);
|
||||
line(
|
||||
ui,
|
||||
&format!("Share of net income ({})", percent(st.share)),
|
||||
16.0,
|
||||
amt(st.income_share),
|
||||
None,
|
||||
false,
|
||||
);
|
||||
ui.label(
|
||||
RichText::new("Less")
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
line(
|
||||
ui,
|
||||
"Distributions",
|
||||
16.0,
|
||||
Some(fmt::accounting(-st.distributions_taken)),
|
||||
None,
|
||||
false,
|
||||
);
|
||||
if !st.other.is_zero() {
|
||||
line(ui, "Other changes", 16.0, amt(st.other), None, false);
|
||||
}
|
||||
underline(ui, false, false);
|
||||
let change = st.contributions_cash + st.contributions_property + st.income_share
|
||||
- st.distributions_taken
|
||||
+ st.other;
|
||||
line(
|
||||
ui,
|
||||
"Net change in capital",
|
||||
0.0,
|
||||
None,
|
||||
Some(fmt::accounting(change)),
|
||||
false,
|
||||
);
|
||||
underline(ui, true, false);
|
||||
line(
|
||||
ui,
|
||||
&format!("Capital, {}", fmt::date(st.span.1)),
|
||||
0.0,
|
||||
None,
|
||||
Some(format!("${}", fmt::accounting(st.closing))),
|
||||
true,
|
||||
);
|
||||
underline(ui, true, true);
|
||||
if !st.income_share.is_zero()
|
||||
&& !st.lines.iter().any(|l| l.kind == CapitalKind::IncomeShare)
|
||||
{
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"No period closed in this span: the share of net income is earned but not yet closed into the capital account.",
|
||||
)
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.faint),
|
||||
);
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
widgets::rule(ui);
|
||||
ui.add_space(6.0);
|
||||
line(
|
||||
ui,
|
||||
"Share of profit, loss, and capital",
|
||||
0.0,
|
||||
None,
|
||||
Some(percent(st.share)),
|
||||
false,
|
||||
);
|
||||
let owed = format!(
|
||||
"Owed to them on Due to Shareholders ({} of {})",
|
||||
percent(st.share),
|
||||
fmt::dollars(st.due_to_members)
|
||||
);
|
||||
line(
|
||||
ui,
|
||||
&owed,
|
||||
0.0,
|
||||
None,
|
||||
Some(fmt::dollars(st.owed_to_member)),
|
||||
false,
|
||||
);
|
||||
if !st.lines.is_empty() {
|
||||
ui.add_space(10.0);
|
||||
ui.collapsing(
|
||||
RichText::new(format!(
|
||||
"{} in this span",
|
||||
fmt::count(st.lines.len(), "capital entry", "capital entries")
|
||||
))
|
||||
.font(theme::regular(12.5)),
|
||||
|ui| {
|
||||
for l in &st.lines {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new(fmt::date(l.date))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
if widgets::link(
|
||||
ui,
|
||||
RichText::new(format!("#{} {}", l.entry, l.description))
|
||||
.font(theme::regular(12.5)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
*go = Some(Route::Entry(l.entry));
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(kind_label(l.kind))
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.faint),
|
||||
);
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
widgets::amount_cell(
|
||||
ui,
|
||||
&fmt::accounting(l.amount),
|
||||
130.0,
|
||||
p.text,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
if open_account {
|
||||
*go = Some(Route::Account(st.capital));
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
|
||||
};
|
||||
widgets::pill(ui, e.origin, p.muted, p.hover);
|
||||
if let Some(v) = &e.vendor
|
||||
&& e.party_id.is_none()
|
||||
&& e.parties.is_empty()
|
||||
{
|
||||
widgets::pill(ui, &format!("{} {v}", icon::STOREFRONT), p.muted, p.hover);
|
||||
}
|
||||
@@ -87,27 +87,42 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
|
||||
shell.go(Route::Entry(r));
|
||||
}
|
||||
}
|
||||
if let Some(party) = e.party_id.and_then(|id| b.ledger.get_party(id)) {
|
||||
let several = e.parties.len() > 1;
|
||||
for share in &e.parties {
|
||||
let Some(party) = b.ledger.get_party(share.id) else {
|
||||
continue;
|
||||
};
|
||||
ui.add_space(4.0);
|
||||
if widgets::link(
|
||||
ui,
|
||||
RichText::new(format!(
|
||||
"{} {}",
|
||||
super::parties::party_icon(party),
|
||||
party.label()
|
||||
let mut text = format!("{} {}", super::parties::party_icon(party), party.label());
|
||||
if several {
|
||||
text.push_str(&format!(" · {}", share_text(share)));
|
||||
}
|
||||
if widgets::link(ui, RichText::new(text).color(p.accent))
|
||||
.on_hover_text(format!(
|
||||
"{} this entry is with ({}): open them",
|
||||
if several {
|
||||
"One of the parties"
|
||||
} else {
|
||||
"The party"
|
||||
},
|
||||
super::parties::roles_text(party).to_lowercase()
|
||||
))
|
||||
.color(p.accent),
|
||||
)
|
||||
.on_hover_text(format!(
|
||||
"The party this entry is with ({}): open them",
|
||||
super::parties::roles_text(party).to_lowercase()
|
||||
))
|
||||
.clicked()
|
||||
.clicked()
|
||||
{
|
||||
shell.go(Route::Party(party.id));
|
||||
}
|
||||
}
|
||||
if e.party_id.is_none()
|
||||
if several && e.parties.iter().any(|x| x.resolved.is_none()) {
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{} The parties' shares don't add up to what this entry moved",
|
||||
icon::WARNING
|
||||
))
|
||||
.color(p.negative),
|
||||
);
|
||||
}
|
||||
if e.parties.is_empty()
|
||||
&& let Some(why) = b.ledger.entry_no_party(e.id)
|
||||
{
|
||||
ui.add_space(4.0);
|
||||
@@ -490,3 +505,22 @@ 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()),
|
||||
};
|
||||
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 {
|
||||
ledger_core::Share::Amount(a) => fmt::dollars(a),
|
||||
_ => String::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -473,6 +473,30 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
||||
};
|
||||
ev(icon::IDENTIFICATION_CARD, text, pid.map(Route::Party))
|
||||
}
|
||||
"party.tin_set" => {
|
||||
let pid = p.get("id").and_then(|v| v.as_u64());
|
||||
let name = party_name(b, pid);
|
||||
let text = match p.get("facts").filter(|f| !f.is_null()) {
|
||||
Some(f) => {
|
||||
let kind = match f.get("tin_type").and_then(|v| v.as_str()) {
|
||||
Some("ssn") => "SSN",
|
||||
Some("ein") => "EIN",
|
||||
Some("itin") => "ITIN",
|
||||
_ => "TIN",
|
||||
};
|
||||
let l4 = f.get("last4").and_then(|v| v.as_str()).unwrap_or("????");
|
||||
if f.get("purged").is_some_and(|v| !v.is_null()) {
|
||||
format!("Purged {name}'s {kind} (••••{l4}) from the vault")
|
||||
} else {
|
||||
format!(
|
||||
"Taxpayer ID for {name} recorded: {kind} ending {l4}, kept in the vault"
|
||||
)
|
||||
}
|
||||
}
|
||||
None => format!("Taxpayer ID facts for {name} cleared"),
|
||||
};
|
||||
ev(icon::IDENTIFICATION_CARD, text, pid.map(Route::Party))
|
||||
}
|
||||
"party.w4_set" => {
|
||||
let pid = p.get("id").and_then(|v| v.as_u64());
|
||||
let name = party_name(b, pid);
|
||||
@@ -491,6 +515,59 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
||||
};
|
||||
ev(icon::IDENTIFICATION_BADGE, text, pid.map(Route::Party))
|
||||
}
|
||||
"entry.parties_set" => {
|
||||
let e = entry_id.unwrap_or(0);
|
||||
let named: Vec<(u64, String)> = p
|
||||
.get("parties")
|
||||
.and_then(|v| v.as_array())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|x| {
|
||||
let id = x.get("party_id").and_then(|v| v.as_u64())?;
|
||||
let share = match x.get("share") {
|
||||
Some(Value::Object(o)) => o
|
||||
.get("amount")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|a| {
|
||||
rust_decimal::Decimal::from_str_exact(a)
|
||||
.map(fmt::dollars)
|
||||
.unwrap_or_else(|_| a.to_string())
|
||||
})
|
||||
.or_else(|| {
|
||||
o.get("percent")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|pc| format!("{pc}%"))
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let name = party_name(b, Some(id));
|
||||
Some((
|
||||
id,
|
||||
match share {
|
||||
Some(sh) => format!("{name} ({sh})"),
|
||||
None => name,
|
||||
},
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
Some(Event {
|
||||
icon: icon::ADDRESS_BOOK,
|
||||
text: if named.is_empty() {
|
||||
format!("Cleared the parties of #{e}")
|
||||
} else {
|
||||
format!(
|
||||
"Tagged #{e} with {}",
|
||||
named
|
||||
.iter()
|
||||
.map(|(_, n)| n.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" and ")
|
||||
)
|
||||
},
|
||||
route: entry_id.map(Route::Entry),
|
||||
also: named.first().map(|(id, _)| Route::Party(*id)),
|
||||
})
|
||||
}
|
||||
"entry.party_set" => {
|
||||
let pid = p.get("party_id").and_then(|v| v.as_u64());
|
||||
let e = entry_id.unwrap_or(0);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
pub mod accounts;
|
||||
pub mod assets;
|
||||
pub mod capital;
|
||||
pub mod cash;
|
||||
pub mod company;
|
||||
pub mod documents;
|
||||
|
||||
@@ -159,6 +159,21 @@ pub fn masked_tin(last4: &str) -> String {
|
||||
format!("•••-••-{last4}")
|
||||
}
|
||||
|
||||
/// A party's taxpayer-ID facts as the viewer shows them: "TIN on file
|
||||
/// ••••1234 (SSN)", never more than the last four; once purged from the
|
||||
/// vault, "TIN ••••1234 (SSN), purged from the vault".
|
||||
pub fn tin_on_file(t: &ledger_core::TinFacts) -> String {
|
||||
if t.vault_record.is_none() && t.purged.is_some() {
|
||||
format!(
|
||||
"TIN ••••{} ({}), purged from the vault",
|
||||
t.last4,
|
||||
t.tin_type.label()
|
||||
)
|
||||
} else {
|
||||
format!("TIN on file ••••{} ({})", t.last4, t.tin_type.label())
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a party stands on its W-9.
|
||||
///
|
||||
/// ```text
|
||||
@@ -741,6 +756,11 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
|
||||
r.context_menu(|ui| widgets::copy_item(ui, "Copy amount", &value.to_string()));
|
||||
});
|
||||
|
||||
// A member's page is their capital account statement too.
|
||||
if let Some(st) = b.capital_statement(id, span) {
|
||||
super::capital::capital_card(ui, b, &st, party.label(), &mut go);
|
||||
ui.add_space(14.0);
|
||||
}
|
||||
super::columns(ui, 860.0, &[1.0, 1.0], |col, ui| {
|
||||
if col == 0 {
|
||||
what_for_card(ui, b, &flow, &label, &mut go);
|
||||
@@ -882,6 +902,19 @@ fn facts_card(ui: &mut Ui, party: &Party, go: &mut Option<Route>) {
|
||||
&party.id.to_string(),
|
||||
);
|
||||
ui.end_row();
|
||||
k(ui, "Taxpayer ID");
|
||||
match &party.tin {
|
||||
Some(t) => {
|
||||
ui.label(RichText::new(tin_on_file(t)).monospace())
|
||||
.on_hover_text(
|
||||
"Only the last four digits are in the books; the full number is kept in the encrypted TIN vault, for the CPA package.",
|
||||
);
|
||||
}
|
||||
None => {
|
||||
ui.label(RichText::new("not recorded").color(p.faint));
|
||||
}
|
||||
}
|
||||
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))
|
||||
@@ -1141,12 +1174,31 @@ fn entries_card(ui: &mut Ui, b: &Books, info: &PartyInfo, go: &mut Option<Route>
|
||||
let text = if draft { p.muted } else { p.text };
|
||||
let accounts: Vec<String> =
|
||||
e.accounts.iter().map(|a| b.account_name(*a)).collect();
|
||||
let sub = accounts.join(" → ");
|
||||
let mut sub = accounts.join(" → ");
|
||||
let kind = if draft {
|
||||
format!("{} · draft", e.origin)
|
||||
} else {
|
||||
e.origin.to_string()
|
||||
};
|
||||
// Shared with other parties: show their part.
|
||||
let mut amount = e.amount;
|
||||
if e.parties.len() > 1
|
||||
&& let Some(mine) = e.parties.iter().find(|x| x.id == info.id)
|
||||
{
|
||||
let others: Vec<&str> = e
|
||||
.parties
|
||||
.iter()
|
||||
.filter(|x| x.id != info.id)
|
||||
.map(|x| x.label.as_str())
|
||||
.collect();
|
||||
sub = format!(
|
||||
"Their share, {} of {}, with {} · {sub}",
|
||||
super::entry::share_text(mine),
|
||||
fmt::dollars(e.amount),
|
||||
others.join(" & ")
|
||||
);
|
||||
amount = mine.resolved.map(|v| v.abs()).unwrap_or(e.amount);
|
||||
}
|
||||
let resp = row(
|
||||
ui,
|
||||
w,
|
||||
@@ -1155,7 +1207,7 @@ fn entries_card(ui: &mut Ui, b: &Books, info: &PartyInfo, go: &mut Option<Route>
|
||||
(format!("#{}", e.id), 64.0, false, p.muted),
|
||||
(e.description.clone(), 0.0, false, text),
|
||||
(kind, 150.0, false, if draft { p.warning } else { p.muted }),
|
||||
(fmt::money(e.amount), amt, true, text),
|
||||
(fmt::money(amount), amt, true, text),
|
||||
],
|
||||
Some((sub.as_str(), p.faint)),
|
||||
false,
|
||||
|
||||
@@ -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,
|
||||
W4Facts, W9Facts, vendor_1099_report,
|
||||
TinFacts, TinType, W4Facts, W9Facts, vendor_1099_report,
|
||||
};
|
||||
use ledger_gui::books::Books;
|
||||
use ledger_gui::nav::{Route, Section};
|
||||
@@ -577,6 +577,56 @@ fn w9_text_never_shows_more_than_the_last_four() {
|
||||
);
|
||||
}
|
||||
|
||||
/// pages/journal.rs `party.tin_set`: recorded, purged, and cleared, each
|
||||
/// in plain language with the TIN's type and last four, never more of it.
|
||||
#[test]
|
||||
fn tin_text_never_shows_more_than_the_last_four() {
|
||||
let f = fixture("tintext");
|
||||
let mut s = Store::open(&f.dir.0.join("books-data")).unwrap();
|
||||
let facts = |tin_type, purged: Option<&str>| TinFacts {
|
||||
tin_type,
|
||||
last4: "4915".into(),
|
||||
vault_record: purged.is_none().then(|| "ab".repeat(32)),
|
||||
purged: purged.map(str::to_string),
|
||||
};
|
||||
s.set_party_tin(f.pat, Some(facts(TinType::Ssn, None)))
|
||||
.unwrap();
|
||||
s.set_party_tin(
|
||||
f.pat,
|
||||
Some(facts(
|
||||
TinType::Ssn,
|
||||
Some("purged from the wanderbooks vault on 2026-10-15: retention over"),
|
||||
)),
|
||||
)
|
||||
.unwrap();
|
||||
s.set_party_tin(f.pat, None).unwrap();
|
||||
s.set_party_tin(f.pat, Some(facts(TinType::Itin, None)))
|
||||
.unwrap();
|
||||
drop(s);
|
||||
let b = load(&f.dir.0);
|
||||
let events: Vec<_> = b
|
||||
.journal
|
||||
.iter()
|
||||
.filter(|l| l.line_type == "party.tin_set")
|
||||
.filter_map(|l| describe(&b, l))
|
||||
.collect();
|
||||
let texts: Vec<&str> = events.iter().map(|e| e.text.as_str()).collect();
|
||||
assert_eq!(
|
||||
texts,
|
||||
vec![
|
||||
"Taxpayer ID for Pat Lee recorded: SSN ending 4915, kept in the vault",
|
||||
"Purged Pat Lee's SSN (••••4915) from the vault",
|
||||
"Taxpayer ID facts for Pat Lee cleared",
|
||||
"Taxpayer ID for Pat Lee recorded: ITIN ending 4915, kept in the vault",
|
||||
]
|
||||
);
|
||||
assert!(events.iter().all(|e| e.route == Some(Route::Party(f.pat))));
|
||||
// No more than four digits in any of them.
|
||||
for t in &texts {
|
||||
assert!(t.chars().filter(|c| c.is_ascii_digit()).count() <= 4, "{t}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attention_asks_for_a_w9_until_its_facts_are_recorded() {
|
||||
let f = fixture("attention");
|
||||
@@ -914,3 +964,510 @@ fn entries_without_a_counterparty_need_attention_unless_off() {
|
||||
let (dir, _s, ..) = counterparty_books("np-off", Some("off"));
|
||||
assert!(item(&load_with_toml(&dir.0)).is_none());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- party shares and the capital statement
|
||||
//
|
||||
// Oracles: EntryParty (resolved shares), PartyFlow (each party's share),
|
||||
// CapitalStatement (INVARIANTS 1–6), ledger_core::k1, docs/parties-and-attachments.md
|
||||
// "Shares" and "A member's capital statement". Values worked by hand.
|
||||
|
||||
struct MemberBooks {
|
||||
dir: TempDir,
|
||||
tobin: PartyId,
|
||||
mo: PartyId,
|
||||
reimburse: EntryId,
|
||||
property: EntryId,
|
||||
distribution: EntryId,
|
||||
}
|
||||
|
||||
/// Two 50/50 members. 2025: a joint cash contribution of $1,000.00 each.
|
||||
/// 2026: equipment contributed ($600.00 each, Feb), an expense paid from
|
||||
/// joint funds ($300.00 on 2100, Mar), revenue $1,000.00 (Apr), $100.00
|
||||
/// of 2100 reimbursed to both (May), $200.00 distributed to each (Jun),
|
||||
/// and Jan–Jun closed to the members ($700.00 net: $350.00 each).
|
||||
fn member_books(tag: &str) -> MemberBooks {
|
||||
let dir = TempDir::new(tag);
|
||||
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
|
||||
let acct = |s: &mut Store, c: &str, n: &str, t| s.add_account(c, n, t, None, false).unwrap();
|
||||
let bank = acct(&mut s, "1000", "Bluevine", AccountType::Asset);
|
||||
let equip = acct(&mut s, "1500", "Equipment", AccountType::Asset);
|
||||
let due = acct(
|
||||
&mut s,
|
||||
"2100",
|
||||
"Due to Shareholders",
|
||||
AccountType::Liability,
|
||||
);
|
||||
let cap_s = acct(
|
||||
&mut s,
|
||||
"3000",
|
||||
"Owner Capital — Tobin Harlow",
|
||||
AccountType::Equity,
|
||||
);
|
||||
let cap_k = acct(
|
||||
&mut s,
|
||||
"3001",
|
||||
"Owner Capital — Morgan Ellis",
|
||||
AccountType::Equity,
|
||||
);
|
||||
let rev = acct(&mut s, "4000", "App Revenue", AccountType::Revenue);
|
||||
let exp = acct(&mut s, "6050", "Domain & Hosting", AccountType::Expense);
|
||||
let tobin = s.add_party("Tobin Harlow", &[Role::Member]).unwrap();
|
||||
let mo = s
|
||||
.add_party("Morgan Ellis", &[Role::Member])
|
||||
.unwrap();
|
||||
let both = [
|
||||
ledger_core::PartyShare::whole(tobin),
|
||||
ledger_core::PartyShare::whole(mo),
|
||||
];
|
||||
let post = |s: &mut Store, date, what: &str, lines: &[(u64, Side, i64)]| {
|
||||
let e = s.create_entry(date, what).unwrap();
|
||||
for &(a, side, c) in lines {
|
||||
s.add_line(e, a, side, usd(c)).unwrap();
|
||||
}
|
||||
s.post_entry(e).unwrap();
|
||||
e
|
||||
};
|
||||
let open = post(
|
||||
&mut s,
|
||||
d(2025, 6, 1),
|
||||
"Opening contribution",
|
||||
&[
|
||||
(bank, Side::Debit, 200_000),
|
||||
(cap_s, Side::Credit, 100_000),
|
||||
(cap_k, Side::Credit, 100_000),
|
||||
],
|
||||
);
|
||||
s.set_entry_parties(open, &both).unwrap();
|
||||
let property = post(
|
||||
&mut s,
|
||||
d(2026, 2, 1),
|
||||
"Laptop contributed",
|
||||
&[
|
||||
(equip, Side::Debit, 120_000),
|
||||
(cap_s, Side::Credit, 60_000),
|
||||
(cap_k, Side::Credit, 60_000),
|
||||
],
|
||||
);
|
||||
s.set_entry_parties(property, &both).unwrap();
|
||||
post(
|
||||
&mut s,
|
||||
d(2026, 3, 1),
|
||||
"Hosting from joint funds",
|
||||
&[(exp, Side::Debit, 30_000), (due, Side::Credit, 30_000)],
|
||||
);
|
||||
post(
|
||||
&mut s,
|
||||
d(2026, 4, 1),
|
||||
"Sales",
|
||||
&[(bank, Side::Debit, 100_000), (rev, Side::Credit, 100_000)],
|
||||
);
|
||||
let reimburse = post(
|
||||
&mut s,
|
||||
d(2026, 5, 1),
|
||||
"Reimburse the household",
|
||||
&[(due, Side::Debit, 10_000), (bank, Side::Credit, 10_000)],
|
||||
);
|
||||
s.set_entry_parties(reimburse, &both).unwrap();
|
||||
let distribution = post(
|
||||
&mut s,
|
||||
d(2026, 6, 1),
|
||||
"Distribution",
|
||||
&[
|
||||
(cap_s, Side::Debit, 20_000),
|
||||
(cap_k, Side::Debit, 20_000),
|
||||
(bank, Side::Credit, 40_000),
|
||||
],
|
||||
);
|
||||
s.set_entry_parties(distribution, &both).unwrap();
|
||||
let period = s
|
||||
.create_period("H1 2026", d(2026, 1, 1), d(2026, 6, 30))
|
||||
.unwrap();
|
||||
let half = Decimal::new(5, 1);
|
||||
s.close_period_to(
|
||||
period,
|
||||
&ledger_core::periods::CloseTo::Members(vec![
|
||||
ledger_core::MemberClose {
|
||||
capital: cap_s,
|
||||
share: half,
|
||||
distributions: None,
|
||||
},
|
||||
ledger_core::MemberClose {
|
||||
capital: cap_k,
|
||||
share: half,
|
||||
distributions: None,
|
||||
},
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
write_member_toml(&dir.0, "cash_accounts = [\"1000\"]\n");
|
||||
MemberBooks {
|
||||
dir,
|
||||
tobin,
|
||||
mo,
|
||||
reimburse,
|
||||
property,
|
||||
distribution,
|
||||
}
|
||||
}
|
||||
|
||||
/// The fixture's wanderbooks.toml, with `books` as its `[books]` table.
|
||||
fn write_member_toml(dir: &Path, books: &str) {
|
||||
std::fs::write(
|
||||
dir.join("wanderbooks.toml"),
|
||||
format!(
|
||||
"[books]\n{books}[company]\nname = \"Fernhill Labs LLC\"\n\
|
||||
[[company.members]]\nname = \"Tobin Harlow\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
|
||||
[[company.members]]\nname = \"Morgan Ellis\"\nshare = \"50%\"\ncapital_account = \"3001\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Post one entry to the fixture's books after the fact.
|
||||
fn post_more(m: &MemberBooks, date: NaiveDate, lines: &[(&str, Side, i64)]) -> EntryId {
|
||||
let mut s = Store::open(&m.dir.0.join("books-data")).unwrap();
|
||||
let e = s.create_entry(date, "Later").unwrap();
|
||||
for &(code, side, c) in lines {
|
||||
let a = s.ledger().chart().find_by_code(code).unwrap().id;
|
||||
s.add_line(e, a, side, usd(c)).unwrap();
|
||||
}
|
||||
s.post_entry(e).unwrap();
|
||||
e
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_list_every_party_with_its_resolved_share() {
|
||||
let m = member_books("shares-entries");
|
||||
let b = load_with_toml(&m.dir.0);
|
||||
let e = b.entry(m.reimburse).unwrap();
|
||||
assert_eq!(e.party_id, None, "two parties: no sole party");
|
||||
assert_eq!(
|
||||
e.parties
|
||||
.iter()
|
||||
.map(|p| (p.id, p.resolved))
|
||||
.collect::<Vec<_>>(),
|
||||
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.
|
||||
let e = b.entry(m.property).unwrap();
|
||||
assert_eq!(e.parties[1].resolved, Some(cents(60_000)));
|
||||
// The journal says who, for parties_set.
|
||||
let line = b
|
||||
.journal
|
||||
.iter()
|
||||
.find(|l| l.line_type == "entry.parties_set")
|
||||
.unwrap();
|
||||
let ev = describe(&b, line).unwrap();
|
||||
assert!(
|
||||
ev.text.contains("Tobin Harlow and Morgan Ellis"),
|
||||
"{}",
|
||||
ev.text
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_members_money_is_their_share() {
|
||||
let m = member_books("shares-flow");
|
||||
let b = load_with_toml(&m.dir.0);
|
||||
let span = (d(2026, 1, 1), today());
|
||||
for who in [m.tobin, m.mo] {
|
||||
let f = b.party_flow(who, span, true);
|
||||
// Half the reimbursement plus their own distribution; the
|
||||
// property moved no money.
|
||||
assert_eq!(f.paid, cents(25_000));
|
||||
assert_eq!(f.received, Decimal::ZERO);
|
||||
let what: Decimal = f.what_for.iter().map(|(_, v)| *v).sum();
|
||||
assert_eq!(what, f.net(), "what for sums to the net");
|
||||
assert!(b.party(who).unwrap().entries.contains(&m.distribution));
|
||||
}
|
||||
assert_eq!(b.party(m.mo).unwrap().paid_ytd, cents(25_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_members_page_is_their_capital_statement() {
|
||||
let m = member_books("capital");
|
||||
let b = load_with_toml(&m.dir.0);
|
||||
let st = b
|
||||
.capital_statement(m.tobin, (d(2026, 1, 1), today()))
|
||||
.expect("a member");
|
||||
assert_eq!(st.share, Decimal::new(5, 1));
|
||||
assert_eq!(st.opening, cents(100_000));
|
||||
assert_eq!(st.contributions_cash, Decimal::ZERO);
|
||||
assert_eq!(st.contributions_property, cents(60_000));
|
||||
assert_eq!(st.income_share, cents(35_000));
|
||||
assert_eq!(st.distributions_taken, cents(20_000));
|
||||
assert_eq!(st.closing, cents(175_000));
|
||||
assert_eq!(
|
||||
st.closing,
|
||||
st.opening + st.contributions_cash + st.contributions_property + st.income_share
|
||||
- st.distributions_taken
|
||||
);
|
||||
assert_eq!(st.other, Decimal::ZERO);
|
||||
// No [books] financing_accounts: 2100's name says "due to"; $300.00
|
||||
// advanced less $100.00 reimbursed, half each.
|
||||
assert_eq!(st.due_to_members, cents(20_000));
|
||||
assert_eq!(st.owed_to_member, cents(10_000));
|
||||
// The lines behind it (k1_detail INVARIANT 5), in journal order.
|
||||
use ledger_gui::books::CapitalKind as K;
|
||||
let kinds: Vec<(EntryId, K, Decimal)> = st
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| (l.entry, l.kind, l.amount))
|
||||
.collect();
|
||||
assert_eq!(kinds.len(), 3);
|
||||
assert_eq!(
|
||||
kinds[0],
|
||||
(m.property, K::PropertyContribution, cents(60_000))
|
||||
);
|
||||
assert_eq!(kinds[1], (m.distribution, K::Distribution, cents(-20_000)));
|
||||
assert_eq!((kinds[2].1, kinds[2].2), (K::IncomeShare, cents(35_000)));
|
||||
// Over all time: the 2025 contribution is cash (1000 is
|
||||
// [books] cash_accounts), the laptop property.
|
||||
let all = b
|
||||
.capital_statement(m.mo, (d(2025, 1, 1), today()))
|
||||
.unwrap();
|
||||
assert_eq!(all.opening, Decimal::ZERO);
|
||||
assert_eq!(all.contributions_cash, cents(100_000));
|
||||
assert_eq!(all.contributions_property, cents(60_000));
|
||||
assert_eq!(all.income_share, cents(35_000));
|
||||
assert_eq!(all.distributions_taken, cents(20_000));
|
||||
assert_eq!(all.closing, cents(175_000));
|
||||
// Not a member: no statement.
|
||||
let mut s = Store::open(&m.dir.0.join("books-data")).unwrap();
|
||||
let other = s.add_party("Riley Chen", &[Role::Contractor]).unwrap();
|
||||
drop(s);
|
||||
let b = load_with_toml(&m.dir.0);
|
||||
assert!(
|
||||
b.capital_statement(other, (d(2026, 1, 1), today()))
|
||||
.is_none()
|
||||
);
|
||||
// The page shows it.
|
||||
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(m.tobin)));
|
||||
h.run_steps(3);
|
||||
use egui_kittest::kittest::Queryable;
|
||||
h.get_by_label("Capital account");
|
||||
h.get_by_label("Contributions of property (at fair market value)");
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[test]
|
||||
fn the_capital_statement_is_the_k1_worksheet_row() {
|
||||
let m = member_books("capital-k1");
|
||||
// Income not yet closed, so beginnings carry undistributed earnings.
|
||||
post_more(
|
||||
&m,
|
||||
d(2026, 7, 1),
|
||||
&[
|
||||
("1000", Side::Debit, 10_000),
|
||||
("4000", Side::Credit, 10_000),
|
||||
],
|
||||
);
|
||||
for (books, fallback) in [("cash_accounts = [\"1000\"]\n", false), ("", true)] {
|
||||
write_member_toml(&m.dir.0, books);
|
||||
let b = load_with_toml(&m.dir.0);
|
||||
let chart = b.ledger.chart();
|
||||
let id = |c: &str| chart.find_by_code(c).unwrap().id;
|
||||
let members =
|
||||
[("Tobin Harlow", "3000"), ("Morgan Ellis", "3001")].map(|(name, code)| {
|
||||
ledger_core::MemberCapital {
|
||||
name: name.into(),
|
||||
capital_account: id(code),
|
||||
distribution_account: None,
|
||||
share: Decimal::new(5, 1),
|
||||
}
|
||||
});
|
||||
let cash: Vec<_> = if fallback {
|
||||
let mut all: Vec<_> = ledger_core::parties::money_accounts(&b.ledger, &[])
|
||||
.cash
|
||||
.into_iter()
|
||||
.collect();
|
||||
all.sort_unstable();
|
||||
all
|
||||
} else {
|
||||
vec![id("1000")]
|
||||
};
|
||||
for span in [
|
||||
(d(2025, 1, 1), today()),
|
||||
(d(2026, 1, 1), today()),
|
||||
(d(2026, 2, 1), d(2026, 5, 31)),
|
||||
(d(2026, 7, 1), today()),
|
||||
(d(2026, 8, 1), today()),
|
||||
] {
|
||||
let rows =
|
||||
ledger_core::k1::k1_worksheet(&b.ledger, span.0, span.1, &members, &cash).unwrap();
|
||||
for (who, row) in [m.tobin, m.mo].into_iter().zip(&rows) {
|
||||
let st = b.capital_statement(who, span).unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
st.share,
|
||||
st.opening,
|
||||
st.contributions_cash,
|
||||
st.contributions_property,
|
||||
st.income_share,
|
||||
st.distributions_taken,
|
||||
st.other,
|
||||
st.closing,
|
||||
),
|
||||
(
|
||||
row.share,
|
||||
row.beginning,
|
||||
row.contributions_cash,
|
||||
row.contributions_property,
|
||||
row.net_income,
|
||||
row.distributions,
|
||||
row.other,
|
||||
row.ending,
|
||||
),
|
||||
"{} over {span:?} (fallback {fallback})",
|
||||
row.name
|
||||
);
|
||||
assert_eq!(
|
||||
st.closing,
|
||||
st.opening
|
||||
+ st.contributions_cash
|
||||
+ st.contributions_property
|
||||
+ st.income_share
|
||||
- st.distributions_taken
|
||||
+ st.other
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Income not yet closed is the member's (members_equity INVARIANT 3):
|
||||
/// $100.00 of July revenue, unclosed, is $50.00 each of income in the
|
||||
/// span and of capital after it. Tobin, 2026: 1,000 + 600 + (350 + 50)
|
||||
/// − 200 = 1,800. July on: 1,750 + 50 = 1,800. August on: opening 1,800
|
||||
/// (the July income carried), nothing moved.
|
||||
#[test]
|
||||
fn the_capital_statement_counts_income_not_yet_closed() {
|
||||
let m = member_books("capital-unclosed");
|
||||
post_more(
|
||||
&m,
|
||||
d(2026, 7, 1),
|
||||
&[
|
||||
("1000", Side::Debit, 10_000),
|
||||
("4000", Side::Credit, 10_000),
|
||||
],
|
||||
);
|
||||
let b = load_with_toml(&m.dir.0);
|
||||
let st = b
|
||||
.capital_statement(m.tobin, (d(2026, 1, 1), today()))
|
||||
.unwrap();
|
||||
assert_eq!(st.opening, cents(100_000));
|
||||
assert_eq!(st.income_share, cents(40_000));
|
||||
assert_eq!(st.closing, cents(180_000));
|
||||
// The close is still the only income line: July's has no entry on
|
||||
// capital (INVARIANT 3).
|
||||
let closes: Decimal = st
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|l| l.kind == ledger_gui::books::CapitalKind::IncomeShare)
|
||||
.map(|l| l.amount)
|
||||
.sum();
|
||||
assert_eq!(closes, cents(35_000));
|
||||
let july = b
|
||||
.capital_statement(m.tobin, (d(2026, 7, 1), today()))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(july.opening, july.income_share, july.closing),
|
||||
(cents(175_000), cents(5_000), cents(180_000))
|
||||
);
|
||||
assert!(july.lines.is_empty());
|
||||
let aug = b
|
||||
.capital_statement(m.mo, (d(2026, 8, 1), today()))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(aug.opening, aug.income_share, aug.closing),
|
||||
(cents(180_000), Decimal::ZERO, cents(180_000))
|
||||
);
|
||||
}
|
||||
|
||||
/// "Owed to them" reads [books] financing_accounts first (INVARIANT 5):
|
||||
/// with 2200 "Member advances" ($40.00 advanced in July) as the financing
|
||||
/// account, 2100's "due to" name no longer counts: $40.00, $20.00 each.
|
||||
/// Without the setting, the name rule: 2100's $200.00 (2200's name says
|
||||
/// neither "due to" nor "owed to").
|
||||
#[test]
|
||||
fn owed_to_members_reads_the_financing_accounts_first() {
|
||||
let m = member_books("capital-financing");
|
||||
let mut s = Store::open(&m.dir.0.join("books-data")).unwrap();
|
||||
s.add_account(
|
||||
"2200",
|
||||
"Member advances",
|
||||
AccountType::Liability,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
drop(s);
|
||||
post_more(
|
||||
&m,
|
||||
d(2026, 7, 2),
|
||||
&[("6050", Side::Debit, 4_000), ("2200", Side::Credit, 4_000)],
|
||||
);
|
||||
let span = (d(2026, 1, 1), today());
|
||||
write_member_toml(
|
||||
&m.dir.0,
|
||||
"cash_accounts = [\"1000\"]\nfinancing_accounts = [\"2200\"]\n",
|
||||
);
|
||||
let b = load_with_toml(&m.dir.0);
|
||||
let st = b.capital_statement(m.mo, span).unwrap();
|
||||
assert_eq!(
|
||||
(st.due_to_members, st.owed_to_member),
|
||||
(cents(4_000), cents(2_000))
|
||||
);
|
||||
write_member_toml(&m.dir.0, "cash_accounts = [\"1000\"]\n");
|
||||
let b = load_with_toml(&m.dir.0);
|
||||
let st = b.capital_statement(m.mo, span).unwrap();
|
||||
assert_eq!(
|
||||
(st.due_to_members, st.owed_to_member),
|
||||
(cents(20_000), cents(10_000))
|
||||
);
|
||||
}
|
||||
|
||||
/// Without [books] cash_accounts the capital statement reads money as
|
||||
/// core does (every asset account but receivables: Books::
|
||||
/// k1_cash_accounts), so the laptop on 1500 reads as cash: Tobin's 2026
|
||||
/// contributions are $600.00 cash, none property. The Cash page keeps its
|
||||
/// name-based guess ("Bluevine" names no cash word: no cash accounts).
|
||||
#[test]
|
||||
fn without_cash_accounts_the_capital_statement_reads_money_as_core_does() {
|
||||
let m = member_books("capital-no-cash");
|
||||
write_member_toml(&m.dir.0, "");
|
||||
let b = load_with_toml(&m.dir.0);
|
||||
let id = |c: &str| b.ledger.chart().find_by_code(c).unwrap().id;
|
||||
assert_eq!(b.k1_cash_accounts, vec![id("1000"), id("1500")]);
|
||||
assert!(
|
||||
b.cash_accounts.is_empty(),
|
||||
"the Cash page's guess is unchanged"
|
||||
);
|
||||
let st = b
|
||||
.capital_statement(m.tobin, (d(2026, 1, 1), today()))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(st.contributions_cash, st.contributions_property),
|
||||
(cents(60_000), Decimal::ZERO)
|
||||
);
|
||||
assert_eq!(
|
||||
st.lines[0].kind,
|
||||
ledger_gui::books::CapitalKind::CashContribution
|
||||
);
|
||||
assert_eq!(st.closing, cents(175_000));
|
||||
let all = b
|
||||
.capital_statement(m.mo, (d(2025, 1, 1), today()))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(all.contributions_cash, all.contributions_property),
|
||||
(cents(160_000), Decimal::ZERO)
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user