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(),
}