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:
1 parent
2a188bd039
commit
a299077991
54 files changed
+10961
-178
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user