Attachments, statements, reconciliations, TIN vault, counterparty policy
check / check (push) Waiting to run

MCP: books.link_attachment, unlink_attachment, attachments; link_to on
attach_receipt and file_document; record_statement, statements;
books.reconcile previews and, confirmed against a recorded statement,
records the reconciliation; void_reconciliation, reconciliations;
import_bank_csv links its source file; digest lists unreconciled
accounts.

TIN vault: books.set_w9 takes the TIN and the form into the encrypted
vault (last four in the books); books.vendor_1099 include_tins and
save_csv_to for the CPA's copy; books.get_w9; books.purge_tin. A leak
test scans every tool output, the journal, the database, and the blobs.

[books] parties = off | suggest | require (default suggest): every
preview names the entry's party or suggests likely ones; require refuses
to post until it names one or is marked as having none (entry.no_party).

Viewer: documents on every subject page; statements and reconciliation
history per account; reconciliation pages; reconcile and
no-counterparty attention items; party money by period (paid out,
received in, net, what for, by month); Needs attention gets its own
full-width section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-15 15:42:37 -06:00
1 parent e265ed4339
commit ae927c3f73
41 files changed
+8463 -465

No files matched your search

Generated
+1
View File
@@ -3742,6 +3742,7 @@ dependencies = [
"ledger-core",
"ledger-import",
"ledger-store",
"ledger-vault",
"mime_guess",
"proptest",
"rmcp",
+6
View File
@@ -316,6 +316,12 @@ receipt_threshold = "25.00"
# ("Wanderland Labs LLC" -> WL); 1 to 8 letters or digits.
# asset_tag_prefix = "WL"
# Whether entries name their counterparty (the party paid or paid by):
# "suggest" (the default) has the agent propose one when an entry has none;
# "require" refuses to post until it names one or is marked as having none
# (a transfer between your own accounts); "off" never mentions it.
# parties = "suggest"
# Which accounts invoices, bills, payroll, and closes post to (receivables,
# payables, revenue, payroll, retained earnings) is not set here: those
# roles are part of the books, journaled with books.assign_role and listed
+60
View File
@@ -112,6 +112,9 @@ pub struct BooksSettings {
/// ("WL" for "WL-0001"). Unset: [`BooksSettings::asset_tag_prefix`]
/// derives it from the company name.
pub asset_tag_prefix: Option<String>,
/// `[books] parties`: how hard the agent asks for each entry's
/// counterparty. Unset: [`PartyPolicy::Suggest`].
pub parties: PartyPolicy,
/// `[vault] key_file`: the TIN vault's key file. Unset: the vault's
/// default (`~/.config/wanderbooks/vault.key`).
pub vault_key_file: Option<std::path::PathBuf>,
@@ -212,6 +215,7 @@ struct BooksSection {
#[serde(default)]
investing_accounts: Vec<String>,
asset_tag_prefix: Option<String>,
parties: Option<String>,
}
/// The return a federal tax classification files (docs/spec/sources/
@@ -532,9 +536,46 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
}
None => None,
},
parties: match raw.books.parties.as_deref() {
None | Some("suggest") => PartyPolicy::Suggest,
Some("off") => PartyPolicy::Off,
Some("require") | Some("required") => PartyPolicy::Require,
Some(other) => {
return Err(format!(
"[books] parties {other:?}: \"off\", \"suggest\", or \"require\""
));
}
},
})
}
/// `[books] parties`: whether entries name their counterparty
/// (docs/parties-and-attachments.md "Counterparties"; DECISION, Stephen,
/// 2026-10-15: the default suggests, books may require or turn it off).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PartyPolicy {
/// Never mention counterparties.
Off,
/// A draft or preview without a party names the likely one; nothing
/// is refused.
#[default]
Suggest,
/// Posting is refused until the entry names a party or is marked as
/// having none.
Require,
}
impl PartyPolicy {
/// Its wire name.
pub fn key(self) -> &'static str {
match self {
PartyPolicy::Off => "off",
PartyPolicy::Suggest => "suggest",
PartyPolicy::Require => "require",
}
}
}
/// Load `wanderbooks.toml` from a path.
pub fn load(path: &Path) -> Result<BooksSettings, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
@@ -545,6 +586,25 @@ pub fn load(path: &Path) -> Result<BooksSettings, String> {
mod tests {
use super::*;
/// `[books] parties`: suggest by default; off, suggest, require (and
/// "required") parse; anything else is refused, naming the choices.
#[test]
fn parties_policy() {
let base = "[company]\nname = \"Acme LLC\"\n[books]\n";
assert_eq!(parse(base).unwrap().parties, PartyPolicy::Suggest);
for (v, want) in [
("off", PartyPolicy::Off),
("suggest", PartyPolicy::Suggest),
("require", PartyPolicy::Require),
("required", PartyPolicy::Require),
] {
let s = parse(&format!("{base}parties = \"{v}\"\n")).unwrap();
assert_eq!(s.parties, want, "{v}");
}
let e = parse(&format!("{base}parties = \"always\"\n")).unwrap_err();
assert!(e.contains("require"), "{e}");
}
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
+53
View File
@@ -266,6 +266,8 @@ pub struct Ledger {
next_party_id: crate::PartyId,
/// The party each entry is with (the counterparty).
entry_parties: HashMap<EntryId, crate::PartyId>,
/// Entries marked as having no counterparty, with why.
entry_no_party: HashMap<EntryId, String>,
}
impl Ledger {
@@ -308,6 +310,7 @@ impl Ledger {
parties: std::collections::BTreeMap::new(),
next_party_id: 1,
entry_parties: HashMap::new(),
entry_no_party: HashMap::new(),
}
}
@@ -605,6 +608,10 @@ impl Ledger {
("parties", self.parties == other.parties),
("next_party_id", self.next_party_id == other.next_party_id),
("entry_parties", self.entry_parties == other.entry_parties),
(
"entry_no_party",
self.entry_no_party == other.entry_no_party,
),
]
.into_iter()
.filter(|(_, same)| !same)
@@ -2844,6 +2851,7 @@ impl Ledger {
.ok_or(LedgerError::UnknownParty(p))?
.vendor;
self.entry_parties.insert(entry, p);
self.entry_no_party.remove(&entry);
match vendor {
Some(v) => {
self.entry_vendors.insert(entry, v);
@@ -2861,6 +2869,51 @@ impl Ledger {
Ok(())
}
/// Mark an entry as having no counterparty, with why (a transfer
/// between the company's own accounts, an accrual, a correction), so a
/// books policy that requires parties can tell "none" from "not yet".
///
/// ```text
/// SOURCE: docs/parties-and-attachments.md "Counterparties"; DECISION
/// (Stephen, 2026-10-15).
/// SCENARIOS:
/// 1. IF the entry is unknown, already names a party, or the reason
/// is blank or fails the party text rules: SHOULD refuse.
/// 2. Tagging the entry with a party later clears the mark.
/// ```
pub fn set_entry_no_party(&mut self, entry: EntryId, reason: &str) -> Result<(), LedgerError> {
self.check_set_entry_no_party(entry, reason)?;
self.entry_no_party.insert(entry, reason.trim().to_string());
Ok(())
}
/// The validation behind [`Self::set_entry_no_party`].
pub fn check_set_entry_no_party(
&self,
entry: EntryId,
reason: &str,
) -> Result<(), LedgerError> {
if !self.entries.contains_key(&entry) {
return Err(LedgerError::UnknownEntry(entry));
}
if self.entry_parties.contains_key(&entry) {
return Err(LedgerError::InvalidParty(format!(
"entry {entry} names a party; clear it first"
)));
}
if reason.trim().is_empty() {
return Err(LedgerError::InvalidParty(
"say why the entry has no counterparty".into(),
));
}
crate::parties::check_text("reason", reason).map_err(LedgerError::InvalidParty)
}
/// Why an entry has no counterparty, when it was marked so.
pub fn entry_no_party(&self, entry: EntryId) -> Option<&str> {
self.entry_no_party.get(&entry).map(String::as_str)
}
/// The vendor an entry is tagged with.
pub fn entry_vendor(&self, entry: EntryId) -> Option<VendorId> {
self.entry_vendors.get(&entry).copied()
+7
View File
@@ -199,6 +199,13 @@ impl App {
}
Route::Party(party_id)
}
T::Reconciliation { id } => {
// Its own page: statement, balance, the lines it cleared.
if b.reconciliation(id).is_none() {
return;
}
Route::Reconciliation(id)
}
T::Search { query } => {
let st = &mut self.state.transactions;
st.query = query;
+79 -2
View File
@@ -2,7 +2,8 @@
//! 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, contractors and landlords paid without W-9 facts, and legal
//! tax basis, contractors and landlords paid without W-9 facts, bank and
//! card accounts not reconciled through last month, 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).
@@ -47,7 +48,19 @@ use rust_decimal::Decimal;
/// (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).
/// pages::parties::collect_w9_prompt, naming books.set_w9); a cash
/// or card account (Books::reconcilable_accounts) whose
/// Books::reconciliation_due is Some(date) (Todo, one per account,
/// titled "Reconcile <account> through <date>", its prompt from
/// pages::reconciliation::reconcile_prompt, naming
/// books.record_statement and books.reconcile); when `[books]
/// parties` isn't off, the posted entries dated this calendar year
/// (Jan 1 through today) that name no party and aren't marked as
/// having none (Ledger::entry_no_party), leaving out the ones the
/// engine makes from a schedule (period closes, depreciation,
/// amortization: no counterparty by nature) (one item: Info under
/// suggest, Todo under require; its prompt names
/// books.set_entry_party).
/// 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.
@@ -356,6 +369,70 @@ pub fn find(b: &Books) -> Vec<Attention> {
}
}
for a in b.reconcilable_accounts() {
let Some(through) = b.reconciliation_due(a) else {
continue;
};
let name = b.account_name(a);
out.push(Attention {
severity: Severity::Todo,
title: format!("Reconcile {name} through {}", fmt::date(through)),
detail: match b.reconciled_through(a) {
Some(d) => format!(
"Reconciled through {}. The monthly close reconciles it to each statement since.",
fmt::date(d)
),
None => "Never reconciled. The monthly close reconciles it to its statement.".into(),
},
target: Some(Route::Account(a)),
prompt: crate::pages::reconciliation::reconcile_prompt(b, a, through),
});
}
let policy = b.settings.parties;
if policy != ledger_config::PartyPolicy::Off {
let year_start = chrono::NaiveDate::from_ymd_opt(today.year(), 1, 1).expect("valid");
let unnamed: Vec<_> = b
.entries
.iter()
.filter(|e| {
e.status == EntryStatus::Posted
&& e.date >= year_start
&& e.date <= today
&& e.party_id.is_none()
&& b.ledger.entry_no_party(e.id).is_none()
&& !matches!(e.origin, "Period close" | "Depreciation" | "Amortization")
})
.collect();
if !unnamed.is_empty() {
let list: Vec<String> = unnamed
.iter()
.map(|e| format!("{} ({}, {})", e.id, e.description, e.date))
.collect();
out.push(Attention {
severity: if policy == ledger_config::PartyPolicy::Require {
Severity::Todo
} else {
Severity::Info
},
title: format!(
"{} no counterparty",
fmt::count(unnamed.len(), "entry names", "entries name")
),
detail: format!(
"Posted in {} with no party and not marked as having none. Tagging each entry with who it was with keeps the parties' totals and 1099s complete.",
today.year()
),
target: Some(Route::Transactions),
prompt: format!(
"In wanderbooks, these posted entries from {} name no counterparty: {}. Propose a party for each, grouped by party (adding any that aren't in the directory yet), and once I agree, tag them with books.set_entry_party. For any with truly no counterparty (like a transfer between the company's own accounts), mark it with books.set_entry_party with party null and a no_party_reason.",
today.year(),
list.join("; ")
),
});
}
}
let due = b.depreciation_due(today);
if due.amount > Decimal::ZERO {
let months = if due.periods.is_empty() {
+473 -6
View File
@@ -11,12 +11,14 @@ use ledger_core::{
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 ledger_store::{Attachment, JournalLine, Reconciliation, Snapshot, Statement, Subject};
use rust_decimal::Decimal;
use serde_json::Value;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::PathBuf;
use crate::nav::Route;
/// One entry, indexed for listing and detail.
#[derive(Clone, Debug)]
pub struct EntryInfo {
@@ -28,7 +30,9 @@ pub struct EntryInfo {
pub amount: Decimal,
/// Accounts touched, debits first: "6300 Software".
pub accounts: Vec<AccountId>,
/// Indexes into [`Books::attachments`] of linked, current receipts.
/// Indexes into [`Books::attachments`] of its current receipts: the
/// files filed on it and the files linked to it (`attachment.linked`
/// with an entry subject), as the server's receipt policy counts them.
pub receipts: Vec<usize>,
/// How the entry came to be, in words.
pub origin: &'static str,
@@ -216,6 +220,78 @@ pub struct Books {
pub entry_assets: HashMap<EntryId, AssetId>,
/// Every party's dealings with the company, by party id.
pub parties: BTreeMap<PartyId, PartyInfo>,
/// Every attachment's subjects (see [`Books::links_of`]), by
/// attachment id; withdrawn attachments keep theirs.
pub links: BTreeMap<u64, Vec<Subject>>,
/// Recorded bank and card statements, by period end.
pub statements: Vec<Statement>,
/// Reconciliations, voided ones included, by date then id.
pub reconciliations: Vec<Reconciliation>,
}
/// A span of dates a party's money is summed over (the party page's and
/// the directory's period picker).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PartyPeriod {
/// January 1 of today's year through today.
#[default]
ThisYear,
/// Last calendar year.
LastYear,
/// One calendar year (through today, if it is this one).
Year(i32),
/// The books' first entry through today.
AllTime,
}
/// What one party's posted entries moved over a span, and what for.
///
/// ```text
/// GOAL: answer "how much did we spend with them, and how much did we
/// get back?" (Stephen, 2026-10-15) with the books' own numbers.
/// SOURCE: ledger_core::parties::activity (paid, received) and
/// ledger_core::parties::money_accounts (the money set), with
/// Books::cash_accounts, as PartyInfo uses them.
/// INVARIANTS:
/// 1. paid, received: activity(ledger, party, span, cash accounts)'s,
/// exactly.
/// 2. net() = paid − received.
/// 3. what_for: over the party's posted entries dated in the span, each
/// account not in money_accounts with its debits − credits, the
/// accounts whose sum is zero left out, largest magnitude first
/// (then by account id). Positive: what money paid for; negative:
/// where money received came from (or, on payables and
/// receivables, what is still owed).
/// 4. Σ what_for = net(), exactly: every entry balances, so its
/// non-money lines net to what its money lines moved (a reversal
/// reverses both).
/// 5. entries: how many of the party's entries are posted and dated
/// in the span.
/// ```
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PartyFlow {
pub paid: Decimal,
pub received: Decimal,
pub what_for: Vec<(AccountId, Decimal)>,
pub entries: usize,
}
impl PartyFlow {
/// Paid out less received in.
pub fn net(&self) -> Decimal {
self.paid - self.received
}
}
/// One month of a party's money, for the out-versus-in chart.
#[derive(Clone, Debug, PartialEq)]
pub struct PartyMonth {
/// The month's first day.
pub month: NaiveDate,
/// activity's paid over the month (clipped to the span).
pub paid: Decimal,
/// activity's received over the month (clipped to the span).
pub received: Decimal,
}
/// Whether what's on disk is what the journal says.
@@ -343,12 +419,34 @@ impl Books {
events.entry(id).or_default().push(i);
}
}
let mut receipts: HashMap<EntryId, Vec<usize>> = HashMap::new();
for (i, a) in attachments.iter().enumerate() {
if let (Some(e), None) = (a.entry_id, &a.withdrawn_at) {
receipts.entry(e).or_default().push(i);
let link_rows = snap.attachment_links().map_err(|e| e.to_string())?;
let mut links: BTreeMap<u64, Vec<Subject>> = BTreeMap::new();
for a in &attachments {
if let Some(e) = a.entry_id {
links.entry(a.id).or_default().push(Subject::Entry(e));
}
}
for (att, sub) in &link_rows {
links.entry(*att).or_default().push(*sub);
}
// An entry's receipts: filed on it, or linked to it (the server's
// receipt policy counts both).
let mut receipts: HashMap<EntryId, Vec<usize>> = HashMap::new();
for (i, a) in attachments.iter().enumerate() {
if a.withdrawn_at.is_some() {
continue;
}
for sub in links.get(&a.id).into_iter().flatten() {
if let Subject::Entry(e) = sub {
let r = receipts.entry(*e).or_default();
if !r.contains(&i) {
r.push(i);
}
}
}
}
let statements = snap.statements(None).map_err(|e| e.to_string())?;
let reconciliations = snap.reconciliations(None).map_err(|e| e.to_string())?;
let fresh_from_seq = prev_seq.unwrap_or(journal.len() as u64);
let mut fresh = HashSet::new();
let closings = ledger.closing_entry_ids();
@@ -488,6 +586,9 @@ impl Books {
asset_trails,
entry_assets,
parties: BTreeMap::new(),
links,
statements,
reconciliations,
};
books.parties = books.party_infos();
books.kpis = books.compute_kpis();
@@ -821,6 +922,372 @@ impl Books {
}
}
// ------------------------------------------------------------------
// Attachment links, statements, reconciliations
// ------------------------------------------------------------------
/// One attachment by id.
pub fn attachment(&self, id: u64) -> Option<&Attachment> {
self.attachments.iter().find(|a| a.id == id)
}
/// Every subject an attachment is linked to: its filing entry (a
/// receipt filed on an entry) first, then its `attachment.linked`
/// subjects, as `Store::attachment_links` lists them.
pub fn links_of(&self, attachment_id: u64) -> &[Subject] {
self.links.get(&attachment_id).map_or(&[], |v| v.as_slice())
}
/// The current (not withdrawn) attachments linked to any of
/// `subjects`, each once, by id.
///
/// ```text
/// GOAL: every subject's page shows the files that are evidence of it.
/// SOURCE: docs/parties-and-attachments.md (Attachments: an
/// attachment links to any number of subjects; an entry's are
/// those filed on it and those linked to it);
/// Store::attachments_for, which this mirrors.
/// INVARIANTS:
/// 1. a is listed iff it isn't withdrawn and links_of(a) shares a
/// subject with `subjects`.
/// 2. No attachment twice; ascending id.
/// ```
pub fn documents_for(&self, subjects: &[Subject]) -> Vec<&Attachment> {
self.attachments
.iter()
.filter(|a| a.withdrawn_at.is_none())
.filter(|a| self.links_of(a.id).iter().any(|s| subjects.contains(s)))
.collect()
}
/// The subjects whose documents an entry's page shows: the entry, and
/// the invoice, bill, or payroll run it records (those have no page
/// of their own).
pub fn entry_subjects(&self, id: EntryId) -> Vec<Subject> {
let l = &self.ledger;
let mut out = vec![Subject::Entry(id)];
out.extend(
l.invoices()
.into_iter()
.filter(|i| i.entry_id == id)
.map(|i| Subject::Invoice(i.id)),
);
out.extend(
l.bills()
.into_iter()
.filter(|b| b.entry_id == id)
.map(|b| Subject::Bill(b.id)),
);
out.extend(
l.list_payroll_runs()
.into_iter()
.filter(|r| r.entry_id == id)
.map(|r| Subject::PayrollRun(r.id)),
);
out
}
/// A subject in words: "#12 Adobe (…)", "1000 Operating Checking",
/// "WL-0001 MacBook Pro", a party's label, "Invoice 3", "Period 2025",
/// "Reconciliation of 1000 Operating Checking through Sep 30, 2026",
/// "the company".
pub fn subject_label(&self, s: Subject) -> String {
let l = &self.ledger;
match s {
Subject::Entry(e) => self
.entry(e)
.map(|x| format!("#{} {}", x.id, x.description))
.unwrap_or_else(|| format!("#{e}")),
Subject::Account(a) => self.account_label(a),
Subject::Asset(a) => self.asset_label(a),
Subject::Party(p) => self.party_label(p),
Subject::Invoice(i) => format!("Invoice {i}"),
Subject::Bill(i) => format!("Bill {i}"),
Subject::Period(i) => l
.list_periods()
.iter()
.find(|p| p.id == i)
.map(|p| format!("Period {}", p.name))
.unwrap_or_else(|| format!("Period {i}")),
Subject::PayrollRun(i) => match l.get_payroll_run(i) {
Some(r) => format!("Payroll {}", crate::fmt::date(r.date)),
None => format!("Payroll run {i}"),
},
Subject::Schedule(i) => l
.get_schedule(i)
.map(|x| x.description.clone())
.unwrap_or_else(|| format!("Schedule {i}")),
Subject::Reconciliation(i) => match self.reconciliation(i) {
Some(r) => format!(
"Reconciliation of {} through {}",
self.account_label(r.account_id),
crate::fmt::date(r.statement_date)
),
None => format!("Reconciliation {i}"),
},
Subject::Company => "the company".into(),
}
}
/// Where a subject opens: its page, or (an invoice, bill, or payroll
/// run) the entry that records it, or (a period, schedule, the
/// company) the company page.
pub fn subject_route(&self, s: Subject) -> Option<Route> {
let l = &self.ledger;
Some(match s {
Subject::Entry(e) => Route::Entry(e),
Subject::Account(a) => Route::Account(a),
Subject::Asset(a) => Route::Asset(a),
Subject::Party(p) => Route::Party(p),
Subject::Invoice(i) => Route::Entry(l.get_invoice(i)?.entry_id),
Subject::Bill(i) => Route::Entry(l.get_bill(i)?.entry_id),
Subject::PayrollRun(i) => Route::Entry(l.get_payroll_run(i)?.entry_id),
Subject::Reconciliation(i) => Route::Reconciliation(i),
Subject::Period(_) | Subject::Schedule(_) | Subject::Company => Route::Company,
})
}
/// The statement an attachment records, if any.
pub fn statement(&self, attachment_id: u64) -> Option<&Statement> {
self.statements
.iter()
.find(|s| s.attachment_id == attachment_id)
}
/// One account's recorded statements, by period end.
pub fn statements_for(&self, account: AccountId) -> Vec<&Statement> {
self.statements
.iter()
.filter(|s| s.account_id == account)
.collect()
}
/// One reconciliation by id.
pub fn reconciliation(&self, id: u64) -> Option<&Reconciliation> {
self.reconciliations.iter().find(|r| r.id == id)
}
/// One account's reconciliations, voided ones included, by date.
pub fn reconciliations_for(&self, account: AccountId) -> Vec<&Reconciliation> {
self.reconciliations
.iter()
.filter(|r| r.account_id == account)
.collect()
}
/// The latest statement date an account is reconciled through: the
/// newest reconciliation of it not voided (None: never reconciled).
pub fn reconciled_through(&self, account: AccountId) -> Option<NaiveDate> {
self.reconciliations
.iter()
.filter(|r| r.account_id == account && r.voided.is_none())
.map(|r| r.statement_date)
.max()
}
/// The last day of the month before today's.
pub fn last_month_end(&self) -> NaiveDate {
self.month_start() - chrono::Days::new(1)
}
/// The accounts the books expect to reconcile monthly: the cash
/// accounts, then the card accounts, each once.
pub fn reconcilable_accounts(&self) -> Vec<AccountId> {
let mut out = self.cash_accounts.clone();
for a in &self.card_accounts {
if !out.contains(a) {
out.push(*a);
}
}
out
}
/// When an account is due a reconciliation: the date it should be
/// reconciled through, else None.
///
/// ```text
/// GOAL: the monthly close leaves every bank and card account
/// reconciled through last month's end (docs/parties-and-
/// attachments.md, Reconciliations: "an account with activity
/// not reconciled through last month's end").
/// SOURCE: that rule; CONVENTION (a bank or card statement closes
/// monthly; AGENTS.md's monthly close reconciles the bank).
/// INVARIANTS:
/// 1. Some(last_month_end()) iff the account has a posted line
/// dated on or before last_month_end() (activity to reconcile)
/// and reconciled_through(account) is None or earlier than
/// last_month_end().
/// 2. None otherwise (including activity only this month).
/// ```
pub fn reconciliation_due(&self, account: AccountId) -> Option<NaiveDate> {
let through = self.last_month_end();
let active = self
.ledger
.posted_entries()
.any(|e| e.date() <= through && e.lines().iter().any(|l| l.account == account));
(active && self.reconciled_through(account).is_none_or(|d| d < through)).then_some(through)
}
// ------------------------------------------------------------------
// A party's money over a span
// ------------------------------------------------------------------
/// The dates a [`PartyPeriod`] covers: a year is January 1 to
/// December 31, cut at today; all time starts at the earliest entry
/// in the books (today when there is none).
pub fn party_span(&self, period: PartyPeriod) -> (NaiveDate, NaiveDate) {
let year = |y: i32| {
(
NaiveDate::from_ymd_opt(y, 1, 1).expect("valid"),
NaiveDate::from_ymd_opt(y, 12, 31)
.expect("valid")
.min(self.today),
)
};
match period {
PartyPeriod::ThisYear => year(self.today.year()),
PartyPeriod::LastYear => year(self.today.year() - 1),
PartyPeriod::Year(y) => year(y),
PartyPeriod::AllTime => {
let first = self
.entries
.iter()
.map(|e| e.date)
.min()
.unwrap_or(self.today)
.min(self.today);
(first, self.today)
}
}
}
/// The calendar year a period's 1099 amount is for (all time: this
/// year's).
pub fn party_period_year(&self, period: PartyPeriod) -> i32 {
match period {
PartyPeriod::LastYear => self.today.year() - 1,
PartyPeriod::Year(y) => y,
PartyPeriod::ThisYear | PartyPeriod::AllTime => self.today.year(),
}
}
/// A period in words: "This year (2026)", "Last year (2025)", "2024",
/// "All time".
pub fn party_period_label(&self, period: PartyPeriod) -> String {
let y = self.today.year();
match period {
PartyPeriod::ThisYear => format!("This year ({y})"),
PartyPeriod::LastYear => format!("Last year ({})", y - 1),
PartyPeriod::Year(x) => x.to_string(),
PartyPeriod::AllTime => "All time".into(),
}
}
/// The periods to offer for a party (None: for the directory): this
/// year, last year, every other year with a posted entry (the
/// party's, or any for the directory), newest first, then all time.
pub fn party_periods(&self, party: Option<PartyId>) -> Vec<PartyPeriod> {
let y = self.today.year();
let mut years: Vec<i32> = self
.entries
.iter()
.filter(|e| e.status == EntryStatus::Posted && e.date <= self.today)
.filter(|e| party.is_none_or(|p| e.party_id == Some(p)))
.map(|e| e.date.year())
.filter(|&x| x < y - 1)
.collect();
years.sort_unstable_by(|a, b| b.cmp(a));
years.dedup();
let mut out = vec![PartyPeriod::ThisYear, PartyPeriod::LastYear];
out.extend(years.into_iter().map(PartyPeriod::Year));
out.push(PartyPeriod::AllTime);
out
}
/// What a party's posted entries moved over `span` (see
/// [`PartyFlow`]). `with_breakdown` false leaves what_for empty (the
/// directory needs only the totals).
pub fn party_flow(
&self,
party: PartyId,
span: (NaiveDate, NaiveDate),
with_breakdown: bool,
) -> PartyFlow {
let l = &self.ledger;
let a = ledger_core::parties::activity(l, party, span, &self.cash_accounts);
let posted: Vec<&ledger_core::JournalEntry> = a
.entries
.iter()
.filter_map(|&id| l.get_entry(id))
.filter(|e| {
e.status() == EntryStatus::Posted && e.date() >= span.0 && e.date() <= span.1
})
.collect();
let mut what_for = Vec::new();
if with_breakdown {
let money = ledger_core::parties::money_accounts(l, &self.cash_accounts);
let mut by: BTreeMap<AccountId, Decimal> = BTreeMap::new();
for e in &posted {
for line in e.lines().iter().filter(|x| !money.contains(&x.account)) {
*by.entry(line.account).or_default() += match line.side {
Side::Debit => line.amount.amount,
Side::Credit => -line.amount.amount,
};
}
}
what_for = by.into_iter().filter(|(_, v)| !v.is_zero()).collect();
what_for.sort_by(|x, y| y.1.abs().cmp(&x.1.abs()).then(x.0.cmp(&y.0)));
}
PartyFlow {
paid: a.paid,
received: a.received,
what_for,
entries: posted.len(),
}
}
/// A party's 1099 worksheet line for a calendar year and that year's
/// threshold: ledger-core's vendor_1099_report_with (cash and
/// financing accounts) line for its vendor record; None without a
/// vendor record in the report, or when the year's threshold isn't
/// known.
pub fn party_1099(&self, party: PartyId, year: i32) -> Option<(Vendor1099Line, Decimal)> {
let l = &self.ledger;
let vendor = l.get_party(party)?.vendor?;
let sources = ledger_core::PaymentAccounts {
cash: self.cash_accounts.clone(),
financing: self.financing_accounts.clone(),
};
let r = ledger_core::vendor_1099_report_with(l, year, None, &sources).ok()?;
let line = r.vendors.iter().find(|v| v.vendor_id == vendor)?.clone();
Some((line, r.threshold))
}
/// A party's money month by month over `span`: every month the span
/// touches, each month's activity clipped to the span (so the months
/// sum to the span's paid and received exactly).
pub fn party_months(&self, party: PartyId, span: (NaiveDate, NaiveDate)) -> Vec<PartyMonth> {
let mut out = Vec::new();
let mut m = NaiveDate::from_ymd_opt(span.0.year(), span.0.month(), 1).expect("valid");
while m <= span.1 {
let end = (m + chrono::Months::new(1) - chrono::Days::new(1)).min(span.1);
let start = m.max(span.0);
let a = ledger_core::parties::activity(
&self.ledger,
party,
(start, end),
&self.cash_accounts,
);
out.push(PartyMonth {
month: m,
paid: a.paid,
received: a.received,
});
m = m + chrono::Months::new(1);
}
out
}
/// Blob store path for an attachment.
pub fn blob_root(&self) -> PathBuf {
self.data_dir.join("blobs")
+165 -5
View File
@@ -2,15 +2,17 @@
//! small design studio (single-member LLC) with client invoices, template
//! sales paid out by a platform, contractors (one without a W-9), rent on a
//! recurring schedule, a credit card, a laptop on depreciation, prepaid
//! insurance, owner draws, a closed year, PDF receipts, and a few loose
//! ends for the "needs attention" list. Deterministic for a given `today`.
//! insurance, owner draws, a closed year, PDF receipts, bank statements
//! with reconciliations (one voided and redone), documents linked to the
//! company, an asset, and a party, and a few loose ends for the "needs
//! attention" list. Deterministic for a given `today`.
use chrono::{Datelike, Days, Months, NaiveDate};
use ledger_core::{
AccountType, CloseTo, Currency, Form1099, Frequency, Money, NewAsset, NewRecurring,
NewSchedule, ScheduleKind, Side, arap::DocumentLine,
};
use ledger_store::{Store, StoreError};
use ledger_store::{Statement, Store, StoreError, Subject};
use rust_decimal::Decimal;
use std::path::Path;
@@ -339,7 +341,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
let alex = s.add_party("Alex Rivera", &[ledger_core::Role::Member])?;
// Formation documents.
s.file_document(
let (articles, _, _) = s.file_document(
"formation",
"Articles of Organization.pdf",
"application/pdf",
@@ -353,7 +355,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
),
Some("Filed with the state"),
)?;
s.file_document(
let (ein_letter, _, _) = s.file_document(
"tax",
"IRS EIN confirmation.pdf",
"application/pdf",
@@ -894,5 +896,163 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
month_index += 1;
}
let _ = (bank_fees, client_class);
// The company's papers, linked to it; the laptop's warranty and
// photo, linked to the asset; Sam's contract, linked to Sam and the
// company.
s.link_attachment(articles, Subject::Company)?;
s.link_attachment(ein_letter, Subject::Company)?;
let (warranty, _, _) = s.file_document(
"warranty",
"MacBook Pro AppleCare.pdf",
"application/pdf",
&receipt_pdf(
"AppleCare+ for Mac",
&[
"Agreement for MacBook Pro 14-inch".into(),
format!("Coverage from {}", laptop_date.format("%B %-d, %Y")),
"Three years of hardware coverage".into(),
],
),
Some("Warranty, three years"),
)?;
s.link_attachment(warranty, Subject::Asset(asset))?;
let sam_party = s.ledger().party_of_vendor(sam).expect("linked");
let (contract_doc, _, _) = s.file_document(
"contract",
"Sam Okafor services agreement.pdf",
"application/pdf",
&receipt_pdf(
"Independent Contractor Agreement",
&[
"Juniper Studio LLC and Sam Okafor".into(),
"Illustration services, billed per project".into(),
format!("Signed {}", (start + Months::new(1)).format("%B %-d, %Y")),
],
),
Some("Signed agreement"),
)?;
s.link_attachment(contract_doc, Subject::Party(sam_party))?;
s.link_attachment(contract_doc, Subject::Company)?;
// Statements and reconciliations: savings reconciled through last
// month; checking through the month before (its first reconciliation
// voided and redone against the bank's reissued statement, one check
// outstanding at the first statement); the card never.
let month_end = |m: NaiveDate| m + Months::new(1) - Days::new(1);
let lines_on = |s: &Store, account: u64, through: NaiveDate| -> Vec<(u64, usize, NaiveDate)> {
let mut out = Vec::new();
for e in s.ledger().posted_entries() {
if e.date() <= through {
for (i, l) in e.lines().iter().enumerate() {
if l.account == account && !s.ledger().is_cleared(e.id(), i) {
out.push((e.id(), i, e.date()));
}
}
}
}
out.sort_by_key(|x| (x.2, x.0, x.1));
out
};
let statement = |s: &mut Store,
account: u64,
name: &str,
month: NaiveDate,
note: &str,
opening: Option<Decimal>|
-> Result<u64, StoreError> {
let end = month_end(month);
// The bank's opening is its last closing: what had cleared by then.
let opening = match opening {
Some(o) => o,
None => {
s.ledger()
.reconciliation_report(account, month - Days::new(1), Decimal::ZERO)?
.cleared_balance
}
};
let closing = s
.ledger()
.reconciliation_report(account, end, Decimal::ZERO)?
.cleared_balance;
let (att, _, _) = s.file_document(
"statement",
&format!(
"{name} statement {}{}.pdf",
month.format("%Y-%m"),
if note.is_empty() {
String::new()
} else {
format!(" ({})", note.to_lowercase())
}
),
"application/pdf",
&receipt_pdf(
&format!("{name} statement"),
&[
format!(
"{} through {}",
month.format("%B %-d, %Y"),
end.format("%B %-d, %Y")
),
format!("Opening balance: ${opening}"),
format!("Closing balance: ${closing}"),
note.to_string(),
],
),
Some(&format!("{name}, {}", month.format("%B %Y"))),
)?;
s.record_statement(Statement {
attachment_id: att,
account_id: account,
period_start: month,
period_end: end,
opening_balance: opening,
closing_balance: closing,
})?;
Ok(att)
};
let last_month = this_month - Months::new(1);
// Savings: every line through last month cleared.
for (e, i, _) in lines_on(&s, savings, month_end(last_month)) {
s.mark_cleared(e, i, true)?;
}
let st = statement(&mut s, savings, "Savings", last_month, "", None)?;
s.complete_reconciliation(st)?;
// Checking, three months back: the month's last check still out.
let m1 = this_month - Months::new(3);
let pending = lines_on(&s, checking, month_end(m1));
let outstanding = pending.last().map(|x| (x.0, x.1));
for (e, i, _) in pending {
if Some((e, i)) != outstanding {
s.mark_cleared(e, i, true)?;
}
}
let first = statement(&mut s, checking, "Operating Checking", m1, "", None)?;
let voided = s.complete_reconciliation(first)?;
s.void_reconciliation(
voided.id,
"The bank reissued this statement; reconciled again against the corrected copy",
)?;
let reissued = statement(&mut s, checking, "Operating Checking", m1, "Reissued", None)?;
let m1_closing = s
.ledger()
.reconciliation_report(checking, month_end(m1), Decimal::ZERO)?
.cleared_balance;
s.complete_reconciliation(reissued)?;
// Two months back: everything through it cleared.
let m2 = this_month - Months::new(2);
for (e, i, _) in lines_on(&s, checking, month_end(m2)) {
s.mark_cleared(e, i, true)?;
}
let second = statement(
&mut s,
checking,
"Operating Checking",
m2,
"",
Some(m1_closing),
)?;
s.complete_reconciliation(second)?;
Ok(())
}
+4 -1
View File
@@ -59,6 +59,9 @@ pub enum Route {
Reports(ReportKind),
Accounts,
Account(AccountId),
/// One reconciliation: its statement, balance, and the lines it
/// cleared.
Reconciliation(u64),
/// The fixed asset register.
Assets,
/// One fixed asset.
@@ -83,7 +86,7 @@ impl Route {
Route::Profit => Section::Profit,
Route::Transactions | Route::Entry(_) => Section::Transactions,
Route::Reports(_) => Section::Reports,
Route::Accounts | Route::Account(_) => Section::Accounts,
Route::Accounts | Route::Account(_) | Route::Reconciliation(_) => Section::Accounts,
Route::Assets | Route::Asset(_) => Section::Assets,
Route::Parties | Route::Party(_) => Section::Parties,
Route::Documents | Route::Document(_) => Section::Documents,
+198 -2
View File
@@ -1,6 +1,8 @@
//! The chart of accounts with balances, and one account's register: every
//! posting with a running balance and the balance over time.
//! posting with a running balance and the balance over time, its bank or
//! card statements, its reconciliation history, and its documents.
use super::assets::row;
use super::page;
use crate::app::Shell;
use crate::books::Books;
@@ -11,9 +13,11 @@ use crate::widgets;
use chrono::NaiveDate;
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::AccountId;
use ledger_core::AccountType;
use ledger_core::analysis::balance_history;
use ledger_core::reports::general_ledger;
use ledger_store::Subject;
use rust_decimal::Decimal;
#[derive(Default)]
@@ -185,7 +189,10 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
.color(p.muted),
&fmt::plain(&fmt::dollars(bal)).unwrap_or_default(),
);
ui.add_space(14.0);
ui.add_space(6.0);
let mut go_to: Option<Route> = None;
reconciled_badge(ui, b, id);
ui.add_space(8.0);
let hist = balance_history(&b.ledger, &[id]);
if hist.len() > 1 && a.account_type.is_balance_sheet() {
widgets::titled_card(
@@ -196,6 +203,19 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
);
ui.add_space(12.0);
}
statements_and_reconciliations(ui, b, id, &mut go_to);
super::documents::documents_card(
ui,
b,
"Documents",
&[Subject::Account(id)],
"No documents linked to this account (statements, the agreement that opened it).",
&mut go_to,
);
ui.add_space(14.0);
if let Some(r) = go_to.take() {
shell.go(r);
}
ui.horizontal_wrapped(|ui| {
ui.label(
RichText::new(format!(
@@ -401,3 +421,179 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
}
});
}
/// Where the account stands on reconciliation: "Reconciled through
/// <date>" (in green when that covers last month, else with a notice and
/// the agent prompt), or "Never reconciled" for an account the books
/// expect to reconcile or one with statements. Nothing for other
/// accounts.
fn reconciled_badge(ui: &mut Ui, b: &Books, id: AccountId) {
let p = pal(ui.ctx());
let expected = b.reconcilable_accounts().contains(&id);
let through = b.reconciled_through(id);
if !expected && through.is_none() && b.statements_for(id).is_empty() {
return;
}
let due = b.reconciliation_due(id);
ui.horizontal_wrapped(|ui| {
match through {
Some(d) => {
let (fg, bg) = if due.is_some() {
(p.warning, p.warning_soft)
} else {
(p.positive, p.positive_soft)
};
widgets::pill(
ui,
&format!("{} Reconciled through {}", icon::CHECK_SQUARE, fmt::date(d)),
fg,
bg,
);
}
None => {
widgets::pill(ui, "Never reconciled", p.warning, p.warning_soft);
}
}
if let Some(d) = due {
ui.label(
RichText::new(format!(
"Due through {}: the monthly close reconciles every bank and card account to its statement.",
fmt::date(d)
))
.font(theme::regular(12.5))
.color(p.muted),
);
widgets::copy_prompt(ui, &super::reconciliation::reconcile_prompt(b, id, d));
}
});
}
/// The account's statements (period, opening, closing, the file) and its
/// reconciliations (date, statement balance, cleared lines, standing),
/// side by side when there's room. Nothing when it has neither.
fn statements_and_reconciliations(ui: &mut Ui, b: &Books, id: AccountId, go: &mut Option<Route>) {
let p = pal(ui.ctx());
let statements = b.statements_for(id);
let recs = b.reconciliations_for(id);
if statements.is_empty() && recs.is_empty() {
return;
}
super::columns(ui, 1100.0, &[1.0, 1.0], |col, ui| {
if col == 0 {
widgets::titled_card(
ui,
"Statements",
|_| {},
|ui| {
if statements.is_empty() {
ui.label(RichText::new("No statements recorded.").color(p.muted));
return;
}
let amt = widgets::AMOUNT_MIN + 8.0;
let w = ui.available_width().max(180.0 + amt * 2.0 + 120.0);
widgets::min_width(ui, "statements", w, |ui| {
let head =
|t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
row(
ui,
w,
&[
head("Period", 0.0, false),
head("Opening", amt, true),
head("Closing", amt, true),
],
None,
true,
false,
);
widgets::rule(ui);
for st in statements.iter().rev() {
let file = b
.attachment(st.attachment_id)
.map_or_else(String::new, |a| a.file_name.clone());
let resp = row(
ui,
w,
&[
(
format!(
"{} – {}",
fmt::date_short(st.period_start),
fmt::date(st.period_end)
),
0.0,
false,
p.text,
),
(fmt::money(st.opening_balance), amt, true, p.muted),
(fmt::money(st.closing_balance), amt, true, p.text),
],
Some((&format!("{} {file}", icon::FILE_PDF), p.faint)),
false,
true,
);
if resp.on_hover_text("Open the statement").clicked() {
*go = Some(Route::Document(st.attachment_id));
}
}
});
},
);
} else {
widgets::titled_card(
ui,
"Reconciliations",
|_| {},
|ui| {
if recs.is_empty() {
ui.label(RichText::new("Never reconciled.").color(p.muted));
return;
}
let amt = widgets::AMOUNT_MIN + 8.0;
let w = ui.available_width().max(130.0 + amt + 70.0 + 110.0);
widgets::min_width(ui, "reconciliations", w, |ui| {
let head =
|t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
row(
ui,
w,
&[
head("Through", 0.0, false),
head("Balance", amt, true),
head("Lines", 70.0, true),
],
None,
true,
false,
);
widgets::rule(ui);
for r in recs.iter().rev() {
use super::reconciliation::Standing;
let (sub, color) = match super::reconciliation::standing(b, r) {
Standing::Current => ("Current".to_string(), p.positive),
Standing::Superseded => ("Superseded".to_string(), p.faint),
Standing::Voided(why) => (format!("Voided: {why}"), p.warning),
};
let text = if r.voided.is_some() { p.muted } else { p.text };
let resp = row(
ui,
w,
&[
(fmt::date(r.statement_date), 0.0, false, text),
(fmt::money(r.statement_balance), amt, true, text),
(r.cleared.len().to_string(), 70.0, true, p.muted),
],
Some((&sub, color)),
false,
true,
);
if resp.on_hover_text("Open this reconciliation").clicked() {
*go = Some(Route::Reconciliation(r.id));
}
}
});
},
);
}
});
}
+8 -50
View File
@@ -1164,58 +1164,16 @@ pub fn show_asset(ui: &mut Ui, b: &Books, id: AssetId, shell: &mut Shell<'_>) {
},
);
ui.add_space(14.0);
let docs: Vec<_> = b
.attachments
.iter()
.filter(|x| {
x.withdrawn_at.is_none() && x.entry_id.is_some_and(|e| entries.contains(&e))
})
.collect();
widgets::titled_card(
let mut subjects = vec![ledger_store::Subject::Asset(id)];
subjects.extend(entries.iter().map(|e| ledger_store::Subject::Entry(*e)));
super::documents::documents_card_with(
ui,
b,
"Receipts and documents",
|_| {},
|ui| {
if docs.is_empty() {
ui.label(
RichText::new("None linked to its entries (depreciation needs none: the schedule is its evidence).")
.color(p.muted),
);
}
for d in &docs {
ui.horizontal(|ui| {
ui.label(
RichText::new(if d.mime_type == "application/pdf" {
icon::FILE_PDF
} else {
icon::FILE_IMAGE
})
.color(p.muted),
);
if widgets::link_truncated(
ui,
RichText::new(format!(
"{} · {} · entry #{}",
d.file_name,
fmt::label(&d.category),
d.entry_id.unwrap_or(0)
))
.font(theme::regular(13.0)),
)
.clicked()
{
go = Some(Route::Document(d.id));
}
});
if let Some(desc) = &d.description {
ui.label(
RichText::new(desc)
.font(theme::regular(11.5))
.color(p.faint),
);
}
}
},
&subjects,
&[ledger_store::Subject::Asset(id)],
"None linked to it or its entries (photos, the warranty, the manual; depreciation needs none: the schedule is its evidence).",
&mut go,
);
ui.add_space(14.0);
+26
View File
@@ -180,6 +180,32 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
);
}
});
let mut subjects = vec![ledger_store::Subject::Company];
subjects.extend(
b.ledger
.list_periods()
.iter()
.map(|x| ledger_store::Subject::Period(x.id)),
);
subjects.extend(
b.ledger
.schedules()
.map(|x| ledger_store::Subject::Schedule(x.id)),
);
let mut go = None;
super::documents::documents_card_with(
ui,
b,
"Company documents",
&subjects,
&[ledger_store::Subject::Company],
"No documents linked to the company, its periods, or its schedules yet (formation papers, the operating agreement, tax filings).",
&mut go,
);
ui.add_space(16.0);
if let Some(r) = go {
shell.go(r);
}
assets(ui, b, shell);
schedules(ui, b);
recurring(ui, b);
+271 -16
View File
@@ -1,5 +1,7 @@
//! Every receipt and document the books hold, rendered: what it is, what
//! entry it proves, and whether it was withdrawn (and why).
//! Every receipt and document the books hold, rendered: what it is,
//! everything it is linked to, and whether it was withdrawn (and why).
//! Also the compact "Documents" card every subject's page shows
//! ([`documents_card`]).
use super::page;
use crate::app::Shell;
@@ -11,7 +13,7 @@ use crate::theme::{self, pal};
use crate::widgets;
use egui::{CornerRadius, RichText, Sense, Stroke, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_store::Attachment;
use ledger_store::{Attachment, Subject};
#[derive(Default)]
pub struct State {
@@ -275,23 +277,36 @@ fn detail(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>) {
&a.blob_sha,
);
ui.end_row();
if let Some(e) = a.entry_id {
k(ui, "Proves");
let label = b
.entry(e)
.map(|x| {
format!("#{} {} ({})", x.id, x.description, fmt::dollars(x.amount))
})
.unwrap_or_else(|| format!("#{e}"));
if widgets::link_truncated(ui, RichText::new(label).color(p.accent))
.clicked()
{
shell.go(Route::Entry(e));
}
if let Some(st) = b.statement(a.id) {
k(ui, "Statement");
ui.add(
egui::Label::new(format!(
"{}, {} – {}",
b.account_label(st.account_id),
fmt::date(st.period_start),
fmt::date(st.period_end)
))
.wrap(),
);
ui.end_row();
k(ui, "Opening");
ui.label(fmt::dollars(st.opening_balance));
ui.end_row();
k(ui, "Closing");
ui.label(
RichText::new(fmt::dollars(st.closing_balance))
.font(theme::semibold(13.5)),
);
ui.end_row();
}
});
ui.add_space(10.0);
let mut go = None;
links_section(ui, b, a, &mut go);
if let Some(r) = go {
shell.go(r);
}
ui.add_space(10.0);
super::entry::render(ui, b, a, shell, ui.available_width());
if let Some(d) = &a.description {
ui.add_space(6.0);
@@ -308,3 +323,243 @@ fn detail(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>) {
}
});
}
/// The icon for a subject's kind.
pub fn subject_icon(s: Subject) -> &'static str {
match s {
Subject::Entry(_) => icon::RECEIPT,
Subject::Account(_) => icon::TREE_STRUCTURE,
Subject::Asset(_) => icon::PACKAGE,
Subject::Party(_) => icon::ADDRESS_BOOK,
Subject::Invoice(_) | Subject::Bill(_) => icon::FILE_TEXT,
Subject::Period(_) => icon::CALENDAR_BLANK,
Subject::PayrollRun(_) => icon::USERS,
Subject::Schedule(_) => icon::CALENDAR_CHECK,
Subject::Reconciliation(_) => icon::CHECK_SQUARE,
Subject::Company => icon::BUILDINGS,
}
}
/// A small chip naming a subject, which opens it (see
/// [`Books::subject_route`]) when it has somewhere to go.
pub fn subject_chip(ui: &mut Ui, b: &Books, s: Subject, go: &mut Option<Route>) {
let p = pal(ui.ctx());
let route = b.subject_route(s);
// A reconciliation's chip names its date before anything is cut.
let mut label = match s {
Subject::Reconciliation(r) => b.reconciliation(r).map_or_else(
|| b.subject_label(s),
|r| {
format!(
"Reconciliation, {} through {}",
b.account_name(r.account_id),
fmt::date(r.statement_date)
)
},
),
_ => b.subject_label(s),
};
if label.chars().count() > 60 {
label = label.chars().take(59).collect::<String>() + "…";
}
let r = ui.add(
egui::Button::new(
RichText::new(format!("{} {label}", subject_icon(s)))
.font(theme::regular(11.5))
.color(if route.is_some() { p.accent } else { p.muted }),
)
.fill(if route.is_some() {
p.accent_soft
} else {
p.hover
})
.stroke(Stroke::NONE)
.corner_radius(CornerRadius::same(255))
.min_size(vec2(0.0, 22.0)),
);
if let Some(to) = route
&& r.on_hover_text(format!("Open {}", b.subject_label(s)))
.clicked()
{
*go = Some(to);
}
}
/// The compact list of the documents linked to `subjects`: each file's
/// name (opening its document page), category and description, and the
/// other places it is linked as chips that open them.
///
/// ```text
/// GOAL: every subject's page shows the files that are evidence of it,
/// and where else each one is evidence.
/// SOURCE: docs/parties-and-attachments.md (Attachments, Viewer).
/// INVARIANTS:
/// 1. Lists exactly Books::documents_for(subjects), in that order.
/// 2. A file's chips are links_of(file) less `here` (the page's own
/// subject; [`documents_card`] passes `subjects`).
/// 3. Empty: `empty` in muted text (nothing else).
/// ```
pub fn documents_card_with(
ui: &mut Ui,
b: &Books,
title: &str,
subjects: &[Subject],
here: &[Subject],
empty: &str,
go: &mut Option<Route>,
) {
let p = pal(ui.ctx());
let docs = b.documents_for(subjects);
widgets::titled_card(
ui,
title,
|ui| {
if !docs.is_empty() {
widgets::pill(ui, &docs.len().to_string(), p.muted, p.hover);
}
},
|ui| {
if docs.is_empty() {
ui.add(egui::Label::new(RichText::new(empty).color(p.muted)).wrap());
return;
}
for (i, d) in docs.iter().enumerate() {
if i > 0 {
ui.add_space(4.0);
widgets::rule(ui);
ui.add_space(4.0);
}
document_row(ui, b, d, here, go);
}
},
);
}
/// [`documents_card_with`], the chips leaving out `subjects` themselves.
pub fn documents_card(
ui: &mut Ui,
b: &Books,
title: &str,
subjects: &[Subject],
empty: &str,
go: &mut Option<Route>,
) {
documents_card_with(ui, b, title, subjects, subjects, empty, go);
}
/// One file in a [`documents_card_with`].
fn document_row(ui: &mut Ui, b: &Books, d: &Attachment, here: &[Subject], go: &mut Option<Route>) {
let p = pal(ui.ctx());
ui.horizontal_top(|ui| {
ui.label(RichText::new(file_icon(d)).size(18.0).color(p.muted));
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 2.0;
if widgets::link_truncated(ui, RichText::new(&d.file_name).font(theme::semibold(13.0)))
.on_hover_text("Open this document")
.clicked()
{
*go = Some(Route::Document(d.id));
}
let mut sub = vec![];
match b.statement(d.id) {
None => sub.push(fmt::label(&d.category)),
Some(st) => sub.push(format!(
"Statement {} – {}, closing {}",
fmt::date_short(st.period_start),
fmt::date(st.period_end),
fmt::dollars(st.closing_balance)
)),
}
if let Some(desc) = &d.description {
sub.push(desc.clone());
}
ui.add(
egui::Label::new(
RichText::new(sub.join(" · "))
.font(theme::regular(11.5))
.color(p.muted),
)
.wrap(),
);
let elsewhere: Vec<Subject> = b
.links_of(d.id)
.iter()
.copied()
.filter(|s| !here.contains(s))
.collect();
if !elsewhere.is_empty() {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
ui.label(
RichText::new("Also on")
.font(theme::regular(11.5))
.color(p.faint),
);
for s in elsewhere {
subject_chip(ui, b, s, go);
}
});
}
});
});
}
/// A PDF or an image.
pub fn file_icon(a: &Attachment) -> &'static str {
if a.mime_type == "application/pdf" {
icon::FILE_PDF
} else if a.mime_type.starts_with("image/") {
icon::FILE_IMAGE
} else {
icon::FILE
}
}
/// Everything a document is linked to: a chip per subject (an entry's
/// with its date and amount in the label), opening it; and, for a
/// statement, the reconciliations that used it.
fn links_section(ui: &mut Ui, b: &Books, a: &Attachment, go: &mut Option<Route>) {
let p = pal(ui.ctx());
let links = b.links_of(a.id);
ui.label(
RichText::new("LINKED TO")
.font(theme::semibold(11.5))
.color(p.faint),
);
ui.add_space(4.0);
if links.is_empty() {
ui.label(
RichText::new("Nothing yet: filed on its own.")
.font(theme::regular(12.5))
.color(p.muted),
);
}
for s in links {
ui.horizontal(|ui| {
subject_chip(ui, b, *s, go);
if let Subject::Entry(e) = s
&& let Some(x) = b.entry(*e)
{
ui.label(
RichText::new(format!(
"{} · {}",
fmt::date(x.date),
fmt::dollars(x.amount)
))
.font(theme::regular(11.5))
.color(p.muted),
);
}
if let Subject::Reconciliation(r) = s
&& let Some(r) = b.reconciliation(*r)
&& let Some(why) = &r.voided
{
ui.label(
RichText::new(format!("voided: {why}"))
.font(theme::regular(11.5))
.color(p.warning),
);
}
});
}
}
+46 -3
View File
@@ -107,6 +107,19 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
shell.go(Route::Party(party.id));
}
}
if e.party_id.is_none()
&& let Some(why) = b.ledger.entry_no_party(e.id)
{
ui.add_space(4.0);
ui.add(
egui::Label::new(
RichText::new(format!("{} No counterparty: {why}", icon::USER_MINUS))
.color(p.muted),
)
.wrap(),
)
.on_hover_text("Marked as having no counterparty (entry.no_party)");
}
if let Some(&asset) = b.entry_assets.get(&e.id) {
ui.add_space(4.0);
if widgets::link(
@@ -268,18 +281,25 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
});
ui.add_space(18.0);
// Receipts.
// Receipts and documents: filed on it, linked to it, or linked
// to the invoice, bill, or payroll run it records; a withdrawn
// receipt filed on it stays, marked.
let subjects = b.entry_subjects(e.id);
let all_for_entry: Vec<&Attachment> = b
.attachments
.iter()
.filter(|a| a.entry_id == Some(e.id))
.filter(|a| {
a.entry_id == Some(e.id)
|| (a.withdrawn_at.is_none()
&& b.links_of(a.id).iter().any(|s| subjects.contains(s)))
})
.collect();
section(
ui,
if all_for_entry.len() == 1 {
"Receipt"
} else {
"Receipts"
"Receipts and documents"
},
);
if all_for_entry.is_empty() {
@@ -295,6 +315,29 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
}));
}
for a in all_for_entry {
let elsewhere: Vec<ledger_store::Subject> = b
.links_of(a.id)
.iter()
.copied()
.filter(|s| *s != ledger_store::Subject::Entry(e.id))
.collect();
if !elsewhere.is_empty() {
let mut go = None;
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
ui.label(
RichText::new("Linked to")
.font(theme::regular(11.5))
.color(p.faint),
);
for s in elsewhere {
super::documents::subject_chip(ui, b, s, &mut go);
}
});
if let Some(r) = go {
shell.go(r);
}
}
attachment_view(ui, b, a, shell, true);
ui.add_space(12.0);
}
+70 -34
View File
@@ -93,25 +93,26 @@ fn tax_basis_text(b: &Books, asset_id: Option<u64>, p: &Value) -> String {
}
/// What an attachment link's subject (`{"kind": .., "id": ..}`) is, in
/// words, and where it opens.
/// words (Books::subject_label), and where it opens
/// (Books::subject_route).
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),
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 {
@@ -272,10 +273,10 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
Some(Event {
icon: icon::FILE_TEXT,
text: format!(
"Statement for {}, {} to {}: closing balance {closing}",
"Recorded {}'s statement, {} – {}: closing balance {closing}",
account.map_or("an account".into(), |a| b.account_label(a)),
s(p, "period_start"),
s(p, "period_end")
date_text(p, "period_start"),
date_text(p, "period_end")
),
route: Some(Route::Document(att)),
also: account.map(Route::Account),
@@ -287,25 +288,48 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
.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}",
"Reconciled {} through {} at {balance} ({} cleared)",
account.map_or("an account".into(), |a| b.account_label(a)),
s(p, "statement_date")
date_text(p, "statement_date"),
fmt::count(lines, "line", "lines")
),
route: account.map(Route::Account),
also: p
.get("attachment_id")
route: p
.get("id")
.and_then(|v| v.as_u64())
.map(Route::Document),
.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)),
})
}
"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")),
@@ -697,6 +721,15 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
),
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)),
@@ -933,11 +966,14 @@ fn detail(ui: &mut Ui, b: &Books, line: &JournalLine, shell: &mut Shell<'_>) {
for r in ev.route.iter().chain(&ev.also) {
ui.add_space(6.0);
if ui
.button(format!(
"{} Open {}",
icon::ARROW_SQUARE_OUT,
crate::palette::describe(b, r)
))
.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());
+2
View File
@@ -10,6 +10,7 @@ pub mod journal;
pub mod overview;
pub mod parties;
pub mod profit;
pub mod reconciliation;
pub mod reports;
pub mod transactions;
@@ -54,6 +55,7 @@ pub fn show(ui: &mut Ui, b: &Books, route: &Route, shell: &mut Shell<'_>) {
Route::Reports(kind) => reports::show(ui, b, *kind, shell),
Route::Accounts => accounts::show_chart(ui, b, shell),
Route::Account(id) => accounts::show_account(ui, b, *id, shell),
Route::Reconciliation(id) => reconciliation::show(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),
+102 -58
View File
@@ -21,28 +21,28 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
widgets::page_header(ui, b.company_name(), &headline(b));
kpis(ui, b, shell);
let _ = k;
columns(ui, 980.0, &[2.0, 1.0], |i, ui| match i {
0 => {
let (more, _) = widgets::titled_card(
// What needs doing gets the page's full width, under the numbers:
// long items wrap in their own room instead of squeezing the chart.
attention(ui, b, shell);
ui.add_space(16.0);
let (more, _) = widgets::titled_card(
ui,
"Cash over time",
|ui| {
widgets::link(
ui,
"Cash over time",
|ui| {
widgets::link(
ui,
RichText::new("Cash details")
.font(theme::regular(12.5))
.color(pal(ui.ctx()).accent),
)
.clicked()
},
|ui| charts::balance_line(ui, &b.cash_history(), b.today, 230.0),
);
if more {
shell.go(Route::Cash);
}
}
_ => attention(ui, b, shell),
});
RichText::new("Cash details")
.font(theme::regular(12.5))
.color(pal(ui.ctx()).accent),
)
.clicked()
},
|ui| charts::balance_line(ui, &b.cash_history(), b.today, 230.0),
);
if more {
shell.go(Route::Cash);
}
ui.add_space(16.0);
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
0 => income_vs_spending(ui, b, shell),
_ => where_it_went(ui, b, shell),
@@ -253,33 +253,52 @@ fn color_for(c: egui::Color32, p: &theme::Palette) -> egui::Color32 {
}
}
/// "Needs attention", full width: a summary of how many problems, to-dos,
/// and notes there are, then each item as a row (its severity, the title
/// that opens where to look, what's wrong, and the prompt for the agent at
/// the right; stacked under the text when narrow). The first few show;
/// the rest wait behind a button that says how many.
fn attention(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let count = |s: Severity| b.attention.iter().filter(|a| a.severity == s).count();
widgets::titled_card(
ui,
"Needs attention",
|ui| {
if !b.attention.is_empty() {
widgets::pill(
ui,
&b.attention.len().to_string(),
p.warning,
p.warning_soft,
);
// Laid out right to left: problems end up first, reading on.
for (sev, one, many, fg, bg) in [
(Severity::Info, "note", "notes", p.muted, p.hover),
(Severity::Todo, "to do", "to do", p.warning, p.warning_soft),
(
Severity::Problem,
"problem",
"problems",
p.negative,
p.negative_soft,
),
] {
let n = count(sev);
if n > 0 {
widgets::pill(ui, &fmt::count(n, one, many), fg, bg);
}
}
},
|ui| {
if b.attention.is_empty() {
widgets::empty(
ui,
icon::CHECK_CIRCLE,
"All clear",
"Nothing is missing or overdue.",
);
ui.horizontal(|ui| {
ui.label(
RichText::new(icon::CHECK_CIRCLE)
.size(18.0)
.color(p.positive),
);
ui.label(
RichText::new("All clear: nothing is missing or overdue.")
.font(theme::regular(13.5))
.color(p.muted),
);
});
return;
}
// No inner scrolling: the card grows to fit. Past a handful of
// items, the rest sit behind a button that says how many.
const FIRST: usize = 6;
let id = ui.id().with("attention-expanded");
let mut expanded: bool = ui.ctx().data(|d| d.get_temp(id)).unwrap_or(false);
@@ -288,34 +307,59 @@ fn attention(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
} else {
b.attention.len().min(FIRST)
};
let wide = ui.available_width() >= 720.0;
for (i, a) in b.attention.iter().take(shown).enumerate() {
if i > 0 {
ui.add_space(4.0);
ui.add_space(6.0);
widgets::rule(ui);
ui.add_space(4.0);
ui.add_space(6.0);
}
let (glyph, color) = match a.severity {
Severity::Problem => (icon::WARNING_CIRCLE, p.negative),
Severity::Todo => (icon::CIRCLE_DASHED, p.warning),
Severity::Info => (icon::INFO, p.muted),
let (glyph, color, word) = match a.severity {
Severity::Problem => (icon::WARNING_CIRCLE, p.negative, "Problem"),
Severity::Todo => (icon::CIRCLE_DASHED, p.warning, "To do"),
Severity::Info => (icon::INFO, p.muted, "Note"),
};
let full = ui.available_width();
let button_w = if wide { 150.0 } else { 0.0 };
ui.horizontal_top(|ui| {
ui.label(RichText::new(glyph).size(16.0).color(color));
ui.vertical(|ui| {
let t = RichText::new(&a.title).font(theme::semibold(13.0));
let r = widgets::link(ui, t);
if r.clicked()
&& let Some(to) = &a.target
{
shell.go(to.clone());
}
ui.label(
RichText::new(&a.detail)
.font(theme::regular(12.0))
.color(p.muted),
);
widgets::copy_prompt(ui, &a.prompt);
});
ui.label(RichText::new(glyph).size(18.0).color(color))
.on_hover_text(word);
let text_w = (full - 30.0 - button_w).max(120.0);
ui.allocate_ui_with_layout(
egui::vec2(text_w, 0.0),
egui::Layout::top_down(egui::Align::Min),
|ui| {
ui.set_width(text_w);
ui.spacing_mut().item_spacing.y = 2.0;
let t = RichText::new(&a.title).font(theme::semibold(13.5));
let r = ui.add(egui::Label::new(t).wrap().sense(egui::Sense::click()));
if a.target.is_some() && r.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
if r.clicked()
&& let Some(to) = &a.target
{
shell.go(to.clone());
}
ui.add(
egui::Label::new(
RichText::new(&a.detail)
.font(theme::regular(12.5))
.color(p.muted),
)
.wrap(),
);
if !wide {
ui.add_space(4.0);
widgets::copy_prompt(ui, &a.prompt);
}
},
);
if wide {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
widgets::copy_prompt(ui, &a.prompt);
});
}
});
}
let hidden = b.attention.len() - shown;
+345 -53
View File
@@ -9,7 +9,7 @@
use super::assets::{Notice, facts, notice_box, row};
use super::page;
use crate::app::Shell;
use crate::books::{Books, PartyInfo};
use crate::books::{Books, PartyFlow, PartyInfo, PartyPeriod};
use crate::fmt;
use crate::nav::{ReportKind, Route};
use crate::theme::{self, pal};
@@ -29,6 +29,59 @@ pub struct State {
pub show_archived: bool,
/// Search text: name or display name.
pub query: String,
/// The span the money columns and a party's page sum over.
pub period: PartyPeriod,
/// How the directory is ordered.
pub sort: Sort,
}
/// The directory's order: by name, or by an amount, largest first.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Sort {
#[default]
Name,
PaidOut,
ReceivedIn,
Net,
}
/// The directory's rows for `st` in its order: [`listed`] by name, or by
/// the chosen amount over `st.period` largest first (ties by name), each
/// with its [`PartyFlow`] (totals only).
pub fn sorted<'a>(b: &'a Books, st: &State) -> Vec<(&'a Party, PartyFlow)> {
let span = b.party_span(st.period);
let mut rows: Vec<(&Party, PartyFlow)> = listed(b, st)
.into_iter()
.map(|p| (p, b.party_flow(p.id, span, false)))
.collect();
let key = |f: &PartyFlow| match st.sort {
Sort::Name => Decimal::ZERO,
Sort::PaidOut => f.paid,
Sort::ReceivedIn => f.received,
Sort::Net => f.net(),
};
// Stable: equal amounts keep the by-name order.
rows.sort_by_key(|x| std::cmp::Reverse(key(&x.1)));
rows
}
/// The period chips: each of [`Books::party_periods`], the chosen one
/// lit.
fn period_chips(ui: &mut Ui, b: &Books, party: Option<PartyId>, period: &mut PartyPeriod) {
let p = pal(ui.ctx());
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(6.0, 6.0);
ui.label(
RichText::new(format!("{} Period", icon::CALENDAR_BLANK))
.font(theme::regular(12.5))
.color(p.muted),
);
for x in b.party_periods(party) {
if chip(ui, &b.party_period_label(x), *period == x) {
*period = x;
}
}
});
}
/// A role in words, capitalized: "Payout platform".
@@ -270,14 +323,11 @@ fn chip(ui: &mut Ui, text: &str, on: bool) -> bool {
/// The party directory.
pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let year = b.today.year();
page(ui, "parties", |ui| {
widgets::page_header(
ui,
"Parties",
&format!(
"Everyone the company deals with — vendors, contractors, customers, platforms, agencies — and what it paid each in {year}. Click one for everything the books hold about them."
),
"Everyone the company deals with — vendors, contractors, customers, platforms, agencies — and what the company paid each and received from each. Click one for everything the books hold about them.",
);
if b.ledger.parties().next().is_none() {
widgets::card(ui, |ui| {
@@ -322,8 +372,30 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
}
}
});
ui.add_space(8.0);
period_chips(ui, b, None, &mut st.period);
ui.add_space(6.0);
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(6.0, 6.0);
ui.label(
RichText::new(format!("{} Sort", icon::SORT_DESCENDING))
.font(theme::regular(12.5))
.color(p.muted),
);
for (x, label) in [
(Sort::Name, "Name"),
(Sort::PaidOut, "Paid out"),
(Sort::ReceivedIn, "Received in"),
(Sort::Net, "Net"),
] {
if chip(ui, label, st.sort == x) {
st.sort = x;
}
}
});
ui.add_space(12.0);
let rows = listed(b, st);
let period_label = b.party_period_label(st.period);
let rows = sorted(b, st);
let missing: Vec<&Party> = b
.ledger
.parties()
@@ -356,42 +428,56 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
}
let mut go = None;
widgets::card(ui, |ui| {
let amt = widgets::AMOUNT_MIN + 24.0;
// Narrow, the roles and W-9 columns give way and the entry
// count goes, so the amount stays in view.
let amt = widgets::AMOUNT_MIN + 16.0;
// Narrow, the entry count and then the roles go (the role
// chips above still filter), so the amounts stay in view.
let avail = ui.available_width();
let roles_w = (avail * 0.2).clamp(140.0, 210.0);
let w9_w = (avail * 0.2).clamp(160.0, 230.0);
let n_w = if avail >= 860.0 { 76.0 } else { 0.0 };
let fixed = roles_w + w9_w + n_w + amt;
let w = avail.max(fixed + 200.0);
let show_roles = avail >= 900.0;
let roles_w = if show_roles {
(avail * 0.16).clamp(120.0, 200.0)
} else {
0.0
};
let w9_w = (avail * 0.16).clamp(140.0, 210.0);
let n_w = if avail >= 1000.0 { 70.0 } else { 0.0 };
let fixed = roles_w + w9_w + n_w + amt * 3.0;
let w = avail.max(fixed + 180.0);
// Cells without the entry count when it has no room.
let cells = |c: [(String, f32, bool, egui::Color32); 5]| -> Vec<_> {
let cells = |c: [(String, f32, bool, egui::Color32); 7]| -> Vec<_> {
c.into_iter()
.enumerate()
.filter(|(i, _)| *i != 3 || n_w > 0.0)
.filter(|(i, _)| (*i != 3 || n_w > 0.0) && (*i != 1 || show_roles))
.map(|(_, c)| c)
.collect()
};
widgets::min_width(ui, "party_list", w, |ui| {
let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
let head = |t: &str, cw: f32, right: bool, on: bool| {
(
t.to_string(),
cw,
right,
if on { p.accent } else { p.muted },
)
};
let sort = st.sort;
row(
ui,
w,
&cells([
head("Name", 0.0, false),
head("Roles", roles_w, false),
head("W-9", w9_w, false),
head("Entries", n_w, true),
head(&format!("Paid in {year}"), amt, true),
head("Name", 0.0, false, sort == Sort::Name),
head("Roles", roles_w, false, false),
head("W-9", w9_w, false, false),
head("Entries", n_w, true, false),
head("Paid out", amt, true, sort == Sort::PaidOut),
head("Received in", amt, true, sort == Sort::ReceivedIn),
head("Net", amt, true, sort == Sort::Net),
]),
None,
true,
false,
);
widgets::rule(ui);
for x in &rows {
let info = b.party(x.id).cloned().unwrap_or_default();
for (x, flow) in &rows {
let text = if x.archived { p.muted } else { p.text };
let status = w9_status(b, x);
let w9_color = match status {
@@ -413,7 +499,16 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
sub.push(format!("Party {}", x.id));
}
let sub = sub.join(" · ");
let (paid, paid_color) = paid_cell(info.paid_ytd, p);
let money = |d: Decimal, c| {
if d.is_zero() {
("—".to_string(), p.faint)
} else {
(fmt::money(d), c)
}
};
let (out_t, out_c) = money(flow.paid, p.text);
let (in_t, in_c) = money(flow.received, p.positive);
let (net_t, net_c) = paid_cell(flow.net(), p);
let resp = row(
ui,
w,
@@ -421,25 +516,21 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
(x.label().to_string(), 0.0, false, text),
(roles_text(x), roles_w, false, p.muted),
(status.text(), w9_w, false, w9_color),
(info.entries.len().to_string(), n_w, true, p.muted),
(paid, amt, true, paid_color),
(flow.entries.to_string(), n_w, true, p.muted),
(out_t, amt, true, out_c),
(in_t, amt, true, in_c),
(net_t, amt, true, net_c),
]),
Some((sub.as_str(), p.faint)),
false,
true,
);
let resp = resp.on_hover_text(if info.paid_ytd < Decimal::ZERO {
format!(
"Open this party. On net the company received {} from them in {year}.",
fmt::dollars(-info.paid_ytd)
)
} else {
"Open this party".to_string()
});
let resp = resp.on_hover_text("Open this party");
resp.context_menu(|ui| {
widgets::copy_item(ui, "Copy name", &x.name);
widgets::copy_item(ui, "Copy party id", &x.id.to_string());
widgets::copy_item(ui, "Copy paid this year", &info.paid_ytd.to_string());
widgets::copy_item(ui, "Copy paid out", &flow.paid.to_string());
widgets::copy_item(ui, "Copy received in", &flow.received.to_string());
});
if resp.clicked() {
go = Some(x.id);
@@ -451,7 +542,7 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
ui.add(
egui::Label::new(
RichText::new(format!(
"Paid in {year}: what the party's posted entries since January 1 moved out of cash, cards, and what's owed back to whoever paid for the company; a plus is money received from them. The 1099 amount, on each party's page, is the 1099 worksheet's."
"{period_label}: Paid out is what each party's posted entries moved out of cash, cards, and what's owed back to whoever paid for the company; Received in is what came back in; Net is paid out less received (a plus means more came in). Entries counts the posted ones in the period. The 1099 amount, on each party's page, is the 1099 worksheet's."
))
.font(theme::regular(11.5))
.color(p.faint),
@@ -541,18 +632,46 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
});
ui.add_space(14.0);
// This year's totals.
// The money over the chosen period: out, in, and the net.
let period = shell.state.parties.period;
period_chips(ui, b, Some(id), &mut shell.state.parties.period);
ui.add_space(10.0);
let span = b.party_span(period);
let flow = b.party_flow(id, span, true);
let label = b.party_period_label(period);
let span_words = format!("{} – {}", fmt::date(span.0), fmt::date(span.1));
let mut tiles: Vec<Tile> = Vec::new();
let received = info.paid_ytd < Decimal::ZERO;
tiles.push((
if received {
format!("Received in {year}")
} else {
format!("Paid in {year}")
},
info.paid_ytd.abs(),
"Over this party's posted entries since January 1: credits less debits to cash, cards, and what's owed back to whoever paid for the company (received, when more came in than went out).".into(),
Some((fmt::count(info.entries.len(), "entry", "entries"), p.muted)),
"Paid out".into(),
flow.paid,
format!("{label}, {span_words}: what this party's posted entries moved out of cash, cards, and what's owed back to whoever paid for the company (net of refunds and reversals of payments)."),
Some((label.clone(), p.muted)),
));
tiles.push((
"Received in".into(),
flow.received,
format!("{label}, {span_words}: what this party's posted entries brought into cash and those accounts (net of reversals)."),
Some((label.clone(), p.muted)),
));
let net = flow.net();
tiles.push((
"Net".into(),
net.abs(),
"Paid out less received in.".into(),
Some((
if net < Decimal::ZERO {
"more received than paid".to_string()
} else if net.is_zero() {
"even".to_string()
} else {
"more paid than received".to_string()
},
if net < Decimal::ZERO {
p.positive
} else {
p.muted
},
)),
));
if party.customer.is_some() {
tiles.push((
@@ -581,28 +700,33 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
}),
));
}
if let Some(l) = &info.form_1099 {
let threshold = info.threshold_1099.unwrap_or_default();
let tax_year = b.party_period_year(period);
let form = if tax_year == year {
info.form_1099.clone().zip(info.threshold_1099)
} else {
b.party_1099(id, tax_year)
};
if let Some((l, threshold)) = &form {
let sub = match l.form {
Some(_) if l.reportable => (
format!("Reportable · threshold {}", fmt::dollars(threshold)),
format!("Reportable · threshold {}", fmt::dollars(*threshold)),
p.warning,
),
Some(_) => (
format!("Under the {} threshold", fmt::dollars(threshold)),
format!("Under the {} threshold", fmt::dollars(*threshold)),
p.muted,
),
None => ("Not marked for a 1099".into(), p.muted),
};
tiles.push((
format!("1099 amount, {year}"),
format!("1099 amount, {tax_year}"),
l.paid,
"Cash paid in the calendar year, from the 1099 worksheet (ledger-core): posted entries tagged with its vendor record, net of refunds and reversals. Card payments aren't counted (the card issuer reports them).".into(),
Some(sub),
));
}
let n = tiles.len();
super::grid(ui, n, 190.0, 5, |i, ui| {
super::grid(ui, n, 170.0, 6, |i, ui| {
let (label, value, how, sub) = &tiles[i];
let r = widgets::kpi(
ui,
@@ -617,6 +741,13 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
r.context_menu(|ui| widgets::copy_item(ui, "Copy amount", &value.to_string()));
});
super::columns(ui, 860.0, &[1.0, 1.0], |col, ui| {
if col == 0 {
what_for_card(ui, b, &flow, &label, &mut go);
} else {
months_card(ui, b, id, span, &label);
}
});
super::columns(ui, 860.0, &[1.0, 1.0], |col, ui| {
if col == 0 {
facts_card(ui, party, &mut go);
@@ -648,6 +779,23 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
payroll_card(ui, b, party, &info, &mut go);
ui.add_space(14.0);
}
let mut subjects = vec![ledger_store::Subject::Party(id)];
subjects.extend(
info.invoices
.iter()
.map(|i| ledger_store::Subject::Invoice(*i)),
);
subjects.extend(info.bills.iter().map(|i| ledger_store::Subject::Bill(*i)));
super::documents::documents_card_with(
ui,
b,
"Documents",
&subjects,
&[ledger_store::Subject::Party(id)],
"No documents linked to them (contracts, a W-9 is kept in the vault, correspondence).",
&mut go,
);
ui.add_space(14.0);
widgets::titled_card(
ui,
"History",
@@ -1180,3 +1328,147 @@ fn payroll_card(ui: &mut Ui, b: &Books, party: &Party, info: &PartyInfo, go: &mu
},
);
}
/// "What for": the period's money by the account on the other side of
/// each entry ([`PartyFlow::what_for`]), which sums to the net exactly.
fn what_for_card(ui: &mut Ui, b: &Books, flow: &PartyFlow, label: &str, go: &mut Option<Route>) {
let p = pal(ui.ctx());
widgets::titled_card(
ui,
"What for",
|ui| {
ui.label(
RichText::new(label)
.font(theme::regular(12.0))
.color(p.muted),
);
},
|ui| {
if flow.what_for.is_empty() {
ui.label(
RichText::new("No posted entries with them in this period.").color(p.muted),
);
return;
}
let amt = widgets::AMOUNT_MIN + 16.0;
let w = ui.available_width().max(200.0 + amt);
let roles = [
(ledger_core::PAYABLES, "billed, not yet paid"),
(ledger_core::RECEIVABLES, "invoiced, not yet collected"),
];
widgets::min_width(ui, "what_for", w, |ui| {
for (account, amount) in &flow.what_for {
let note = roles
.iter()
.find(|(r, _)| b.ledger.role_account(r) == Some(*account))
.map(|(_, n)| *n);
let (text, color) = if *amount < Decimal::ZERO {
(format!("+{}", fmt::money(-*amount)), p.positive)
} else {
(fmt::money(*amount), p.text)
};
let resp = row(
ui,
w,
&[
(b.account_label(*account), 0.0, false, p.text),
(text, amt, true, color),
],
note.map(|n| (n, p.faint)),
false,
true,
);
if resp.on_hover_text("Open the account's register").clicked() {
*go = Some(Route::Account(*account));
}
}
widgets::rule(ui);
let net = flow.net();
row(
ui,
w,
&[
("Net".into(), 0.0, false, p.text),
(
if net < Decimal::ZERO {
format!("+{}", fmt::money(-net))
} else {
fmt::money(net)
},
amt,
true,
if net < Decimal::ZERO {
p.positive
} else {
p.text
},
),
],
None,
true,
false,
);
});
ui.add_space(4.0);
ui.add(
egui::Label::new(
RichText::new("Each entry's other side: what the money paid for, or (with a plus) where money received came from. Sums to the net exactly.")
.font(theme::regular(11.5))
.color(p.faint),
)
.wrap(),
);
},
);
}
/// Out versus in, month by month over the period ([`Books::party_months`]).
fn months_card(ui: &mut Ui, b: &Books, id: PartyId, span: (NaiveDate, NaiveDate), label: &str) {
let p = pal(ui.ctx());
let months = b.party_months(id, span);
// All time can be long: the chart shows the last 24 months of it.
let shown: Vec<_> = months.iter().rev().take(24).rev().collect();
widgets::titled_card(
ui,
"By month",
|ui| {
ui.label(
RichText::new(if months.len() > shown.len() {
format!("{label}, last {} months", shown.len())
} else {
label.to_string()
})
.font(theme::regular(12.0))
.color(p.muted),
);
},
|ui| {
if shown
.iter()
.all(|m| m.paid.is_zero() && m.received.is_zero())
{
widgets::empty(ui, icon::CHART_BAR, "No money moved", "");
return;
}
let multi_year =
shown.first().map(|m| m.month.year()) != shown.last().map(|m| m.month.year());
let groups: Vec<crate::charts::BarGroup> = shown
.iter()
.map(|m| crate::charts::BarGroup {
label: if multi_year && m.month.month() == 1 {
m.month.format("%b %y").to_string()
} else {
m.month.format("%b").to_string()
},
values: vec![m.paid, m.received],
})
.collect();
crate::charts::bar_chart(
ui,
&groups,
&[("Paid out", p.series[2]), ("Received in", p.series[1])],
200.0,
);
},
);
}
@@ -0,0 +1,417 @@
//! One reconciliation: the account and statement it agrees with, the
//! balance, every line it found cleared (each opening its entry), and the
//! lines on the account through that date it did not clear. Shared with
//! the account page and "Needs attention": the prompt that asks the agent
//! to reconcile an account, and where a reconciliation stands.
use super::assets::{Notice, facts, notice_box, row};
use super::page;
use crate::app::Shell;
use crate::books::Books;
use crate::fmt;
use crate::nav::Route;
use crate::theme::{self, pal};
use crate::widgets;
use chrono::NaiveDate;
use egui::{RichText, Ui};
use egui_phosphor::regular as icon;
use ledger_core::{AccountId, EntryId, Side};
use ledger_store::{Reconciliation, Subject};
use rust_decimal::Decimal;
/// The agent prompt that reconciles an account through a date.
///
/// ```text
/// GOAL: one paste gets the agent to do the monthly reconciliation the
/// way the books require it (docs/parties-and-attachments.md,
/// Reconciliations: a statement recorded and attached, the
/// difference exactly zero).
/// INVARIANTS:
/// 1. Starts "In wanderbooks,"; names the account's code and name, the
/// date (YYYY-MM-DD), books.record_statement, books.mark_cleared,
/// and books.reconcile.
/// 2. Says where it last stood: reconciled through a date, or never.
/// ```
pub fn reconcile_prompt(b: &Books, account: AccountId, through: NaiveDate) -> String {
let since = match b.reconciled_through(account) {
Some(d) => format!("It is reconciled through {d}, so start with the statement after that"),
None => "It has never been reconciled, so start with its first statement".to_string(),
};
format!(
"In wanderbooks, reconcile {} through {through}. {since}. For each monthly statement: file the PDF, record it with books.record_statement (its period and opening and closing balances), mark the lines that cleared with books.mark_cleared, then run books.reconcile against it (preview first; the difference must be exactly zero). Show me anything that doesn't match, and the account when it's done.",
b.account_label(account)
)
}
/// Where a reconciliation stands among its account's.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Standing {
/// The account's newest reconciliation not voided.
Current,
/// A later reconciliation not voided supersedes it.
Superseded,
/// Voided, with the reason.
Voided(String),
}
/// A reconciliation's [`Standing`].
pub fn standing(b: &Books, r: &Reconciliation) -> Standing {
match &r.voided {
Some(why) => Standing::Voided(why.clone()),
None if b.reconciled_through(r.account_id) == Some(r.statement_date) => Standing::Current,
None => Standing::Superseded,
}
}
/// A line's amount in the account's normal direction (a debit raises an
/// asset, a credit raises a liability).
fn signed(b: &Books, account: AccountId, side: Side, amount: Decimal) -> Decimal {
let normal = b
.ledger
.chart()
.get(account)
.map_or(Side::Debit, |a| a.account_type.normal_side());
if side == normal { amount } else { -amount }
}
/// Every posted line on the reconciliation's account dated on or before
/// its statement date, as (entry, line index, date, description, signed
/// amount, cleared by it), by date then entry.
pub fn lines(
b: &Books,
r: &Reconciliation,
) -> Vec<(EntryId, usize, NaiveDate, String, Decimal, bool)> {
let mut out = Vec::new();
for e in b.ledger.posted_entries() {
if e.date() > r.statement_date {
continue;
}
for (i, l) in e.lines().iter().enumerate() {
if l.account == r.account_id {
out.push((
e.id(),
i,
e.date(),
e.description().to_string(),
signed(b, r.account_id, l.side, l.amount.amount),
r.cleared.contains(&(e.id(), i)),
));
}
}
}
out.sort_by(|a, b| a.2.cmp(&b.2).then(a.0.cmp(&b.0)).then(a.1.cmp(&b.1)));
out
}
pub fn show(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let Some(r) = b.reconciliation(id) else {
page(ui, "reconciliation", |ui| {
widgets::empty(
ui,
icon::CHECK_SQUARE,
"No such reconciliation",
"It isn't in these books.",
);
});
return;
};
let mut go: Option<Route> = None;
page(ui, "reconciliation", |ui| {
ui.horizontal(|ui| {
if widgets::link(
ui,
RichText::new(format!(
"{} {}",
icon::ARROW_LEFT,
b.account_label(r.account_id)
))
.font(theme::regular(12.5))
.color(p.muted),
)
.clicked()
{
go = Some(Route::Account(r.account_id));
}
widgets::copyable(
ui,
RichText::new(format!("· Reconciliation {id}"))
.font(theme::regular(12.5))
.color(p.muted),
&id.to_string(),
);
});
ui.add_space(4.0);
ui.label(
RichText::new(format!(
"{} reconciled through {}",
b.account_name(r.account_id),
fmt::date(r.statement_date)
))
.font(theme::bold(26.0)),
);
ui.add_space(6.0);
ui.horizontal_wrapped(|ui| match standing(b, r) {
Standing::Current => {
widgets::pill(
ui,
&format!("{} Current", icon::CHECK),
p.positive,
p.positive_soft,
);
}
Standing::Superseded => {
widgets::pill(ui, "Superseded by a later reconciliation", p.muted, p.hover);
}
Standing::Voided(why) => {
widgets::pill(ui, &format!("Voided: {why}"), p.warning, p.warning_soft);
}
});
ui.add_space(14.0);
let all = lines(b, r);
let cleared: Vec<_> = all.iter().filter(|l| l.5).collect();
let outstanding: Vec<_> = all.iter().filter(|l| !l.5).collect();
let cleared_total: Decimal = cleared.iter().map(|l| l.4).sum();
let st = b.statement(r.attachment_id);
super::columns(ui, 860.0, &[1.0, 1.0], |col, ui| {
if col == 0 {
widgets::titled_card(
ui,
"Reconciliation",
|_| {},
|ui| {
facts(ui, |ui, k| {
k(ui, "Account");
if widgets::link_truncated(
ui,
RichText::new(b.account_label(r.account_id)).color(p.accent),
)
.clicked()
{
go = Some(Route::Account(r.account_id));
}
ui.end_row();
k(ui, "Through");
ui.label(fmt::date(r.statement_date));
ui.end_row();
k(ui, "Statement balance");
widgets::copyable(
ui,
RichText::new(fmt::dollars(r.statement_balance))
.font(theme::semibold(14.0)),
&r.statement_balance.to_string(),
);
ui.end_row();
k(ui, "Cleared lines");
ui.label(format!(
"{} totaling {}",
fmt::count(r.cleared.len(), "line", "lines"),
fmt::dollars(cleared_total)
));
ui.end_row();
k(ui, "Books' balance then");
ui.label(fmt::dollars(b.balance_on(r.account_id, r.statement_date)));
ui.end_row();
});
ui.add_space(6.0);
ui.add(
egui::Label::new(
RichText::new("Recorded only when the cleared lines agree with the statement to the cent. A reconciliation is never edited: a mistake is voided and redone.")
.font(theme::regular(11.5))
.color(p.faint),
)
.wrap(),
);
},
);
} else {
widgets::titled_card(
ui,
"Statement",
|_| {},
|ui| {
let file = b.attachment(r.attachment_id);
facts(ui, |ui, k| {
if let Some(st) = st {
k(ui, "Period");
ui.label(format!(
"{} – {}",
fmt::date(st.period_start),
fmt::date(st.period_end)
));
ui.end_row();
k(ui, "Opening");
ui.label(fmt::dollars(st.opening_balance));
ui.end_row();
k(ui, "Closing");
ui.label(fmt::dollars(st.closing_balance));
ui.end_row();
}
k(ui, "File");
let name = file.map_or_else(
|| format!("attachment {}", r.attachment_id),
|a| a.file_name.clone(),
);
if widgets::link_truncated(
ui,
RichText::new(format!("{} {name}", icon::FILE_PDF)).color(p.accent),
)
.clicked()
{
go = Some(Route::Document(r.attachment_id));
}
ui.end_row();
});
},
);
}
});
lines_card(
ui,
b,
"Cleared lines",
&cleared,
"It cleared no lines (the account had no activity through this date).",
&mut go,
);
ui.add_space(14.0);
if !outstanding.is_empty() {
notice_box(
ui,
&Notice {
problem: false,
text: format!(
"{} on the account through {} had not cleared the bank by this statement (outstanding then).",
fmt::count(outstanding.len(), "line", "lines"),
fmt::date(r.statement_date)
),
},
);
lines_card(ui, b, "Outstanding then", &outstanding, "", &mut go);
ui.add_space(14.0);
}
super::documents::documents_card(
ui,
b,
"Documents",
&[Subject::Reconciliation(id)],
"No documents linked to it.",
&mut go,
);
ui.add_space(14.0);
widgets::titled_card(
ui,
"History",
|_| {},
|ui| {
for line in b.journal.iter().filter(|l| {
matches!(
l.line_type.as_str(),
"reconciliation.completed" | "reconciliation.voided"
) && l.payload.get("id").and_then(|v| v.as_u64()) == Some(id)
}) {
let ev = super::journal::describe_any(b, line);
ui.horizontal(|ui| {
ui.label(RichText::new(ev.icon).color(p.muted));
if widgets::link_truncated(
ui,
RichText::new(&ev.text).font(theme::regular(12.5)),
)
.clicked()
{
go = Some(Route::JournalEvent(line.seq));
}
ui.label(
RichText::new(format!(
"event {} · {}",
line.seq,
fmt::timestamp(&line.ts)
))
.font(theme::regular(11.0))
.color(p.faint),
);
});
}
},
);
});
if let Some(to) = go {
shell.go(to);
}
}
type Line = (EntryId, usize, NaiveDate, String, Decimal, bool);
/// A table of account lines, each opening its entry.
fn lines_card(
ui: &mut Ui,
b: &Books,
title: &str,
rows: &[&Line],
empty: &str,
go: &mut Option<Route>,
) {
let p = pal(ui.ctx());
widgets::titled_card(
ui,
title,
|ui| {
if !rows.is_empty() {
widgets::pill(ui, &rows.len().to_string(), p.muted, p.hover);
}
},
|ui| {
if rows.is_empty() {
ui.label(RichText::new(empty).color(p.muted));
return;
}
let amt = widgets::AMOUNT_MIN + 16.0;
let w = ui.available_width().max(104.0 + 70.0 + 200.0 + amt);
widgets::min_width(ui, title, w, |ui| {
let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
row(
ui,
w,
&[
head("Date", 104.0, false),
head("Entry", 70.0, false),
head("Description", 0.0, false),
head("Amount", amt, true),
],
None,
true,
false,
);
widgets::rule(ui);
for (e, _, date, desc, amount, _) in rows.iter().copied() {
let resp = row(
ui,
w,
&[
(fmt::date(*date), 104.0, false, p.muted),
(format!("#{e}"), 70.0, false, p.muted),
(desc.clone(), 0.0, false, p.text),
(
fmt::money(*amount),
amt,
true,
if *amount < Decimal::ZERO {
p.negative
} else {
p.text
},
),
],
None,
false,
true,
);
if resp.on_hover_text("Open its entry").clicked() {
*go = Some(Route::Entry(*e));
}
}
});
let _ = b;
},
);
}
+68 -2
View File
@@ -1,7 +1,8 @@
//! Ctrl+K: jump to anything. Pages, reports, accounts, entries (by text,
//! 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.
//! (vendors and customers among them, by name or display name),
//! documents, bank and card statements, and reconciliations (by account
//! and date), ranked by a simple fuzzy match.
use crate::books::Books;
use crate::fmt;
@@ -234,6 +235,15 @@ pub fn fuzzy(hay: &str, query: &str) -> Option<i32> {
/// case) adds 500.
/// 6. Every party result opens Route::Party(its id); archived parties
/// are found too, and say so.
/// INVARIANTS (statements and reconciliations):
/// 7. A statement is listed when the query fuzzily matches "statement",
/// its account's code and name, its period's dates (as shown and as
/// YYYY-MM-DD), and its file name; it opens Route::Document(its
/// attachment).
/// 8. A reconciliation is listed when the query fuzzily matches
/// "reconciliation reconciled", its account's code and name, and its
/// statement date (as shown and as YYYY-MM-DD); it opens
/// Route::Reconciliation(its id), voided ones too, saying so.
/// ```
pub fn search(b: &Books, query: &str) -> Vec<Item> {
let q = query.trim();
@@ -371,6 +381,61 @@ pub fn search(b: &Books, query: &str) -> Vec<Item> {
});
}
}
for st in &b.statements {
let file = b
.attachment(st.attachment_id)
.map_or("", |a| a.file_name.as_str());
let hay = format!(
"statement {} {} {} {} {} {file}",
b.account_label(st.account_id),
fmt::date(st.period_start),
fmt::date(st.period_end),
st.period_start,
st.period_end
);
if let Some(score) = fuzzy(&hay, q) {
out.push(Item {
icon: icon::FILE_TEXT,
title: format!(
"Statement: {}, {} – {}",
b.account_name(st.account_id),
fmt::date_short(st.period_start),
fmt::date(st.period_end)
),
detail: format!("Closing {} · {file}", fmt::dollars(st.closing_balance)),
route: Route::Document(st.attachment_id),
score,
});
}
}
for r in &b.reconciliations {
let hay = format!(
"reconciliation reconciled {} {} {}",
b.account_label(r.account_id),
fmt::date(r.statement_date),
r.statement_date
);
if let Some(score) = fuzzy(&hay, q) {
let mut detail = vec![
fmt::dollars(r.statement_balance),
fmt::count(r.cleared.len(), "cleared line", "cleared lines"),
];
if r.voided.is_some() {
detail.push("voided".into());
}
out.push(Item {
icon: icon::CHECK_SQUARE,
title: format!(
"Reconciliation: {} through {}",
b.account_name(r.account_id),
fmt::date(r.statement_date)
),
detail: detail.join(" · "),
route: Route::Reconciliation(r.id),
score,
});
}
}
for a in &b.attachments {
let hay = format!("{} {}", a.file_name, a.description.as_deref().unwrap_or(""));
if let Some(score) = fuzzy(&hay, q) {
@@ -410,6 +475,7 @@ pub fn describe(b: &Books, r: &Route) -> String {
.unwrap_or_default(),
Route::Asset(id) => b.asset_label(*id),
Route::Party(id) => b.party_label(*id),
Route::Reconciliation(id) => b.subject_label(ledger_store::Subject::Reconciliation(*id)),
Route::JournalEvent(seq) => format!("Event {seq}"),
_ => String::new(),
}
+605
View File
@@ -0,0 +1,605 @@
//! Attachment links, statements, and reconciliations in the viewer: each
//! subject shows exactly the files linked to it, each account its
//! statements and reconciliations, "Needs attention" asks for a
//! reconciliation until one covers last month, the journal says what
//! happened and opens it, and search finds statements and
//! reconciliations.
use chrono::NaiveDate;
use egui_kittest::Harness;
use ledger_core::{AccountType, Currency, EntryId, Money, NewAsset, PartyId, Role, Side};
use ledger_gui::books::Books;
use ledger_gui::nav::Route;
use ledger_gui::pages::journal::describe;
use ledger_gui::{App, Source, attention, data, palette};
use ledger_store::{Statement, Store, Subject};
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-attachments-{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()
}
struct Fixture {
dir: TempDir,
store: Store,
checking: u64,
card: u64,
party: PartyId,
asset: u64,
contribution: EntryId,
software: EntryId,
linked_only: EntryId,
receipt: u64,
contract: u64,
photo: u64,
}
/// Checking funded in July; software paid in August (its receipt filed on
/// it, and also linked to a second August entry that has no receipt of
/// its own); a contract linked to a party, the company, and the software
/// entry; a photo linked to an asset; a card account with no activity.
fn fixture(tag: &str) -> Fixture {
let dir = TempDir::new(tag);
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
let acct = |s: &mut Store, code: &str, name: &str, t| {
s.add_account(code, name, t, None, false).unwrap()
};
let checking = acct(&mut s, "1000", "Checking", AccountType::Asset);
let equipment = acct(&mut s, "1500", "Equipment", AccountType::Asset);
let accum = acct(
&mut s,
"1510",
"Accumulated Depreciation",
AccountType::Asset,
);
let card = acct(&mut s, "2050", "Business Card", AccountType::Liability);
let capital = acct(&mut s, "3000", "Capital", AccountType::Equity);
let software_acct = acct(&mut s, "6300", "Software", AccountType::Expense);
let depreciation = acct(&mut s, "6800", "Depreciation", AccountType::Expense);
let post = |s: &mut Store, date, what: &str, dr, cr, c, receipt: bool| {
let e = s.create_entry(date, what).unwrap();
s.add_line(e, dr, Side::Debit, usd(c)).unwrap();
s.add_line(e, cr, Side::Credit, usd(c)).unwrap();
if receipt {
s.attach_receipt(
Some(e),
&format!("{what}.pdf"),
"application/pdf",
what.as_bytes(),
None,
)
.unwrap();
}
s.post_entry(e).unwrap();
e
};
let contribution = post(
&mut s,
d(2026, 7, 5),
"Contribution",
checking,
capital,
500_000,
true,
);
let software = post(
&mut s,
d(2026, 8, 10),
"Software",
software_acct,
checking,
12_000,
true,
);
// Its receipt also covers this one (one invoice, two entries).
let e = s
.create_entry(d(2026, 8, 12), "Software, second seat")
.unwrap();
s.add_line(e, software_acct, Side::Debit, usd(3_000))
.unwrap();
s.add_line(e, checking, Side::Credit, usd(3_000)).unwrap();
let receipt = s.list_attachments(Some(software)).unwrap()[0].id;
s.link_attachment(receipt, Subject::Entry(e)).unwrap();
s.post_entry(e).unwrap();
let linked_only = e;
let party = s.add_party("Acme Software", &[Role::Contractor]).unwrap();
let (contract, _, _) = s
.file_document(
"contract",
"acme-agreement.pdf",
"application/pdf",
b"%PDF agreement",
Some("Signed agreement"),
)
.unwrap();
s.link_attachment(contract, Subject::Party(party)).unwrap();
s.link_attachment(contract, Subject::Company).unwrap();
s.link_attachment(contract, Subject::Entry(software))
.unwrap();
let asset = s
.register_asset(NewAsset {
description: "Laptop".into(),
purchase_date: d(2026, 7, 6),
cost: usd(200_000),
salvage_value: usd(0),
useful_life_months: 36,
cost_account: equipment,
accum_depr_account: accum,
expense_account: depreciation,
})
.unwrap();
let (photo, _, _) = s
.file_document("photo", "laptop.png", "image/png", b"png bytes", None)
.unwrap();
s.link_attachment(photo, Subject::Asset(asset)).unwrap();
Fixture {
dir,
store: s,
checking,
card,
party,
asset,
contribution,
software,
linked_only,
receipt,
contract,
photo,
}
}
/// Clear every line on `account` through `through`, file and record its
/// statement for `month_start..=through`, and reconcile it. Returns the
/// statement's attachment id and the reconciliation id.
fn reconcile(
s: &mut Store,
account: u64,
month_start: NaiveDate,
through: NaiveDate,
) -> (u64, u64) {
let mut lines = Vec::new();
for e in s.ledger().posted_entries() {
if e.date() <= through {
for (i, l) in e.lines().iter().enumerate() {
if l.account == account && !s.ledger().is_cleared(e.id(), i) {
lines.push((e.id(), i));
}
}
}
}
for (e, i) in lines {
s.mark_cleared(e, i, true).unwrap();
}
let report = |s: &Store, date| {
s.ledger()
.reconciliation_report(account, date, Decimal::ZERO)
.unwrap()
.cleared_balance
};
let opening = report(s, month_start.pred_opt().unwrap());
let closing = report(s, through);
let (att, _, _) = s
.file_document(
"statement",
&format!("statement-{through}.pdf"),
"application/pdf",
format!("statement {through}").as_bytes(),
None,
)
.unwrap();
s.record_statement(Statement {
attachment_id: att,
account_id: account,
period_start: month_start,
period_end: through,
opening_balance: opening,
closing_balance: closing,
})
.unwrap();
let r = s.complete_reconciliation(att).unwrap();
(att, r.id)
}
fn ids(docs: Vec<&ledger_store::Attachment>) -> Vec<u64> {
docs.into_iter().map(|a| a.id).collect()
}
#[test]
fn each_subject_shows_exactly_its_links() {
let f = fixture("links");
let b = load(&f.dir.0);
// An entry: filed on it, and linked to it.
assert_eq!(
ids(b.documents_for(&[Subject::Entry(f.software)])),
vec![f.receipt, f.contract]
);
assert_eq!(
ids(b.documents_for(&[Subject::Entry(f.linked_only)])),
vec![f.receipt]
);
assert_eq!(
ids(b.documents_for(&[Subject::Party(f.party)])),
vec![f.contract]
);
assert_eq!(ids(b.documents_for(&[Subject::Company])), vec![f.contract]);
assert_eq!(
ids(b.documents_for(&[Subject::Asset(f.asset)])),
vec![f.photo]
);
assert!(b.documents_for(&[Subject::Account(f.checking)]).is_empty());
// Every link of the contract, in the store's order (by kind, id).
assert_eq!(
b.links_of(f.contract),
f.store.attachment_links(f.contract).unwrap().as_slice()
);
assert_eq!(b.links_of(f.receipt)[0], Subject::Entry(f.software));
assert!(
b.links_of(f.receipt)
.contains(&Subject::Entry(f.linked_only))
);
// The receipt policy counts a linked receipt (the server's rule).
let only = b.entry(f.linked_only).unwrap();
assert!(!only.missing_receipt);
assert_eq!(only.receipts.len(), 1);
assert_eq!(b.attachments[only.receipts[0]].id, f.receipt);
// Every subject the books hold matches the store's own list.
for sub in [
Subject::Entry(f.contribution),
Subject::Entry(f.software),
Subject::Entry(f.linked_only),
Subject::Party(f.party),
Subject::Company,
Subject::Asset(f.asset),
Subject::Account(f.checking),
] {
assert_eq!(
ids(b.documents_for(&[sub])),
f.store
.attachments_for(sub)
.unwrap()
.iter()
.map(|a| a.id)
.collect::<Vec<_>>(),
"{sub:?}"
);
}
// Each chip opens its subject.
assert_eq!(
b.subject_route(Subject::Party(f.party)),
Some(Route::Party(f.party))
);
assert_eq!(b.subject_route(Subject::Company), Some(Route::Company));
assert_eq!(
b.subject_route(Subject::Asset(f.asset)),
Some(Route::Asset(f.asset))
);
}
#[test]
fn unlinking_and_withdrawing_take_a_file_off_its_subjects() {
let mut f = fixture("unlink");
f.store
.unlink_attachment(f.contract, Subject::Company, "Not company-wide")
.unwrap();
// A file comes off its links before it is withdrawn.
f.store
.unlink_attachment(f.photo, Subject::Asset(f.asset), "Blurry")
.unwrap();
f.store
.withdraw_attachment(f.photo, "Blurry; retaking it")
.unwrap();
let b = load(&f.dir.0);
assert!(b.documents_for(&[Subject::Company]).is_empty());
assert!(b.documents_for(&[Subject::Asset(f.asset)]).is_empty());
assert_eq!(
ids(b.documents_for(&[Subject::Party(f.party)])),
vec![f.contract]
);
assert!(!b.links_of(f.contract).contains(&Subject::Company));
assert!(b.links_of(f.photo).is_empty());
}
#[test]
fn statements_and_reconciliations_per_account() {
let mut f = fixture("recs");
let (jul_st, jul) = reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 7, 31));
f.store.void_reconciliation(jul, "Wrong statement").unwrap();
let (_, jul2) = reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 7, 31));
let (aug_st, aug) = reconcile(&mut f.store, f.checking, d(2026, 8, 1), d(2026, 8, 31));
let b = load(&f.dir.0);
let st = b.statements_for(f.checking);
assert_eq!(st.len(), 3);
assert_eq!(st[0].attachment_id, jul_st);
assert_eq!(st.last().unwrap().attachment_id, aug_st);
assert_eq!(
st.iter().map(|s| (*s).clone()).collect::<Vec<_>>(),
f.store.statements(Some(f.checking)).unwrap()
);
let recs = b.reconciliations_for(f.checking);
assert_eq!(
recs.iter().map(|r| r.id).collect::<Vec<_>>(),
vec![jul, jul2, aug]
);
assert_eq!(recs[0].voided.as_deref(), Some("Wrong statement"));
// Cleared lines: the contribution's, then August's two payments.
let a = b.reconciliation(aug).unwrap();
assert_eq!(a.cleared.len(), 3);
assert_eq!(a.statement_balance, cents(500_000 - 12_000 - 3_000));
assert!(b.reconciliations_for(f.card).is_empty());
assert_eq!(b.reconciled_through(f.checking), Some(d(2026, 8, 31)));
assert_eq!(b.reconciled_through(f.card), None);
use ledger_gui::pages::reconciliation::{Standing, lines, standing};
assert_eq!(standing(&b, a), Standing::Current);
assert_eq!(
standing(&b, b.reconciliation(jul2).unwrap()),
Standing::Superseded
);
assert_eq!(
standing(&b, b.reconciliation(jul).unwrap()),
Standing::Voided("Wrong statement".into())
);
// Its lines are the account's through the date, each marked.
let ls = lines(&b, a);
assert_eq!(ls.len(), 3);
assert!(ls.iter().all(|l| l.5));
let total: Decimal = ls.iter().map(|l| l.4).sum();
assert_eq!(total, a.statement_balance);
// The statement is linked to its account and its reconciliation.
assert!(b.links_of(aug_st).contains(&Subject::Account(f.checking)));
assert!(b.links_of(aug_st).contains(&Subject::Reconciliation(aug)));
assert_eq!(
b.subject_route(Subject::Reconciliation(aug)),
Some(Route::Reconciliation(aug))
);
}
fn reconcile_items(b: &Books) -> Vec<String> {
attention::find(b)
.into_iter()
.filter(|a| a.title.starts_with("Reconcile "))
.map(|a| a.title)
.collect()
}
#[test]
fn attention_asks_for_a_reconciliation_until_last_month_is_covered() {
let mut f = fixture("attention");
let b = load(&f.dir.0);
// Checking has activity; the card has none (no item).
assert_eq!(b.last_month_end(), d(2026, 9, 30));
assert_eq!(
reconcile_items(&b),
vec!["Reconcile Checking through Sep 30, 2026".to_string()]
);
let item = attention::find(&b)
.into_iter()
.find(|a| a.title.starts_with("Reconcile "))
.unwrap();
assert!(item.prompt.starts_with("In wanderbooks,"));
assert!(item.prompt.contains("books.record_statement"));
assert!(item.prompt.contains("books.reconcile"));
assert!(item.prompt.contains("1000 Checking"));
assert!(item.prompt.contains("2026-09-30"));
assert!(item.prompt.contains("never been reconciled"));
assert_eq!(item.target, Some(Route::Account(f.checking)));
// Through August only: still due.
reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 8, 31));
let b = load(&f.dir.0);
assert_eq!(reconcile_items(&b).len(), 1);
assert!(
attention::find(&b)
.iter()
.any(|a| a.prompt.contains("reconciled through 2026-08-31"))
);
// Through September (last month): clear.
let (_, sep) = reconcile(&mut f.store, f.checking, d(2026, 9, 1), d(2026, 9, 30));
let b = load(&f.dir.0);
assert!(reconcile_items(&b).is_empty(), "{:?}", reconcile_items(&b));
assert_eq!(b.reconciliation_due(f.checking), None);
// Voided, it's due again.
f.store.void_reconciliation(sep, "Redo").unwrap();
let b = load(&f.dir.0);
assert_eq!(reconcile_items(&b).len(), 1);
// Card activity before this month makes the card due too.
let e = f.store.create_entry(d(2026, 9, 3), "Card charge").unwrap();
let software = f.store.ledger().chart().find_by_code("6300").unwrap().id;
f.store
.add_line(e, software, Side::Debit, usd(1_000))
.unwrap();
f.store
.add_line(e, f.card, Side::Credit, usd(1_000))
.unwrap();
f.store
.attach_receipt(Some(e), "c.pdf", "application/pdf", b"card", None)
.unwrap();
f.store.post_entry(e).unwrap();
let b = load(&f.dir.0);
assert!(
reconcile_items(&b).contains(&"Reconcile Business Card through Sep 30, 2026".to_string())
);
}
#[test]
fn journal_text_names_and_opens_the_subjects() {
let mut f = fixture("journal");
let (st, r) = reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 7, 31));
f.store.void_reconciliation(r, "Wrong statement").unwrap();
f.store
.unlink_attachment(f.contract, Subject::Company, "Not company-wide")
.unwrap();
let b = load(&f.dir.0);
let find = |t: &str| {
b.journal
.iter()
.filter(|l| l.line_type == t)
.map(|l| describe(&b, l).unwrap())
.collect::<Vec<_>>()
};
let linked = find("attachment.linked");
let party_link = linked
.iter()
.find(|e| e.also == Some(Route::Party(f.party)))
.unwrap();
assert_eq!(
party_link.text,
"Linked acme-agreement.pdf to Acme Software"
);
assert_eq!(party_link.route, Some(Route::Document(f.contract)));
assert!(
linked
.iter()
.any(|e| e.text == "Linked laptop.png to Laptop"
&& e.also == Some(Route::Asset(f.asset)))
);
assert!(
linked
.iter()
.any(|e| e.text == format!("Linked acme-agreement.pdf to #{} Software", f.software))
);
assert!(
linked
.iter()
.any(|e| e.also == Some(Route::Reconciliation(r)))
);
let unlinked = &find("attachment.unlinked")[0];
assert_eq!(
unlinked.text,
"Unlinked acme-agreement.pdf from the company: Not company-wide"
);
assert_eq!(unlinked.also, Some(Route::Company));
let recorded = &find("statement.recorded")[0];
assert!(
recorded.text.contains("1000 Checking") && recorded.text.contains("Jul 31, 2026"),
"{}",
recorded.text
);
assert_eq!(recorded.route, Some(Route::Document(st)));
assert_eq!(recorded.also, Some(Route::Account(f.checking)));
let done = &find("reconciliation.completed")[0];
assert!(
done.text
.starts_with("Reconciled 1000 Checking through Jul 31, 2026"),
"{}",
done.text
);
assert_eq!(done.route, Some(Route::Reconciliation(r)));
let voided = &find("reconciliation.voided")[0];
assert_eq!(
voided.text,
"Voided the reconciliation of 1000 Checking through Jul 31, 2026: Wrong statement"
);
assert_eq!(voided.route, Some(Route::Reconciliation(r)));
}
#[test]
fn search_finds_statements_and_reconciliations() {
let mut f = fixture("search");
let (st, r) = reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 7, 31));
let b = load(&f.dir.0);
let routes = |q: &str| -> Vec<Route> {
palette::search(&b, q)
.into_iter()
.map(|i| i.route)
.collect()
};
assert!(routes("reconciliation checking").contains(&Route::Reconciliation(r)));
assert!(routes("checking 2026-07-31").contains(&Route::Reconciliation(r)));
assert!(routes("statement checking jul").contains(&Route::Document(st)));
assert!(!routes("statement savings").contains(&Route::Document(st)));
}
#[test]
fn every_new_page_renders() {
let mut f = fixture("render");
let (st, r) = reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 7, 31));
for route in [
Route::Overview,
Route::Account(f.checking),
Route::Account(f.card),
Route::Reconciliation(r),
Route::Reconciliation(999),
Route::Document(st),
Route::Document(f.contract),
Route::Entry(f.software),
Route::Entry(f.linked_only),
Route::Party(f.party),
Route::Asset(f.asset),
Route::Company,
Route::Parties,
] {
let books = load(&f.dir.0);
let mut h = Harness::builder()
.with_size(egui::vec2(1100.0, 900.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, route.clone()));
h.run_steps(3);
assert_eq!(h.state_mut().route(), &route);
}
}
#[test]
fn books_show_opens_a_reconciliation() {
use ledger_store::viewer::ViewerTarget;
let mut f = fixture("show");
let (_, r) = reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 7, 31));
let books = load(&f.dir.0);
let mut h = Harness::builder()
.with_size(egui::vec2(1100.0, 900.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, Route::Overview));
h.state_mut().show(ViewerTarget::Reconciliation { id: r });
h.run_steps(2);
assert_eq!(h.state_mut().route(), &Route::Reconciliation(r));
// An unknown one goes nowhere.
h.state_mut().show(ViewerTarget::Reconciliation { id: 999 });
assert_eq!(h.state_mut().route(), &Route::Reconciliation(r));
}
+286
View File
@@ -628,3 +628,289 @@ fn attention_asks_for_a_w9_until_its_facts_are_recorded() {
}
);
}
/// Paid out, received in, net, and what for, over each period, worked by
/// hand from the fixture; the breakdown and the months sum to the money
/// moved, exactly.
#[test]
fn party_money_by_period_and_what_for() {
use ledger_gui::books::PartyPeriod;
let f = fixture("periods");
let b = &f.books;
let o = &f.oracle;
let code = |c: &str| o.chart().find_by_code(c).unwrap().id;
let labor = code("6050");
let payables = o.role_account(&ledger_core::PAYABLES).unwrap();
let receivables = o.role_account(&ledger_core::RECEIVABLES).unwrap();
let flow = |p, period| b.party_flow(p, b.party_span(period), true);
// The spans.
assert_eq!(
b.party_span(PartyPeriod::ThisYear),
(d(2026, 1, 1), today())
);
assert_eq!(
b.party_span(PartyPeriod::LastYear),
(d(2025, 1, 1), d(2025, 12, 31))
);
assert_eq!(b.party_span(PartyPeriod::AllTime), (d(2025, 1, 1), today()));
assert_eq!(
b.party_periods(Some(f.sam)),
vec![
PartyPeriod::ThisYear,
PartyPeriod::LastYear,
PartyPeriod::AllTime
]
);
// Sam this year: cash 1,000 + card 200 + owner 300 + (250 − 250
// reversed) + 500 of the 800 bill paid = 2,000 out. What for: labor
// 1,000 + 200 + 300 + 250 − 250 = 1,500, the bill's 800 (general
// expenses, the bill's account), less the 300 still owed on the bill
// (payables −800 + 500).
let sam = flow(f.sam, PartyPeriod::ThisYear);
assert_eq!((sam.paid, sam.received), (cents(200_000), Decimal::ZERO));
assert_eq!(sam.net(), cents(200_000));
assert_eq!(
sam.what_for,
vec![
(labor, cents(150_000)),
(code("6999"), cents(80_000)),
(payables, cents(-30_000))
]
);
// Feb, Mar card, Apr owner, May and its reversal, the bill, its
// payment (the draft isn't posted).
assert_eq!(sam.entries, 7);
// Last year: December's 500.
let last = flow(f.sam, PartyPeriod::LastYear);
assert_eq!((last.paid, last.received), (cents(50_000), Decimal::ZERO));
assert_eq!(last.what_for, vec![(labor, cents(50_000))]);
assert_eq!(last.entries, 1);
// All time: both.
let all = flow(f.sam, PartyPeriod::AllTime);
assert_eq!(all.paid, cents(250_000));
assert_eq!(all.entries, 8);
// Acme this year: 600 received on the 1,000 invoice. What for: the
// revenue (1,000 in), less the 400 still receivable.
let acme = flow(f.acme, PartyPeriod::ThisYear);
assert_eq!((acme.paid, acme.received), (Decimal::ZERO, cents(60_000)));
assert_eq!(acme.net(), cents(-60_000));
let revenue = acme
.what_for
.iter()
.find(|(a, _)| *a != receivables)
.unwrap();
assert_eq!(revenue.1, cents(-100_000));
assert!(acme.what_for.contains(&(receivables, cents(40_000))));
// Last year: invoiced 400, nothing moved; what for nets to zero.
let acme_last = flow(f.acme, PartyPeriod::LastYear);
assert_eq!(
(acme_last.paid, acme_last.received),
(Decimal::ZERO, Decimal::ZERO)
);
assert_eq!(
acme_last.what_for.iter().map(|x| x.1).sum::<Decimal>(),
Decimal::ZERO
);
// Gumroad: the 90 deposit is 100 of sales less a 10 fee.
let g = flow(f.gumroad, PartyPeriod::ThisYear);
assert_eq!(g.received, cents(9_000));
assert_eq!(
g.what_for,
vec![(code("4100"), cents(-10_000)), (code("6000"), cents(1_000))]
);
// Every party, every period: the breakdown sums to the net, and the
// months to paid and received, exactly; the totals are activity's.
for p in o.parties() {
for period in b.party_periods(Some(p.id)) {
let span = b.party_span(period);
let fl = b.party_flow(p.id, span, true);
let a = ledger_core::parties::activity(o, p.id, span, &b.cash_accounts);
assert_eq!((fl.paid, fl.received), (a.paid, a.received));
assert_eq!(
fl.what_for.iter().map(|x| x.1).sum::<Decimal>(),
fl.net(),
"{} {period:?}",
p.name
);
let months = b.party_months(p.id, span);
assert_eq!(months.iter().map(|m| m.paid).sum::<Decimal>(), fl.paid);
assert_eq!(
months.iter().map(|m| m.received).sum::<Decimal>(),
fl.received
);
}
}
// Sam's months this year: Feb 1,000, Mar 200, Apr 300, May 0 (paid
// and reversed), Jun 500.
let months = b.party_months(f.sam, b.party_span(PartyPeriod::ThisYear));
assert_eq!(months.len(), 10);
let paid: Vec<Decimal> = months.iter().map(|m| m.paid).collect();
assert_eq!(
&paid[..6],
&[
Decimal::ZERO,
cents(100_000),
cents(20_000),
cents(30_000),
Decimal::ZERO,
cents(50_000)
]
);
// The 1099 amount follows the period's calendar year.
let (line, _) = b.party_1099(f.sam, 2025).unwrap();
assert_eq!(line.paid, cents(50_000));
let (line, _) = b.party_1099(f.sam, 2026).unwrap();
assert_eq!(line.paid, cents(150_000));
}
#[test]
fn directory_sorts_by_the_periods_amounts() {
use ledger_gui::books::PartyPeriod;
use ledger_gui::pages::parties::{Sort, sorted};
let f = fixture("sorted");
let b = &f.books;
let names =
|st: &State| -> Vec<PartyId> { sorted(b, st).into_iter().map(|(p, _)| p.id).collect() };
let mut st = State::default();
assert_eq!(
names(&st),
listed(b, &st).into_iter().map(|p| p.id).collect::<Vec<_>>()
);
st.sort = Sort::PaidOut;
assert_eq!(names(&st)[0], f.sam);
st.sort = Sort::ReceivedIn;
assert_eq!(&names(&st)[..2], &[f.acme, f.gumroad]);
st.sort = Sort::Net;
assert_eq!(names(&st)[0], f.sam);
assert_eq!(*names(&st).last().unwrap(), f.acme);
// Last year, only Sam was paid.
st.period = PartyPeriod::LastYear;
st.sort = Sort::PaidOut;
let rows = sorted(b, &st);
assert_eq!(rows[0].0.id, f.sam);
assert_eq!(rows[0].1.paid, cents(50_000));
assert!(rows[1..].iter().all(|(_, fl)| fl.paid.is_zero()));
}
/// Books with three posted entries this year: one tagged with a party,
/// one marked as having none, one neither; under `parties` = `policy`
/// (None: unset).
fn counterparty_books(
tag: &str,
policy: Option<&str>,
) -> (TempDir, Store, EntryId, EntryId, EntryId) {
let dir = TempDir::new(tag);
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
let cash = s
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let savings = s
.add_account("1010", "Savings", AccountType::Asset, None, false)
.unwrap();
let labor = s
.add_account("6050", "Contract Labor", AccountType::Expense, None, false)
.unwrap();
let riley = s.add_party("Riley Chen", &[Role::Contractor]).unwrap();
let post = |s: &mut Store, date, what: &str, dr, cr| {
let e = s.create_entry(date, what).unwrap();
s.add_line(e, dr, Side::Debit, usd(10_000)).unwrap();
s.add_line(e, cr, Side::Credit, usd(10_000)).unwrap();
s.attach_receipt(Some(e), "r.pdf", "application/pdf", what.as_bytes(), None)
.unwrap();
s.post_entry(e).unwrap();
e
};
let tagged = post(&mut s, d(2026, 3, 1), "Riley", labor, cash);
s.set_entry_party(tagged, Some(riley)).unwrap();
let transfer = post(&mut s, d(2026, 4, 1), "To savings", savings, cash);
s.set_entry_no_party(transfer, "Between the company's own accounts")
.unwrap();
let unnamed = post(&mut s, d(2026, 5, 1), "Mystery payment", labor, cash);
if let Some(p) = policy {
std::fs::write(
dir.0.join("wanderbooks.toml"),
format!("[books]\nparties = \"{p}\"\n"),
)
.unwrap();
}
(dir, s, tagged, transfer, unnamed)
}
fn load_with_toml(dir: &Path) -> Books {
data::load_once(&Source {
data_dir: dir.join("books-data"),
books_toml: Some(dir.join("wanderbooks.toml")),
today: Some(today()),
})
.unwrap()
}
#[test]
fn entries_without_a_counterparty_need_attention_unless_off() {
use ledger_gui::books::Severity;
let item = |b: &Books| {
attention::find(b)
.into_iter()
.find(|a| a.title.ends_with("no counterparty"))
};
// Unset is suggest: one note naming just the unmarked entry.
let (dir, mut s, _, transfer, unnamed) = counterparty_books("np-suggest", None);
let b = load_with_toml(&dir.0);
let a = item(&b).expect("an item");
assert_eq!(a.title, "1 entry names no counterparty");
assert_eq!(a.severity, Severity::Info);
assert!(a.prompt.starts_with("In wanderbooks,"));
assert!(a.prompt.contains("books.set_entry_party"));
assert!(
a.prompt
.contains(&format!("{unnamed} (Mystery payment, 2026-05-01)"))
);
assert!(!a.prompt.contains(&format!("{transfer} (")));
// The mark shows on the entry and reads in the journal.
assert_eq!(
b.ledger.entry_no_party(transfer),
Some("Between the company's own accounts")
);
let line = b
.journal
.iter()
.find(|l| l.line_type == "entry.no_party")
.unwrap();
let ev = describe(&b, line).unwrap();
assert_eq!(
ev.text,
format!(
"Entry #{transfer} marked as having no counterparty: Between the company's own accounts"
)
);
assert_eq!(ev.route, Some(Route::Entry(transfer)));
let mut h = Harness::builder()
.with_size(egui::vec2(1100.0, 900.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, Route::Entry(transfer)));
h.run_steps(3);
use egui_kittest::kittest::Queryable;
h.get_by_label(&format!(
"{} No counterparty: Between the company's own accounts",
egui_phosphor::regular::USER_MINUS
));
// Tagging the last one clears it.
let p = s.add_party("Mystery Co", &[Role::Contractor]).unwrap();
s.set_entry_party(unnamed, Some(p)).unwrap();
assert!(item(&load_with_toml(&dir.0)).is_none());
// Require: a to-do.
let (dir, _s, ..) = counterparty_books("np-require", Some("require"));
assert_eq!(
item(&load_with_toml(&dir.0)).unwrap().severity,
Severity::Todo
);
// Off: never mentioned.
let (dir, _s, ..) = counterparty_books("np-off", Some("off"));
assert!(item(&load_with_toml(&dir.0)).is_none());
}
+16
View File
@@ -64,6 +64,19 @@ fn screenshots() {
.map(|p| p.id)
})
.unwrap_or(1);
let reconciliation = loaded.reconciliations.last().map(|r| r.id).unwrap_or(1);
let voided = loaded
.reconciliations
.iter()
.find(|r| r.voided.is_some())
.map_or(reconciliation, |r| r.id);
// A document linked to more than one subject, else the last.
let linked_doc = loaded
.attachments
.iter()
.rev()
.find(|a| loaded.links_of(a.id).len() > 1)
.map_or(last_doc, |a| a.id);
drop(loaded);
let routes: Vec<(&str, Route)> = vec![
("overview", Route::Overview),
@@ -98,6 +111,9 @@ fn screenshots() {
("parties", Route::Parties),
("party", Route::Party(party)),
("document", Route::Document(last_doc)),
("document-linked", Route::Document(linked_doc)),
("reconciliation", Route::Reconciliation(reconciliation)),
("reconciliation-voided", Route::Reconciliation(voided)),
("event", Route::JournalEvent(last_event)),
("documents", Route::Documents),
("company", Route::Company),
+3 -2
View File
@@ -1210,10 +1210,11 @@ fn attention_is_pure_on_mixed_books() {
assert_eq!(x.prompt, y.prompt);
assert_eq!(x.severity, y.severity);
}
// Two drafts, three posted without receipts.
// Two drafts, three posted without receipts (and the checking
// account, with activity before this month, due its reconciliation).
let todos = first
.iter()
.filter(|a| a.severity == Severity::Todo)
.filter(|a| a.severity == Severity::Todo && !a.title.starts_with("Reconcile "))
.count();
let problems = first
.iter()
+1
View File
@@ -15,6 +15,7 @@ ledger-core = { path = "../ledger-core" }
ledger-store = { path = "../ledger-store" }
ledger-import = { path = "../ledger-import" }
ledger-config = { path = "../ledger-config" }
ledger-vault = { path = "../ledger-vault" }
base64 = "0.22"
mime_guess = "2"
rust_decimal = "1"
File diff suppressed because it is too large. Load diff
+16
View File
@@ -50,9 +50,23 @@ pub struct Settings {
/// The prefix of generated asset tags (`[books] asset_tag_prefix`, or
/// the company's initials).
pub asset_tag_prefix: String,
/// `[vault] key_file`: the TIN vault's key file (unset: the vault's
/// default, `ledger_vault::default_key_file`).
pub vault_key_file: Option<PathBuf>,
/// `[books] parties`: suggest, require, or never mention each entry's
/// counterparty.
pub parties: ledger_config::PartyPolicy,
}
impl Settings {
/// The vault key file in force: `[vault] key_file`, else the default
/// outside the books (`~/.config/wanderbooks/vault.key`).
pub fn key_file(&self) -> PathBuf {
self.vault_key_file
.clone()
.unwrap_or_else(ledger_vault::default_key_file)
}
/// The prefix for generated asset tags ("FA" when none is set).
pub fn tag_prefix(&self) -> &str {
if self.asset_tag_prefix.is_empty() {
@@ -96,6 +110,8 @@ impl Source {
let policy = ledger_config::effective_receipt_policy(&self.config_json, &books)?;
Ok(Settings {
asset_tag_prefix: books.asset_tag_prefix(),
vault_key_file: books.vault_key_file.clone(),
parties: books.parties,
policy,
roles: CashRoles {
cash: books.cash_accounts,
+453 -11
View File
@@ -356,6 +356,16 @@ pub struct Vendor1099Params {
/// Override the reporting threshold, decimal string (default: $600
/// through 2025, $2,000 from 2026).
pub threshold: Option<String>,
/// True: each line carries its payee's full TIN, read from the TIN
/// vault (or a note saying why not). The output then holds full TINs:
/// for the CPA only, when the human asks for it. Default false (last
/// four digits only).
#[serde(default)]
pub include_tins: bool,
/// Also write the worksheet as CSV to this local path (TINs included
/// only with include_tins). Never inside the books dir; an existing
/// file is refused.
pub save_csv_to: Option<String>,
}
/// One vendor's year.
@@ -373,6 +383,18 @@ pub struct Vendor1099LineOut {
pub paid: String,
/// Marked for a 1099 and paid at least the threshold.
pub reportable: bool,
/// The vendor's party.
pub party_id: Option<u64>,
/// The TIN's last four digits, from the party's W-9 facts.
pub tin_last4: Option<String>,
/// The full TIN, formatted (123-45-6789, 12-3456789): only with
/// include_tins, and only when the vault holds it.
pub tin: Option<String>,
/// The TIN's type (ssn, ein, itin): only with include_tins.
pub tin_type: Option<String>,
/// With include_tins, why `tin` is missing (not in the vault, the vault
/// locked, a record that doesn't match the books).
pub tin_note: Option<String>,
}
/// Result of `books.vendor_1099`.
@@ -388,6 +410,11 @@ pub struct Vendor1099Out {
pub missing_w9: Vec<String>,
/// Caveats for the human and CPA.
pub notes: Vec<String>,
/// With include_tins: this output holds full TINs. For the CPA only;
/// never paste it anywhere else.
pub warning: Option<String>,
/// The CSV written with save_csv_to, if any.
pub saved_csv: Option<String>,
}
/// Parameters for `books.verify`.
@@ -436,6 +463,9 @@ pub struct ShowParams {
pub asset: Option<String>,
/// A party, by id or name (as `books.party`).
pub party: Option<String>,
/// A reconciliation (id from `books.reconciliations`): its account's
/// page, with the reconciliation and its statement.
pub reconciliation: Option<u64>,
/// Search the transactions list (description, account, vendor, amount).
pub search: Option<String>,
/// A page: overview, cash, profit, transactions, reports, accounts,
@@ -548,6 +578,8 @@ pub struct SettingsOut {
pub basis: String,
/// The receipt rule in force.
pub receipt_rule: String,
/// `[books] parties`: "off", "suggest", or "require".
pub parties: String,
/// `[[company.members]]`.
pub members: Vec<MemberSettingOut>,
/// `[books] cash_accounts`.
@@ -823,6 +855,11 @@ pub struct ImportBankCsvParams {
/// "month_first" (mm/dd/yyyy, US banks) or "day_first" (dd/mm/yyyy).
#[serde(default)]
pub date_order: Option<String>,
/// The CSV as filed (`books.file_document` category import_source, or
/// any stored attachment): linked to the bank account imported into,
/// so the import traces to its source. Never linked to the drafts: a
/// bank export is not a receipt.
pub source_attachment_id: Option<u64>,
}
/// One imported bank transaction (as a draft entry).
@@ -847,6 +884,11 @@ pub struct ImportBankCsvOut {
pub imported: Vec<ImportedTxnOut>,
/// Number of transactions imported.
pub count: usize,
/// The source attachment, if one was given.
pub source_attachment_id: Option<u64>,
/// True when this call linked it to the account (false when it already
/// was, or none was given).
pub source_linked: bool,
}
/// Parameters for `books.import_chart_csv`.
@@ -946,6 +988,10 @@ pub struct DraftEntryParams {
/// party with a vendor record also tags the entry's vendor; given with
/// `vendor`, both must name the same vendor.
pub party: Option<String>,
/// Why the entry has no counterparty (a transfer between the company's
/// own accounts, an accrual), when `[books] parties = "require"` and
/// none applies. Not with `party` or `vendor`.
pub no_party_reason: Option<String>,
}
/// Result of `books.draft_entry`.
@@ -1061,6 +1107,34 @@ pub struct PostEntryOut {
/// receipt threshold (same date, description, and debit accounts).
/// Empty when no split is detected.
pub split_detected: Vec<u64>,
/// `[books] parties`: "off", "suggest", or "require".
pub party_policy: String,
/// The entry's counterparty, when tagged.
pub party_id: Option<u64>,
/// Its name.
pub party: Option<String>,
/// Why it has none, when marked so.
pub no_party_reason: Option<String>,
/// True when the entry names no party and isn't marked as having none
/// (always false when the policy is "off"). Under "require", posting
/// with confirmed=true fails in this state.
pub party_missing: bool,
/// Likely counterparties, best first, when `party_missing`: tag one
/// with `books.set_entry_party`, or add the party with
/// `books.add_party` first.
pub party_suggestions: Vec<PartySuggestionOut>,
}
/// A likely counterparty for an entry.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct PartySuggestionOut {
/// The party's id.
pub party_id: u64,
/// Its name.
pub name: String,
/// Why: "named in the description", "named in a receipt", or "tagged
/// on entry N with the same description".
pub why: String,
}
/// Parameters for `books.reverse_entry`.
@@ -1107,6 +1181,11 @@ pub struct AttachReceiptParams {
pub content_base64: String,
/// Optional description.
pub description: Option<String>,
/// More subjects to link it to in the same call (each journaled as its
/// own `attachment.linked`). Every subject is checked before anything
/// is filed: one bad subject files nothing.
#[serde(default)]
pub link_to: Vec<SubjectParam>,
}
/// Result of `books.attach_receipt`.
@@ -1118,12 +1197,15 @@ pub struct AttachReceiptOut {
pub sha256: String,
/// True when identical bytes were already stored (deduplicated).
pub deduplicated: bool,
/// Everything it is linked to now (the entry it is filed on first).
pub links: Vec<SubjectOut>,
}
/// Parameters for `books.file_document`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct FileDocumentParams {
/// One of: formation, legal, tax, accounting, other.
/// One of: formation, legal, tax, accounting, statement, contract,
/// photo, warranty, import_source, other.
pub category: String,
/// Original file name.
pub file_name: String,
@@ -1133,6 +1215,11 @@ pub struct FileDocumentParams {
pub content_base64: String,
/// Optional description.
pub description: Option<String>,
/// Subjects to link it to in the same call (each journaled as its own
/// `attachment.linked`). Every subject is checked before anything is
/// filed: one bad subject files nothing.
#[serde(default)]
pub link_to: Vec<SubjectParam>,
}
/// Parameters for `books.link_receipt`.
@@ -1221,6 +1308,8 @@ pub struct ReceiptOut {
pub withdrawn: bool,
/// Why it was withdrawn, if it was.
pub withdrawn_reason: Option<String>,
/// Everything it is linked to (the entry it is filed on first).
pub links: Vec<SubjectOut>,
}
/// Result of `books.list_receipts`.
@@ -1255,6 +1344,10 @@ pub struct GetReceiptOut {
pub mime_type: String,
/// Size in bytes.
pub size_bytes: u64,
/// Description, if any.
pub description: Option<String>,
/// Everything it is linked to (the entry it is filed on first).
pub links: Vec<SubjectOut>,
/// File bytes, base64-encoded.
pub content_base64: String,
}
@@ -1421,6 +1514,25 @@ pub struct DigestOut {
/// didn't load, or a setting that disagrees with the chart. Fix each
/// with the human, then check books.settings.
pub settings_problems: Vec<String>,
/// Cash and card accounts (`[books] cash_accounts`, and liabilities
/// with a recorded statement) with activity not reconciled through the
/// end of last month: record each statement and reconcile it.
pub unreconciled_accounts: Vec<UnreconciledAccountOut>,
}
/// An account due for reconciliation (`books.digest`).
#[derive(Debug, Serialize, JsonSchema)]
pub struct UnreconciledAccountOut {
/// Account code.
pub code: String,
/// Account name.
pub name: String,
/// The last statement date it is reconciled through, if any.
pub reconciled_through: Option<String>,
/// The date it should be reconciled through: the end of last month.
pub due_through: String,
/// The calls that complete it.
pub next_step: String,
}
/// A torn journal tail set aside on open.
@@ -1976,20 +2088,32 @@ pub struct MarkClearedOut {
pub ok: bool,
}
/// Parameters for `books.reconcile`.
/// Parameters for `books.reconcile`. Either a recorded statement
/// (`statement_attachment`), or `account`, `statement_date`, and
/// `statement_balance` for a check against any balance.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ReconcileParams {
/// Account id or code (e.g. "1000") to reconcile.
pub account: String,
/// Statement date, YYYY-MM-DD.
pub statement_date: String,
/// Statement balance, decimal string.
pub statement_balance: String,
/// Account id or code (e.g. "1000") to reconcile. With
/// statement_attachment, optional (it must be the statement's).
pub account: Option<String>,
/// Statement date, YYYY-MM-DD. With statement_attachment, optional
/// (it must be the statement's period end).
pub statement_date: Option<String>,
/// Statement balance, decimal string. With statement_attachment,
/// optional (it must be the statement's closing balance).
pub statement_balance: Option<String>,
/// The attachment holding the statement, recorded with
/// `books.record_statement`: reconcile to what it says.
pub statement_attachment: Option<u64>,
/// True: record the reconciliation (needs statement_attachment and a
/// difference of exactly zero). Without it, a preview that records
/// nothing.
pub confirmed: Option<bool>,
}
/// One uncleared line in the reconciliation report.
/// One line in the reconciliation report.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct UnclearedLineOut {
pub struct ReconcileLineOut {
/// Entry id.
pub entry_id: u64,
/// Line index within the entry.
@@ -2007,8 +2131,14 @@ pub struct UnclearedLineOut {
/// Output of `books.reconcile`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ReconcileOut {
/// "preview" (nothing recorded) or "reconciled" (recorded).
pub status: String,
/// Account code.
pub account_code: String,
/// The statement date reconciled through, YYYY-MM-DD.
pub statement_date: String,
/// The statement's attachment, when reconciling to a recorded one.
pub statement_attachment: Option<u64>,
/// Book balance (all posted lines), decimal string.
pub book_balance: String,
/// Cleared balance (cleared lines on/before statement date), decimal string.
@@ -2018,7 +2148,14 @@ pub struct ReconcileOut {
/// cleared_balance - statement_balance. Zero means reconciled.
pub difference: String,
/// Uncleared lines on/before the statement date.
pub uncleared_lines: Vec<UnclearedLineOut>,
pub uncleared_lines: Vec<ReconcileLineOut>,
/// Cleared lines on/before the statement date: what a confirmed
/// reconciliation records.
pub cleared_lines: Vec<ReconcileLineOut>,
/// Why confirmed=true would be refused now (empty: it would record).
pub problems: Vec<String>,
/// The recorded reconciliation (status "reconciled" only).
pub reconciliation: Option<ReconciliationOut>,
}
// ---------------------------------------------------------------------------
@@ -2437,6 +2574,116 @@ pub struct SetW9Params {
/// True: clear the W-9 facts (give no other fact).
#[serde(default)]
pub clear: bool,
/// The full TIN (nine digits, hyphens allowed), stored only in the
/// encrypted TIN vault, never in the books; the books keep its last
/// four. Needs tin_type. Never echoed back.
pub tin: Option<SecretTin>,
/// The TIN's type: ssn, ein, or itin. Required with tin.
pub tin_type: Option<String>,
/// A local PDF, PNG, or JPEG of the signed W-9: stored only in the
/// vault (it shows the full TIN), never with the receipts and
/// documents. Read it back with `books.get_w9`.
pub w9_file_path: Option<String>,
}
/// A full TIN as a tool takes it: text (or a JSON number) whose value is
/// never printed: its Debug, and any error deserializing it, say nothing
/// of the digits.
#[derive(Clone, JsonSchema)]
#[schemars(transparent)]
pub struct SecretTin(pub String);
impl std::fmt::Debug for SecretTin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SecretTin(..)")
}
}
impl<'de> Deserialize<'de> for SecretTin {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct V;
impl serde::de::Visitor<'_> for V {
type Value = SecretTin;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("a TIN: nine digits as a string")
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<SecretTin, E> {
Ok(SecretTin(v.to_string()))
}
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<SecretTin, E> {
Ok(SecretTin(v.to_string()))
}
fn visit_i64<E: serde::de::Error>(self, _: i64) -> Result<SecretTin, E> {
Err(E::custom("tin must be nine digits"))
}
fn visit_f64<E: serde::de::Error>(self, _: f64) -> Result<SecretTin, E> {
Err(E::custom("tin must be nine digits"))
}
}
d.deserialize_any(V)
}
}
/// Parameters for `books.get_w9`.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct GetW9Params {
/// The party: id or name.
pub party: String,
/// Where to write the W-9 file: a local path outside the books dir
/// (the file shows the full TIN; it must never be committed with the
/// books). An existing file is refused.
pub save_to: String,
}
/// Result of `books.get_w9`: where the file went, never its contents.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct GetW9Out {
/// The party.
pub party_id: u64,
/// The path written.
pub path: String,
/// The file's original name.
pub file_name: String,
/// Its size in bytes.
pub size_bytes: u64,
/// What to tell the human.
pub note: String,
}
/// Parameters for `books.purge_tin`.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct PurgeTinParams {
/// The party: id or name.
pub party: String,
/// Why (retention period over, the payee asked, a wrong TIN):
/// journaled with the W-9 facts.
pub reason: String,
/// True: delete the vault records and journal it. Without it, a
/// preview that changes nothing.
pub confirmed: Option<bool>,
}
/// Result of `books.purge_tin`.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct PurgeTinOut {
/// "preview" or "purged".
pub status: String,
/// The party.
pub party_id: u64,
/// Its name.
pub name: String,
/// Whether the vault holds (preview) or held (purged) its TIN.
pub tin_in_vault: bool,
/// Whether the vault holds (held) its W-9 file.
pub w9_file_in_vault: bool,
/// The TIN's last four digits, which the books keep.
pub tin_last4: Option<String>,
/// True when the purge is journaled (`party.w9_set` with the vault
/// record cleared and kept_at saying why); false when the party has no
/// W-9 facts to update.
pub journaled: bool,
/// What happens (preview) or happened.
pub note: String,
}
/// Parameters for `books.set_w4`: what a W-4 said, never the SSN. Either
@@ -2490,6 +2737,10 @@ pub struct SetEntryPartyParams {
/// in `books.vendor_1099`); one without, or clearing, clears the vendor
/// tag too.
pub party: Option<String>,
/// With `party` null: mark the entry as having no counterparty, with
/// why (journaled as `entry.no_party`). Satisfies `[books] parties =
/// "require"`.
pub no_party_reason: Option<String>,
}
/// A party's W-9 facts.
@@ -2507,6 +2758,9 @@ pub struct W9Out {
pub tin_last4: String,
/// Where the form is kept.
pub kept_at: String,
/// True when the books record a TIN vault record for it (the full TIN
/// is in the vault, never here).
pub in_vault: bool,
}
/// A party's W-4 facts.
@@ -2713,3 +2967,191 @@ pub struct PartyDetailOut {
/// Payroll lines naming it, by run.
pub payroll: Vec<PartyPayrollOut>,
}
// ---------------------------------------------------------------------------
// Attachment links, statements, reconciliations
// ---------------------------------------------------------------------------
/// A reference: a number or text (a code, tag, or name).
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum RefId {
/// A number (an id; for an account, its code or id).
Number(u64),
/// Text: an account code, an asset tag, a party name, or an id.
Text(String),
}
/// What an attachment is linked to.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct SubjectParam {
/// entry, account, asset, party, invoice, bill, period, payroll_run,
/// schedule, reconciliation, or company.
pub kind: String,
/// Which one: an entry, invoice, bill, period, payroll run, schedule,
/// or reconciliation id; an account's code (or id); an asset's tag (or
/// id); a party's id or name. Omitted for the company.
pub id: Option<RefId>,
}
/// A subject in a tool's output.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct SubjectOut {
/// Its kind (as in SubjectParam).
pub kind: String,
/// Its id (absent for the company). For an account, the account id;
/// `label` gives the code.
pub id: Option<u64>,
/// Human words, e.g. "account 1000 Bank", "entry #5 January rent".
pub label: String,
}
/// Parameters for `books.link_attachment`.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct LinkAttachmentParams {
/// A stored attachment (receipt or document), not withdrawn.
pub attachment_id: u64,
/// What to link it to.
pub subject: SubjectParam,
}
/// Parameters for `books.unlink_attachment`.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct UnlinkAttachmentParams {
/// The attachment.
pub attachment_id: u64,
/// The subject to take it off.
pub subject: SubjectParam,
/// Why, recorded in the journal.
pub reason: String,
}
/// Result of `books.link_attachment` / `books.unlink_attachment`.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct AttachmentLinksOut {
/// The attachment.
pub attachment_id: u64,
/// Everything it is linked to now (the entry it is filed on first).
pub links: Vec<SubjectOut>,
}
/// Parameters for `books.attachments`.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct AttachmentsParams {
/// The subject whose files to list.
pub subject: SubjectParam,
}
/// Result of `books.attachments`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct AttachmentsOut {
/// The subject.
pub subject: SubjectOut,
/// Its files (for an entry: filed on it or linked to it), not
/// withdrawn, by id, each with all its links.
pub attachments: Vec<ReceiptOut>,
}
/// Parameters for `books.record_statement`.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct RecordStatementParams {
/// The filed statement (`books.file_document` category statement).
pub attachment_id: u64,
/// The bank, cash, or card account it is for: code or id.
pub account: String,
/// First day it covers, YYYY-MM-DD.
pub period_start: String,
/// Last day it covers (the statement date), YYYY-MM-DD.
pub period_end: String,
/// Opening balance as printed, in the account's normal direction
/// (what the bank holds for a bank account; what is owed for a card),
/// decimal string, cents at most.
pub opening_balance: String,
/// Closing balance as printed, same direction, decimal string.
pub closing_balance: String,
}
/// A recorded statement.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct StatementOut {
/// The attachment holding it.
pub attachment_id: u64,
/// Its file name.
pub file_name: String,
/// Account code.
pub account_code: String,
/// Account name.
pub account_name: String,
/// First day, YYYY-MM-DD.
pub period_start: String,
/// Last day (statement date), YYYY-MM-DD.
pub period_end: String,
/// Opening balance, decimal string.
pub opening_balance: String,
/// Closing balance, decimal string.
pub closing_balance: String,
/// The reconciliation (not voided) made against it, if any.
pub reconciliation_id: Option<u64>,
}
/// Parameters for `books.statements` and `books.reconciliations`.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct AccountFilterParams {
/// Only this account's (code or id). Omit for every account.
pub account: Option<String>,
}
/// Result of `books.statements`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct StatementsOut {
/// Recorded statements, by statement date.
pub statements: Vec<StatementOut>,
}
/// A cleared line a reconciliation recorded.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ClearedRefOut {
/// Entry id.
pub entry_id: u64,
/// Line index within the entry.
pub line_index: usize,
}
/// A recorded reconciliation.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ReconciliationOut {
/// Its id.
pub reconciliation_id: u64,
/// Account code.
pub account_code: String,
/// Account name.
pub account_name: String,
/// The statement date it reconciles through, YYYY-MM-DD.
pub statement_date: String,
/// The statement's closing balance, decimal string.
pub statement_balance: String,
/// The statement's attachment.
pub statement_attachment: u64,
/// The lines it found cleared.
pub cleared: Vec<ClearedRefOut>,
/// True when voided.
pub voided: bool,
/// Why it was voided, if it was.
pub voided_reason: Option<String>,
}
/// Parameters for `books.void_reconciliation`.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct VoidReconciliationParams {
/// The reconciliation's id.
pub reconciliation_id: u64,
/// Why, recorded in the journal.
pub reason: String,
}
/// Result of `books.reconciliations`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ReconciliationsOut {
/// Reconciliations (voided ones included, marked), by date.
pub reconciliations: Vec<ReconciliationOut>,
}
+1 -1
View File
@@ -147,7 +147,7 @@ fn mcp_end_to_end_over_stdio() {
// Tool listing: the narrow tools; chart is read-only.
let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 82, "expected 82 tools, got {}", tools.len());
assert_eq!(tools.len(), 91, "expected 91 tools, got {}", tools.len());
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
assert_eq!(chart["annotations"]["readOnlyHint"], true);
assert!(
File diff suppressed because it is too large. Load diff
+102 -4
View File
@@ -175,6 +175,14 @@ fn test_dir(name: &str) -> PathBuf {
dir
}
/// A directory beside the books dir `dir`, outside it (vault keys, files
/// with TINs).
fn outside(dir: &Path) -> PathBuf {
let d = PathBuf::from(format!("{}-outside", dir.display()));
std::fs::create_dir_all(&d).unwrap();
d
}
/// Write a `wanderbooks.toml` with the given receipt threshold and extra
/// TOML (appended under `[books]` unless it opens its own table).
fn books_toml(dir: &Path, threshold: &str, extra: &str) -> PathBuf {
@@ -201,6 +209,9 @@ fn spawn_child(dir: &Path, toml: &Path) -> Child {
// books.show writes a viewer request here; keep it out of the
// user's real runtime dir.
.env("XDG_RUNTIME_DIR", &run)
// The TIN vault's default key file is under XDG_CONFIG_HOME: never
// the user's real one (the happy path names its own anyway).
.env("XDG_CONFIG_HOME", outside(dir).join("config"))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
@@ -572,9 +583,13 @@ fn happy_path(name: &str) -> Mcp {
let toml = books_toml(
&dir,
"1000000.00",
"financing_accounts = [\"2100\"]\n\
&format!(
"financing_accounts = [\"2100\"]\n\
[[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
[[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n",
[[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\
[vault]\nkey_file = \"{}\"\n",
outside(&dir).join("vault.key").display()
),
);
let mut m = Mcp::start(&dir, &toml);
@@ -675,6 +690,18 @@ fn happy_path(name: &str) -> Mcp {
json!({"party": "Landlord", "received_on": "2026-01-03", "entity_type": "LLC (partnership)",
"tin_last4": "4321", "kept_at": "CPA portal"}),
);
let w9_pdf = outside(&dir).join("landlord-w9.pdf");
std::fs::write(&w9_pdf, b"%PDF-1.4 W-9 EIN 12-3454321").unwrap();
m.ok(
"books.set_w9",
json!({"party": "Landlord", "received_on": "2026-01-03", "entity_type": "LLC (partnership)",
"tin": "12-3454321", "tin_type": "ein", "w9_file_path": w9_pdf.to_str().unwrap()}),
);
m.ok(
"books.get_w9",
json!({"party": "Landlord",
"save_to": outside(&dir).join("landlord-w9-copy.pdf").to_str().unwrap()}),
);
m.ok("books.parties", json!({"role": "landlord", "year": 2026}));
// Owner contribution: draft, lines, receipt, preview, post.
@@ -748,6 +775,25 @@ fn happy_path(name: &str) -> Mcp {
)["attachment_id"]
.as_u64()
.unwrap();
let filed = m.ok(
"books.file_document",
json!({"category": "contract", "file_name": "lease.txt", "mime_type": "text/plain",
"content_base64": b64(b"lease"), "link_to": [{"kind": "company"}]}),
)["attachment_id"]
.as_u64()
.unwrap();
m.ok(
"books.link_attachment",
json!({"attachment_id": filed, "subject": {"kind": "party", "id": "Landlord"}}),
);
m.ok(
"books.attachments",
json!({"subject": {"kind": "party", "id": "Landlord"}}),
);
m.ok(
"books.unlink_attachment",
json!({"attachment_id": filed, "subject": {"kind": "company"}, "reason": "a lease, not a formation document"}),
);
let local = dir.join("local-receipt.txt");
std::fs::write(&local, b"a local receipt").unwrap();
m.ok(
@@ -899,15 +945,54 @@ fn happy_path(name: &str) -> Mcp {
);
// Bank import and reconciliation.
let csv = "Date,Description,Amount\n2026-03-05,Coffee,-4.50\n";
let source = m.ok(
"books.file_document",
json!({"category": "import_source", "file_name": "bank.csv", "mime_type": "text/csv",
"content_base64": b64(csv.as_bytes())}),
)["attachment_id"]
.as_u64()
.unwrap();
m.ok(
"books.import_bank_csv",
json!({"csv_text": "Date,Description,Amount\n2026-03-05,Coffee,-4.50\n",
"bank_account_code": "1000", "default_offset_code": "6999"}),
json!({"csv_text": csv, "bank_account_code": "1000", "default_offset_code": "6999",
"source_attachment_id": source}),
);
m.ok(
"books.reconcile",
json!({"account": "1000", "statement_date": "2026-12-31", "statement_balance": "100.00"}),
);
// January's statement: the contribution and the rent cleared.
m.ok(
"books.mark_cleared",
json!({"entry_id": e1, "line_index": 0, "cleared": true}),
);
let stmt = m.ok(
"books.file_document",
json!({"category": "statement", "file_name": "2026-01.pdf", "mime_type": "application/pdf",
"content_base64": b64(b"%PDF statement")}),
)["attachment_id"]
.as_u64()
.unwrap();
m.ok(
"books.record_statement",
json!({"attachment_id": stmt, "account": "1000", "period_start": "2026-01-01",
"period_end": "2026-01-31", "opening_balance": "0.00", "closing_balance": "9900.00"}),
);
m.ok("books.statements", json!({}));
m.ok("books.reconcile", json!({"statement_attachment": stmt}));
let rec = m.ok(
"books.reconcile",
json!({"statement_attachment": stmt, "confirmed": true}),
)["reconciliation"]["reconciliation_id"]
.as_u64()
.unwrap();
m.ok("books.reconciliations", json!({"account": "1000"}));
m.ok("books.show", json!({"reconciliation": rec}));
m.ok(
"books.void_reconciliation",
json!({"reconciliation_id": rec, "reason": "redo after the March import"}),
);
// Periods.
let p = m.ok(
@@ -943,6 +1028,19 @@ fn happy_path(name: &str) -> Mcp {
m.ok("books.digest", json!({"since": "2026-01-01"}));
m.ok("books.search_entries", json!({"text": "rent", "limit": 10}));
m.ok("books.vendor_1099", json!({"year": 2026}));
m.ok(
"books.vendor_1099",
json!({"year": 2026, "include_tins": true,
"save_csv_to": outside(&dir).join("1099.csv").to_str().unwrap()}),
);
m.ok(
"books.purge_tin",
json!({"party": "Landlord", "reason": "test purge"}),
);
m.ok(
"books.purge_tin",
json!({"party": "Landlord", "reason": "test purge", "confirmed": true}),
);
m.ok("books.party", json!({"party": "Landlord", "year": 2026}));
m.ok("books.party", json!({"party": "Pat", "year": 2026}));
m.ok("books.party", json!({"party": "Client"}));
+166
View File
@@ -1147,3 +1147,169 @@ fn no_party_output_holds_a_full_tin() {
assert!(digits.iter().all(|&n| n < 9), "{text}");
}
}
// ---------------------------------------------------------------- [books] parties
/// Books with the chart and extra `[books]` TOML.
fn books_with(name: &str, extra: &str) -> Mcp {
let dir = test_dir(name);
let toml = books_toml(&dir, "Spec Co", extra);
let mut m = Mcp::start(&dir, &toml);
m.accounts(CHART);
m
}
/// Oracle: PostEntryOut party fields and the "suggest" default
/// (docs/parties-and-attachments.md "Counterparties"): an untagged preview
/// names likely parties (description, then the same description on an
/// earlier tagged entry); nothing is refused.
#[test]
fn suggest_names_likely_parties_and_refuses_nothing() {
let mut m = books("suggest");
assert_eq!(
m.read_only("books.settings", json!({}))["parties"],
"suggest"
);
let apple = m.add_party("Apple Inc.", &[]);
let id = m.ok(
"books.draft_entry",
json!({"date": "2026-03-01", "description": "APPLE.COM/BILL iCloud"}),
)["entry_id"]
.as_u64()
.unwrap();
m.ok(
"books.add_line",
json!({"entry_id": id, "account": "6999", "side": "debit", "amount": "2.99"}),
);
m.ok(
"books.add_line",
json!({"entry_id": id, "account": "2050", "side": "credit", "amount": "2.99"}),
);
let pv = m.ok("books.post_entry", json!({"entry_id": id}));
assert_eq!(pv["party_policy"], "suggest");
assert_eq!(pv["party_missing"], true);
assert_eq!(pv["party_suggestions"][0]["party_id"], apple);
assert_eq!(
pv["party_suggestions"][0]["why"],
"named in the description"
);
m.ok(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
// Tagged: the next entry with the same description is suggested from it.
m.ok(
"books.set_entry_party",
json!({"entry_id": id, "party": apple.to_string()}),
);
let other = m.add_party("Zed", &[]);
let _ = other;
let id2 = m.ok(
"books.draft_entry",
json!({"date": "2026-04-01", "description": "APPLE.COM/BILL iCloud"}),
)["entry_id"]
.as_u64()
.unwrap();
m.ok(
"books.add_line",
json!({"entry_id": id2, "account": "6999", "side": "debit", "amount": "2.99"}),
);
m.ok(
"books.add_line",
json!({"entry_id": id2, "account": "2050", "side": "credit", "amount": "2.99"}),
);
let pv = m.ok("books.post_entry", json!({"entry_id": id2}));
let s = pv["party_suggestions"].as_array().unwrap();
assert_eq!(s.len(), 1, "one party, listed once: {s:?}");
assert_eq!(s[0]["party_id"], apple);
}
/// Oracle: "require": posting is refused (party_required) and journals
/// nothing until the entry names a party or is marked as having none;
/// no_party_reason is refused alongside a party.
#[test]
fn require_refuses_until_tagged_or_marked_none() {
let mut m = books_with("require", "parties = \"require\"\n");
assert_eq!(
m.read_only("books.settings", json!({}))["parties"],
"require"
);
let bank = m.add_party("Bluevine", &["financial_institution"]);
let id = m.draft(
"2026-03-01",
json!({}),
&[("6999", "debit", "5.00"), ("1000", "credit", "5.00")],
);
let before = m.journal();
let e = m.refuse(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
assert!(e.contains("party_required"), "{e}");
assert_eq!(m.journal(), before, "refusal journals nothing");
m.ok(
"books.set_entry_party",
json!({"entry_id": id, "party": bank.to_string()}),
);
let pv = m.ok(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
assert_eq!(pv["status"], "posted");
assert_eq!(pv["party_id"], bank);
// Marked none at draft time: posts, and the reason is journaled.
let t = m.draft(
"2026-03-02",
json!({"no_party_reason": "transfer between own accounts"}),
&[("1300", "debit", "1.00"), ("1000", "credit", "1.00")],
);
let pv = m.ok(
"books.post_entry",
json!({"entry_id": t, "confirmed": true}),
);
assert_eq!(pv["no_party_reason"], "transfer between own accounts");
assert_eq!(pv["party_missing"], false);
assert_eq!(m.count("entry.no_party"), 1);
// Both at once is refused.
let e = m.refuse(
"books.draft_entry",
json!({"date": "2026-03-03", "description": "x", "party": "Bluevine", "no_party_reason": "none"}),
);
assert!(e.contains("no_party_reason"), "{e}");
let e = m.refuse(
"books.set_entry_party",
json!({"entry_id": t, "party": "Bluevine", "no_party_reason": "none"}),
);
assert!(e.contains("not both"), "{e}");
// Tagging later clears the mark; marking again needs no party.
m.ok(
"books.set_entry_party",
json!({"entry_id": t, "party": "Bluevine"}),
);
m.ok(
"books.set_entry_party",
json!({"entry_id": t, "no_party_reason": "own-account transfer"}),
);
let r = m.read_only("books.verify", json!({}));
assert_eq!(r["status"], "ok", "{r}");
}
/// Oracle: "off": the preview never raises a party; a bad value is a
/// settings error.
#[test]
fn off_never_raises_it() {
let mut m = books_with("off", "parties = \"off\"\n");
m.add_party("Apple", &[]);
let id = m.draft(
"2026-03-01",
json!({}),
&[("6999", "debit", "5.00"), ("1000", "credit", "5.00")],
);
let pv = m.ok("books.post_entry", json!({"entry_id": id}));
assert_eq!(pv["party_missing"], false);
assert!(pv["party_suggestions"].as_array().unwrap().is_empty());
m.ok(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
}
File diff suppressed because it is too large. Load diff
+27 -2
View File
@@ -18,9 +18,12 @@ use crate::blob::BlobStore;
use crate::error::StoreError;
use crate::journal::{GENESIS_PREV, JournalLine, sha256_hex};
use crate::store::{
Attachment, BLOBS_DIR, DB_FILE, JOURNAL_FILE, Store, apply_line_to_sqlite, compare_projections,
projection_rows, query_attachments, read_attachment_bytes, replay_line_into_ledger,
Attachment, BLOBS_DIR, DB_FILE, JOURNAL_FILE, Reconciliation, Statement, Store, Subject,
apply_line_to_sqlite, compare_projections, projection_rows, query_attachment_links,
query_attachments, query_reconciliations, query_statements, read_attachment_bytes,
replay_line_into_ledger,
};
use ledger_core::AccountId;
use ledger_core::{Currency, EntryId, Ledger};
use rusqlite::Connection;
use std::io::{Read, Seek, SeekFrom};
@@ -216,6 +219,28 @@ impl Snapshot {
query_attachments(&self.conn, entry_id)
}
/// Every attachment link beyond a receipt's filing entry, as
/// (attachment id, subject), by attachment then subject: what
/// [`Store::attachment_links`] reads, for every attachment at once.
pub fn attachment_links(&self) -> Result<Vec<(u64, Subject)>, StoreError> {
query_attachment_links(&self.conn)
}
/// Recorded bank and card statements, optionally one account's, by
/// period end (as [`Store::statements`]).
pub fn statements(&self, account: Option<AccountId>) -> Result<Vec<Statement>, StoreError> {
query_statements(&self.conn, account)
}
/// Reconciliations, voided ones included, optionally one account's,
/// by date (as [`Store::reconciliations`]).
pub fn reconciliations(
&self,
account: Option<AccountId>,
) -> Result<Vec<Reconciliation>, StoreError> {
query_reconciliations(&self.conn, account)
}
/// Compare the writer's SQLite database (opened read-only) with this
/// snapshot's own projection of the journal, every table, row for row.
///
+137 -77
View File
@@ -821,6 +821,20 @@ impl Store {
Ok(())
}
/// Mark an entry as having no counterparty, with why
/// (`Ledger::set_entry_no_party`). Journaled as `entry.no_party
/// {entry_id, reason}`.
pub fn set_entry_no_party(&mut self, entry: EntryId, reason: &str) -> Result<(), StoreError> {
self.ledger.check_set_entry_no_party(entry, reason)?;
let line = self.append_journal(
"entry.no_party",
json!({"entry_id": entry, "reason": reason.trim()}),
)?;
self.ledger.set_entry_no_party(entry, reason)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Correct a registered asset's cost, salvage, life, or in-service date
/// (see `Ledger::correct_asset`), with the reason. Refused once any
/// depreciation for it is posted. Journaled as `asset.corrected`, with
@@ -2546,8 +2560,9 @@ impl Store {
// Attachment links, statements, reconciliations
// ------------------------------------------------------------------
/// Whether `subject` exists in the books.
fn subject_exists(&self, subject: Subject) -> Result<bool, StoreError> {
/// Whether `subject` exists in the books (what [`Store::link_attachment`]
/// checks, so callers can validate several subjects before linking any).
pub fn subject_exists(&self, subject: Subject) -> Result<bool, StoreError> {
let l = &self.ledger;
Ok(match subject {
Subject::Entry(i) => l.get_entry(i).is_some(),
@@ -2776,39 +2791,7 @@ impl Store {
/// Recorded statements, optionally one account's, by period end.
pub fn statements(&self, account: Option<AccountId>) -> Result<Vec<Statement>, StoreError> {
let mut stmt = self.conn.prepare(
"SELECT attachment_id, account_id, period_start, period_end, opening_balance, closing_balance FROM statements ORDER BY period_end, attachment_id",
)?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, String>(2)?,
r.get::<_, String>(3)?,
r.get::<_, String>(4)?,
r.get::<_, String>(5)?,
))
})?;
let mut out = Vec::new();
for row in rows {
let (att, acct, start, end, open, close) = row?;
if account.is_some_and(|a| a != acct as u64) {
continue;
}
let dec = |t: &str| {
t.parse::<Decimal>()
.map_err(|_| StoreError::JournalCorrupt(format!("statement amount {t}")))
};
out.push(Statement {
attachment_id: att as u64,
account_id: acct as u64,
period_start: parse_date(&start)?,
period_end: parse_date(&end)?,
opening_balance: dec(&open)?,
closing_balance: dec(&close)?,
});
}
Ok(out)
query_statements(&self.conn, account)
}
/// Reconcile an account to a recorded statement: the books' cleared
@@ -2936,47 +2919,7 @@ impl Store {
&self,
account: Option<AccountId>,
) -> Result<Vec<Reconciliation>, StoreError> {
let mut stmt = self.conn.prepare(
"SELECT id, account_id, statement_date, statement_balance, attachment_id, voided_reason FROM reconciliations ORDER BY statement_date, id",
)?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, String>(2)?,
r.get::<_, String>(3)?,
r.get::<_, i64>(4)?,
r.get::<_, Option<String>>(5)?,
))
})?;
let mut out = Vec::new();
for row in rows {
let (id, acct, date, bal, att, voided) = row?;
if account.is_some_and(|a| a != acct as u64) {
continue;
}
let cleared: Vec<(EntryId, usize)> = self
.conn
.prepare(
"SELECT entry_id, line_index FROM reconciliation_lines WHERE reconciliation_id = ? ORDER BY entry_id, line_index",
)?
.query_map(params![id], |r| {
Ok((r.get::<_, i64>(0)? as u64, r.get::<_, i64>(1)? as usize))
})?
.collect::<Result<_, _>>()?;
out.push(Reconciliation {
id: id as u64,
account_id: acct as u64,
statement_date: parse_date(&date)?,
statement_balance: bal.parse().map_err(|_| {
StoreError::JournalCorrupt(format!("reconciliation balance {bal}"))
})?,
attachment_id: att as u64,
cleared,
voided,
});
}
Ok(out)
query_reconciliations(&self.conn, account)
}
/// `(linked entry, withdrawn?)` for an attachment.
@@ -4138,6 +4081,9 @@ pub(crate) fn replay_line_into_ledger(
};
ledger.set_entry_party(get_u64(p, "entry_id")?, party)?;
}
"entry.no_party" => {
ledger.set_entry_no_party(get_u64(p, "entry_id")?, get_str(p, "reason")?)?;
}
"asset.corrected" => {
ledger.correct_asset(
get_u64(p, "asset_id")?,
@@ -4710,7 +4656,7 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
)?;
}
"role.assigned" | "party.added" | "party.updated" | "party.role_set" | "party.w9_set"
| "party.w4_set" | "entry.party_set" => {
| "party.w4_set" | "entry.party_set" | "entry.no_party" => {
// Control roles and the party directory live in the ledger
// (rebuilt by replay); the events mirror below keeps the
// history.
@@ -5328,6 +5274,120 @@ pub struct Reconciliation {
pub voided: Option<String>,
}
/// Recorded statements in the projection, optionally one account's, by
/// period end (shared by [`Store::statements`] and the read-only
/// snapshot).
pub(crate) fn query_statements(
conn: &Connection,
account: Option<AccountId>,
) -> Result<Vec<Statement>, StoreError> {
let mut stmt = conn.prepare(
"SELECT attachment_id, account_id, period_start, period_end, opening_balance, closing_balance FROM statements ORDER BY period_end, attachment_id",
)?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, String>(2)?,
r.get::<_, String>(3)?,
r.get::<_, String>(4)?,
r.get::<_, String>(5)?,
))
})?;
let mut out = Vec::new();
for row in rows {
let (att, acct, start, end, open, close) = row?;
if account.is_some_and(|a| a != acct as u64) {
continue;
}
let dec = |t: &str| {
t.parse::<Decimal>()
.map_err(|_| StoreError::JournalCorrupt(format!("statement amount {t}")))
};
out.push(Statement {
attachment_id: att as u64,
account_id: acct as u64,
period_start: parse_date(&start)?,
period_end: parse_date(&end)?,
opening_balance: dec(&open)?,
closing_balance: dec(&close)?,
});
}
Ok(out)
}
/// Reconciliations in the projection, optionally one account's, by date
/// (shared by [`Store::reconciliations`] and the read-only snapshot).
pub(crate) fn query_reconciliations(
conn: &Connection,
account: Option<AccountId>,
) -> Result<Vec<Reconciliation>, StoreError> {
let mut stmt = conn.prepare(
"SELECT id, account_id, statement_date, statement_balance, attachment_id, voided_reason FROM reconciliations ORDER BY statement_date, id",
)?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, String>(2)?,
r.get::<_, String>(3)?,
r.get::<_, i64>(4)?,
r.get::<_, Option<String>>(5)?,
))
})?;
let mut out = Vec::new();
for row in rows {
let (id, acct, date, bal, att, voided) = row?;
if account.is_some_and(|a| a != acct as u64) {
continue;
}
let cleared: Vec<(EntryId, usize)> = conn
.prepare(
"SELECT entry_id, line_index FROM reconciliation_lines WHERE reconciliation_id = ? ORDER BY entry_id, line_index",
)?
.query_map(params![id], |r| {
Ok((r.get::<_, i64>(0)? as u64, r.get::<_, i64>(1)? as usize))
})?
.collect::<Result<_, _>>()?;
out.push(Reconciliation {
id: id as u64,
account_id: acct as u64,
statement_date: parse_date(&date)?,
statement_balance: bal
.parse()
.map_err(|_| StoreError::JournalCorrupt(format!("reconciliation balance {bal}")))?,
attachment_id: att as u64,
cleared,
voided,
});
}
Ok(out)
}
/// Every row of `attachment_links`: (attachment, subject), by attachment
/// then subject. A receipt's filing entry isn't a row here (it is the
/// attachment's `entry_id`).
pub(crate) fn query_attachment_links(conn: &Connection) -> Result<Vec<(u64, Subject)>, StoreError> {
let mut stmt = conn.prepare(
"SELECT attachment_id, subject_kind, subject_id FROM attachment_links ORDER BY attachment_id, subject_kind, subject_id",
)?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, i64>(2)?,
))
})?;
let mut out = Vec::new();
for row in rows {
let (att, kind, id) = row?;
if let Some(sub) = Subject::from_parts(&kind, id as u64) {
out.push((att as u64, sub));
}
}
Ok(out)
}
/// Attachments in the projection, optionally only one entry's, by id.
pub(crate) fn query_attachments(
conn: &Connection,
+6
View File
@@ -68,6 +68,12 @@ pub enum ViewerTarget {
/// Party id.
party_id: u64,
},
/// One reconciliation: its account, with the account's statements and
/// reconciliations.
Reconciliation {
/// Reconciliation id.
id: u64,
},
/// The transactions list, searched.
Search {
/// Search text: description, account, vendor, amount.
+33 -10
View File
@@ -260,7 +260,7 @@ impl Vault {
}
fn path(&self, party: u64, kind: Kind) -> PathBuf {
self.dir.join(format!("{}-{party}.bin", kind.name()))
record_path(&self.dir, party, kind)
}
fn aad(party: u64, kind: Kind) -> Vec<u8> {
@@ -361,18 +361,41 @@ impl Vault {
/// Delete everything the vault holds for a party (INVARIANT 6). Returns
/// whether anything was there.
pub fn purge(&self, party: u64) -> Result<bool, Error> {
let mut any = false;
for kind in [Kind::Tin, Kind::W9] {
match std::fs::remove_file(self.path(party, kind)) {
Ok(()) => any = true,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(Error::Io(e)),
}
}
Ok(any)
purge_records(&self.dir, party)
}
}
fn record_path(dir: &Path, party: u64, kind: Kind) -> PathBuf {
dir.join(format!("{}-{party}.bin", kind.name()))
}
/// What the vault in `dir` holds for a party, `(TIN, W-9 file)`, without
/// the key: whether each record's file exists (nothing is decrypted).
pub fn records_held(dir: &Path, party: u64) -> Result<(bool, bool), Error> {
let exists = |kind| match std::fs::metadata(record_path(dir, party, kind)) {
Ok(_) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(Error::Io(e)),
};
Ok((exists(Kind::Tin)?, exists(Kind::W9)?))
}
/// Delete everything the vault in `dir` holds for a party (INVARIANT 6),
/// without the key: purging must work even where the vault can't be read
/// (the key lost, or books restored on another machine). Returns whether
/// anything was there.
pub fn purge_records(dir: &Path, party: u64) -> Result<bool, Error> {
let mut any = false;
for kind in [Kind::Tin, Kind::W9] {
match std::fs::remove_file(record_path(dir, party, kind)) {
Ok(()) => any = true,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(Error::Io(e)),
}
}
Ok(any)
}
/// Write a file readable by its owner only (0600 on Unix), creating its
/// directory.
fn write_private(path: &Path, bytes: &[u8]) -> Result<(), Error> {
+41
View File
@@ -180,3 +180,44 @@ fn purge_removes_a_party() {
assert!(v.tin(2).unwrap().is_some());
assert!(!v.purge(1).unwrap(), "nothing left to purge");
}
/// INVARIANT 6, without the key: `records_held` says what a party has on
/// disk and `purge_records` deletes it, so a vault whose key is lost can
/// still be purged.
#[test]
fn purge_and_holdings_need_no_key() {
let d = dir("keyless");
let v = vault(&d);
let tin = Tin::new("123456789", TinType::Ssn).unwrap();
v.put_tin(1, &tin).unwrap();
v.put_w9(
1,
&W9File {
file_name: "w9.pdf".into(),
mime_type: "application/pdf".into(),
bytes: b"%PDF".to_vec(),
},
)
.unwrap();
v.put_tin(2, &tin).unwrap();
std::fs::remove_file(d.join("keys/vault.key")).unwrap();
let vdir = d.join("vault");
assert_eq!(ledger_vault::records_held(&vdir, 1).unwrap(), (true, true));
assert_eq!(ledger_vault::records_held(&vdir, 2).unwrap(), (true, false));
assert_eq!(
ledger_vault::records_held(&vdir, 3).unwrap(),
(false, false)
);
assert!(ledger_vault::purge_records(&vdir, 1).unwrap());
assert_eq!(
ledger_vault::records_held(&vdir, 1).unwrap(),
(false, false)
);
assert_eq!(ledger_vault::records_held(&vdir, 2).unwrap(), (true, false));
assert!(!ledger_vault::purge_records(&vdir, 1).unwrap());
// A vault directory that doesn't exist holds nothing.
assert_eq!(
ledger_vault::records_held(&d.join("nowhere"), 1).unwrap(),
(false, false)
);
}
+170 -35
View File
@@ -150,7 +150,12 @@ below.
amounts wanderbooks may no longer know as of today (`{ name,
known_through, catalog, source, why }`): research the current value or
ask the human, then update it upstream (pull request or issue at
github.com/swaits/wanderbooks).
github.com/swaits/wanderbooks). `unreconciled_accounts` lists the cash
and card accounts due for reconciliation (`{ code, name,
reconciled_through?, due_through, next_step }`): every `[books]
cash_accounts` account and every liability with a recorded statement
that has posted activity through the end of last month but no
reconciliation (not voided) through it.
## Tools — fixed assets
@@ -226,12 +231,15 @@ through the policy.
## Tools — the viewer
- `books.show { entry_id | account | report | attachment_id | asset |
party | search | page, start?, end? }` → `{ shown, note }`. Asks the
human's viewer (`ledger-gui`) to open an entry, an account register or a
report (for start/end dates if given), a stored file, an asset, a party
(by id or name, resolved as in "Tools — parties"), a transactions search,
or a page (`parties` among them). Exactly one target; entries, accounts,
attachments, assets, and parties are checked to exist. Never touches the books: the request is a small JSON file in
party | reconciliation | search | page, start?, end? }` → `{ shown,
note }`. Asks the human's viewer (`ledger-gui`) to open an entry, an
account register or a report (for start/end dates if given), a stored
file, an asset, a party (by id or name, resolved as in "Tools —
parties"), a reconciliation (by id: its account's page, with its
statements and reconciliations; viewer target `{show: "reconciliation",
id}`), a transactions search, or a page (`parties` among them). Exactly
one target; entries, accounts, attachments, assets, parties, and
reconciliations are checked to exist. Never touches the books: the request is a small JSON file in
the user's runtime directory (`$XDG_RUNTIME_DIR/wanderbooks/viewer-<hash
of the data dir>.json`), written atomically, watched by the viewer. A
viewer started within two minutes of a request opens it; older requests
@@ -269,8 +277,9 @@ entries tagged with the vendor. Card payments move no asset (the card
processor's 1099-K covers them), so they drop out; reversals of tagged
entries carry the tag and net out. Thresholds: $600 for payments through
2025, $2,000 from 2026 (indexed for inflation after 2026); always
overridable, always for the CPA to confirm. TINs are never stored, only
whether a W-9 is on file.
overridable, always for the CPA to confirm. The books keep whether a W-9
is on file and the TIN's last four; full TINs live only in the TIN vault
("Tools — the TIN vault").
- `books.list_vendors {}` → `{ vendors: [{ id, name, form_1099?,
tin_on_file, party_id }] }`.
@@ -282,11 +291,21 @@ whether a W-9 is on file.
takes `vendor` too; bills and their payments are tagged automatically.
`books.set_entry_party` tags the vendor too when the party has a vendor
record, so tagging by party counts for 1099s the same way.
- `books.vendor_1099 { year, threshold? }` → `{ year, threshold, vendors:
[{ vendor_id, name, form_1099?, tin_on_file, paid, reportable }],
missing_w9, notes }`. For a year whose threshold wanderbooks doesn't know
(after 2026 until the indexed amount is added) it refuses with what to
do; pass `threshold` explicitly once the figure is confirmed.
- `books.vendor_1099 { year, threshold?, include_tins?, save_csv_to? }` →
`{ year, threshold, vendors: [{ vendor_id, name, form_1099?, tin_on_file,
paid, reportable, party_id?, tin_last4?, tin?, tin_type?, tin_note? }],
missing_w9, notes, warning?, saved_csv? }`. For a year whose threshold
wanderbooks doesn't know (after 2026 until the indexed amount is added)
it refuses with what to do; pass `threshold` explicitly once the figure
is confirmed. `tin_last4` comes from the party's W-9 facts. With
`include_tins: true` (default false) each line carries `tin`, the full
TIN formatted as the IRS writes it (123-45-6789; 12-3456789 for an
EIN), and `tin_type`, read from the vault, or `tin_note` saying why not
(not in the vault, the vault locked, a record whose last four or hash
disagree with the books); `warning` then says the output holds full
TINs, for the CPA only. This is the only tool output that ever holds a
full TIN. `save_csv_to` also writes the worksheet as CSV (TINs only with
`include_tins`) to a new file outside the books dir, mode 0600.
## Tools — parties
@@ -330,10 +349,14 @@ texts are at most 500 characters, no control characters.
`party.role_set`. Removing the last 1099 role with W-9 facts on file, or
employee with W-4 facts, is refused: clear the facts first.
- `books.set_w9 { party, received_on, entity_type, exempt_payee_code?,
fatca_code?, tin_last4, kept_at }` or `{ party, clear: true }` → the
party. Journaled as `party.w9_set`. A party with a vendor record also
gets the record's `tin_on_file` set to match (journaled as
`vendor.1099_set`), so `books.vendor_1099`'s `missing_w9` follows.
fatca_code?, tin_last4, kept_at, tin?, tin_type?, w9_file_path? }` or
`{ party, clear: true }` → the party. Journaled as `party.w9_set`. A
party with a vendor record also gets the record's `tin_on_file` set to
match (journaled as `vendor.1099_set`), so `books.vendor_1099`'s
`missing_w9` follows. `tin`, `tin_type`, and `w9_file_path` put the full
TIN and the signed form in the TIN vault (below); with them `tin_last4`
comes from the TIN (a given one must agree) and `kept_at` defaults to
`"wanderbooks vault"`.
- `books.set_w4 { party, received_on, state, kept_at }` or `{ party, clear:
true }` → the party. Journaled as `party.w4_set`.
- `books.set_entry_party { entry_id, party? }` → the entry (as
@@ -354,7 +377,7 @@ texts are at most 500 characters, no control characters.
The party (every mutating party tool returns it): `{ party_id, name,
label, display_name?, roles, website?, address?, notes?, archived,
vendor_id?, customer_id?, w9?: { received_on, entity_type,
exempt_payee_code?, fatca_code?, tin_last4, kept_at }, w4?: { received_on,
exempt_payee_code?, fatca_code?, tin_last4, kept_at, in_vault }, w4?: { received_on,
state, kept_at } }`. `label` is the display name, else the name; `roles`
include vendor and customer when it has those records.
@@ -383,11 +406,86 @@ include vendor and customer when it has those records.
payments drop out); `form_1099` is the record's mark.
- `gross_pay` / `net_pay`: payroll lines naming it, in the year.
## Tools — the TIN vault
Full TINs (SSN, EIN, ITIN) and signed W-9 forms, kept so the CPA's 1099
worksheet is complete, never in the books (`ledger-vault`; design in
`docs/parties-and-attachments.md` "The TIN vault"; B-26).
- **Where:** `vault/` in the books dir (the parent of the data dir, beside
`wanderbooks.toml`; git-ignored), one file per record, each encrypted
(XChaCha20-Poly1305) and bound to its party. **Key:** the file named by
`[vault] key_file` in `wanderbooks.toml`, else
`~/.config/wanderbooks/vault.key` (`$XDG_CONFIG_HOME`), mode 0600,
created the first time a TIN or form is stored, never when reading. A
key file inside the books dir is refused, and so is storing into books
kept in git (a `.git` beside them) whose `.gitignore` doesn't list
`vault/`. Without the key the vault is
locked: the books open and verify; the worksheet says the TINs are
missing.
- **Never out:** no tool output, error, or log holds more than a TIN's
last four, except `books.vendor_1099 { include_tins: true }`. The tool
arguments that take a TIN never echo it, even when refused.
- `books.set_w9 { ..., tin, tin_type, w9_file_path? }` (above): validates
the TIN against its type (nine digits, hyphens allowed; an ITIN starts
with 9 and has an IRS group in digits 4-5; an SSN or EIN doesn't start
with 9), checks the facts on a copy of the ledger, then writes the vault
and journals the facts with `vault_record`, the SHA-256 of the TIN
record (of the form's record when only a form is given). The form (PDF,
PNG, JPEG; 25 MB at most) goes to the vault only, never the attachment
store. Facts given later without a TIN keep the vault record when their
last four agree with the vault's TIN, and are refused when they don't
(pass the new TIN, or purge). Clearing the facts (`clear: true`) is
refused while the vault holds the party's TIN or form: purge first.
- `books.get_w9 { party, save_to }` → `{ party_id, path, file_name,
size_bytes, note }`. Writes the party's form from the vault to `save_to`:
a new file (an existing one is refused), in a folder that exists,
outside the books dir, mode 0600. Returns the path, never the contents.
Journals nothing.
- `books.purge_tin { party, reason, confirmed? }` → `{ status: "preview" |
"purged", party_id, name, tin_in_vault, w9_file_in_vault, tin_last4?,
journaled, note }`. Without `confirmed`, a preview that changes nothing.
Confirmed, deletes the party's vault records (needs no key) and, when the
party has W-9 facts, journals `party.w9_set` with `vault_record` cleared,
the last four kept, and `kept_at` saying when and why it was purged.
Refused when the reason is blank or there is nothing to purge.
## Tools — attachments
A stored file (a receipt or a document) links to any number of subjects;
a subject is `{ kind, id? }`: `entry` (id), `account` (code or id),
`asset` (tag or id), `party` (id or name), `invoice`, `bill`, `period`,
`payroll_run`, `schedule`, `reconciliation` (ids), or `company` (no id).
Every subject is checked to exist. In outputs a subject is `{ kind, id?,
label }` (`id` absent for the company; for an account, its id, with the
code in `label`). A file linked to an entry is one of the entry's receipts:
the receipt policy counts it.
- `books.link_attachment { attachment_id, subject }` → `{ attachment_id,
links }`. Journaled as `attachment.linked`. Refused for an unknown or
withdrawn attachment, a subject that doesn't exist, or one it is linked
to already (or filed on).
- `books.unlink_attachment { attachment_id, subject, reason }` → `{
attachment_id, links }`. Journaled as `attachment.unlinked` with the
reason; the file stays filed. Refused when the reason is blank, it isn't
linked there, it is filed on that entry (`books.unlink_receipt`), it is
a posted entry's last receipt under the receipt policy
(`receipt_required`), or it is the statement of a reconciliation not
voided.
- `books.attachments { subject }` → `{ subject, attachments: [...] }`: the
files on a subject (an entry's: filed on it and linked to it), not
withdrawn, each as in `books.list_receipts`.
- `link_to: [subject]` on `books.attach_receipt` and `books.file_document`
links the new file in the same call, each link its own
`attachment.linked`. All or nothing: every subject is checked (exists,
named once, not the entry it is filed on) before anything is filed.
## Tools — receipts & documents
- `books.attach_receipt { entry_id?, file_name, mime_type, content_base64 }`
→ `{ receipt_id, sha256, deduplicated, size_bytes }`. `entry_id` is
optional: a receipt can be filed before its entry exists.
- `books.attach_receipt { entry_id?, file_name, mime_type, content_base64,
description?, link_to? }` → `{ attachment_id, sha256, deduplicated, links
}`. `entry_id` is optional: a receipt can be filed before its entry
exists. `link_to` as in "Tools — attachments".
- `books.link_receipt { receipt_id, entry_id }` → link a previously filed
(unlinked, not withdrawn) receipt to its entry. A receipt linked elsewhere
must be unlinked first.
@@ -403,13 +501,19 @@ include vendor and customer when it has those records.
blob store. Nothing in the evidence trail is ever erased — receipts change
by new events, the same way entries change by reversals.
- `books.list_receipts { entry_id?, include_withdrawn? }` → metadata list
(no bytes), each with `withdrawn` / `withdrawn_reason`. Withdrawn
attachments are omitted unless `include_withdrawn` is true.
- `books.get_receipt { receipt_id }` → `{ metadata, content_base64 }`.
(no bytes), each with `withdrawn` / `withdrawn_reason` and `links` (every
subject it is linked to, its filing entry first). Withdrawn attachments
are omitted unless `include_withdrawn` is true. `entry_id` lists the
files filed on the entry and linked to it.
- `books.get_receipt { attachment_id }` → `{ metadata, description?,
links, content_base64 }`.
- `books.file_document { category: "formation" | "legal" | "tax" |
"accounting" | "other", file_name, mime_type, content_base64,
description? }` → `{ document_id, sha256, deduplicated }`. The Drive-folder
replacement (today's 01 Formation / 02 Legal / 03 Tax / 04 Accounting).
"accounting" | "statement" | "contract" | "photo" | "warranty" |
"import_source" | "other", file_name, mime_type, content_base64,
description?, link_to? }` → `{ attachment_id, sha256, deduplicated, links
}`. The Drive-folder replacement (today's 01 Formation / 02 Legal / 03
Tax / 04 Accounting). Never a W-9: it shows the full TIN (`books.set_w9
w9_file_path`).
## Receipt enforcement
@@ -512,8 +616,12 @@ posting.
## Tools — import
- `books.import_bank_csv { csv_text, bank_account_code, default_offset_code,
rules? }` → creates one draft per transaction (QBO upload layout).
`rules` maps description substrings to account codes.
rules?, date_order?, source_attachment_id? }` → creates one draft per
transaction (QBO upload layout). `rules` maps description substrings to
account codes. `source_attachment_id` (the export as filed, category
`import_source`) is checked before any draft and linked to the bank
account (`source_linked`; false when it was already), never to the
drafts: a bank export is not a receipt.
- `books.import_chart_csv { csv_text }` → creates accounts from
`code,name,type,parent_code` CSV. All-or-nothing.
- `books.import_local_file { path, category: "receipt" | "document", ... }`
@@ -590,11 +698,38 @@ R-FUTURE-3 and rides the engine roadmap, not the MCP layer.
- `books.mark_cleared { entry_id, line_index, cleared }` → `{ ok }`.
Only posted lines can be marked; the flag is workflow metadata, not
accounting data (posted entries stay immutable).
- `books.reconcile { account, statement_date, statement_balance }` →
`{ account_code, book_balance, cleared_balance, statement_balance,
difference, uncleared_lines: [{ entry_id, line_index, date, description,
side, amount }] }`. `difference` is cleared − statement; zero means
reconciled.
- `books.record_statement { attachment_id, account, period_start,
period_end, opening_balance, closing_balance }` → the statement `{
attachment_id, file_name, account_code, account_name, period_start,
period_end, opening_balance, closing_balance, reconciliation_id? }`.
Records what a filed statement says (balances as printed, in the
account's normal direction, cents at most) and links it to the account.
Journaled as `statement.recorded` then `attachment.linked`. Refused for
an unknown or withdrawn attachment, one already recording a statement,
an account that isn't an asset or liability, or a period that runs
backwards.
- `books.statements { account? }` → `{ statements }`, by statement date.
- `books.reconcile { statement_attachment? | account, statement_date,
statement_balance, confirmed? }` → `{ status: "preview" | "reconciled",
account_code, statement_date, statement_attachment?, book_balance,
cleared_balance, statement_balance, difference, uncleared_lines,
cleared_lines, problems, reconciliation? }`, each line `{ entry_id,
line_index, date, description, side, amount }`. `difference` is cleared
− statement; zero means reconciled. With `statement_attachment` the
account, date, and balance are the recorded statement's (any given must
agree). Without `confirmed: true` it is a preview that records nothing;
`problems` lists why a confirmation would be refused. Confirmed, it
needs a recorded statement, a difference of exactly zero, and a date
after the account's last reconciliation not voided; it then journals
`reconciliation.completed` (the cleared lines, the statement) and links
the statement to it, returning `reconciliation`.
- `books.reconciliations { account? }` → `{ reconciliations: [{
reconciliation_id, account_code, account_name, statement_date,
statement_balance, statement_attachment, cleared: [{ entry_id,
line_index }], voided, voided_reason? }] }`, voided ones included.
- `books.void_reconciliation { reconciliation_id, reason }` → the
reconciliation, voided. Journaled as `reconciliation.voided`; the
account can be reconciled through that date again.
## Tools — A/R and A/P
+67 -3
View File
@@ -106,6 +106,13 @@ bill, payroll run, documents, and history. Members link to their capital
accounts. Global search finds parties. Needs-attention: a contractor
paid without W-9 facts; a 1099 party paid over the threshold.
Money with a party (Stephen, 2026-10-15: "how much have I spent on Apple
versus how much did I get back"): paid out, received in, and net, for a
chosen period (this year, last year, any year, all time), from
`ledger_core::parties::activity`; what it was for (the non-money lines by
account); a monthly out-vs-in chart; the 1099 amount for the calendar
year. The directory shows the same columns, sortable.
## The TIN vault
Decision (Stephen, 2026-10-15): wanderbooks holds full TINs, so the 1099
@@ -138,6 +145,21 @@ worksheet it hands the CPA is complete, but never in the journal.
contains a nine-digit run.
- **Books without a vault** (another machine, a restored backup) still
open and verify; the 1099 worksheet then says which TINs are missing.
- **Tools** (built; `docs/mcp-interface.md` "Tools — the TIN vault"):
`books.set_w9` takes `tin` + `tin_type` and `w9_file_path` (the form
goes to the vault, never the attachment store) and journals the last
four with `vault_record`; facts that disagree with the vault's TIN are
refused, and clearing facts with a vault record needs a purge first.
`books.get_w9 {party, save_to}` writes the form to a new file outside the
books dir and returns only the path. `books.purge_tin {party, reason,
confirmed?}` previews, then deletes the records (no key needed) and
journals the purge as `party.w9_set` with `vault_record` cleared and
`kept_at` saying when and why. `books.vendor_1099 {include_tins: true}`
is the one output with full TINs (with a warning; `save_csv_to` writes
the CSV outside the books dir). A key file inside the books dir is
refused. `tests/spec_vault.rs` scans every tool response, the server's
log, and every file in the books dir outside `vault/` for the TINs in
any formatting.
## Attachments
@@ -158,6 +180,14 @@ that entry). A receipt is an attachment linked to an entry; the receipt
policy counts links to the entry, as now. A file stored once can be
linked to many subjects (no more refiling).
Tools (built; `docs/mcp-interface.md` "Tools — attachments"):
`books.link_attachment`, `books.unlink_attachment` (refused for a posted
entry's last receipt under the receipt policy, and for a standing
reconciliation's statement), `books.attachments {subject}`, `link_to` on
`books.attach_receipt` and `books.file_document` (all or nothing), and
`links` on every listed attachment. `books.file_document` takes the kinds
below as its category (`receipt` is `books.attach_receipt`'s).
### Kinds
`receipt`, `statement`, `contract`, `formation`, `legal`, `tax`,
@@ -177,11 +207,23 @@ is never edited; a later one supersedes it; `reconciliation.voided
history with its statement; Needs-attention: an account with activity not
reconciled through last month's end.
Tools (built; `docs/mcp-interface.md` "Tools — bank reconciliation"):
`books.record_statement`, `books.statements`, `books.reconcile` (preview
by default; `statement_attachment` + `confirmed: true` records it),
`books.reconciliations`, `books.void_reconciliation`; `books.show
reconciliation`; `books.digest` `unreconciled_accounts` (the `[books]
cash_accounts` and liabilities with statements, not reconciled through
last month's end). The statement of a reconciliation not voided can't be
unlinked from it.
### Import sources
`books.import_bank_csv` files the CSV (kind `import_source`), links it to
every entry it created, and records the row each came from, so every
imported line traces to its source row.
Built: file the export (`books.file_document` category `import_source`)
and pass `source_attachment_id` to `books.import_bank_csv`; it is linked
to the bank account imported into. Not to the drafts it creates: a file
linked to an entry counts as its receipt, and a bank export is not one
(DECISION, 2026-10-15, building the MCP tools). Recording the row each
draft came from is not built yet.
### Projection
@@ -210,6 +252,28 @@ the digest, the viewer's Needs attention, and the session-start hook, and
enforcement where a rule is a rule (tax basis at registration). The
standard: do it now, completely.
### Counterparties
Decided (Stephen, 2026-10-15): the books should be easy to make complete
without forcing one style. `[books] parties = "off" | "suggest" |
"require"`, default `"suggest"`:
- `off`: never mention counterparties.
- `suggest`: a draft or preview without a party names the likely one
(existing parties matched against the description, the receipt's text,
the vendor), or offers to create one; `next_steps` carries the exact
call. Nothing is refused.
- `require`: posting is refused until the entry names a party or is
marked as having none (transfers, depreciation, amortization, closes
are exempt by kind).
Also: memorized matches (journaled rules like description contains
"OPENROUTER" → OpenRouter, applied by bank-CSV import and new drafts,
reviewable and removable); a backfill punch list grouping untagged
entries by likely party for one confirmation per group; 1099 nudges (a
party given contractor or landlord, or a payment that looks like
services to an individual, asks for the role and the W-9).
## After that: updates (check and seamless upgrade)
Decided (Stephen, 2026-10-15), after the completeness engine. New versions
+20 -2
View File
@@ -521,8 +521,26 @@ and the full TIN lives only in the encrypted vault, outside the journal
need the employee role; payroll lines that name their employee name a
party with that role.
The full TIN, when the books need it for the 1099 worksheet, lives in the
TIN vault (`vault/` in the books dir, git-ignored; encrypted under a key
file outside the books dir): `books.set_w9` `tin` writes it there and
journals only the last four and the vault record's hash; the signed W-9
goes there too, never to the attachment store. No tool output, error, or
log holds a full TIN except `books.vendor_1099` with `include_tins`
(DECISION, Stephen, 2026-10-15: for the CPA, when asked); files holding
one (`books.get_w9`, `save_csv_to`) are written only outside the books
dir. `books.purge_tin` deletes a party's vault records and journals that
it did.
**Enforced:** `crate::parties` (has_full_tin, check_text, W9Facts::check,
W4Facts::check), `Ledger::add_party`, `update_party`, `set_party_role`,
`set_party_w9`, `set_party_w4`, `set_entry_party`, `validate_payroll`.
`set_party_w9`, `set_party_w4`, `set_entry_party`, `validate_payroll`;
`ledger-vault` (Tin::new, the encrypted records, records_held,
purge_records); `ledger-mcp` `books.set_w9`, `books.get_w9`,
`books.purge_tin`, `books.vendor_1099` (open_vault refuses a key file in
the books dir; outside_books for files with a TIN).
**Checked:** `ledger-core` `tests/spec_parties.rs`; `ledger-store`
`tests/parties.rs`; `ledger-mcp` `tests/spec_parties.rs`.
`tests/parties.rs`; `ledger-vault` `tests/vault.rs`; `ledger-mcp`
`tests/spec_parties.rs`, `tests/spec_vault.rs` (every response, the log,
and every file in the books dir outside `vault/` scanned for the TINs in
any formatting).