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:
7 files changed
+338
-48
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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()),
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user