Attachments, statements, reconciliations, TIN vault, counterparty policy

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 a299077991
commit 08ce558ed3
41 files changed
+8463 -465

No files matched your search

+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?" (owner, 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",
/// "FL-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")