Parties, attachment links, statements, reconciliations (store), TIN vault crate

Parties: one directory for vendors, contractors, customers, employees,
members, platforms, agencies, banks, landlords (ledger-core parties.rs).
W-9/W-4 facts without the number (last four only; any nine-digit run in a
party field is refused, Treas. Reg. 301.6109-4). Vendor and customer
records link to parties on replay; core tags every bill, invoice, payment,
and reversal with its counterparty. Journal events party.*,
entry.party_set; payroll lines name the employee party.

MCP: books.add_party, update_party, set_party_roles, set_w9, set_w4,
parties, party, set_entry_party; draft_entry party; run_payroll employee;
show party. Viewer: Parties directory and party pages, search, journal
text, W-9 attention, demo data.

One definition of a party's money (ledger_core::parties::activity) for
MCP and viewer. 1099 amounts count payments from cash and financing
accounts (a member paying for the company counts; a card doesn't; a
prepayment counts when made): vendor_1099_report_with, B-19.

Store: attachment links to any subject, statements, reconciliations
(schema v11), with replay and projection. ledger-vault: encrypted TIN and
W-9 records (ChaCha20-Poly1305, key file outside the books).

Spec-blind tests for parties (core, store, MCP, viewer) and attachments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-15 14:43:40 -06:00
1 parent 2a188bd039
commit a299077991
54 files changed
+10961 -178

No files matched your search

+8
View File
@@ -22,6 +22,7 @@ pub struct PageState {
pub reports: pages::reports::State,
pub accounts: pages::accounts::State,
pub assets: pages::assets::State,
pub parties: pages::parties::State,
pub documents: pages::documents::State,
pub journal: pages::journal::State,
}
@@ -123,6 +124,7 @@ impl App {
"reports" => Route::Reports(ReportKind::ProfitAndLoss),
"accounts" => Route::Accounts,
"assets" => Route::Assets,
"parties" => Route::Parties,
"documents" => Route::Documents,
"company" => Route::Company,
"journal" => Route::Journal,
@@ -191,6 +193,12 @@ impl App {
}
Route::Asset(asset_id)
}
T::Party { party_id } => {
if b.ledger.get_party(party_id).is_none() {
return;
}
Route::Party(party_id)
}
T::Search { query } => {
let st = &mut self.state.transactions;
st.query = query;
+46 -3
View File
@@ -2,7 +2,9 @@
//! and recurring entries due, open past periods, uncategorized tax lines,
//! 1099 vendors without a W-9, overdue bills and invoices, money owed to
//! owners, fixed asset depreciation not posted, assets without a tag or a
//! tax basis, and legal rules (like the 1099 threshold) due for an update. Each item says what's wrong, where to look, and the prompt that
//! tax basis, contractors and landlords paid without W-9 facts, and legal
//! rules (like the 1099 threshold) due for an update. Each item says
//! what's wrong, where to look, and the prompt that
//! asks the agent to fix it (the GUI never changes the books itself).
use crate::books::{Attention, Books, Severity};
@@ -38,7 +40,14 @@ use rust_decimal::Decimal;
/// disagreeing today (unregistered_cost != 0: Problem); an active
/// asset in service with no tag (Todo, one per asset); an active
/// asset in service with no tax basis (Asset::tax_basis is None:
/// Todo, one per asset, its prompt naming books.set_asset_tax_basis).
/// Todo, one per asset, its prompt naming books.set_asset_tax_basis);
/// a party not archived whose W-9 is Missing (pages::parties::
/// w9_status: it files a 1099, has no W-9 facts, and its vendor
/// record doesn't say one is on file) and that was paid this year
/// (PartyInfo::paid_ytd > 0),
/// unless its vendor is already a 1099 vendor without a W-9 above
/// (Todo, one per party, its prompt from
/// pages::parties::collect_w9_prompt, naming books.set_w9).
/// 3. Nothing else: books with none of those yield no items.
/// 4. Every item's prompt starts "In wanderbooks," or names
/// wanderbooks, and its amounts are the books' exact amounts.
@@ -229,6 +238,8 @@ pub fn find(b: &Books) -> Vec<Attention> {
if today.month() == 1 {
years.insert(0, today.year() - 1);
}
// Vendors the "needs a W-9" item already names this year.
let mut asked_w9 = std::collections::HashSet::new();
for year in years {
// A year whose threshold isn't known is flagged by the stale-rule
// item above; never guess it here.
@@ -236,6 +247,10 @@ pub fn find(b: &Books) -> Vec<Attention> {
continue;
};
for v in r.vendors.iter().filter(|v| v.reportable && !v.tin_on_file) {
if year == today.year() {
asked_w9.insert(v.vendor_id);
}
let party = b.ledger.party_of_vendor(v.vendor_id);
out.push(Attention {
severity: Severity::Problem,
title: format!("{} needs a W-9", v.name),
@@ -244,7 +259,7 @@ pub fn find(b: &Books) -> Vec<Attention> {
fmt::dollars(v.paid),
fmt::dollars(r.threshold)
),
target: Some(Route::Company),
target: Some(party.map_or(Route::Company, Route::Party)),
prompt: format!(
"Remind me to request a W-9 from {}; once I have it, mark it on file in wanderbooks with books.set_vendor_1099.",
v.name
@@ -253,6 +268,34 @@ pub fn find(b: &Books) -> Vec<Attention> {
}
}
for p in b.ledger.parties().filter(|p| {
!p.archived
&& crate::pages::parties::w9_status(b, p) == crate::pages::parties::W9Status::Missing
&& !p.vendor.is_some_and(|v| asked_w9.contains(&v))
}) {
let Some(info) = b.party(p.id).filter(|i| i.paid_ytd > Decimal::ZERO) else {
continue;
};
out.push(Attention {
severity: Severity::Todo,
title: format!("Collect a W-9 from {}", p.label()),
detail: format!(
"Paid {} in {} as {}, with no W-9 facts recorded: collect a W-9 before the next payment.",
fmt::dollars(info.paid_ytd),
today.year(),
if p.roles.contains(&ledger_core::Role::Landlord)
&& !p.roles.contains(&ledger_core::Role::Contractor)
{
"a landlord"
} else {
"a contractor"
}
),
target: Some(Route::Party(p.id)),
prompt: crate::pages::parties::collect_w9_prompt(b, p),
});
}
let overdue = |rows: Vec<ledger_core::AgingRow>| -> (usize, Decimal) {
let late: Vec<Decimal> = rows
.iter()
+215 -36
View File
@@ -8,8 +8,8 @@ use ledger_config::BooksSettings;
use ledger_core::analysis::{balance_history, balance_series};
use ledger_core::reports::{balance_sheet_as_of, profit_and_loss_for_period};
use ledger_core::{
AccountId, AccountType, AssetId, AssetRegister, AssetStatus, EntryId, EntryStatus, Ledger,
Side, fixed_asset_register,
AccountId, AccountType, AssetId, AssetRegister, AssetStatus, BillId, EntryId, EntryStatus,
InvoiceId, Ledger, Party, PartyId, PayrollRunId, Side, Vendor1099Line, fixed_asset_register,
};
use ledger_store::{Attachment, JournalLine, Snapshot};
use rust_decimal::Decimal;
@@ -42,8 +42,12 @@ pub struct EntryInfo {
/// Net effect on cash accounts: positive in, negative out, zero if the
/// entry doesn't touch cash.
pub cash_effect: Decimal,
/// The customer or vendor, from invoices, bills, and vendor tags.
/// 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.
pub party: Option<String>,
/// The directory party the entry is with (see [`PartyInfo`]).
pub party_id: Option<PartyId>,
/// Lower-cased text for search: description, accounts, vendor, amount.
pub haystack: String,
}
@@ -81,6 +85,62 @@ impl AssetTrail {
}
}
/// One party's dealings with the company: its entries, documents, and
/// this year's totals, computed once per refresh.
///
/// ```text
/// GOAL: answer "who is this, and what has the company done with them?"
/// with the books' own numbers.
/// SOURCE: docs/parties-and-attachments.md (Parties → Viewer); the
/// 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 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).
/// INVARIANTS:
/// 1. entries: every entry that is the party's, drafts included, in
/// Books::entries order (newest first).
/// 2. paid_ytd = activity's paid − received over the year, exactly.
/// Positive: paid to them, net; negative: received from them, net.
/// 3. invoiced_ytd = Σ Invoice::total() over its customer record's
/// invoices dated in the year; billed_ytd the same for its vendor
/// record's bills.
/// 4. open_invoices = Σ Invoice::balance() over its customer record's
/// invoices; open_bills = Σ Bill::balance() over its vendor record's
/// bills (any date: what is still owed today).
/// 5. form_1099: its vendor record's line in
/// vendor_1099_report_with(ledger, year, None, cash and financing
/// accounts), when it has a vendor
/// record in that report and the report knows the year's threshold
/// (threshold_1099 is then that threshold).
/// 6. payroll_runs: the runs with a line whose employee is the party,
/// 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,
/// customer.added, vendor.1099_set, and entry.vendor_set events.
/// ```
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PartyInfo {
pub id: PartyId,
pub entries: Vec<EntryId>,
pub paid_ytd: Decimal,
pub invoiced_ytd: Decimal,
pub billed_ytd: Decimal,
pub open_invoices: Decimal,
pub open_bills: Decimal,
pub invoices: Vec<InvoiceId>,
pub bills: Vec<BillId>,
pub payroll_runs: Vec<PayrollRunId>,
pub form_1099: Option<Vendor1099Line>,
pub threshold_1099: Option<Decimal>,
pub events: Vec<usize>,
}
/// Depreciation due and not yet in the books.
///
/// ```text
@@ -154,6 +214,8 @@ pub struct Books {
pub asset_trails: HashMap<AssetId, AssetTrail>,
/// The asset each depreciation, disposal, or purchase entry is about.
pub entry_assets: HashMap<EntryId, AssetId>,
/// Every party's dealings with the company, by party id.
pub parties: BTreeMap<PartyId, PartyInfo>,
}
/// Whether what's on disk is what the journal says.
@@ -288,7 +350,6 @@ impl Books {
}
}
let fresh_from_seq = prev_seq.unwrap_or(journal.len() as u64);
let parties = parties(&ledger, &journal);
let mut fresh = HashSet::new();
let closings = ledger.closing_entry_ids();
let mut entries = Vec::new();
@@ -345,7 +406,13 @@ impl Books {
Side::Credit => -l.amount.amount,
})
.sum();
let party = vendor.clone().or_else(|| parties.get(&id).cloned());
// 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 mut haystack = format!(
"{} {} #{} {}",
e.description(),
@@ -362,6 +429,12 @@ 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);
}
entries.push(EntryInfo {
id,
date: e.date(),
@@ -378,6 +451,7 @@ impl Books {
missing_receipt,
cash_effect,
party,
party_id,
haystack: haystack.to_lowercase(),
});
}
@@ -413,7 +487,9 @@ impl Books {
integrity,
asset_trails,
entry_assets,
parties: BTreeMap::new(),
};
books.parties = books.party_infos();
books.kpis = books.compute_kpis();
books.attention = crate::attention::find(&books);
Ok(books)
@@ -578,6 +654,140 @@ impl Books {
}
}
/// One party's dealings (None for an unknown party).
pub fn party(&self, id: PartyId) -> Option<&PartyInfo> {
self.parties.get(&id)
}
/// A party's label (its display name, else its name).
pub fn party_label(&self, id: PartyId) -> String {
self.ledger
.get_party(id)
.map(|p| p.label().to_string())
.unwrap_or_else(|| format!("party {id}"))
}
/// Whether a party files a 1099: it has a 1099 role (contractor,
/// landlord), or its vendor record is marked for a 1099.
pub fn files_1099(&self, p: &Party) -> bool {
p.roles.iter().any(|r| r.gets_1099())
|| p.vendor
.and_then(|v| self.ledger.get_vendor(v))
.is_some_and(|v| v.form_1099.is_some())
}
/// Every party's [`PartyInfo`].
fn party_infos(&self) -> BTreeMap<PartyId, PartyInfo> {
let l = &self.ledger;
let year = self.today.year();
let year_start = NaiveDate::from_ymd_opt(year, 1, 1).expect("valid");
let in_year = |d: NaiveDate| d >= year_start && d <= self.today;
let mut out: BTreeMap<PartyId, PartyInfo> = l
.parties()
.map(|p| {
(
p.id,
PartyInfo {
id: p.id,
..Default::default()
},
)
})
.collect();
for (id, info) in out.iter_mut() {
let a = ledger_core::parties::activity(
l,
*id,
(year_start, self.today),
&self.cash_accounts,
);
info.entries = self
.entries
.iter()
.filter(|e| a.entries.contains(&e.id))
.map(|e| e.id)
.collect();
info.paid_ytd = a.paid - a.received;
}
for inv in l.invoices() {
let id = inv.id;
if let Some(info) = l
.party_of_customer(inv.customer_id)
.and_then(|p| out.get_mut(&p))
{
info.invoices.push(id);
info.open_invoices += inv.balance();
if in_year(inv.date) {
info.invoiced_ytd += inv.total();
}
}
}
for bill in l.bills() {
let id = bill.id;
if let Some(info) = l
.party_of_vendor(bill.vendor_id)
.and_then(|p| out.get_mut(&p))
{
info.bills.push(id);
info.open_bills += bill.balance();
if in_year(bill.date) {
info.billed_ytd += bill.total();
}
}
}
let sources = ledger_core::PaymentAccounts {
cash: self.cash_accounts.clone(),
financing: self.financing_accounts.clone(),
};
if let Ok(r) = ledger_core::vendor_1099_report_with(l, year, None, &sources) {
for v in &r.vendors {
if let Some(info) = l.party_of_vendor(v.vendor_id).and_then(|p| out.get_mut(&p)) {
info.form_1099 = Some(v.clone());
info.threshold_1099 = Some(r.threshold);
}
}
}
for run in l.list_payroll_runs() {
for p in l.parties() {
let names = |x: &ledger_core::PayrollLine| match x.employee {
Some(e) => e == p.id,
None => {
ledger_core::parties::name_key(&x.employee_name)
== ledger_core::parties::name_key(&p.name)
}
};
if run.lines.iter().any(names)
&& let Some(info) = out.get_mut(&p.id)
&& !info.payroll_runs.contains(&run.id)
{
info.payroll_runs.push(run.id);
}
}
}
let u = |v: &Value, k: &str| v.get(k).and_then(|v| v.as_u64());
for (i, line) in self.journal.iter().enumerate() {
let p = &line.payload;
let t = line.line_type.as_str();
let party = if t.starts_with("party.") {
u(p, "id")
} else {
match t {
"entry.party_set" => u(p, "party_id"),
"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" => {
u(p, "vendor_id").and_then(|v| l.party_of_vendor(v))
}
_ => None,
}
};
if let Some(info) = party.and_then(|p| out.get_mut(&p)) {
info.events.push(i);
}
}
out
}
/// Depreciation due through the last finished month on or before
/// `as_of` and not yet posted (see [`DepreciationDue`]).
pub fn depreciation_due(&self, as_of: NaiveDate) -> DepreciationDue {
@@ -617,37 +827,6 @@ impl Books {
}
}
/// Customer names for entries created by invoices and their payments,
/// from the journal. (Bills and bill payments need nothing here: the
/// ledger tags their entries with the vendor, which takes precedence.)
fn parties(ledger: &Ledger, journal: &[JournalLine]) -> HashMap<EntryId, String> {
let mut out = HashMap::new();
let customer = |id: Option<u64>| {
id.and_then(|c| ledger.get_customer(c))
.map(|c| c.name.clone())
};
let u = |p: &Value, k: &str| p.get(k).and_then(|v| v.as_u64());
for line in journal {
let p = &line.payload;
let Some(entry) = u(p, "entry_id") else {
continue;
};
let name = match line.line_type.as_str() {
"invoice.created" => customer(u(p, "customer_id")),
"invoice.payment_received" => customer(
u(p, "invoice_id")
.and_then(|i| ledger.get_invoice(i))
.map(|i| i.customer_id),
),
_ => None,
};
if let Some(n) = name {
out.insert(entry, n);
}
}
out
}
/// 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> {
+93 -2
View File
@@ -250,6 +250,94 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
.map(|n| s.add_customer(n))
.collect::<Result<_, _>>()?;
// The party directory: who they are, beyond the vendor and customer
// records (roles, W-9 facts with only the TIN's last four, sites).
{
use ledger_core::{PartyField, Role, W9Facts};
let party_of = |s: &Store, v: u64| s.ledger().party_of_vendor(v).expect("linked");
let sam_p = party_of(&s, sam);
s.set_party_role(sam_p, Role::Contractor, true)?;
s.set_party_w9(
sam_p,
Some(W9Facts {
received: start + Days::new(20),
entity_type: "Individual/sole proprietor".into(),
exempt_payee_code: None,
fatca_code: None,
tin_last4: "4821".into(),
kept_at: "wanderbooks vault".into(),
vault_record: None,
}),
)?;
s.update_party(
sam_p,
PartyField::Notes(Some(
"Illustrator for client projects; invoices after each delivery.".into(),
)),
)?;
let riley_p = party_of(&s, riley);
s.set_party_role(riley_p, Role::Contractor, true)?;
s.update_party(riley_p, PartyField::Website(Some("rileychen.dev".into())))?;
let lofts = party_of(&s, landlord);
s.set_party_role(lofts, Role::Landlord, true)?;
s.set_party_w9(
lofts,
Some(W9Facts {
received: start + Days::new(3),
entity_type: "LLC taxed as a partnership".into(),
exempt_payee_code: None,
fatca_code: None,
tin_last4: "3307".into(),
kept_at: "CPA portal".into(),
vault_record: None,
}),
)?;
s.update_party(
lofts,
PartyField::Address(Some("1200 NW Everett St, Portland, OR 97209".into())),
)?;
s.update_party(lofts, PartyField::DisplayName(Some("Everett Lofts".into())))?;
if let Some(fern) = s.ledger().party_of_customer(customers[0]) {
s.update_party(
fern,
PartyField::Website(Some("fernandfield.coffee".into())),
)?;
s.update_party(
fern,
PartyField::Address(Some("88 SE Division St, Portland, OR 97202".into())),
)?;
}
let irs = s.add_party("Internal Revenue Service", &[Role::TaxAgency])?;
s.update_party(irs, PartyField::DisplayName(Some("IRS".into())))?;
s.update_party(irs, PartyField::Website(Some("irs.gov".into())))?;
let odr = s.add_party("Oregon Department of Revenue", &[Role::TaxAgency])?;
s.update_party(odr, PartyField::Website(Some("oregon.gov/dor".into())))?;
let old = s.add_party("Etsy, Inc.", &[Role::PayoutPlatform])?;
s.update_party(
old,
PartyField::Notes(Some("Sold templates here before moving to Gumroad.".into())),
)?;
s.update_party(old, PartyField::Archived(true))?;
}
let gumroad = s.add_party("Gumroad, Inc.", &[ledger_core::Role::PayoutPlatform])?;
s.update_party(
gumroad,
ledger_core::PartyField::DisplayName(Some("Gumroad".into())),
)?;
s.update_party(
gumroad,
ledger_core::PartyField::Website(Some("gumroad.com".into())),
)?;
let chase = s.add_party(
"JPMorgan Chase Bank, N.A.",
&[ledger_core::Role::FinancialInstitution],
)?;
s.update_party(
chase,
ledger_core::PartyField::DisplayName(Some("Chase".into())),
)?;
let alex = s.add_party("Alex Rivera", &[ledger_core::Role::Member])?;
// Formation documents.
s.file_document(
"formation",
@@ -562,7 +650,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
// Pay the card in full on the 25th.
let date = day(m, 25);
if in_range(date) && card_balance > Decimal::ZERO {
post(
let paid = post(
&mut s,
date,
"Credit card payment",
@@ -582,6 +670,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
None,
None,
)?;
s.set_entry_party(paid, Some(chase))?;
card_balance = Decimal::ZERO;
}
// Client invoices: two or three a month, net 30.
@@ -657,6 +746,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
None,
)?;
s.post_entry(e)?;
s.set_entry_party(e, Some(gumroad))?;
}
}
// Contractors.
@@ -715,7 +805,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
if month_index >= 3 {
let date = day(m, 28);
if in_range(date) {
post(
let draw = post(
&mut s,
date,
"Owner draw",
@@ -726,6 +816,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
None,
None,
)?;
s.set_entry_party(draw, Some(alex))?;
}
}
if month_index % 3 == 2 {
+12 -2
View File
@@ -1,6 +1,6 @@
//! Where you are and how you got there: a browser-style history.
use ledger_core::{AccountId, AssetId, EntryId};
use ledger_core::{AccountId, AssetId, EntryId, PartyId};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ReportKind {
@@ -63,6 +63,10 @@ pub enum Route {
Assets,
/// One fixed asset.
Asset(AssetId),
/// The party directory.
Parties,
/// One party.
Party(PartyId),
Documents,
Document(u64),
Company,
@@ -81,6 +85,7 @@ impl Route {
Route::Reports(_) => Section::Reports,
Route::Accounts | Route::Account(_) => Section::Accounts,
Route::Assets | Route::Asset(_) => Section::Assets,
Route::Parties | Route::Party(_) => Section::Parties,
Route::Documents | Route::Document(_) => Section::Documents,
Route::Company => Section::Company,
Route::Journal | Route::JournalEvent(_) => Section::Journal,
@@ -97,13 +102,14 @@ pub enum Section {
Reports,
Accounts,
Assets,
Parties,
Documents,
Company,
Journal,
}
impl Section {
pub const ALL: [Section; 10] = [
pub const ALL: [Section; 11] = [
Section::Overview,
Section::Cash,
Section::Profit,
@@ -111,6 +117,7 @@ impl Section {
Section::Reports,
Section::Accounts,
Section::Assets,
Section::Parties,
Section::Documents,
Section::Company,
Section::Journal,
@@ -125,6 +132,7 @@ impl Section {
Section::Reports => "Reports",
Section::Accounts => "Accounts",
Section::Assets => "Assets",
Section::Parties => "Parties",
Section::Documents => "Documents",
Section::Company => "Company",
Section::Journal => "Journal",
@@ -141,6 +149,7 @@ impl Section {
Section::Reports => i::FILE_TEXT,
Section::Accounts => i::TREE_STRUCTURE,
Section::Assets => i::PACKAGE,
Section::Parties => i::ADDRESS_BOOK,
Section::Documents => i::FILES,
Section::Company => i::BUILDINGS,
Section::Journal => i::SCROLL,
@@ -156,6 +165,7 @@ impl Section {
Section::Reports => Route::Reports(ReportKind::ProfitAndLoss),
Section::Accounts => Route::Accounts,
Section::Assets => Route::Assets,
Section::Parties => Route::Parties,
Section::Documents => Route::Documents,
Section::Company => Route::Company,
Section::Journal => Route::Journal,
+3 -3
View File
@@ -281,7 +281,7 @@ pub fn status_text(disposed: Option<(NaiveDate, Decimal)>) -> String {
}
}
fn notice_box(ui: &mut Ui, n: &Notice) {
pub(crate) fn notice_box(ui: &mut Ui, n: &Notice) {
let p = pal(ui.ctx());
let (fg, bg) = if n.problem {
(p.warning, p.warning_soft)
@@ -310,7 +310,7 @@ fn notice_box(ui: &mut Ui, n: &Notice) {
/// One painted table row: `cells` are (text, width, right-aligned,
/// color); the cell whose width is 0 takes what's left. A second line
/// (`sub`) goes under the flexible cell.
fn row(
pub(crate) fn row(
ui: &mut Ui,
w: f32,
cells: &[(String, f32, bool, Color32)],
@@ -1345,7 +1345,7 @@ fn tax_card(ui: &mut Ui, a: &Asset) {
}
/// A two-column grid of facts; `add` gets the label painter.
fn facts(ui: &mut Ui, add: impl FnOnce(&mut Ui, &dyn Fn(&mut Ui, &str))) {
pub(crate) fn facts(ui: &mut Ui, add: impl FnOnce(&mut Ui, &dyn Fn(&mut Ui, &str))) {
let p = pal(ui.ctx());
egui::Grid::new(ui.next_auto_id())
.num_columns(2)
+17 -4
View File
@@ -183,7 +183,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
assets(ui, b, shell);
schedules(ui, b);
recurring(ui, b);
parties(ui, b);
parties(ui, b, shell);
});
}
@@ -421,8 +421,18 @@ fn recurring(ui: &mut Ui, b: &Books) {
ui.add_space(16.0);
}
fn parties(ui: &mut Ui, b: &Books) {
fn parties(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let mut go: Option<crate::nav::Route> = None;
// A record's name opens its party.
let mut name = |ui: &mut Ui, text: &str, party: Option<u64>| {
let r = widgets::link(ui, RichText::new(text).font(theme::semibold(13.0)));
if let Some(id) = party
&& r.on_hover_text("Open this party").clicked()
{
go = Some(crate::nav::Route::Party(id));
}
};
let year = chrono::Datelike::year(&b.today);
let r1099 = vendor_1099_report(&b.ledger, year, None).ok();
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
@@ -444,7 +454,7 @@ fn parties(ui: &mut Ui, b: &Books) {
.map(|x| x.paid)
.unwrap_or_default();
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(&v.name).font(theme::semibold(13.0)));
name(ui, &v.name, b.ledger.party_of_vendor(v.id));
if let Some(f) = v.form_1099 {
widgets::pill(ui, f.label(), p.accent, p.accent_soft);
if v.tin_on_file {
@@ -490,7 +500,7 @@ fn parties(ui: &mut Ui, b: &Books) {
.map(|i| i.balance())
.sum();
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(&c.name).font(theme::semibold(13.0)));
name(ui, &c.name, b.ledger.party_of_customer(c.id));
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
@@ -509,4 +519,7 @@ fn parties(ui: &mut Ui, b: &Books) {
);
}
});
if let Some(r) = go {
shell.go(r);
}
}
+23 -1
View File
@@ -51,7 +51,9 @@ 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 {
if let Some(v) = &e.vendor
&& e.party_id.is_none()
{
widgets::pill(ui, &format!("{} {v}", icon::STOREFRONT), p.muted, p.hover);
}
if e.missing_receipt {
@@ -85,6 +87,26 @@ 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)) {
ui.add_space(4.0);
if widgets::link(
ui,
RichText::new(format!(
"{} {}",
super::parties::party_icon(party),
party.label()
))
.color(p.accent),
)
.on_hover_text(format!(
"The party this entry is with ({}): open them",
super::parties::roles_text(party).to_lowercase()
))
.clicked()
{
shell.go(Route::Party(party.id));
}
}
if let Some(&asset) = b.entry_assets.get(&e.id) {
ui.add_space(4.0);
if widgets::link(
+286 -12
View File
@@ -92,6 +92,74 @@ fn tax_basis_text(b: &Books, asset_id: Option<u64>, p: &Value) -> String {
text
}
/// What an attachment link's subject (`{"kind": .., "id": ..}`) is, in
/// words, and where it opens.
fn subject_text(b: &Books, subject: Option<&Value>) -> (String, Option<Route>) {
let kind = subject
.and_then(|s| s.get("kind"))
.and_then(|v| v.as_str())
.unwrap_or("");
let id = subject.and_then(|s| s.get("id")).and_then(|v| v.as_u64());
match (kind, id) {
("entry", Some(e)) => (format!("#{e}"), Some(Route::Entry(e))),
("account", Some(a)) => (b.account_label(a), Some(Route::Account(a))),
("asset", Some(a)) => (b.asset_label(a), Some(Route::Asset(a))),
("party", Some(p)) => (b.party_label(p), Some(Route::Party(p))),
("company", _) => ("the company".into(), Some(Route::Company)),
("payroll_run", Some(i)) => (format!("payroll run {i}"), None),
(k, Some(i)) if !k.is_empty() => (format!("{} {i}", k.replace('_', " ")), None),
_ => ("a subject".into(), None),
}
}
/// A party's label, or "a party" when the event names none.
fn party_name(b: &Books, id: Option<u64>) -> String {
match id {
Some(id) => b.party_label(id),
None => "a party".to_string(),
}
}
/// "Display name of Gumroad, Inc. set to Gumroad", "Archived Etsy": one
/// `party.updated` event in words. Names are today's (the display name's
/// change names the legal name, so it reads the same afterwards).
fn party_update_text(b: &Books, id: Option<u64>, change: Option<&Value>) -> String {
let name = party_name(b, id);
let legal = id
.and_then(|i| b.ledger.get_party(i))
.map_or_else(|| name.clone(), |p| p.name.clone());
let field = change
.and_then(|c| c.get("field"))
.and_then(|v| v.as_str())
.unwrap_or("");
let value = change.and_then(|c| c.get("value"));
let text = value.and_then(|v| v.as_str());
let words = match field {
"name" => "Name",
"display_name" => "Display name",
"website" => "Website",
"address" => "Address",
"notes" => "Notes",
_ => "",
};
match (field, text) {
("archived", _) => {
if value.and_then(|v| v.as_bool()).unwrap_or(false) {
format!("Archived {name}")
} else {
format!("Unarchived {name}")
}
}
("name", Some(t)) => format!("Renamed a party to {t}"),
("display_name", Some(t)) => format!("Display name of {legal} set to {t}"),
("display_name", None) => format!("Display name of {legal} cleared"),
("notes", Some(_)) => format!("Notes of {name} updated"),
("", _) => format!("Updated {name}"),
(_, Some(t)) => format!("{words} of {name} set to {t}"),
(_, None) => format!("{words} of {name} cleared"),
}
}
/// A sentence for a meaningful event; `None` for the intermediate steps of
/// building an entry (created, line added), which the posted event covers.
pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
@@ -177,6 +245,67 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
),
entry_id.map(Route::Entry),
),
"attachment.linked" | "attachment.unlinked" => {
let (what, route) = subject_text(b, p.get("subject"));
let name = attachment_name(b, att);
Some(Event {
icon: if line.line_type == "attachment.linked" {
icon::LINK
} else {
icon::LINK_BREAK
},
text: if line.line_type == "attachment.linked" {
format!("Linked {name} to {what}")
} else {
format!("Unlinked {name} from {what}: {}", s(p, "reason"))
},
route: Some(Route::Document(att)),
also: route,
})
}
"statement.recorded" => {
let account = p.get("account_id").and_then(|v| v.as_u64());
let closing = s(p, "closing_balance")
.parse()
.map(fmt::dollars)
.unwrap_or_default();
Some(Event {
icon: icon::FILE_TEXT,
text: format!(
"Statement for {}, {} to {}: closing balance {closing}",
account.map_or("an account".into(), |a| b.account_label(a)),
s(p, "period_start"),
s(p, "period_end")
),
route: Some(Route::Document(att)),
also: account.map(Route::Account),
})
}
"reconciliation.completed" => {
let account = p.get("account_id").and_then(|v| v.as_u64());
let balance = s(p, "statement_balance")
.parse()
.map(fmt::dollars)
.unwrap_or_default();
Some(Event {
icon: icon::CHECK_SQUARE,
text: format!(
"Reconciled {} through {} at {balance}",
account.map_or("an account".into(), |a| b.account_label(a)),
s(p, "statement_date")
),
route: account.map(Route::Account),
also: p
.get("attachment_id")
.and_then(|v| v.as_u64())
.map(Route::Document),
})
}
"reconciliation.voided" => ev(
icon::PROHIBIT,
format!("Voided reconciliation {id}: {}", s(p, "reason")),
None,
),
"attachment.withdrawn" => ev(
icon::ARCHIVE,
format!("Withdrew {}: {}", attachment_name(b, att), s(p, "reason")),
@@ -236,24 +365,169 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
"vendor.added" => ev(
icon::STOREFRONT,
format!("Added vendor {}", s(p, "name")),
Some(Route::Company),
Some(
p.get("id")
.and_then(|v| v.as_u64())
.and_then(|v| b.ledger.party_of_vendor(v))
.map_or(Route::Company, Route::Party),
),
),
"customer.added" => ev(
icon::USER_PLUS,
format!("Added customer {}", s(p, "name")),
Some(Route::Company),
Some(
p.get("id")
.and_then(|v| v.as_u64())
.and_then(|c| b.ledger.party_of_customer(c))
.map_or(Route::Company, Route::Party),
),
),
"class.added" => ev(icon::TAG, format!("Added class {}", s(p, "name")), None),
"vendor.1099_set" => ev(
icon::IDENTIFICATION_CARD,
"Updated a vendor's 1099 details".into(),
Some(Route::Company),
),
"entry.vendor_set" => ev(
icon::STOREFRONT,
format!("Tagged #{} with a vendor", entry_id.unwrap_or(0)),
entry_id.map(Route::Entry),
),
"party.added" => {
let roles: Vec<String> = p
.get("roles")
.and_then(|v| v.as_array())
.map(|rs| {
rs.iter()
.filter_map(|r| r.as_str().and_then(ledger_core::Role::parse))
.map(|r| super::parties::role_label(r).to_lowercase())
.collect()
})
.unwrap_or_default();
let name = match s(p, "name") {
"" => party_name(b, p.get("id").and_then(|v| v.as_u64())),
n => n.to_string(),
};
ev(
icon::ADDRESS_BOOK,
if roles.is_empty() {
format!("Added party {name}")
} else {
format!("Added party {name} ({})", roles.join(", "))
},
p.get("id").and_then(|v| v.as_u64()).map(Route::Party),
)
}
"party.updated" => {
let pid = p.get("id").and_then(|v| v.as_u64());
ev(
icon::PENCIL_SIMPLE,
party_update_text(b, pid, p.get("change")),
pid.map(Route::Party),
)
}
"party.role_set" => {
let pid = p.get("id").and_then(|v| v.as_u64());
let name = party_name(b, pid);
let role = ledger_core::Role::parse(s(p, "role"));
let on = p.get("on").and_then(|v| v.as_bool()).unwrap_or(true);
let text = match (role, on) {
(Some(r), true) => format!("{name} is now {}", super::parties::role_noun(r)),
(Some(r), false) => format!("{name} is no longer {}", super::parties::role_noun(r)),
(None, _) => format!("Changed the roles of {name}"),
};
ev(icon::USER_GEAR, text, pid.map(Route::Party))
}
"party.w9_set" => {
let pid = p.get("id").and_then(|v| v.as_u64());
let name = party_name(b, pid);
let text = match p.get("facts").filter(|f| !f.is_null()) {
Some(f) => {
let mut t = format!("W-9 for {name} recorded");
if let Some(d) = f.get("received").and_then(|v| v.as_str()) {
t.push_str(&format!(", received {d}"));
}
if let Some(e) = f.get("entity_type").and_then(|v| v.as_str()) {
t.push_str(&format!(": {e}"));
}
if let Some(l4) = f.get("tin_last4").and_then(|v| v.as_str()) {
t.push_str(&format!(", TIN ending {l4}"));
}
t
}
None => format!("W-9 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);
let text = match p.get("facts").filter(|f| !f.is_null()) {
Some(f) => {
let mut t = format!("W-4 for {name} recorded");
if let Some(d) = f.get("received").and_then(|v| v.as_str()) {
t.push_str(&format!(", received {d}"));
}
if let Some(st) = f.get("state").and_then(|v| v.as_str()) {
t.push_str(&format!(" (works in {st})"));
}
t
}
None => format!("W-4 facts for {name} cleared"),
};
ev(icon::IDENTIFICATION_BADGE, text, pid.map(Route::Party))
}
"entry.party_set" => {
let pid = p.get("party_id").and_then(|v| v.as_u64());
let e = entry_id.unwrap_or(0);
Some(Event {
icon: icon::ADDRESS_BOOK,
text: match pid {
Some(_) => format!("Tagged #{e} with {}", party_name(b, pid)),
None => format!("Cleared the party of #{e}"),
},
route: entry_id.map(Route::Entry),
also: pid.map(Route::Party),
})
}
"vendor.1099_set" => {
let vendor = p.get("vendor_id").and_then(|v| v.as_u64());
let name = vendor
.and_then(|v| b.ledger.get_vendor(v))
.map(|v| v.name.clone());
let form = p
.get("form_1099")
.and_then(|v| v.as_str())
.and_then(ledger_core::Form1099::parse)
.map(|f| f.label());
let w9 = p.get("tin_on_file").and_then(|v| v.as_bool());
let text = match (name, form, w9) {
(Some(n), Some(f), Some(w)) => format!(
"1099 details of {n}: {f}, W-9 {}",
if w { "on file" } else { "not on file" }
),
(Some(n), None, Some(_)) => format!("{n} no longer gets a 1099"),
(Some(n), _, None) => format!("Updated the 1099 details of {n}"),
(None, ..) => "Updated a vendor's 1099 details".into(),
};
ev(
icon::IDENTIFICATION_CARD,
text,
Some(
vendor
.and_then(|v| b.ledger.party_of_vendor(v))
.map_or(Route::Company, Route::Party),
),
)
}
"entry.vendor_set" => {
let vendor = p.get("vendor_id").and_then(|v| v.as_u64());
let e = entry_id.unwrap_or(0);
Some(Event {
icon: icon::STOREFRONT,
text: match vendor.and_then(|v| b.ledger.get_vendor(v)) {
Some(v) => format!("Tagged #{e} with vendor {}", v.name),
None if vendor.is_none() && p.get("vendor_id").is_some() => {
format!("Cleared the vendor of #{e}")
}
None => format!("Tagged #{e} with a vendor"),
},
route: entry_id.map(Route::Entry),
also: vendor
.and_then(|v| b.ledger.party_of_vendor(v))
.map(Route::Party),
})
}
"invoice.created" => ev(
icon::INVOICE,
format!("Invoice {id} created"),
+3
View File
@@ -8,6 +8,7 @@ pub mod documents;
pub mod entry;
pub mod journal;
pub mod overview;
pub mod parties;
pub mod profit;
pub mod reports;
pub mod transactions;
@@ -55,6 +56,8 @@ pub fn show(ui: &mut Ui, b: &Books, route: &Route, shell: &mut Shell<'_>) {
Route::Account(id) => accounts::show_account(ui, b, *id, shell),
Route::Assets => assets::show_register(ui, b, shell),
Route::Asset(id) => assets::show_asset(ui, b, *id, shell),
Route::Parties => parties::show_list(ui, b, shell),
Route::Party(id) => parties::show_party(ui, b, *id, shell),
Route::Documents => documents::show(ui, b, None, shell),
Route::Document(id) => documents::show(ui, b, Some(*id), shell),
Route::Company => company::show(ui, b, shell),
File diff suppressed because it is too large. Load diff
+6 -5
View File
@@ -78,7 +78,7 @@ fn list(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
ui.add(
egui::TextEdit::singleline(&mut st.query)
.hint_text(format!(
"{} Search description, account, vendor, amount",
"{} Search description, account, party, amount",
icon::MAGNIFYING_GLASS
))
.desired_width(if narrow { ui.available_width() } else { 340.0 }),
@@ -204,10 +204,11 @@ fn row(ui: &mut Ui, b: &Books, e: &EntryInfo, on: bool, since_update: f64) -> eg
max_w,
);
let accounts: Vec<String> = e.accounts.iter().map(|a| b.account_name(*a)).collect();
let mut sub = format!("#{} · {} · {}", e.id, e.origin, accounts.join(" → "));
if let Some(v) = &e.party {
sub.push_str(&format!(" · {v}"));
}
// The party first: it's what's cut last when the row is narrow.
let sub = match &e.party {
Some(v) => format!("#{} · {} · {v} · {}", e.id, e.origin, accounts.join(" → ")),
None => format!("#{} · {} · {}", e.id, e.origin, accounts.join(" → ")),
};
widgets::clipped_text(
&painter,
egui::pos2(text_left, rect.center().y + 10.0),
+40 -8
View File
@@ -1,6 +1,7 @@
//! Ctrl+K: jump to anything. Pages, reports, accounts, entries (by text,
//! amount, or #id), fixed assets (by tag, serial, or description), vendors,
//! and documents, ranked by a simple fuzzy match.
//! amount, or #id), fixed assets (by tag, serial, or description), parties
//! (vendors and customers among them, by name or display name), and
//! documents, ranked by a simple fuzzy match.
use crate::books::Books;
use crate::fmt;
@@ -224,6 +225,15 @@ pub fn fuzzy(hay: &str, query: &str) -> Option<i32> {
/// tag, serial, and description; a query found whole in its tag or
/// serial (ignoring case) adds 500, in its description 50.
/// 3. Every asset result opens Route::Asset(its id).
/// INVARIANTS (parties):
/// 4. A party whose name or display name equals the trimmed query,
/// ignoring case, scores 1500: above an entry's exact #id, below an
/// exact asset tag.
/// 5. Otherwise a party is listed when the query fuzzily matches its
/// name and display name; a query found whole in either (ignoring
/// case) adds 500.
/// 6. Every party result opens Route::Party(its id); archived parties
/// are found too, and say so.
/// ```
pub fn search(b: &Books, query: &str) -> Vec<Item> {
let q = query.trim();
@@ -329,13 +339,34 @@ pub fn search(b: &Books, query: &str) -> Vec<Item> {
});
}
}
for v in b.ledger.list_vendors() {
if let Some(score) = fuzzy(&v.name, q) {
for p in b.ledger.parties() {
let display = p.display_name.as_deref().unwrap_or("");
let exact = |t: &str| !t.is_empty() && t.trim().to_lowercase() == ql;
let whole = |t: &str| !t.is_empty() && t.to_lowercase().contains(&ql);
let score = if exact(&p.name) || exact(display) {
Some(1500)
} else {
fuzzy(&format!("{} {display}", p.name), q).map(|s| {
s + if whole(&p.name) || whole(display) {
500
} else {
0
}
})
};
if let Some(score) = score {
let mut detail = vec![crate::pages::parties::roles_text(p)];
if p.display_name.is_some() {
detail.push(p.name.clone());
}
if p.archived {
detail.push("archived".into());
}
out.push(Item {
icon: icon::STOREFRONT,
title: v.name.clone(),
detail: "Vendor".into(),
route: Route::Company,
icon: crate::pages::parties::party_icon(p),
title: p.label().to_string(),
detail: detail.join(" · "),
route: Route::Party(p.id),
score,
});
}
@@ -378,6 +409,7 @@ pub fn describe(b: &Books, r: &Route) -> String {
.map(|a| a.file_name.clone())
.unwrap_or_default(),
Route::Asset(id) => b.asset_label(*id),
Route::Party(id) => b.party_label(*id),
Route::JournalEvent(seq) => format!("Event {seq}"),
_ => String::new(),
}
+630
View File
@@ -0,0 +1,630 @@
//! Parties in the viewer: each party's totals are the books' numbers
//! (ledger-core's records and 1099 worksheet, summed exactly as
//! `PartyInfo` defines); search, books.show, the journal, the entry page,
//! and "Needs attention" know parties.
use chrono::NaiveDate;
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,
};
use ledger_gui::books::Books;
use ledger_gui::nav::{Route, Section};
use ledger_gui::pages::journal::describe;
use ledger_gui::pages::parties::{
State, W9Status, listed, masked_tin, role_counts, roles_text, w9_status,
};
use ledger_gui::{App, Source, attention, data, palette};
use ledger_store::Store;
use ledger_store::viewer::ViewerTarget;
use rust_decimal::Decimal;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
static DIRS: AtomicUsize = AtomicUsize::new(0);
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> TempDir {
let n = DIRS.fetch_add(1, Ordering::Relaxed);
let d = std::env::temp_dir().join(format!(
"ledger-gui-parties-{tag}-{}-{n}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
TempDir(d)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn cents(c: i64) -> Decimal {
Decimal::new(c, 2)
}
fn usd(c: i64) -> Money {
Money::new(cents(c), Currency::USD)
}
fn today() -> NaiveDate {
d(2026, 10, 15)
}
fn load(dir: &Path) -> Books {
data::load_once(&Source {
data_dir: dir.join("books-data"),
books_toml: None,
today: Some(today()),
})
.unwrap()
}
fn doc(c: i64) -> Vec<DocumentLine> {
vec![DocumentLine {
description: "work".into(),
amount: cents(c),
}]
}
fn w9(last4: &str) -> W9Facts {
W9Facts {
received: d(2026, 9, 1),
entity_type: "Individual/sole proprietor".into(),
exempt_payee_code: None,
fatca_code: None,
tin_last4: last4.into(),
kept_at: "wanderbooks vault".into(),
vault_record: None,
}
}
struct Fixture {
dir: TempDir,
books: Books,
/// The store's own ledger, reopened from disk (the oracle's input).
oracle: ledger_core::Ledger,
sam: PartyId,
sam_vendor: u64,
riley: PartyId,
acme: PartyId,
gumroad: PartyId,
irs: PartyId,
pat: PartyId,
old: PartyId,
bill_entry: EntryId,
invoice_entry: EntryId,
reversal: EntryId,
draft: EntryId,
remit: EntryId,
}
/// Sam (a vendor marked for a 1099-NEC, no W-9) paid by cash, card, and
/// an owner, last year and this, with a payment reversed, a bill partly
/// paid, and a draft; Riley (a contractor, no vendor record) paid by
/// cash; Acme (a customer) invoiced this year and last, partly paid;
/// Gumroad paying out; the IRS paid a payroll remittance; Pat on payroll
/// (one run by party, one older run by name); Old Co paid, then archived.
fn fixture(tag: &str) -> Fixture {
let dir = TempDir::new(tag);
let data = dir.0.join("books-data");
let mut s = Store::create(&data, Currency::USD).unwrap();
let cash = s
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let card = s
.add_account("2050", "Business Card", AccountType::Liability, None, false)
.unwrap();
let owed = s
.add_account(
"2100",
"Due to Shareholders",
AccountType::Liability,
None,
false,
)
.unwrap();
let capital = s
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let sales = s
.add_account("4100", "Template Sales", AccountType::Revenue, None, false)
.unwrap();
let fees = s
.add_account("6000", "Platform Fees", AccountType::Expense, None, false)
.unwrap();
let labor = s
.add_account("6050", "Contract Labor", AccountType::Expense, None, false)
.unwrap();
let e = s.create_entry(d(2025, 1, 1), "Owner contribution").unwrap();
s.add_line(e, cash, Side::Debit, usd(10_000_000)).unwrap();
s.add_line(e, capital, Side::Credit, usd(10_000_000))
.unwrap();
s.post_entry(e).unwrap();
let pay = |s: &mut Store, date, what: &str, cr, c| {
let e = s.create_entry(date, what).unwrap();
s.add_line(e, labor, Side::Debit, usd(c)).unwrap();
s.add_line(e, cr, Side::Credit, usd(c)).unwrap();
s.post_entry(e).unwrap();
e
};
let sam_vendor = s.add_vendor("Sam Okafor").unwrap();
s.set_vendor_1099(sam_vendor, Some(Form1099::Nec), false)
.unwrap();
let sam = s.ledger().party_of_vendor(sam_vendor).unwrap();
s.set_party_role(sam, Role::Contractor, true).unwrap();
let riley = s.add_party("Riley Chen", &[Role::Contractor]).unwrap();
let acme_c = s.add_customer("Acme Buyer").unwrap();
let acme = s.ledger().party_of_customer(acme_c).unwrap();
let gumroad = s
.add_party("Gumroad, Inc.", &[Role::PayoutPlatform])
.unwrap();
s.update_party(gumroad, PartyField::DisplayName(Some("Gumroad".into())))
.unwrap();
let irs = s
.add_party("Internal Revenue Service", &[Role::TaxAgency])
.unwrap();
let pat = s.add_party("Pat Lee", &[Role::Employee]).unwrap();
s.set_party_w4(
pat,
Some(W4Facts {
received: d(2026, 1, 2),
state: "UT".into(),
kept_at: "payroll provider".into(),
}),
)
.unwrap();
let old = s.add_party("Old Co", &[Role::Contractor]).unwrap();
// Sam: last year (not this year's), then cash, card, an owner, a
// reversed payment, a bill partly paid, and a draft.
let e = pay(&mut s, d(2025, 12, 15), "Sam, December", cash, 50_000);
s.set_entry_vendor(e, Some(sam_vendor)).unwrap();
let e = pay(&mut s, d(2026, 2, 1), "Sam, February", cash, 100_000);
s.set_entry_vendor(e, Some(sam_vendor)).unwrap();
let e = pay(&mut s, d(2026, 3, 1), "Sam, by card", card, 20_000);
s.set_entry_party(e, Some(sam)).unwrap();
let e = pay(&mut s, d(2026, 4, 1), "Sam, paid by Tobin", owed, 30_000);
s.set_entry_vendor(e, Some(sam_vendor)).unwrap();
let e = pay(&mut s, d(2026, 5, 1), "Sam, twice by mistake", cash, 25_000);
s.set_entry_vendor(e, Some(sam_vendor)).unwrap();
let reversal = s.reverse_entry(e, d(2026, 5, 2), "Paid twice").unwrap();
let bill = s
.create_bill(sam_vendor, d(2026, 6, 1), d(2026, 7, 1), doc(80_000))
.unwrap();
let bill_entry = s.ledger().get_bill(bill).unwrap().entry_id;
s.pay_bill(bill, d(2026, 6, 15), cents(50_000), cash)
.unwrap();
let draft = s.create_entry(d(2026, 10, 1), "Sam, October").unwrap();
s.add_line(draft, labor, Side::Debit, usd(9_900)).unwrap();
s.add_line(draft, cash, Side::Credit, usd(9_900)).unwrap();
s.set_entry_party(draft, Some(sam)).unwrap();
// Riley, by cash, tagged by party.
let e = pay(&mut s, d(2026, 7, 1), "Riley, July", cash, 70_000);
s.set_entry_party(e, Some(riley)).unwrap();
// Acme: last year's invoice still open, this year's partly paid.
s.create_invoice(acme_c, d(2025, 12, 1), d(2025, 12, 31), doc(40_000))
.unwrap();
let inv = s
.create_invoice(acme_c, d(2026, 2, 10), d(2026, 3, 10), doc(100_000))
.unwrap();
let invoice_entry = s.ledger().get_invoice(inv).unwrap().entry_id;
s.receive_payment(inv, d(2026, 3, 1), cents(60_000), cash)
.unwrap();
// Gumroad's payout: net to cash, fee expensed, gross revenue.
let e = s.create_entry(d(2026, 8, 1), "Gumroad payout").unwrap();
s.add_line(e, cash, Side::Debit, usd(9_000)).unwrap();
s.add_line(e, fees, Side::Debit, usd(1_000)).unwrap();
s.add_line(e, sales, Side::Credit, usd(10_000)).unwrap();
s.post_entry(e).unwrap();
s.set_entry_party(e, Some(gumroad)).unwrap();
// Payroll: Pat by party, then an older-style line by name; the
// remittance goes to the IRS.
let line = |employee: Option<PartyId>| PayrollLine {
employee_name: "Pat Lee".into(),
employee,
gross_pay: cents(100_000),
federal_withholding: cents(10_000),
state_withholding: cents(3_000),
ss_employee: cents(6_200),
medicare_employee: cents(1_450),
ss_employer: cents(6_200),
medicare_employer: cents(1_450),
other_deductions: Decimal::ZERO,
};
s.run_payroll(d(2026, 8, 15), vec![line(Some(pat))], cash)
.unwrap();
s.run_payroll(d(2026, 8, 31), vec![line(None)], cash)
.unwrap();
let remit = s.remit_payroll_taxes(d(2026, 9, 5), cash).unwrap();
s.set_entry_party(remit, Some(irs)).unwrap();
// Old Co: paid, then archived.
let e = pay(&mut s, d(2026, 1, 20), "Old Co", cash, 12_300);
s.set_entry_party(e, Some(old)).unwrap();
s.update_party(old, PartyField::Archived(true)).unwrap();
drop(s);
let oracle = Store::open(&data).unwrap().ledger().clone();
Fixture {
books: load(&dir.0),
dir,
oracle,
sam,
sam_vendor,
riley,
acme,
gumroad,
irs,
pat,
old,
bill_entry,
invoice_entry,
reversal,
draft,
remit,
}
}
#[test]
fn each_partys_totals_are_the_books_numbers() {
let f = fixture("totals");
let b = &f.books;
let o = &f.oracle;
let info = |p| b.party(p).cloned().unwrap();
// Sam: this year's cash 1,000 + card 200 + owner 300 + (250 − 250
// reversed) + 500 of the bill paid. Last year's 500, the bill itself,
// and the draft move nothing this year.
let sam = info(f.sam);
assert_eq!(sam.paid_ytd, cents(200_000));
let bill = o.get_bill(1).unwrap();
assert_eq!(sam.billed_ytd, bill.total());
assert_eq!(sam.open_bills, bill.balance());
assert_eq!(sam.open_bills, cents(30_000));
assert_eq!(sam.bills, vec![1]);
// The 1099 amount is ledger-core's worksheet line, exactly.
let r = vendor_1099_report(o, 2026, None).unwrap();
let line = r
.vendors
.iter()
.find(|v| v.vendor_id == f.sam_vendor)
.unwrap();
assert_eq!(sam.form_1099.as_ref(), Some(line));
assert_eq!(sam.threshold_1099, Some(r.threshold));
// (Cash only: card and owner-paid aren't in it.)
assert_eq!(line.paid, cents(150_000));
// Every entry tagged, drafts included, newest first.
assert!(sam.entries.contains(&f.bill_entry));
assert!(sam.entries.contains(&f.reversal));
assert!(sam.entries.contains(&f.draft));
assert_eq!(sam.entries.len(), 9, "{:?}", sam.entries);
let dates: Vec<NaiveDate> = sam
.entries
.iter()
.map(|e| b.entry(*e).unwrap().date)
.collect();
assert!(dates.windows(2).all(|w| w[0] >= w[1]), "{dates:?}");
// Riley: 700 by cash.
assert_eq!(info(f.riley).paid_ytd, cents(70_000));
// Acme: received 600 on net (negative paid); invoiced this year 1,000;
// still owes last year's 400 and this year's 400.
let acme = info(f.acme);
assert_eq!(acme.paid_ytd, cents(-60_000));
let invoices: Vec<_> = (1..o.next_invoice_id())
.filter_map(|i| o.get_invoice(i))
.collect();
assert_eq!(
acme.invoiced_ytd,
invoices
.iter()
.filter(|i| i.date >= d(2026, 1, 1))
.map(|i| i.total())
.sum::<Decimal>()
);
assert_eq!(
acme.open_invoices,
invoices.iter().map(|i| i.balance()).sum::<Decimal>()
);
assert_eq!(acme.open_invoices, cents(80_000));
assert_eq!(acme.invoices.len(), 2);
assert!(acme.form_1099.is_none());
// Gumroad: the net deposit came in.
assert_eq!(info(f.gumroad).paid_ytd, cents(-9_000));
// The IRS: the remittance's cash, from its own lines.
let remit = o.get_entry(f.remit).unwrap();
let cash = o.chart().find_by_code("1000").unwrap().id;
let out: Decimal = remit
.lines()
.iter()
.filter(|l| l.account == cash && l.side == Side::Credit)
.map(|l| l.amount.amount)
.sum();
assert!(out > Decimal::ZERO);
assert_eq!(info(f.irs).paid_ytd, out);
// Pat: both runs (one by party, one by name), no tagged entries.
let pat = info(f.pat);
assert_eq!(pat.payroll_runs.len(), 2);
assert!(pat.entries.is_empty());
assert_eq!(pat.paid_ytd, Decimal::ZERO);
// Every entry's party is the one whose list holds it.
for e in &b.entries {
if let Some(p) = e.party_id {
assert!(info(p).entries.contains(&e.id), "#{}", e.id);
assert_eq!(e.party.as_deref(), Some(b.party_label(p).as_str()));
}
}
assert_eq!(b.entry(f.bill_entry).unwrap().party_id, Some(f.sam));
assert_eq!(b.entry(f.invoice_entry).unwrap().party_id, Some(f.acme));
assert_eq!(b.entry(f.reversal).unwrap().party_id, Some(f.sam));
}
#[test]
fn the_directory_lists_filters_and_hides_archived() {
let f = fixture("list");
let b = &f.books;
let names = |st: &State| -> Vec<String> {
listed(b, st)
.iter()
.map(|p| p.label().to_string())
.collect()
};
let mut st = State::default();
assert_eq!(
names(&st),
vec![
"Acme Buyer",
"Gumroad",
"Internal Revenue Service",
"Pat Lee",
"Riley Chen",
"Sam Okafor"
]
);
st.show_archived = true;
assert!(names(&st).contains(&"Old Co".to_string()));
st.role = Some(Role::Contractor);
assert_eq!(names(&st), vec!["Old Co", "Riley Chen", "Sam Okafor"]);
st.show_archived = false;
st.query = " sam ".into();
assert_eq!(names(&st), vec!["Sam Okafor"]);
st.role = None;
st.query = "gumroad inc".into();
assert_eq!(names(&st), vec!["Gumroad"]);
let counts = role_counts(b, false);
assert!(counts.contains(&(Role::Contractor, 2)));
assert!(counts.contains(&(Role::Vendor, 1)));
assert!(!counts.iter().any(|(r, _)| *r == Role::Landlord));
let sam = b.ledger.get_party(f.sam).unwrap();
assert_eq!(roles_text(sam), "Vendor, contractor");
assert_eq!(w9_status(b, sam), W9Status::Missing);
assert_eq!(
w9_status(b, b.ledger.get_party(f.gumroad).unwrap()),
W9Status::NotNeeded
);
assert_eq!(masked_tin("1234"), "•••-••-1234");
}
#[test]
fn search_finds_parties_by_name() {
let f = fixture("search");
let b = &f.books;
for q in ["Gumroad", "gumroad, inc.", " GUMROAD "] {
let items = palette::search(b, q);
assert_eq!(items[0].route, Route::Party(f.gumroad), "{q}");
assert_eq!(items[0].score, 1500, "{q}");
}
let first_party = |q: &str| {
palette::search(b, q)
.into_iter()
.find(|i| matches!(i.route, Route::Party(_)))
};
assert_eq!(first_party("rile").unwrap().route, Route::Party(f.riley));
// Vendors and customers are found as their parties.
assert_eq!(
palette::search(b, "Sam Okafor")[0].route,
Route::Party(f.sam)
);
assert_eq!(first_party("acme").unwrap().route, Route::Party(f.acme));
// Archived parties are found, and say so.
let old = first_party("Old Co").unwrap();
assert_eq!(old.route, Route::Party(f.old));
assert!(old.detail.contains("archived"), "{}", old.detail);
assert_eq!(palette::describe(b, &Route::Party(f.gumroad)), "Gumroad");
// Transactions search by party name too.
let sam_entry = b.entry(f.bill_entry).unwrap();
assert!(sam_entry.haystack.contains("sam okafor"));
}
#[test]
fn routes_and_books_show_open_parties() {
let f = fixture("routes");
assert_eq!(Route::Parties.section(), Section::Parties);
assert_eq!(Route::Party(f.sam).section(), Section::Parties);
assert_eq!(Section::Parties.home(), Route::Parties);
assert!(Section::ALL.contains(&Section::Parties));
let books = load(&f.dir.0);
let mut h = Harness::builder()
.with_size(egui::vec2(1440.0, 920.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, Route::Overview));
h.state_mut().show(ViewerTarget::Page {
page: "parties".into(),
});
h.run_steps(2);
assert_eq!(h.state_mut().route(), &Route::Parties);
h.state_mut().show(ViewerTarget::Party {
party_id: f.gumroad,
});
h.run_steps(2);
assert_eq!(h.state_mut().route(), &Route::Party(f.gumroad));
// An unknown party is ignored.
h.state_mut().show(ViewerTarget::Party { party_id: 999 });
h.run_steps(1);
assert_eq!(h.state_mut().route(), &Route::Party(f.gumroad));
// Every party's page, and the entry page of a tagged entry, render.
for p in [f.sam, f.riley, f.acme, f.gumroad, f.irs, f.pat, f.old, 999] {
h.state_mut().goto(Route::Party(p));
h.run_steps(2);
}
h.state_mut().goto(Route::Entry(f.bill_entry));
h.run_steps(2);
h.state_mut().state_mut().parties.show_archived = true;
h.state_mut().goto(Route::Parties);
h.run_steps(2);
}
#[test]
fn journal_text_for_party_events() {
let f = fixture("journal");
let b = &f.books;
let texts: Vec<(String, Option<Route>, Option<Route>)> = b
.journal
.iter()
.filter(|l| l.line_type.starts_with("party.") || l.line_type == "entry.party_set")
.filter_map(|l| describe(b, l))
.map(|e| (e.text, e.route, e.also))
.collect();
let has = |t: &str, route: Option<Route>| {
assert!(
texts.iter().any(|(x, r, _)| x == t && *r == route),
"no {t:?} ({route:?}) in {texts:#?}"
);
};
has(
"Added party Riley Chen (contractor)",
Some(Route::Party(f.riley)),
);
has(
"Added party Gumroad, Inc. (payout platform)",
Some(Route::Party(f.gumroad)),
);
has(
"Display name of Gumroad, Inc. set to Gumroad",
Some(Route::Party(f.gumroad)),
);
has("Sam Okafor is now a contractor", Some(Route::Party(f.sam)));
has(
"W-4 for Pat Lee recorded, received 2026-01-02 (works in UT)",
Some(Route::Party(f.pat)),
);
has("Archived Old Co", Some(Route::Party(f.old)));
let tag = texts
.iter()
.find(|(x, _, _)| x.starts_with("Tagged #") && x.ends_with("with Gumroad"))
.expect("entry.party_set text");
assert!(matches!(tag.1, Some(Route::Entry(_))));
assert_eq!(tag.2, Some(Route::Party(f.gumroad)));
// Party history: its own events and its records'.
let sam = b.party(f.sam).unwrap();
let kinds: Vec<&str> = sam
.events
.iter()
.map(|&i| b.journal[i].line_type.as_str())
.collect();
for k in [
"vendor.added",
"vendor.1099_set",
"party.role_set",
"entry.vendor_set",
"entry.party_set",
] {
assert!(kinds.contains(&k), "{k} not in {kinds:?}");
}
}
#[test]
fn w9_text_never_shows_more_than_the_last_four() {
let f = fixture("w9text");
let mut s = Store::open(&f.dir.0.join("books-data")).unwrap();
s.set_party_w9(f.riley, Some(w9("6789"))).unwrap();
s.set_party_w9(f.riley, None).unwrap();
drop(s);
let b = load(&f.dir.0);
let texts: Vec<String> = b
.journal
.iter()
.filter(|l| l.line_type == "party.w9_set")
.filter_map(|l| describe(&b, l))
.map(|e| e.text)
.collect();
assert_eq!(
texts,
vec![
"W-9 for Riley Chen recorded, received 2026-09-01: Individual/sole proprietor, TIN ending 6789",
"W-9 facts for Riley Chen cleared",
]
);
}
#[test]
fn attention_asks_for_a_w9_until_its_facts_are_recorded() {
let f = fixture("attention");
let items = attention::find(&f.books);
let item = |name: &str| {
items
.iter()
.find(|a| a.title == format!("Collect a W-9 from {name}"))
};
let riley = item("Riley Chen").expect("Riley's W-9 item");
assert_eq!(riley.target, Some(Route::Party(f.riley)));
assert!(riley.detail.contains("$700.00"), "{}", riley.detail);
assert!(riley.prompt.starts_with("In wanderbooks,"));
assert!(riley.prompt.contains("books.set_w9"));
assert!(riley.prompt.contains("last four"));
assert!(item("Sam Okafor").is_some(), "Sam is paid, no W-9");
assert!(item("Old Co").is_none(), "archived");
assert!(item("Gumroad").is_none(), "no 1099 role");
// The existing items are still found.
assert!(
items
.iter()
.any(|a| a.title == "$300.00 owed on Due to Shareholders"),
"{:#?}",
items.iter().map(|a| &a.title).collect::<Vec<_>>()
);
// Recording the facts clears it.
let mut s = Store::open(&f.dir.0.join("books-data")).unwrap();
s.set_party_w9(f.riley, Some(w9("1234"))).unwrap();
drop(s);
let b = load(&f.dir.0);
let items = attention::find(&b);
assert!(
!items
.iter()
.any(|a| a.title == "Collect a W-9 from Riley Chen")
);
assert!(
items
.iter()
.any(|a| a.title == "Collect a W-9 from Sam Okafor")
);
assert_eq!(
w9_status(&b, b.ledger.get_party(f.riley).unwrap()),
W9Status::OnFile {
last4: "1234".into(),
received: d(2026, 9, 1)
}
);
}
+3
View File
@@ -134,6 +134,7 @@ fn the_viewer_never_writes_into_the_books() {
.next()
.map(|a| a.id)
.unwrap();
let party = books.ledger.parties().next().map(|p| p.id).unwrap();
drop(books);
// Follow live for a moment.
@@ -174,6 +175,8 @@ fn the_viewer_never_writes_into_the_books() {
Route::Account(cash),
Route::Assets,
Route::Asset(asset),
Route::Parties,
Route::Party(party),
Route::Document(doc),
Route::JournalEvent(event),
Route::Documents,
+15
View File
@@ -51,6 +51,19 @@ fn screenshots() {
.next()
.map(|a| a.id)
.unwrap_or(1);
// A party with the most to show: W-9 facts first, else any.
let party = std::env::var("SHOT_PARTY")
.ok()
.and_then(|p| p.parse().ok())
.or_else(|| {
loaded
.ledger
.parties()
.find(|p| p.w9.is_some() && p.vendor.is_some())
.or_else(|| loaded.ledger.parties().next())
.map(|p| p.id)
})
.unwrap_or(1);
drop(loaded);
let routes: Vec<(&str, Route)> = vec![
("overview", Route::Overview),
@@ -82,6 +95,8 @@ fn screenshots() {
("account", Route::Account(first_cash)),
("assets", Route::Assets),
("asset", Route::Asset(first_asset)),
("parties", Route::Parties),
("party", Route::Party(party)),
("document", Route::Document(last_doc)),
("event", Route::JournalEvent(last_event)),
("documents", Route::Documents),
+1
View File
@@ -614,6 +614,7 @@ fn more_origins_and_parties() {
today,
vec![ledger_core::PayrollLine {
employee_name: "Pat".into(),
employee: None,
gross_pay: Decimal::new(100_000, 2),
federal_withholding: Decimal::new(10_000, 2),
state_withholding: Decimal::new(3_000, 2),