Files
wanderbooks/crates/ledger-gui/src/pages/journal.rs
T
swaitsandClaude Opus 5.5 1304b9c0ad
check / check (push) Canceled after 0s
P3: dated 1099 decisions, payment methods and the card exception (defects 23, 12, 18, D-40)
A vendor's 1099 decision is its own dated fact (files NEC or MISC, not
reportable with its reason, or undetermined), applied by each payment's
date. Payment methods are recorded; card and network payments are left
out and listed; a member's check through financing counts; a bank payment
with no method counts (D-40); $2,000 for payments after 2025. One
worksheet for the tool, the viewer and the CPA package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-21 12:12:26 -06:00

1283 lines
48 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The journal: every event, in order, as recorded. The audit trail an
//! accountant wants and the raw truth everything else is built from.
use super::page;
use crate::app::Shell;
use crate::books::{Books, entry_refs};
use crate::fmt;
use crate::nav::Route;
use crate::theme::{self, pal};
use crate::widgets;
use egui::{CornerRadius, Margin, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_store::JournalLine;
use serde_json::Value;
#[derive(Default)]
pub struct State {
pub query: String,
pub hide_steps: bool,
}
pub struct Event {
pub icon: &'static str,
pub text: String,
pub route: Option<Route>,
/// A second place it's about (the asset of a depreciation entry).
pub also: Option<Route>,
}
fn s<'a>(p: &'a Value, k: &str) -> &'a str {
p.get(k).and_then(|v| v.as_str()).unwrap_or("")
}
fn amount(p: &Value) -> String {
p.get("amount")
.and_then(|v| v.as_str())
.and_then(|a| a.parse().ok())
.map(fmt::dollars)
.unwrap_or_default()
}
fn entry_text(b: &Books, id: u64) -> String {
b.entry(id)
.map(|e| {
format!(
"#{} {} ({})",
e.id,
e.description,
fmt::dollars_or(e.amount)
)
})
.unwrap_or_else(|| format!("#{id}"))
}
fn attachment_name(b: &Books, id: u64) -> String {
b.attachments
.iter()
.find(|a| a.id == id)
.map(|a| a.file_name.clone())
.unwrap_or_else(|| format!("attachment {id}"))
}
/// "Tax basis of FL-0001: personal-use conversion, contributor's basis
/// $2,000.00 → tax basis $1,700.00". The tax basis is ledger-core's
/// (`Asset::tax_basis`) for the event's rule and basis on the asset's
/// book cost, which never changes once registered.
fn tax_basis_text(b: &Books, asset_id: Option<u64>, p: &Value) -> String {
let asset = asset_id.and_then(|id| b.ledger.get_asset(id));
let name = match (asset, asset_id) {
(Some(a), _) => a.tag.clone().unwrap_or_else(|| a.description.clone()),
(None, Some(id)) => format!("asset {id}"),
(None, None) => "an asset".to_string(),
};
let rule = ledger_core::TaxBasisRule::parse(s(p, "rule"));
let contributor: Option<rust_decimal::Decimal> = p
.get("contributor_basis")
.and_then(|v| v.as_str())
.and_then(|v| v.parse().ok());
let Some(rule) = rule else {
return format!("Tax basis of {name} recorded");
};
let tax = asset.and_then(|a| {
let mut a = a.clone();
a.tax = Some(ledger_core::TaxBasis {
rule,
contributor_basis: contributor,
});
a.tax_basis()
});
let mut text = format!("Tax basis of {name}: {}", super::assets::rule_words(rule));
if let Some(c) = contributor {
text.push_str(&format!(", contributor's basis {}", fmt::dollars(c)));
}
match (tax, contributor.is_some()) {
(Some(t), true) => text.push_str(&format!(" → tax basis {}", fmt::dollars(t))),
(Some(t), false) => text.push_str(&format!(", tax basis {}", fmt::dollars(t))),
(None, _) => {}
}
text
}
/// What an attachment link's subject (`{"kind": .., "id": ..}`) is, in
/// words (Books::subject_label), and where it opens
/// (Books::subject_route).
fn subject_text(b: &Books, subject: Option<&Value>) -> (String, Option<Route>) {
let parsed =
subject.and_then(|v| serde_json::from_value::<ledger_store::Subject>(v.clone()).ok());
match parsed {
Some(ledger_store::Subject::Company) => ("the company".into(), Some(Route::Company)),
Some(sub) => (b.subject_label(sub), b.subject_route(sub)),
None => ("a subject".into(), None),
}
}
/// A date from the payload, in words ("Sep 30, 2026"), or as written.
fn date_text(p: &Value, k: &str) -> String {
let t = s(p, k);
t.parse::<chrono::NaiveDate>()
.map(fmt::date)
.unwrap_or_else(|_| t.to_string())
}
/// 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> {
let p = &line.payload;
let id = p.get("id").and_then(|v| v.as_u64()).unwrap_or(0);
let entry_id = p.get("entry_id").and_then(|v| v.as_u64());
let att = p.get("attachment_id").and_then(|v| v.as_u64()).unwrap_or(0);
let ev = |icon, text: String, route: Option<Route>| {
Some(Event {
icon,
text,
route,
also: None,
})
};
let asset_id = p.get("asset_id").and_then(|v| v.as_u64());
let asset = |id: Option<u64>| {
id.map(|a| {
b.ledger
.get_asset(a)
.map(|x| x.description.clone())
.unwrap_or_else(|| format!("asset {a}"))
})
.unwrap_or_default()
};
let with_asset = |icon, text: String| {
Some(Event {
icon,
text,
route: entry_id.map(Route::Entry),
also: asset_id.map(Route::Asset),
})
};
match line.line_type.as_str() {
"entry.created" | "entry.line_added" | "entry.line_class_set" => None,
"store.created" => ev(icon::BOOK_OPEN, "Books created".into(), None),
"entry.posted" => ev(
icon::CHECK_CIRCLE,
format!("Posted {}", entry_text(b, id)),
Some(Route::Entry(id)),
),
"entry.reversed" => ev(
icon::ARROW_COUNTER_CLOCKWISE,
format!(
"Reversed #{} with #{id}",
p.get("reverses").and_then(|v| v.as_u64()).unwrap_or(0)
),
Some(Route::Entry(id)),
),
"receipt.attached" => ev(
icon::PAPERCLIP,
match entry_id {
Some(e) => format!("Attached {} to #{e}", s(p, "file_name")),
None => format!("Filed receipt {}", s(p, "file_name")),
},
Some(Route::Document(att)),
),
"document.filed" => ev(
icon::FILE_ARROW_UP,
format!(
"Filed {} ({})",
s(p, "file_name"),
fmt::label(s(p, "category"))
),
Some(Route::Document(att)),
),
"receipt.linked" => ev(
icon::LINK,
format!(
"Linked {} to #{}",
attachment_name(b, att),
entry_id.unwrap_or(0)
),
entry_id.map(Route::Entry),
),
"receipt.unlinked" => ev(
icon::LINK_BREAK,
format!(
"Unlinked {} from #{}: {}",
attachment_name(b, att),
entry_id.unwrap_or(0),
s(p, "reason")
),
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!(
"Recorded {}'s statement, {} – {}: closing balance {closing}",
account.map_or("an account".into(), |a| b.account_label(a)),
date_text(p, "period_start"),
date_text(p, "period_end")
),
route: Some(Route::Document(att)),
also: account.map(Route::Account),
})
}
"statement.voided" => Some(Event {
icon: icon::PROHIBIT,
text: format!(
"Voided the statement recorded on {}: {}",
attachment_name(b, att),
s(p, "reason")
),
route: Some(Route::Document(att)),
also: None,
}),
"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();
let lines = p
.get("cleared")
.and_then(|v| v.as_array())
.map_or(0, |v| v.len());
Some(Event {
icon: icon::CHECK_SQUARE,
text: format!(
"Reconciled {} through {} at {balance} ({} cleared)",
account.map_or("an account".into(), |a| b.account_label(a)),
date_text(p, "statement_date"),
fmt::count(lines, "line", "lines")
),
route: p
.get("id")
.and_then(|v| v.as_u64())
.map(Route::Reconciliation),
also: account.map(Route::Account),
})
}
"reconciliation.voided" => {
let r = p
.get("id")
.and_then(|v| v.as_u64())
.and_then(|i| b.reconciliation(i));
Some(Event {
icon: icon::PROHIBIT,
text: match r {
Some(r) => format!(
"Voided the reconciliation of {} through {}: {}",
b.account_label(r.account_id),
fmt::date(r.statement_date),
s(p, "reason")
),
None => format!("Voided reconciliation {id}: {}", s(p, "reason")),
},
route: p
.get("id")
.and_then(|v| v.as_u64())
.map(Route::Reconciliation),
also: r.map(|r| Route::Account(r.account_id)),
})
}
"attachment.withdrawn" => ev(
icon::ARCHIVE,
format!("Withdrew {}: {}", attachment_name(b, att), s(p, "reason")),
Some(Route::Document(att)),
),
"account.added" => ev(
icon::PLUS_CIRCLE,
format!("Added account {} {}", s(p, "code"), s(p, "name")),
Some(Route::Account(id)),
),
"account.renumbered" => ev(
icon::TAG,
format!(
"Renumbered {} from {}",
b.account_label(p.get("account_id").and_then(|v| v.as_u64()).unwrap_or(0)),
s(p, "previous_code")
),
p.get("account_id")
.and_then(|v| v.as_u64())
.map(Route::Account),
),
// Its code and both names as journaled: a later rename or
// renumber never rewrites what this line said.
"account.renamed" => ev(
icon::TAG,
format!(
"Renamed {} from \u{201c}{}\u{201d} to \u{201c}{}\u{201d}",
s(p, "code"),
s(p, "previous"),
s(p, "name")
),
p.get("account_id")
.and_then(|v| v.as_u64())
.map(Route::Account),
),
"role.assigned" => {
let role = s(p, "role");
let control = ledger_core::control_account(role);
let purpose = control.map_or(role, |c| c.role);
let mut chars = purpose.chars();
let purpose: String = chars
.next()
.map(|f| f.to_uppercase().chain(chars).collect())
.unwrap_or_default();
let account = p.get("account_id").and_then(|v| v.as_u64());
ev(
icon::TAG,
match account {
Some(id) => format!("{purpose} now posts to {}", b.account_label(id)),
None => format!(
"{purpose} back to its default code {}",
control.map_or("", |c| c.code)
),
},
account.map(Route::Account),
)
}
"account.tax_set" => ev(
icon::TAG,
format!(
"Tax category of {}: {}",
b.account_label(p.get("account_id").and_then(|v| v.as_u64()).unwrap_or(0)),
p.get("tax_category")
.and_then(|v| v.as_str())
.unwrap_or("none")
),
p.get("account_id")
.and_then(|v| v.as_u64())
.map(Route::Account),
),
"vendor.added" => ev(
icon::STOREFRONT,
format!("Added vendor {}", s(p, "name")),
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(
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),
"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.creditor_set" => {
let pid = p.get("id").and_then(|v| v.as_u64());
let name = party_name(b, pid);
let account = p
.get("account_id")
.and_then(|v| v.as_u64())
.map(|a| b.account_name(a))
.unwrap_or_else(|| "an account".to_string());
let text = if p.get("on").and_then(|v| v.as_bool()).unwrap_or(true) {
format!("{name} is now owed on {account}")
} else {
format!("{name} is no longer owed on {account}")
};
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.tin_set" => {
let pid = p.get("id").and_then(|v| v.as_u64());
let name = party_name(b, pid);
let text = match p.get("facts").filter(|f| !f.is_null()) {
Some(f) => {
let kind = match f.get("tin_type").and_then(|v| v.as_str()) {
Some("ssn") => "SSN",
Some("ein") => "EIN",
Some("itin") => "ITIN",
_ => "TIN",
};
let l4 = f.get("last4").and_then(|v| v.as_str()).unwrap_or("????");
if f.get("purged").is_some_and(|v| !v.is_null()) {
format!("Purged {name}'s {kind} (••••{l4}) from the vault")
} else {
format!(
"Taxpayer ID for {name} recorded: {kind} ending {l4}, kept in the vault"
)
}
}
None => format!("Taxpayer ID facts for {name} cleared"),
};
ev(icon::IDENTIFICATION_CARD, text, pid.map(Route::Party))
}
"party.w4_set" => {
let pid = p.get("id").and_then(|v| v.as_u64());
let name = party_name(b, pid);
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.parties_set" => {
let e = entry_id.unwrap_or(0);
let named: Vec<(u64, String)> = p
.get("parties")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.filter_map(|x| {
let id = x.get("party_id").and_then(|v| v.as_u64())?;
let share = match x.get("share") {
Some(Value::Object(o)) => o
.get("amount")
.and_then(|v| v.as_str())
.map(|a| {
rust_decimal::Decimal::from_str_exact(a)
.map(fmt::dollars)
.unwrap_or_else(|_| a.to_string())
})
.or_else(|| {
o.get("percent")
.and_then(|v| v.as_str())
.map(|pc| format!("{pc}%"))
}),
_ => None,
};
let name = party_name(b, Some(id));
Some((
id,
match share {
Some(sh) => format!("{name} ({sh})"),
None => name,
},
))
})
.collect();
Some(Event {
icon: icon::ADDRESS_BOOK,
text: if named.is_empty() {
format!("Cleared the parties of #{e}")
} else {
format!(
"Tagged #{e} with {}",
named
.iter()
.map(|(_, n)| n.as_str())
.collect::<Vec<_>>()
.join(" and ")
)
},
route: entry_id.map(Route::Entry),
also: named.first().map(|(id, _)| Route::Party(*id)),
})
}
"entry.party_set" => {
let pid = p.get("party_id").and_then(|v| v.as_u64());
let e = entry_id.unwrap_or(0);
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_or_else(|| "a vendor".to_string(), |v| v.name.clone());
let w9 = |w: bool| if w { "on file" } else { "not on file" };
let text = match ledger_store::form1099::decision_of(line) {
// Recorded with who decided (plan defects 18, 23; D-40).
Ok((_, d, _)) if d.source.is_some() => {
let s = d.source.as_ref().expect("checked");
let what = match &d.decision {
ledger_core::Decision1099::Undetermined => "undetermined".to_string(),
other => crate::pages::reports::decision_text(other),
};
let from = d
.effective
.map(|e| format!(" from {}", fmt::date(e)))
.unwrap_or_default();
format!(
"1099 decision for {name}{from}: {what} (by {}, {})",
s.decided_by,
fmt::date(s.decided_on)
)
}
// Written before decisions were kept: a form, or none
// (which can't say whether anyone decided: plan 5.5).
Ok((_, d, flag)) => match d.decision.form() {
Some(f) => format!("1099 details of {name}: {}, W-9 {}", f.label(), w9(flag)),
None => format!(
"1099 details of {name}: no form recorded (undetermined), W-9 {}",
w9(flag)
),
},
Err(_) => format!("Updated the 1099 details of {name}"),
};
ev(
icon::IDENTIFICATION_CARD,
text,
Some(
vendor
.and_then(|v| b.ledger.party_of_vendor(v))
.map_or(Route::Company, Route::Party),
),
)
}
"vendor.w9_flag_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_or_else(|| "a vendor".to_string(), |v| v.name.clone());
let text = match p.get("tin_on_file").and_then(|v| v.as_bool()) {
Some(true) => format!("W-9 of {name} marked on file"),
Some(false) => format!("W-9 of {name} marked not on file"),
None => format!("Updated the W-9 of {name}"),
};
ev(
icon::IDENTIFICATION_CARD,
text,
Some(
vendor
.and_then(|v| b.ledger.party_of_vendor(v))
.map_or(Route::Company, Route::Party),
),
)
}
"instrument.added" => {
let text = match ledger_store::form1099::instrument_of(line) {
Ok(i) => format!("Added payment method {} ({})", i.label, i.kind.label()),
Err(_) => "Added a payment method".to_string(),
};
ev(icon::CREDIT_CARD, text, None)
}
"entry.instrument_set" => {
let e = entry_id.unwrap_or(0);
let instrument = p.get("instrument_id").and_then(|v| v.as_u64());
Some(Event {
icon: icon::CREDIT_CARD,
text: match instrument.and_then(|i| b.ledger.get_instrument(i)) {
Some(i) => format!("Payment method of #{e}: {}", i.label),
None if instrument.is_none() => format!("Cleared the payment method of #{e}"),
None => format!("Tagged #{e} with a payment method"),
},
route: entry_id.map(Route::Entry),
also: None,
})
}
"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"),
entry_id.map(Route::Entry),
),
"bill.created" => ev(
icon::RECEIPT,
format!("Bill {id} recorded"),
entry_id.map(Route::Entry),
),
"invoice.payment_received" => ev(
icon::HAND_COINS,
format!("Payment received, {}", amount(p)),
entry_id.map(Route::Entry),
),
"bill.paid" => ev(
icon::MONEY,
format!("Bill paid, {}", amount(p)),
entry_id.map(Route::Entry),
),
"period.created" => ev(
icon::CALENDAR_PLUS,
format!("Period {} created", s(p, "name")),
Some(Route::Company),
),
"period.closed" => ev(
icon::LOCK,
"Period closed".into(),
p.get("closing_entry")
.and_then(|v| v.as_u64())
.map(Route::Entry),
),
"asset.registered" => ev(
icon::PACKAGE,
format!("Registered asset {}", s(p, "description")),
Some(Route::Asset(id)),
),
"asset.tagged" => {
let tag = s(p, "tag");
let text = match p.get("previous_tag").and_then(|v| v.as_str()) {
Some(old) => format!("Retagged {} from {old} to {tag}", asset(asset_id)),
None => format!("Tagged {} {tag}", asset(asset_id)),
};
ev(icon::TAG, text, asset_id.map(Route::Asset))
}
"asset.serial_set" => {
let text = match p.get("serial").and_then(|v| v.as_str()) {
Some(serial) => format!("Serial number of {} set to {serial}", asset(asset_id)),
None => format!("Serial number of {} cleared", asset(asset_id)),
};
ev(icon::BARCODE, text, asset_id.map(Route::Asset))
}
"asset.tax_basis_set" => ev(
icon::SCALES,
tax_basis_text(b, asset_id, p),
asset_id.map(Route::Asset),
),
"asset.corrected" => ev(
icon::PENCIL_SIMPLE,
format!(
"Corrected {}: cost {} (was {}), {} months from {}. {}",
asset(asset_id),
p.get("cost")
.and_then(|c| c.get("amount"))
.and_then(|v| v.as_str())
.unwrap_or("?"),
p.get("previous")
.and_then(|c| c.get("cost"))
.and_then(|c| c.get("amount"))
.and_then(|v| v.as_str())
.unwrap_or("?"),
p.get("useful_life_months")
.and_then(|v| v.as_u64())
.unwrap_or(0),
s(p, "purchase_date"),
s(p, "reason")
),
asset_id.map(Route::Asset),
),
"asset.described" => ev(
icon::PENCIL_SIMPLE,
format!(
"Renamed asset {} to {}",
s(p, "previous_description"),
s(p, "description")
),
asset_id.map(Route::Asset),
),
"asset.acquisition_linked" => {
let entry = p.get("entry_id").and_then(|v| v.as_u64()).unwrap_or(0);
Some(Event {
icon: icon::LINK,
text: format!(
"Linked {} to the entry that acquired it, #{entry}",
asset(asset_id)
),
route: asset_id.map(Route::Asset),
also: Some(Route::Entry(entry)),
})
}
"asset.depreciation_posted" => with_asset(
icon::TREND_DOWN,
format!(
"Depreciation posted: {} {} ({})",
asset(asset_id),
s(p, "period"),
amount(p)
),
),
"asset.disposed" => with_asset(
icon::TRASH,
format!(
"Disposed {} on {} for {}",
asset(asset_id),
s(p, "date"),
p.get("proceeds")
.and_then(|v| v.get("amount"))
.and_then(|v| v.as_str())
.and_then(|a| a.parse().ok())
.map(fmt::dollars)
.unwrap_or_default()
),
),
"schedule.registered" => ev(
icon::CALENDAR,
format!("Schedule: {}", s(p, "description")),
Some(Route::Company),
),
"schedule.posted" => ev(
icon::CALENDAR_CHECK,
"Amortization posted".into(),
p.get("entry")
.and_then(|e| e.get("id"))
.and_then(|v| v.as_u64())
.map(Route::Entry),
),
"recurring.registered" => ev(
icon::REPEAT,
format!("Recurring: {}", s(p, "description")),
Some(Route::Company),
),
"journal.tail_repaired" => ev(
icon::WARNING,
format!("Cut-off journal line set aside ({} bytes)", p["bytes"]),
None,
),
"journal.format_set" => {
// The features in force with it (P3: account_renamed,
// require_tax_category): what an earlier version can't read.
let features: Vec<&str> = p["features"]
.as_array()
.map(|a| a.iter().filter_map(|f| f.as_str()).collect())
.unwrap_or_default();
ev(
icon::FILE_TEXT,
if features.is_empty() {
format!("Journal format {} declared", p["format"])
} else {
format!(
"Journal format {} declared, with {}",
p["format"],
features.join(", ")
)
},
None,
)
}
"recurring.generated" => ev(
icon::REPEAT,
"Recurring draft created".into(),
entry_id.map(Route::Entry),
),
"fx.rate_set" => ev(
icon::CURRENCY_CIRCLE_DOLLAR,
format!("{} rate set", s(p, "currency")),
None,
),
"payroll.run" => ev(
icon::USERS,
"Payroll run".into(),
entry_id.map(Route::Entry),
),
"payroll.taxes_remitted" => ev(
icon::BANK,
format!(
"Remitted payroll taxes ({})",
fmt::dollars(s(p, "total").parse().unwrap_or_default())
),
entry_id.map(Route::Entry),
),
"entry.no_party" => ev(
icon::USER_MINUS,
format!(
"Entry #{} marked as having no counterparty: {}",
entry_id.unwrap_or(0),
s(p, "reason")
),
entry_id.map(Route::Entry),
),
"line.cleared" => ev(
icon::CHECK_SQUARE,
format!("Cleared a line of #{}", entry_id.unwrap_or(0)),
entry_id.map(Route::Entry),
),
// The books' record of how they are kept (journal format 2).
"settings.set" => {
let keys: Vec<&str> = p["changes"]
.as_array()
.into_iter()
.flatten()
.filter_map(|c| c.get("key").and_then(|k| k.as_str()))
.collect();
let by = s(&p["source"], "decided_by");
ev(
icon::GEAR_SIX,
if keys.is_empty() {
format!("Settings moved into the books ({by})")
} else {
format!("Settings changed: {} ({by})", keys.join(", "))
},
Some(Route::Company),
)
}
"convention.set" => ev(
icon::SCROLL,
format!(
"Convention: {} ({}, {})",
s(p, "title"),
s(p, "kind").replace('_', " "),
s(&p["source"], "decided_by")
),
Some(Route::Company),
),
"convention.retired" => ev(
icon::SCROLL,
format!("Convention {} retired: {}", s(p, "id"), s(p, "reason")),
Some(Route::Company),
),
"decision.recorded" => ev(
icon::GAVEL,
format!(
"Decision: {} ({}, {})",
s(p, "subject"),
p["decided_by"]
.as_array()
.into_iter()
.flatten()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(" and "),
s(p, "decided_on")
),
Some(Route::Company),
),
"open_item.raised" => ev(
icon::FLAG,
format!("Open item #{id}: {}", s(p, "title")),
Some(Route::Company),
),
"open_item.resolved" => ev(
icon::CHECK_CIRCLE,
format!("Open item #{id} resolved: {}", s(p, "resolution")),
Some(Route::Company),
),
"entry.approved" => ev(
icon::SEAL_CHECK,
format!(
"Entry #{} approved by {}",
entry_id.unwrap_or(0),
s(p, "approver")
),
entry_id.map(Route::Entry),
),
other => ev(
icon::DOT,
other.to_string(),
entry_refs(line).first().copied().map(Route::Entry),
),
}
}
/// Like [`describe`], but every event gets a sentence.
pub fn describe_any(b: &Books, line: &JournalLine) -> Event {
if let Some(e) = describe(b, line) {
return e;
}
let p = &line.payload;
let id = p
.get("id")
.and_then(|v| v.as_u64())
.or_else(|| p.get("entry_id").and_then(|v| v.as_u64()))
.unwrap_or(0);
match line.line_type.as_str() {
"entry.created" => Event {
icon: icon::PENCIL_SIMPLE,
text: format!("Drafted #{id} {}", s(p, "description")),
route: Some(Route::Entry(id)),
also: None,
},
"entry.line_added" => {
let amount = p
.get("amount")
.and_then(|a| a.get("amount").or(Some(a)))
.and_then(|v| v.as_str())
.and_then(|v| v.parse().ok())
.map(fmt::money)
.unwrap_or_default();
Event {
icon: icon::PLUS,
text: format!(
"Line on #{id}: {} {} {amount}",
s(p, "side"),
b.account_label(
p.get("account_id")
.or_else(|| p.get("account"))
.and_then(|v| v.as_u64())
.unwrap_or(0)
),
),
route: Some(Route::Entry(id)),
also: None,
}
}
"entry.line_class_set" => {
let class = p
.get("class_id")
.and_then(|v| v.as_u64())
.and_then(|c| b.ledger.get_class(c as u32))
.map(|c| c.name.clone())
.unwrap_or_else(|| "no class".into());
Event {
icon: icon::TAG,
text: format!(
"Line {} of #{id} tagged {class}",
p.get("line_index")
.and_then(|v| v.as_u64())
.map_or(0, |i| i + 1)
),
route: Some(Route::Entry(id)),
also: None,
}
}
t => Event {
icon: icon::DOT,
text: t.to_string(),
route: None,
also: None,
},
}
}
pub fn show(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
if let Some(seq) = selected
&& let Some(line) = b.journal.get(seq.saturating_sub(1) as usize)
{
super::split(
ui,
"journal",
480.0,
shell,
|ui, shell| list(ui, b, selected, shell),
|ui, shell| detail(ui, b, line, shell),
);
} else {
list(ui, b, None, shell);
}
}
fn list(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
page(ui, "journal", |ui| {
widgets::page_header(
ui,
"Journal",
&format!(
"Every change to the books, in order: {} events, each hash-chained to the one before. Nothing is ever edited or deleted; corrections are new events.",
b.journal.len()
),
);
let st = &mut shell.state.journal;
ui.horizontal_wrapped(|ui| {
ui.add(
egui::TextEdit::singleline(&mut st.query)
.hint_text(format!("{} Filter events", icon::MAGNIFYING_GLASS))
.desired_width(ui.available_width().min(320.0)),
);
ui.checkbox(&mut st.hide_steps, "Hide entry-building steps");
});
ui.add_space(10.0);
let q = st.query.to_lowercase();
let hide = st.hide_steps;
let rows: Vec<(&JournalLine, Event)> = b
.journal
.iter()
.rev()
.filter(|l| !(hide && describe(b, l).is_none()))
.map(|l| (l, describe_any(b, l)))
.filter(|(l, e)| {
q.is_empty() || e.text.to_lowercase().contains(&q) || l.line_type.contains(&q)
})
.collect();
widgets::card(ui, |ui| {
let row_h = 34.0;
egui::ScrollArea::vertical()
.id_salt("journal_rows")
.max_height(ui.available_height().max(400.0))
.show_rows(ui, row_h, rows.len(), |ui, range| {
for (line, ev) in &rows[range] {
let (rect, resp) = ui
.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click());
let on = selected == Some(line.seq);
let fresh = line.seq > b.fresh_from_seq && shell.since_update < 8.0;
if on {
ui.painter()
.rect_filled(rect, CornerRadius::same(6), p.selected);
} else if fresh {
ui.painter()
.rect_filled(rect, CornerRadius::same(6), p.new_highlight);
} else if resp.hovered() {
ui.painter()
.rect_filled(rect, CornerRadius::same(6), p.hover);
}
let painter = ui.painter_at(rect);
let y = rect.center().y;
painter.text(
egui::pos2(rect.left() + 8.0, y),
egui::Align2::LEFT_CENTER,
format!("{}", line.seq),
theme::regular(12.0),
p.faint,
);
painter.text(
egui::pos2(rect.left() + 56.0, y),
egui::Align2::LEFT_CENTER,
ev.icon,
egui::FontId::proportional(15.0),
p.muted,
);
// The timestamp yields to the event when it's tight.
let ts = painter.layout_no_wrap(
fmt::timestamp(&line.ts),
theme::regular(12.0),
p.faint,
);
let ts_w = if rect.width() - 80.0 - ts.size().x - 24.0 >= 200.0 {
let w = ts.size().x;
painter.galley(
egui::pos2(rect.right() - 8.0 - w, y - ts.size().y / 2.0),
ts,
p.faint,
);
w + 24.0
} else {
8.0
};
widgets::clipped_text(
&painter,
egui::pos2(rect.left() + 80.0, y),
&ev.text,
theme::regular(13.0),
p.text,
rect.width() - 80.0 - ts_w,
);
if resp.clicked() {
shell.go(Route::JournalEvent(line.seq));
}
}
});
});
});
}
fn detail(ui: &mut Ui, b: &Books, line: &JournalLine, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let ev = describe_any(b, line);
egui::ScrollArea::vertical()
.id_salt("event_detail")
.show(ui, |ui| {
ui.label(
RichText::new(format!("Event {}", line.seq))
.font(theme::regular(12.5))
.color(p.muted),
);
ui.label(RichText::new(&ev.text).font(theme::semibold(17.0)));
ui.add_space(10.0);
let fact = |ui: &mut Ui, k: &str, v: &str| {
ui.horizontal(|ui| {
ui.add_sized(
vec2(90.0, 18.0),
egui::Label::new(
RichText::new(k).font(theme::regular(12.5)).color(p.muted),
),
);
ui.add(egui::Label::new(RichText::new(v).font(theme::regular(12.5))).wrap());
});
};
fact(ui, "Type", &line.line_type);
fact(ui, "Recorded", &fmt::timestamp(&line.ts));
fact(ui, "Previous", &line.prev);
let own = ledger_store::journal::sha256_hex(line.to_canonical().as_bytes());
fact(ui, "This hash", &own);
for r in ev.route.iter().chain(&ev.also) {
ui.add_space(6.0);
if ui
.button(format!("{} Open {}", icon::ARROW_SQUARE_OUT, {
let d = crate::palette::describe(b, r);
if d.is_empty() {
r.section().title().to_string()
} else {
d
}
}))
.clicked()
{
shell.go(r.clone());
}
}
ui.add_space(12.0);
ui.label(RichText::new("Payload").font(theme::semibold(13.0)));
ui.add_space(4.0);
let json = serde_json::to_string_pretty(&line.payload).unwrap_or_default();
egui::Frame::new()
.fill(p.hover)
.corner_radius(CornerRadius::same(8))
.inner_margin(Margin::same(10))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.add(egui::Label::new(RichText::new(json).monospace().size(12.0)).wrap());
});
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{Build, d, find, render};
/// The journal names an entry it posted by number, description and
/// amount.
#[test]
fn the_journal_names_a_posted_entry() {
let mut b = Build::new("journal-posted");
let (cash, capital) = (b.cash, b.capital);
let e = b.post_as("Opening deposit", d(2026, 1, 2), cash, capital, 100_000);
let (_dir, books) = b.books();
let (texts, _) = render(books, Route::Journal, |_| {});
find(&texts, &format!("Posted #{e} Opening deposit ($1,000.00)"));
}
}