feat: the CPA package carries the evidence; the site answers 'why not QuickBooks?'

CPA package: every receipt behind the period's entries with an index,
company documents, the journal as IIF, the 1099 worksheet, and a README;
include_receipts=false for a light package. The scaffolded AGENTS.md
covers receipts from email, switching from QuickBooks/Xero/spreadsheets,
and working with the CPA. GUI: a books copy without a server database
reads 'Read-only copy'. Site: example prompts, QuickBooks comparison,
what it does and doesn't do yet, the CPA section, FAQ.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-12 17:14:42 -07:00
1 parent 9694d2a89a
commit 45784266b5
7 files changed
+338 -48

No files matched your search

+46
View File
@@ -117,6 +117,49 @@ anything. Keep it conversational; ask a few things at a time.
migration done.
7. Tell them about `just gui`, then commit with `just backup`.
## Receipts from email
If the human has connected their email (a Gmail or Outlook connector, or a
browser extension with their mail open), find receipts there yourself:
search for the vendor and amount around the transaction date, then file
one file per receipt — the PDF attachment if there is one; otherwise save
the message and render it to PDF. Never file a raw `.eml`. Ask before
downloading anything, and never type a password or account number into a
form.
## Switching from QuickBooks, Xero, or a spreadsheet
1. Ask the human to export, from the old system, for every year they want
to bring over: the chart of accounts, the general journal (or general
ledger), and the trial balance, profit and loss, and balance sheet. CSV
or XLSX. Receipts usually live as attachments; export those too.
2. Set up the chart (`books.import_chart_csv` or account by account), then
rebuild the history as entries — or, if they only want a fresh start, one
opening entry from the old trial balance at the cutover date.
3. Diff your trial balance, P&L, balance sheet, and cash flow against the
old system's reports for the same dates, **to the cent**, and show the
human the comparison. Not done until every number matches.
4. Keep the old system read-only until a full month has been kept in
wanderbooks and closed cleanly. Then they can cancel it.
Bank feeds aren't automatic here: import the bank's CSV export
(`books.import_bank_csv`, drafts to review) or read their statements.
## Working with the CPA
- At year end (or whenever the CPA asks), build the package with
`books.export_cpa_package`: every statement as PDF and XLSX, the tax
summary, the general ledger and journal, a QuickBooks Desktop IIF
journal, the 1099 worksheet, and every receipt with an index. Save it
where the human can send it.
- Before that: every expense and revenue account has a tax category
(`books.tax_categories`), the period's entries all have receipts, and
`books.verify` is clean.
- A CPA who wants to look around can get a read-only copy of this
directory (it's a git repository: push it to a private remote and give
them read access) and open it with `wanderbooks gui`, or `ledger-gui
--data-dir books-data`. Nothing they do there can change the books.
## Ongoing
- Every transaction: draft, preview, confirm with the human, post, attach
@@ -125,6 +168,9 @@ anything. Keep it conversational; ask a few things at a time.
lately." Offer it; don't make the human ask.
- Reports are pure functions of the journal. If a number looks wrong, the
journal is the evidence — read it, don't guess.
- Integrity: `books.verify` checks the journal's hash chain, the database
against the journal (rebuilding it if it drifted), and every stored
file. Run it when a number looks wrong or the human asks.
- Back up: `just backup` after every session that posted entries. The git
history of this directory is the archive; `books-data/journal.jsonl` is
the system of record and the SQLite database is disposable.
+1 -1
View File
@@ -376,7 +376,7 @@ fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) {
let (ok, text, tip) = match &b.integrity.disk {
DiskCheck::Matches => (true, "Database matches journal".to_string(), "The server's SQLite database matches a fresh projection of the journal, every table, row for row.".to_string()),
DiskCheck::Behind { applied, journal } => (true, "Database catching up".to_string(), format!("The database has applied {applied} of {journal} journal events; the server applies the rest right after writing them.")),
DiskCheck::Missing => (true, "Database will rebuild".to_string(), "No database file yet; the server builds it from the journal when it starts.".to_string()),
DiskCheck::Missing => (true, "Read-only copy".to_string(), "No server database here: a copy of the books (a CPA's, say), or the server hasn't run yet. The journal and every file are verified either way.".to_string()),
DiskCheck::Differs(why) => (false, "Database differs".to_string(), format!("{why}. The server rebuilds it from the journal (books.verify, or on its next start).")),
DiskCheck::Unreadable(why) => (false, "Database unreadable".to_string(), why.clone()),
};
+101 -1
View File
@@ -3328,7 +3328,7 @@ impl BooksServer {
#[tool(
name = "books.export_cpa_package",
description = "Build the CPA handoff package as a zip (base64): the profit and loss, balance sheet, cash flow, statement of changes in members' equity (multi-member LLCs), and trial balance, each as PDF and XLSX; plus the general ledger (by account) and general journal (entry by entry) as XLSX. cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.",
description = "Build the CPA handoff package as a zip (base64): the profit and loss, balance sheet, cash flow, statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal as IIF (imports into QuickBooks Desktop); the 1099 worksheet as CSV; every receipt behind the year's entries (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.",
annotations(read_only_hint = true)
)]
async fn export_cpa_package(
@@ -3418,7 +3418,107 @@ impl BooksServer {
"general-journal.xlsx".into(),
xlsx(XlsxReport::GeneralJournal(ledger, fy, as_of))?,
));
// 1099 worksheet for the calendar year of as_of.
let year = chrono::Datelike::year(&as_of);
let r1099 = ledger_core::vendor_1099_report(ledger, year, None);
let mut w = String::from("vendor,form,w9_on_file,paid,reportable\n");
for v in &r1099.vendors {
w.push_str(&format!(
"\"{}\",{},{},{},{}\n",
v.name.replace('"', "\"\""),
v.form.map(|f| f.label()).unwrap_or(""),
v.tin_on_file,
money(v.paid),
v.reportable
));
}
files.push((format!("1099-worksheet-{year}.csv"), w.into_bytes()));
// The evidence: every current receipt on the year's posted entries,
// and the company's documents, with an index tying them together.
let mut receipt_count = 0;
if p.include_receipts {
let clean = |s: &str| -> String {
s.chars()
.map(|c| {
if c.is_alphanumeric() || ".-_ ".contains(c) {
c
} else {
'_'
}
})
.collect()
};
let mut index = String::from("entry_id,date,description,amount,file,sha256\n");
for a in store.list_attachments(None).map_err(|e| e.to_string())? {
if a.withdrawn_at.is_some() {
continue;
}
let bytes = store.attachment_bytes(a.id).map_err(|e| e.to_string())?;
match a.entry_id.and_then(|e| ledger.get_entry(e)) {
Some(e)
if e.status() == ledger_core::EntryStatus::Posted
&& e.date() >= fy
&& e.date() <= as_of =>
{
let name = format!(
"receipts/{}-entry-{}-{}",
e.date(),
e.id(),
clean(&a.file_name)
);
let amount: Decimal = e
.lines()
.iter()
.filter(|l| l.side == Side::Debit)
.map(|l| l.amount.amount)
.sum();
index.push_str(&format!(
"{},{},\"{}\",{},{},{}\n",
e.id(),
e.date(),
e.description().replace('"', "\"\""),
money(amount),
name,
a.blob_sha
));
files.push((name, bytes));
receipt_count += 1;
}
None if a.entry_id.is_none() && a.category != "receipts" => {
files.push((
format!("documents/{}/{}", clean(&a.category), clean(&a.file_name)),
bytes,
));
}
_ => {}
}
}
files.push(("receipts/index.csv".into(), index.into_bytes()));
}
let seq = store.journal_seq();
drop(store);
files.push((
"general-journal.iif".into(),
self.export_iif()?.into_bytes(),
));
let readme = format!(
"{name} — books package for the CPA\n\nFiscal year to date: {fy_start} through {as_of_s}. {basis} basis.\nKept in wanderbooks: double-entry books from an append-only, hash-chained journal ({seq} events, verified).\n\nStatements (PDF and XLSX): profit and loss, balance sheet, cash flow, {members}tax summary, trial balance.\nLedgers (XLSX): general ledger by account; general journal entry by entry.\ngeneral-journal.iif: every posted entry, for import into QuickBooks Desktop.\n1099-worksheet-{year}.csv: cash paid per vendor marked for a 1099, against the year's threshold.\n{receipts}\nThe tax summary maps accounts to return categories; it's a worksheet, not a filing. Questions go to the owners, who can ask their bookkeeping agent to trace any number to its entries and receipts.\n",
name = self.company.name,
basis = self.company.basis,
members = if self.company.members.is_empty() {
""
} else {
"members' equity, "
},
receipts = if p.include_receipts {
format!(
"receipts/: the {receipt_count} receipts behind the period's entries; index.csv ties each to its entry, date, amount, and SHA-256.\ndocuments/: the company's formation, legal, and tax documents.\n"
)
} else {
String::new()
},
);
files.insert(0, ("README.txt".into(), readme.into_bytes()));
// Zip it.
let mut zip_buf = Vec::new();
+4
View File
@@ -1917,6 +1917,10 @@ pub struct ExportCpaPackageParams {
/// empty: liabilities are operating working capital.
#[serde(default)]
pub financing_accounts: Vec<String>,
/// Include every receipt for the year's entries and the company's
/// documents (default true).
#[serde(default = "default_true")]
pub include_receipts: bool,
}
/// Output of `books.export_cpa_package`.
+33 -1
View File
@@ -871,13 +871,18 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
.map(|i| zip.by_index(i).unwrap().name().unwrap().to_string())
.collect();
names.sort();
let (receipts, rest): (Vec<&String>, Vec<&String>) =
names.iter().partition(|n| n.starts_with("receipts/"));
assert_eq!(
names,
rest,
[
"1099-worksheet-2026.csv",
"README.txt",
"balance-sheet.pdf",
"balance-sheet.xlsx",
"cash-flow.pdf",
"cash-flow.xlsx",
"general-journal.iif",
"general-journal.xlsx",
"general-ledger.xlsx",
"profit-and-loss.pdf",
@@ -888,9 +893,36 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
"trial-balance.xlsx"
]
);
// Every receipt behind the period's entries, plus an index tying them
// to their entries.
assert!(receipts.iter().any(|n| *n == "receipts/index.csv"));
let files = receipts.len() - 1;
assert!(files > 0, "receipts included");
let mut index = String::new();
std::io::Read::read_to_string(&mut zip.by_name("receipts/index.csv").unwrap(), &mut index)
.unwrap();
assert_eq!(
index.lines().count(),
files + 1,
"one index row per receipt"
);
let mut readme = String::new();
std::io::Read::read_to_string(&mut zip.by_name("README.txt").unwrap(), &mut readme).unwrap();
assert!(readme.contains("hash-chained journal"), "{readme}");
let mut cf = Vec::new();
std::io::Read::read_to_end(&mut zip.by_name("cash-flow.pdf").unwrap(), &mut cf).unwrap();
assert!(cf.starts_with(b"%PDF"));
// Without receipts, a lighter package.
let light = mcp.call(
"books.export_cpa_package",
json!({"as_of": "2026-10-31", "cash_accounts": ["1000"], "include_receipts": false}),
);
let light = zip::ZipArchive::new(std::io::Cursor::new(
b64.decode(light["content_base64"].as_str().unwrap())
.unwrap(),
))
.unwrap();
assert_eq!(light.len(), rest.len());
}
#[test]