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