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
@@ -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