P2 slice 6: the viewer

`wanderbooks gui [BOOK]` finds the data root, reads settings from the
journal, the vault of the book, presence through ops/, Save a copy /
Open on every document, Save the W-9, the Export menu, and colour
schemes. One builder for the CPA package and the 1099 worksheet
(ledger-package), shared by the server and the viewer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-20 11:14:21 -06:00
1 parent b26c099a68
commit 5bc0633cdd
77 files changed
+5833 -2427

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
[package]
name = "ledger-package"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "wanderbooks' CPA handoff package and the reports and worksheets it shares with the server: built from the books read-only, by the server or the viewer."
[dependencies]
ledger-core = { path = "../ledger-core" }
ledger-store = { path = "../ledger-store" }
ledger-import = { path = "../ledger-import" }
ledger-vault = { path = "../ledger-vault" }
chrono = "0.4"
rust_decimal = "1"
zip = "9.0.1"
[lints]
workspace = true
[dev-dependencies]
tempfile = "3"
+87
View File
@@ -0,0 +1,87 @@
//! [`PackageBooks`]: what the package reads of the books, from the
//! server's [`Store`] or the viewer's [`Snapshot`] alike.
//!
//! ```text
//! GOAL: one package builder over either view of the books, and no way
//! for it to write to them.
//! SOURCE: ledger_store::snapshot (a Snapshot reads attachments and their
//! bytes "the same as through Store").
//! INVARIANTS:
//! 1. Every method only reads.
//! 2. A Store and a Snapshot of the same journal answer every method
//! alike: the same ledger, journal seq, attachments (withdrawn ones
//! included), entry links, and bytes.
//! ```
use ledger_core::Ledger;
use ledger_store::{Attachment, Snapshot, Store, StoreError, Subject};
/// The books, read-only, as the CPA package reads them.
pub trait PackageBooks {
/// The ledger every figure comes from.
fn ledger(&self) -> &Ledger;
/// Sequence number of the last journal line: the journal state the
/// package reflects.
fn journal_seq(&self) -> u64;
/// Every attachment (receipts and documents), withdrawn ones
/// included, in id order.
fn all_attachments(&self) -> Result<Vec<Attachment>, StoreError>;
/// The subjects attachment `attachment_id` is linked to beyond the
/// entry it is filed on (an implementation may list that entry too:
/// the package takes the union).
fn links_of(&self, attachment_id: u64) -> Result<Vec<Subject>, StoreError>;
/// An attachment's bytes, verified against its hash.
fn attachment_bytes(&self, attachment_id: u64) -> Result<Vec<u8>, StoreError>;
}
impl PackageBooks for Store {
fn ledger(&self) -> &Ledger {
Store::ledger(self)
}
fn journal_seq(&self) -> u64 {
Store::journal_seq(self)
}
fn all_attachments(&self) -> Result<Vec<Attachment>, StoreError> {
Store::list_attachments(self, None)
}
fn links_of(&self, attachment_id: u64) -> Result<Vec<Subject>, StoreError> {
Store::attachment_links(self, attachment_id)
}
fn attachment_bytes(&self, attachment_id: u64) -> Result<Vec<u8>, StoreError> {
Store::attachment_bytes(self, attachment_id)
}
}
impl PackageBooks for Snapshot {
fn ledger(&self) -> &Ledger {
Snapshot::ledger(self)
}
fn journal_seq(&self) -> u64 {
Snapshot::journal_seq(self)
}
fn all_attachments(&self) -> Result<Vec<Attachment>, StoreError> {
Snapshot::attachments(self, None)
}
fn links_of(&self, attachment_id: u64) -> Result<Vec<Subject>, StoreError> {
Ok(Snapshot::attachment_links(self)?
.into_iter()
.filter(|(id, _)| *id == attachment_id)
.map(|(_, subject)| subject)
.collect())
}
fn attachment_bytes(&self, attachment_id: u64) -> Result<Vec<u8>, StoreError> {
Snapshot::attachment_bytes(self, attachment_id)
}
}
+53
View File
@@ -0,0 +1,53 @@
//! The journal as IIF, for QuickBooks Desktop: `books.export` format iif
//! and the package's `general-journal.iif`.
//!
//! ```text
//! GOAL: the CPA imports every posted entry into QuickBooks Desktop, each
//! account declared with the type QuickBooks needs.
//! SOURCE: ledger_import::iif (the IIF format and account types).
//! INVARIANTS:
//! 1. The cash accounts are BANK; the receivable and payable control
//! accounts AR and AP; a registered asset's cost and accumulated
//! depreciation FIXASSET; every other account follows its type.
//! 2. Every posted entry, and only those.
//! ```
use ledger_core::{AccountId, AccountType, Ledger};
/// Render the journal as IIF text via ledger-import, declaring every
/// account with its QuickBooks type: the cash accounts (`cash_codes`, the
/// books' `cash_accounts`) are BANK, the receivable and payable control
/// accounts AR and AP, a registered asset's cost and accumulated
/// depreciation FIXASSET, and the rest follow their wanderbooks type
/// (other current asset, other current liability, equity, income,
/// expense).
pub fn export_iif(ledger: &Ledger, cash_codes: &[String]) -> Result<String, String> {
use ledger_import::{QbAccountType as Qb, iif_accounts, journal_to_iif};
let fixed: std::collections::HashSet<AccountId> = ledger
.registered_assets()
.flat_map(|a| [a.cost_account, a.accum_depr_account])
.collect();
let receivables = ledger.role_account(&ledger_core::RECEIVABLES);
let payables = ledger.role_account(&ledger_core::PAYABLES);
let accounts = iif_accounts(ledger, |a| {
if cash_codes.contains(&a.code) {
Qb::Bank
} else if Some(a.id) == receivables {
Qb::AccountsReceivable
} else if Some(a.id) == payables {
Qb::AccountsPayable
} else if fixed.contains(&a.id) {
Qb::FixedAsset
} else {
match a.account_type {
AccountType::Asset => Qb::OtherCurrentAsset,
AccountType::Liability => Qb::OtherCurrentLiability,
AccountType::Equity => Qb::Equity,
AccountType::Revenue => Qb::Income,
AccountType::Expense => Qb::Expense,
}
}
});
let entries: Vec<_> = ledger.posted_entries().collect();
journal_to_iif(&accounts, &entries).map_err(|e| e.to_string())
}
+46
View File
@@ -0,0 +1,46 @@
//! The CPA handoff package, and the reports, worksheets, and text helpers
//! the server's tools and the viewer both make: built from the books
//! read-only, through [`PackageBooks`] (the server's
//! [`ledger_store::Store`], the viewer's [`ledger_store::Snapshot`]).
//!
//! ```text
//! GOAL: the server (books.export_cpa_package, books.vendor_1099) and the
//! viewer make the same package and the same 1099 worksheet, byte
//! for byte, from one piece of code, and neither can change the
//! books making them.
//! SOURCE: the books.export_cpa_package and books.vendor_1099 contracts
//! (ledger-mcp server.rs; docs/mcp-interface.md), moved here
//! unchanged so the viewer, which reads the books only through a
//! Snapshot and doesn't depend on ledger-mcp, builds them too.
//! INVARIANTS:
//! 1. Read-only: nothing here writes anywhere (the books, the vault, a
//! file). The builders return bytes; recording a file that holds
//! full TINs (ledger-mcp tin_files.rs) and handing it over are the
//! caller's.
//! 2. Full TINs come only from the TIN source the caller gives
//! ([`TinSource`]); without one, a worksheet carries last fours only.
//! 3. The same books, settings, and request give the same bytes,
//! whichever [`PackageBooks`] they are read through.
//! ```
mod books;
mod iif;
mod package;
mod reports;
mod text;
mod tins;
mod vendor_1099;
pub use books::PackageBooks;
pub use iif::export_iif;
pub use package::{Package, PackageRequest, build_package, package_file_name};
pub use reports::{
Settings, bs_statement, cash_flow_for, cash_flow_statement, member_capitals, pnl_statement,
report_members, report_meta, statement_range, year_earnings,
};
pub use text::{csv_escape, money, parse_date, share_text};
pub use tins::{TinSource, tin_kind, tin_type_key, vault_tin};
pub use vendor_1099::{
Vendor1099Worksheet, Vendor1099WorksheetLine, form_1099_label, payment_accounts,
vendor_1099_csv, vendor_1099_csv_name, vendor_1099_report, vendor_1099_worksheet,
};
+615
View File
@@ -0,0 +1,615 @@
//! The CPA handoff package: every statement, ledger, and worksheet the CPA
//! needs for the return, with the evidence behind the year's entries, in
//! one zip.
//!
//! ```text
//! GOAL: everything the CPA needs for the return in one zip, the K-1s'
//! and 1099s' TINs included when asked, built the same by the
//! server and the viewer.
//! SOURCE: docs/mcp-interface.md; B-26; DECISION (owner, 2026-10-15):
//! "it needs to be in the damn CPA package."
//! INVARIANTS:
//! 1. The statements (profit and loss, balance sheet, cash flow, members'
//! equity with members, tax summary, trial balance) each as PDF and
//! XLSX, for the fiscal year containing as_of, through as_of; the
//! fixed asset register when there are fixed assets; the general
//! ledger and journal (XLSX); the journal as IIF; the 1099 worksheet
//! for as_of's calendar year (or why it can't be made); with members,
//! the K-1 worksheet; a README first.
//! 2. With members, k1-members.csv has one row per member: name, party,
//! share, capital account, TIN and type (or last four without a TIN
//! source), and the K-1 worksheet's capital figures (ledger_core::k1)
//! for the fiscal year to date; the members' total row with more than
//! one. The 1099 worksheet adds each payee's party, last four, TIN,
//! and type with a TIN source.
//! 3. A TIN the source can't give never fails the package: the row says
//! why, and so do the README and tin_gaps (members, and 1099 payees
//! that are reportable).
//! 4. With include_receipts: every current file behind the year's
//! posted entries, filed on them or linked to them, once per entry,
//! each with a row in receipts/index.csv; and the company's
//! documents, the files behind no entry (loose receipts aside).
//! Withdrawn files never.
//! 5. Read-only (crate INVARIANT 1): the package is returned, its files
//! and the zip, and written nowhere.
//! ```
use chrono::NaiveDate;
use ledger_core::{AccountId, Side, reports};
use rust_decimal::Decimal;
use crate::books::PackageBooks;
use crate::iif::export_iif;
use crate::reports::{
Settings, bs_statement, cash_flow_for, member_capitals, pnl_statement, report_members,
report_meta, year_earnings,
};
use crate::text::{csv_escape, money, share_text};
use crate::tins::{TinSource, tin_type_key};
use crate::vendor_1099::vendor_1099_report;
/// What the package is built for.
#[derive(Clone, Copy)]
pub struct PackageRequest<'a> {
/// The day it is as of: the balance sheet's date, the end of every
/// period report (the fiscal year to date).
pub as_of: NaiveDate,
/// The day it is prepared, for the reports' headers.
pub prepared_on: NaiveDate,
/// Cash account codes the cash flow statement reconciles to (empty:
/// the settings' cash accounts).
pub cash_accounts: &'a [String],
/// Financing account codes (empty: the settings' financing accounts).
pub financing_accounts: &'a [String],
/// Investing account codes (empty: the settings' investing accounts).
pub investing_accounts: &'a [String],
/// Whether the receipts and the company's documents go in
/// (INVARIANT 4).
pub include_receipts: bool,
/// Where the worksheets' full TINs come from: none, last fours only.
pub tins: Option<TinSource<'a>>,
}
/// A built package.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Package {
/// The zip's name: `cpa-package-<as_of>.zip`.
pub file_name: String,
/// Every file in the zip, in its order: (name in the zip, bytes).
pub files: Vec<(String, Vec<u8>)>,
/// The zip.
pub zip: Vec<u8>,
/// With a TIN source: each member or reportable 1099 payee whose TIN
/// the package lacks, and why (INVARIANT 3).
pub tin_gaps: Vec<String>,
/// The receipts in receipts/ (one per entry they are behind).
pub receipt_count: usize,
}
/// The package's file name for `as_of`.
pub fn package_file_name(as_of: NaiveDate) -> String {
format!("cpa-package-{as_of}.zip")
}
/// Build the CPA package from `books` (INVARIANTS 1-5).
pub fn build_package<B: PackageBooks + ?Sized>(
books: &B,
settings: &Settings<'_>,
req: &PackageRequest<'_>,
) -> Result<Package, String> {
use ledger_import::{XlsxReport, report, report_to_xlsx, statement_pdf};
let company = settings.company;
let (as_of, today) = (req.as_of, req.prepared_on);
let meta = report_meta(company, req.prepared_on, books.journal_seq());
let ledger = books.ledger();
let tins = req.tins;
let mut tin_gaps: Vec<String> = Vec::new();
// Fiscal year to date, per the company's fiscal_year_start.
let (fy_start, _) = company.fiscal_year_containing(as_of);
let (fy_start, as_of_s) = (fy_start.to_string(), as_of.to_string());
let xlsx = |r: XlsxReport<'_>| report_to_xlsx(&r, &meta).map_err(|e| e.to_string());
let pdf = |st: &ledger_import::Statement| statement_pdf(st, &meta).map_err(|e| e.to_string());
let fy = NaiveDate::parse_from_str(&fy_start, "%Y-%m-%d").map_err(|e| e.to_string())?;
let members = report_members(company);
// The financial statements, each as PDF and XLSX.
let mut statements: Vec<(&str, ledger_import::Statement)> = vec![
(
"profit-and-loss",
report::profit_and_loss(&pnl_statement(
ledger,
Some(&fy_start),
Some(&as_of_s),
None,
today,
)?),
),
(
"balance-sheet",
report::balance_sheet(
&bs_statement(ledger, Some(&as_of_s), today)?,
year_earnings(ledger, company, as_of)?,
&members,
),
),
(
"cash-flow",
report::cash_flow(
&cash_flow_for(
ledger,
settings,
Some(&fy_start),
Some(&as_of_s),
req.cash_accounts,
req.financing_accounts,
req.investing_accounts,
today,
)?,
&members,
),
),
];
let mut k1_csv: Option<(String, Vec<u8>)> = None;
if !company.members.is_empty() {
let capitals = member_capitals(ledger, &company.members)?;
let me =
ledger_core::members_equity(ledger, fy, as_of, &capitals).map_err(|e| e.to_string())?;
statements.push(("members-equity", report::members_equity(&me)));
// The K-1 worksheet: each member's capital account for the
// year (Schedule K-1 Item L) and their TIN (INVARIANT 2).
let cash_codes = if req.cash_accounts.is_empty() {
settings.cash_accounts
} else {
req.cash_accounts
};
let cash: Vec<AccountId> = cash_codes
.iter()
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
.collect();
k1_csv = Some(
match ledger_core::k1::k1_worksheet(ledger, fy, as_of, &capitals, &cash)
.map_err(|e| e.to_string())
.and_then(|rows| {
// A row per member, in their order, or none: a row is
// never paired with another member.
if rows.len() == company.members.len() {
Ok(rows)
} else {
Err(format!(
"the K-1 worksheet has {} rows for {} members",
rows.len(),
company.members.len()
))
}
}) {
Ok(rows) => {
let mut w = String::from("member,party_id,share,capital_account,");
w.push_str(if tins.is_some() {
"tin,tin_type,tin_note,"
} else {
"tin_last4,tin_type,"
});
w.push_str("beginning_capital,contributions_cash,contributions_property,net_income,distributions,other,ending_capital\n");
for (row, m) in rows.iter().zip(&company.members) {
let party = ledger_core::k1::member_party(ledger, &m.name);
let facts = party
.and_then(|id| ledger.get_party(id))
.and_then(|p| p.tin.clone());
let mut cells = vec![
csv_escape(&m.name),
party.map(|id| id.to_string()).unwrap_or_default(),
share_text(m.share),
m.capital_account.clone(),
];
match tins {
Some(tins) => {
let got = match party {
None => Err(format!(
"TIN not in the vault: no party named {:?} with the member role (books.add_party with role member, or books.set_party_roles, then books.set_tin)",
m.name
)),
Some(id) => tins(id),
};
match got {
Ok(t) => {
cells.push(t.formatted());
cells.push(tin_type_key(t.kind).into());
cells.push(String::new());
}
Err(why) => {
tin_gaps.push(format!("member {}: {why}", m.name));
cells.push(String::new());
cells.push(
facts
.map(|f| f.tin_type.key().to_string())
.unwrap_or_default(),
);
cells.push(csv_escape(&why));
}
}
}
None => {
cells.push(
facts.as_ref().map(|f| f.last4.clone()).unwrap_or_default(),
);
cells.push(
facts
.map(|f| f.tin_type.key().to_string())
.unwrap_or_default(),
);
}
}
for v in [
row.beginning,
row.contributions_cash,
row.contributions_property,
row.net_income,
row.distributions,
row.other,
row.ending,
] {
cells.push(money(v));
}
w.push_str(&cells.join(","));
w.push('\n');
}
// The members together: ties to the 1065's Schedule L
// capital and the statement of members' equity. Not
// a K-1 (each member gets their own).
if rows.len() > 1 {
let sum = |f: fn(&ledger_core::k1::K1Row) -> Decimal| {
rows.iter().map(f).sum::<Decimal>()
};
let share: Decimal = company.members.iter().map(|m| m.share).sum();
let mut cells = vec![
csv_escape("Total, all members (not a K-1)"),
String::new(),
share_text(share),
String::new(),
String::new(),
String::new(),
];
if tins.is_some() {
cells.push(String::new());
}
for v in [
sum(|r| r.beginning),
sum(|r| r.contributions_cash),
sum(|r| r.contributions_property),
sum(|r| r.net_income),
sum(|r| r.distributions),
sum(|r| r.other),
sum(|r| r.ending),
] {
cells.push(money(v));
}
w.push_str(&cells.join(","));
w.push('\n');
}
("k1-members.csv".into(), w.into_bytes())
}
Err(e) => (
"k1-members-NOT-INCLUDED.txt".into(),
format!("{e}\n").into_bytes(),
),
},
);
}
statements.push((
"tax-summary",
report::tax_summary(
&ledger_core::tax_summary(ledger, fy, as_of, company.tax_form)
.map_err(|e| e.to_string())?,
),
));
statements.push((
"trial-balance",
report::trial_balance(&reports::trial_balance_as_of(ledger, as_of), as_of),
));
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
for (name, st) in &statements {
files.push((format!("{name}.pdf"), pdf(st)?));
files.push((format!("{name}.xlsx"), xlsx(XlsxReport::Statement(st))?));
}
// The fixed asset register, when there are fixed assets.
if ledger.registered_assets().next().is_some() {
let r = ledger_core::fixed_asset_register(ledger, as_of).map_err(|e| e.to_string())?;
files.push((
"fixed-asset-register.pdf".into(),
ledger_import::asset_register_pdf(&r, &meta).map_err(|e| e.to_string())?,
));
files.push((
"fixed-asset-register.xlsx".into(),
ledger_import::asset_register_xlsx(&r, &meta).map_err(|e| e.to_string())?,
));
}
// The books behind them: by account, and entry by entry.
let gl = ledger_core::general_ledger(ledger, fy, as_of).map_err(|e| e.to_string())?;
files.push((
"general-ledger.xlsx".into(),
xlsx(XlsxReport::GeneralLedger(&gl))?,
));
files.push((
"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 mut worksheet_1099 = false;
match vendor_1099_report(ledger, settings, year, None) {
Ok(r1099) => {
let mut w = String::from("vendor,form,w9_on_file,paid,reportable");
if tins.is_some() {
w.push_str(",party_id,tin_last4,tin,tin_type,tin_note");
}
w.push('\n');
for v in &r1099.vendors {
w.push_str(&format!(
"\"{}\",{},{},{},{}",
v.name.replace('"', "\"\""),
v.form.map(|f| f.label()).unwrap_or(""),
v.tin_on_file,
money(v.paid),
v.reportable
));
// The payee's full TIN from the source (INVARIANT 2).
if let Some(tins) = tins {
let party = ledger.party_of_vendor(v.vendor_id);
let last4 = party.and_then(|id| ledger.get_party(id)).and_then(|p| {
p.w9.as_ref()
.map(|f| f.tin_last4.clone())
.or_else(|| p.tin.as_ref().map(|t| t.last4.clone()))
});
let got = match party {
None => Err("TIN not in the vault: no party for this vendor".into()),
Some(id) => tins(id),
};
let (tin, kind, note) = match got {
Ok(t) => (
t.formatted(),
tin_type_key(t.kind).to_string(),
String::new(),
),
Err(why) => {
if v.reportable {
tin_gaps.push(format!("1099 payee {}: {why}", v.name));
}
(String::new(), String::new(), why)
}
};
w.push_str(&format!(
",{},{},{tin},{kind},{}",
party.map(|id| id.to_string()).unwrap_or_default(),
last4.unwrap_or_default(),
csv_escape(&note)
));
}
w.push('\n');
}
files.push((format!("1099-worksheet-{year}.csv"), w.into_bytes()));
worksheet_1099 = true;
}
// The threshold isn't known for this year: never guess it
// (rules.md B-19); say so in the package instead.
Err(e) => files.push((
format!("1099-worksheet-{year}-NOT-INCLUDED.txt"),
format!("{e}\n").into_bytes(),
)),
}
// The evidence: every current file behind the year's posted
// entries, filed on them or linked to them (a file linked to an
// entry counts as its receipt), one copy per entry with a row in
// the index; and the company's documents, the files behind no
// entry (INVARIANT 4).
let mut receipt_count = 0;
let mut document_count = 0;
// Each file's name in the zip is its own: a second `r.pdf` is
// `r (2).pdf` (compared ignoring case, so it unpacks the same on every
// system), and the index names the file as it is stored.
let mut used: std::collections::HashSet<String> =
files.iter().map(|(n, _)| n.to_lowercase()).collect();
let mut unique = |name: String| -> String {
let (stem, ext) = match name.rsplit_once('.') {
Some((s, e)) if !s.ends_with('/') && !s.is_empty() => (s.to_string(), format!(".{e}")),
_ => (name.clone(), String::new()),
};
let mut n = 1;
let mut candidate = name;
while !used.insert(candidate.to_lowercase()) {
n += 1;
candidate = format!("{stem} ({n}){ext}");
}
candidate
};
if req.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 books.all_attachments().map_err(|e| e.to_string())? {
if a.withdrawn_at.is_some() {
continue;
}
let mut behind: Vec<u64> = a.entry_id.into_iter().collect();
for sub in books.links_of(a.id).map_err(|e| e.to_string())? {
if let ledger_store::Subject::Entry(e) = sub
&& !behind.contains(&e)
{
behind.push(e);
}
}
behind.sort_unstable();
let bytes = books.attachment_bytes(a.id).map_err(|e| e.to_string())?;
if behind.is_empty() {
if a.category != "receipts" {
files.push((
unique(format!(
"documents/{}/{}",
clean(&a.category),
clean(&a.file_name)
)),
bytes,
));
document_count += 1;
}
continue;
}
for e in behind.iter().filter_map(|&e| ledger.get_entry(e)) {
if e.status() != ledger_core::EntryStatus::Posted
|| e.date() < fy
|| e.date() > as_of
{
continue;
}
let name = unique(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.clone()));
receipt_count += 1;
}
}
files.push(("receipts/index.csv".into(), index.into_bytes()));
}
let k1_included = k1_csv
.as_ref()
.is_some_and(|(name, _)| name == "k1-members.csv");
if let Some(k1) = k1_csv {
files.push(k1);
}
let seq = books.journal_seq();
files.push((
"general-journal.iif".into(),
export_iif(ledger, settings.cash_accounts)?.into_bytes(),
));
let include_tins = tins.is_some();
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.\n{worksheet}{k1}{receipts}{tins}\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 = company.name,
basis = company.basis,
members = if company.members.is_empty() {
""
} else {
"members' equity, "
},
worksheet = if worksheet_1099 {
format!(
"1099-worksheet-{year}.csv: cash paid per vendor marked for a 1099, against the year's threshold{}.\n",
if include_tins {
", with each payee's TIN"
} else {
""
}
)
} else {
format!(
"1099-worksheet-{year}-NOT-INCLUDED.txt: why the year's 1099 worksheet isn't here.\n"
)
},
k1 = if company.members.is_empty() {
String::new()
} else if !k1_included {
"k1-members-NOT-INCLUDED.txt: why the members' K-1 worksheet isn't here.\n".to_string()
} else {
let mut k = format!(
"k1-members.csv: each member's capital account for the year (Schedule K-1 Item L: beginning, contributions in cash and in property, share of income, distributions, ending), with their {}{}.\n",
if include_tins {
"taxpayer ID"
} else {
"taxpayer ID's last four"
},
if company.members.len() > 1 {
"; the last row totals the members (not a K-1) to tie to Schedule L and the statement of members' equity"
} else {
""
}
);
let members = &company.members;
for (i, m) in members.iter().enumerate() {
if let Some(j) = &m.files_jointly_with
&& members[..i].iter().all(|o| o.name != *j)
{
k.push_str(&format!(
"{} and {j} file a joint individual return: each receives their own Schedule K-1; both are reported on the one Form 1040 (Schedule E, Part II).\n",
m.name
));
}
}
k
},
tins = if include_tins {
let mut t = String::from(
"\nThis package holds full taxpayer identification numbers: keep it out of email and shared folders, and delete copies once filed.\n",
);
if !tin_gaps.is_empty() {
t.push_str("TINs not included:\n");
for g in &tin_gaps {
t.push_str(&format!("- {g}\n"));
}
}
t
} else {
String::new()
},
receipts = if req.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.\n{}",
if document_count > 0 {
"documents/: the company's formation, legal, and tax documents.\n"
} else {
""
}
)
} else {
String::new()
},
);
files.insert(0, ("README.txt".into(), readme.into_bytes()));
// Zip it.
let mut zip_buf = Vec::new();
{
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_buf));
let opts = zip::write::SimpleFileOptions::default();
for (name, bytes) in &files {
zip.start_file(name.as_str(), opts)
.map_err(|e| e.to_string())?;
use std::io::Write;
zip.write_all(bytes).map_err(|e| e.to_string())?;
}
zip.finish().map_err(|e| e.to_string())?;
}
Ok(Package {
file_name: package_file_name(as_of),
files,
zip: zip_buf,
tin_gaps,
receipt_count,
})
}
+281
View File
@@ -0,0 +1,281 @@
//! The financial statements as the server's report tools and the package
//! make them: from the books' settings, a date range, and account codes.
//!
//! ```text
//! GOAL: a statement in the package is the one the report tool shows for
//! the same range, with the same refusals.
//! SOURCE: ledger-mcp server.rs (books.profit_and_loss, books.balance_sheet,
//! books.cash_flow, books.export_cpa_package); ASC 230-10-20
//! (financing and investing activities).
//! INVARIANTS:
//! 1. A range defaults its end to `today` and its start to the first
//! posted entry (never after the end); an end before its start is
//! refused.
//! 2. The cash flow statement needs at least one cash account, and
//! every code it is given must be in the chart; account lists a call
//! leaves empty default to the settings' roles.
//! 3. The year's earnings split net income year to date from the part
//! not yet closed into equity (closes are cumulative).
//! ```
use chrono::NaiveDate;
use ledger_core::settings::{Company, Member};
use ledger_core::{AccountId, Ledger, PeriodStatus};
use ledger_import::ReportMeta;
use rust_decimal::Decimal;
use crate::text::parse_date;
/// The books' settings the package and its reports read: the company, and
/// the account roles (`books.cash_accounts`, `books.financing_accounts`,
/// `books.investing_accounts`) as chart codes.
#[derive(Clone, Copy, Debug)]
pub struct Settings<'a> {
/// The company: name, basis, fiscal year, members, tax form.
pub company: &'a Company,
/// The cash accounts' codes.
pub cash_accounts: &'a [String],
/// The financing accounts' codes.
pub financing_accounts: &'a [String],
/// The investing accounts' codes.
pub investing_accounts: &'a [String],
}
/// Resolve a statement's date range: `end` defaults to `today` and `start`
/// to the first posted entry (where "from the beginning" really begins, so
/// no statement says "0001-01-01"), or to `end` for empty books. An end
/// before its start is refused (INVARIANT 1).
pub fn statement_range(
ledger: &Ledger,
start: Option<&str>,
end: Option<&str>,
today: NaiveDate,
) -> Result<(NaiveDate, NaiveDate), String> {
let end = match end {
Some(s) => parse_date(s)?,
None => today,
};
let start = match start {
Some(s) => parse_date(s)?,
None => ledger
.posted_entries()
.map(|e| e.date())
.min()
.map_or(end, |first| first.min(end)),
};
if end < start {
return Err("end is before start".to_string());
}
Ok((start, end))
}
/// Profit and loss for a range (see [`statement_range`]), optionally one
/// class.
pub fn pnl_statement(
ledger: &Ledger,
start: Option<&str>,
end: Option<&str>,
class_id: Option<ledger_core::ClassId>,
today: NaiveDate,
) -> Result<ledger_core::ProfitAndLossStatement, String> {
let (start, end) = statement_range(ledger, start, end, today)?;
ledger_core::profit_and_loss_for_period(ledger, start, end, class_id).map_err(|e| e.to_string())
}
/// Balance sheet as of a date (default `today`).
pub fn bs_statement(
ledger: &Ledger,
as_of: Option<&str>,
today: NaiveDate,
) -> Result<ledger_core::BalanceSheetStatement, String> {
let as_of = match as_of {
Some(s) => parse_date(s)?,
None => today,
};
ledger_core::balance_sheet_as_of(ledger, as_of).map_err(|e| e.to_string())
}
/// Build the cash flow statement from account codes, as the exports need it:
/// `start` defaults to the beginning of time, `end` to `today`, and at
/// least one cash account is required (the statement reconciles to them).
pub fn cash_flow_statement(
ledger: &Ledger,
start: Option<&str>,
end: Option<&str>,
cash_codes: &[String],
financing_codes: &[String],
investing_codes: &[String],
today: NaiveDate,
) -> Result<ledger_core::reports::CashFlowStatement, String> {
let resolve = |codes: &[String], what: &str| -> Result<Vec<AccountId>, String> {
codes
.iter()
.map(|code| {
ledger
.chart()
.find_by_code(code)
.map(|a| a.id)
.ok_or_else(|| format!("unknown {what} account code: {code}"))
})
.collect()
};
let cash_accounts = resolve(cash_codes, "cash")?;
if cash_accounts.is_empty() {
return Err(
"cash_accounts is required for the cash flow statement (pass it, or set the books' cash accounts setting: books.settings shows it)"
.to_string(),
);
}
let financing_accounts = resolve(financing_codes, "financing")?;
let investing_accounts = resolve(investing_codes, "investing")?;
let (start, end) = statement_range(ledger, start, end, today)?;
ledger_core::cash_flow(
ledger,
&ledger_core::CashFlowParams {
start,
end,
cash_accounts,
financing_accounts,
investing_accounts,
},
)
.map_err(|e| e.to_string())
}
/// The cash flow statement from account codes, each list defaulting to
/// the settings' roles when the call passes none.
#[allow(clippy::too_many_arguments)]
pub fn cash_flow_for(
ledger: &Ledger,
settings: &Settings<'_>,
start: Option<&str>,
end: Option<&str>,
cash: &[String],
financing: &[String],
investing: &[String],
today: NaiveDate,
) -> Result<ledger_core::reports::CashFlowStatement, String> {
let or = |given: &'_ [String], default: &'_ [String]| -> Vec<String> {
if given.is_empty() {
default.to_vec()
} else {
given.to_vec()
}
};
cash_flow_statement(
ledger,
start,
end,
&or(cash, settings.cash_accounts),
&or(financing, settings.financing_accounts),
&or(investing, settings.investing_accounts),
today,
)
}
/// Header and provenance for a rendered report: the company's name and
/// basis, the day it was prepared, and the journal state it reflects.
pub fn report_meta(company: &Company, prepared_on: NaiveDate, journal_seq: u64) -> ReportMeta {
ReportMeta {
company: company.name.clone(),
basis: company.basis.clone(),
prepared_on,
journal_seq,
}
}
/// Members as reports name them (empty unless a multi-member LLC).
pub fn report_members(company: &Company) -> Vec<ledger_import::Member> {
company
.members
.iter()
.map(|m| ledger_import::Member {
name: m.name.clone(),
capital_code: m.capital_account.clone(),
distribution_code: m.distribution_account.clone(),
})
.collect()
}
/// Members with their capital (and distribution) accounts resolved in the
/// chart, for the statement of members' equity and the K-1 worksheet; a
/// code the chart lacks is refused, naming the member.
pub fn member_capitals(
ledger: &Ledger,
members: &[Member],
) -> Result<Vec<ledger_core::MemberCapital>, String> {
members
.iter()
.map(|m| {
let account = ledger
.chart()
.find_by_code(&m.capital_account)
.ok_or_else(|| {
format!(
"member {}: unknown capital account {}",
m.name, m.capital_account
)
})?;
let distribution_account = match &m.distribution_account {
Some(code) => Some(
ledger
.chart()
.find_by_code(code)
.ok_or_else(|| {
format!("member {}: unknown distribution account {code}", m.name)
})?
.id,
),
None => None,
};
Ok(ledger_core::MemberCapital {
name: m.name.clone(),
capital_account: account.id,
distribution_account,
share: m.share,
})
})
.collect()
}
/// The fiscal year's earnings through `as_of`, for the balance sheet:
/// net income (loss) year to date (closing entries excluded), and the
/// part of it not yet closed into equity: the income after the last
/// close (closes are cumulative), from no earlier than the year's start.
pub fn year_earnings(
ledger: &Ledger,
company: &Company,
as_of: NaiveDate,
) -> Result<ledger_import::YearEarnings, String> {
let (fy_start, _) = company.fiscal_year_containing(as_of);
let net_income_ytd = ledger_core::profit_and_loss_for_period(ledger, fy_start, as_of, None)
.map_err(|e| e.to_string())?
.net_income();
// A close takes everything unclosed through its end, earlier years
// included, so this year's unclosed part is the income from the day
// after the last close (never before the year's start) through
// as_of. (Counting the closing entries dated this year instead
// charged this year with closing last year's income: found by
// year_earnings_split_closed_from_unclosed, 2026-10-14.)
let last_close = ledger
.list_periods()
.iter()
.filter(|p| p.status == PeriodStatus::Closed && p.end <= as_of)
.map(|p| p.end)
.max();
let from = match last_close.and_then(|e| e.succ_opt()) {
Some(after) if after > fy_start => after,
_ => fy_start,
};
let unclosed = if from <= as_of {
ledger_core::profit_and_loss_for_period(ledger, from, as_of, None)
.map_err(|e| e.to_string())?
.net_income()
} else {
Decimal::ZERO
};
Ok(ledger_import::YearEarnings {
net_income_ytd,
unclosed,
})
}
+153
View File
@@ -0,0 +1,153 @@
//! How the books' amounts, shares, dates, and CSV cells are written and
//! read: one spelling for the server's tools, the package, and the viewer.
//!
//! ```text
//! GOAL: an amount, a share, or a date reads the same in every file and
//! tool the books make, and a date in is checked one way.
//! SOURCE: ledger-mcp server.rs INVARIANT 3 (amounts out are decimal
//! strings with at least two places, never rounded); RFC 4180
//! (a CSV field holding a comma, quote, or line break is quoted,
//! its quotes doubled); P0 review round 3, S1 (dates).
//! INVARIANTS:
//! 1. money pads to two places and never rounds; it never prints "-0".
//! 2. share_text depends on a share's value, never its spelling.
//! 3. csv_escape quotes exactly the fields that need it.
//! 4. parse_date takes exactly YYYY-MM-DD, a day the calendar keeps.
//! ```
use chrono::NaiveDate;
use rust_decimal::Decimal;
/// A money amount as a decimal string with at least two places: "0.00",
/// "207.68", "1000.00". Padding only, never rounding: an exact amount with
/// more places (an FX-converted line) keeps them. Never "-0".
pub fn money(n: Decimal) -> String {
let mut n = if n.is_zero() { Decimal::ZERO } else { n };
if n.scale() < 2 {
n.rescale(2);
}
n.to_string()
}
/// A member's share as the K-1 worksheet prints it: at least two places,
/// however it was written ("50%", "0.5" and "0.500" all print "0.50"; a
/// share of a third keeps its places). The printed text depends on the
/// share, never on its spelling.
pub fn share_text(n: Decimal) -> String {
let mut n = n.normalize();
if n.scale() < 2 {
n.rescale(2);
}
n.to_string()
}
/// A CSV field: quoted, with its quotes doubled, when it holds a comma, a
/// quote, or a line feed; else as it is.
pub fn csv_escape(s: &str) -> String {
if s.contains([',', '"', '\n']) {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
/// A date: exactly YYYY-MM-DD, a real day in the calendar the books keep
/// (0001-01-01 through 9999-12-31, `ledger_core::calendar`). No sign, no
/// space, no five-digit year: every date past here is one date arithmetic
/// reaches (P0 review round 3, S1).
pub fn parse_date(s: &str) -> Result<NaiveDate, String> {
let shaped = s.len() == 10
&& s.bytes().enumerate().all(|(i, b)| {
if i == 4 || i == 7 {
b == b'-'
} else {
b.is_ascii_digit()
}
});
shaped
.then(|| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok())
.flatten()
.filter(|d| ledger_core::calendar::in_calendar(*d))
.ok_or_else(|| {
format!("bad date {s:?}: expected YYYY-MM-DD, a real day from 0001-01-01 to 9999-12-31")
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
#[test]
fn money_always_has_cents_and_never_rounds() {
let m = |s: &str| money(Decimal::from_str(s).unwrap());
assert_eq!(money(Decimal::ZERO), "0.00");
assert_eq!(m("-0"), "0.00");
assert_eq!(m("1000"), "1000.00");
assert_eq!(m("5.8"), "5.80");
assert_eq!(m("-207.68"), "-207.68");
assert_eq!(m("12.3456"), "12.3456");
}
/// A share prints by its value, never its spelling: at least two
/// places, a third keeping its own.
#[test]
fn a_share_prints_the_same_however_it_was_written() {
let t = |s: &str| share_text(Decimal::from_str(s).unwrap());
for half in ["0.5", "0.50", "0.500000"] {
assert_eq!(t(half), "0.50", "{half}");
}
assert_eq!(t("1"), "1.00");
assert_eq!(t("1.0"), "1.00");
assert_eq!(t("0.25"), "0.25");
assert_eq!(t("0.3333"), "0.3333");
assert_eq!(t("0.125"), "0.125");
}
#[test]
fn csv_fields_are_quoted_only_when_they_must_be() {
assert_eq!(csv_escape("plain"), "plain");
assert_eq!(csv_escape("a,b"), "\"a,b\"");
assert_eq!(csv_escape("say \"hi\""), "\"say \"\"hi\"\"\"");
assert_eq!(csv_escape("two\nlines"), "\"two\nlines\"");
assert_eq!(csv_escape(""), "");
}
/// A date is exactly YYYY-MM-DD, a real day from 0001-01-01 to
/// 9999-12-31 (ledger_core::calendar): no sign, no space, no fifth
/// year digit, no year 0 (P0 review round 3, S1).
#[test]
fn dates_are_four_digit_years_and_real_days() {
assert_eq!(parse_date("2026-02-28"), Ok(d(2026, 2, 28)));
assert_eq!(parse_date("2028-02-29"), Ok(d(2028, 2, 29)));
assert_eq!(parse_date("0001-01-01"), Ok(d(1, 1, 1)));
assert_eq!(parse_date("9999-12-31"), Ok(d(9999, 12, 31)));
for bad in [
"0000-01-01",
"0000-00-00",
"2026-02-29",
"2026-02-30",
"2026-13-01",
"2026-1-05",
"2026-01-5",
" 2026-01-05",
"2026-01-05 ",
"+2026-01-05",
"-0001-01-01",
"+262142-12-31",
"-262143-01-01",
"99999-12-31",
"2026/01/05",
"2026-01-05T00:00",
"2026-01-05",
"",
] {
let err = parse_date(bad).expect_err(bad);
assert!(err.contains("0001-01-01 to 9999-12-31"), "{bad:?}: {err}");
}
}
}
+100
View File
@@ -0,0 +1,100 @@
//! Full TINs for the CPA's worksheets: where they come from, and the check
//! that the vault's TIN is the one the books record.
//!
//! ```text
//! GOAL: a worksheet for the CPA carries a TIN only when the vault and the
//! books agree on it, and says why when it can't.
//! SOURCE: DECISION (owner, 2026-10-15): the vault holds every member's
//! and payee's full TIN for the CPA package; docs/parties-and-
//! attachments.md "The TIN vault".
//! INVARIANTS:
//! 1. The party's TIN facts, when on file, have the vault TIN's type and
//! last four, and the record's hash when they name one.
//! 2. Its W-9 facts, when on file, have the vault TIN's last four; with
//! no TIN facts, the W-9 facts' vault record is the record's hash.
//! 3. A vault that can't be opened or read, or holds no TIN for the
//! party, is a note, never an error (a package never fails on it).
//! ```
use ledger_core::{Ledger, PartyId};
/// Where a worksheet's full TINs come from: a party's TIN, else why not
/// (a note for the worksheet, never holding more than a last four). The
/// server and the viewer give `|id| vault_tin(ledger, &vault, id)`.
pub type TinSource<'a> = &'a dyn Fn(PartyId) -> Result<ledger_vault::Tin, String>;
/// A TIN type's wire name.
pub fn tin_type_key(t: ledger_vault::TinType) -> &'static str {
match t {
ledger_vault::TinType::Ssn => "ssn",
ledger_vault::TinType::Ein => "ein",
ledger_vault::TinType::Itin => "itin",
}
}
/// The books' name for a vault TIN's type.
pub fn tin_kind(t: ledger_vault::TinType) -> ledger_core::TinType {
match t {
ledger_vault::TinType::Ssn => ledger_core::TinType::Ssn,
ledger_vault::TinType::Ein => ledger_core::TinType::Ein,
ledger_vault::TinType::Itin => ledger_core::TinType::Itin,
}
}
/// The full TIN the vault holds for a party, checked against what the
/// books record of it; else why not, in words for a worksheet's note (the
/// note never holds more than a last four). `vault` is the vault as
/// opened, or why it couldn't be (INVARIANT 3).
pub fn vault_tin(
ledger: &Ledger,
vault: &Result<ledger_vault::Vault, String>,
party: PartyId,
) -> Result<ledger_vault::Tin, String> {
let vault = vault.as_ref().map_err(String::clone)?;
let t = vault
.tin(party)
.map_err(|e| e.to_string())?
.ok_or_else(|| {
"TIN not in the vault: record it with books.set_tin (or books.set_w9 tin, from the W-9)"
.to_string()
})?;
let hash = vault.record_hash(party).ok().flatten();
let p = ledger.get_party(party);
let (facts, w9) = (
p.and_then(|p| p.tin.as_ref()),
p.and_then(|p| p.w9.as_ref()),
);
if facts.is_none() && w9.is_none() {
return Err(
"the vault holds a TIN but the books have no record of it: record it again with books.set_tin"
.into(),
);
}
if let Some(f) = facts {
if f.last4 != t.last4() || f.tin_type != tin_kind(t.kind) {
return Err(format!(
"the vault's TIN ({}, ending {}) isn't the one the books record ({}, ending {}): record the right one with books.set_tin",
tin_type_key(t.kind),
t.last4(),
f.tin_type.key(),
f.last4
));
}
if f.vault_record.is_some() && f.vault_record != hash {
return Err("the vault record isn't the one the books recorded: record the TIN again with books.set_tin".into());
}
}
if let Some(f) = w9 {
if f.tin_last4 != t.last4() {
return Err(format!(
"the vault's TIN ends in {}, the W-9 facts say {}: check the W-9 and fix with books.set_w9",
t.last4(),
f.tin_last4
));
}
if facts.is_none() && f.vault_record.is_some() && f.vault_record != hash {
return Err("the vault record isn't the one the books recorded: check the W-9 and record it again with books.set_w9 tin".into());
}
}
Ok(t)
}
+250
View File
@@ -0,0 +1,250 @@
//! The 1099 worksheet: what `books.vendor_1099` answers and hands over as
//! CSV, and what the viewer saves, with each payee's TIN when asked.
//!
//! ```text
//! GOAL: the CPA gets one 1099 worksheet, the same from the server and
//! the viewer: cash paid per vendor, who is reportable, who lacks a
//! W-9, and each payee's TIN.
//! SOURCE: ledger_core::form1099 (payments out of the cash and financing
//! accounts; an entry's shares resolved with the members); IRS
//! thresholds (rules.md B-19: never guessed); RFC 4180 (CSV).
//! INVARIANTS:
//! 1. Payments come from the settings' cash and financing accounts
//! (codes the chart lacks are skipped), an entry's shares resolved
//! with the company's members.
//! 2. A year whose threshold isn't known is refused, never guessed.
//! 3. Full TINs only from the TIN source given (crate INVARIANT 2): with
//! one, every line carries its TIN or a note why not; without one,
//! none does.
//! 4. The CSV has a header and one row per line, in the worksheet's
//! order; the TIN columns only when asked.
//! ```
use ledger_core::{EntryId, Ledger, PartyId, VendorId};
use rust_decimal::Decimal;
use crate::reports::Settings;
use crate::text::{csv_escape, money};
use crate::tins::{TinSource, tin_type_key};
/// Where payments to vendors come from, for the 1099 worksheet: the
/// settings' cash and financing accounts in the chart (codes the chart
/// lacks are skipped; `books.settings` reports them).
pub fn payment_accounts(ledger: &Ledger, settings: &Settings<'_>) -> ledger_core::PaymentAccounts {
let ids = |codes: &[String]| {
codes
.iter()
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
.collect()
};
ledger_core::PaymentAccounts {
cash: ids(settings.cash_accounts),
financing: ids(settings.financing_accounts),
}
}
/// The 1099 report for `year`, the one every tool, the worksheet, and the
/// CPA package use: payments out of [`payment_accounts`], an entry's
/// shares resolved with the settings' members.
pub fn vendor_1099_report(
ledger: &Ledger,
settings: &Settings<'_>,
year: i32,
threshold: Option<Decimal>,
) -> Result<ledger_core::Vendor1099Report, ledger_core::LedgerError> {
ledger_core::vendor_1099_report_with_members(
ledger,
year,
threshold,
&payment_accounts(ledger, settings),
&settings.company.party_members(ledger),
)
}
/// A 1099 form's label ("1099-NEC", "1099-MISC"), if the vendor is marked.
pub fn form_1099_label(form: Option<ledger_core::Form1099>) -> Option<String> {
form.map(|f| f.label().to_string())
}
/// One vendor's line of the 1099 worksheet.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Vendor1099WorksheetLine {
/// The vendor.
pub vendor_id: VendorId,
/// Its name.
pub name: String,
/// "1099-NEC", "1099-MISC", or none.
pub form_1099: Option<String>,
/// W-9 on file.
pub tin_on_file: bool,
/// Cash paid in the year.
pub paid: Decimal,
/// Marked for a 1099 and paid at least the threshold.
pub reportable: bool,
/// The vendor's party.
pub party_id: Option<PartyId>,
/// The TIN's last four, from the party's W-9 facts, else its TIN
/// facts.
pub tin_last4: Option<String>,
/// The full TIN, formatted (123-45-6789, 12-3456789): only with a TIN
/// source, and only when it has one.
pub tin: Option<String>,
/// The TIN's type (ssn, ein, itin): only with a TIN source.
pub tin_type: Option<String>,
/// With a TIN source, why `tin` is missing.
pub tin_note: Option<String>,
}
/// The 1099 worksheet for a calendar year.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Vendor1099Worksheet {
/// Calendar year.
pub year: i32,
/// The threshold applied.
pub threshold: Decimal,
/// One line per vendor marked for a 1099 or paid in the year, by name.
pub lines: Vec<Vendor1099WorksheetLine>,
/// Reportable vendors with no W-9 on file.
pub missing_w9: Vec<String>,
/// Entries naming several parties whose shares don't resolve (each
/// counted in full toward its vendor).
pub unresolved: Vec<EntryId>,
/// What to check: what counts, exemptions, the threshold's indexing,
/// untagged entries, unresolved shares.
pub notes: Vec<String>,
/// Whether the lines carry TINs from a TIN source.
pub tins_included: bool,
}
/// The 1099 worksheet for `year` (threshold: the IRS figure to use when
/// the year's isn't known, else none), each line with its full TIN from
/// `tins` when given (INVARIANT 3).
pub fn vendor_1099_worksheet(
ledger: &Ledger,
settings: &Settings<'_>,
year: i32,
threshold: Option<Decimal>,
tins: Option<TinSource<'_>>,
) -> Result<Vendor1099Worksheet, String> {
// A year past what wanderbooks knows is refused with guidance
// (research or ask, pass `threshold`, then update the rule).
let r = vendor_1099_report(ledger, settings, year, threshold).map_err(|e| e.to_string())?;
let missing_w9 = r
.vendors
.iter()
.filter(|v| v.reportable && !v.tin_on_file)
.map(|v| v.name.clone())
.collect();
let untagged = ledger
.posted_entries()
.filter(|e| {
chrono::Datelike::year(&e.date()) == year && ledger.entry_vendor(e.id()).is_none()
})
.count();
let mut notes = vec![
"Only entries tagged with a vendor count (books.set_entry_vendor; books.pay_bill tags automatically).".to_string(),
"Corporations are generally exempt, except attorneys' fees and medical payments; royalties report at $10. Confirm with the CPA.".to_string(),
];
if year > 2026 {
notes.push(format!(
"The $2,000 threshold is indexed for inflation after 2026; confirm the {year} amount and pass threshold to override."
));
}
if untagged > 0 {
notes.push(format!(
"{untagged} posted entries in {year} have no vendor tag; review any that paid contractors or rent."
));
}
if !r.unresolved.is_empty() {
notes.push(format!(
"Entries {} name several parties whose shares don't resolve, so each counts in full toward its vendor: fix the shares with books.set_entry_party.",
r.unresolved
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(", ")
));
}
let mut lines = Vec::new();
for v in r.vendors {
let party_id = ledger.party_of_vendor(v.vendor_id);
let tin_last4 = party_id.and_then(|id| ledger.get_party(id)).and_then(|p| {
p.w9.as_ref()
.map(|f| f.tin_last4.clone())
.or_else(|| p.tin.as_ref().map(|t| t.last4.clone()))
});
let mut line = Vendor1099WorksheetLine {
vendor_id: v.vendor_id,
name: v.name,
form_1099: form_1099_label(v.form),
tin_on_file: v.tin_on_file,
paid: v.paid,
reportable: v.reportable,
party_id,
tin_last4,
tin: None,
tin_type: None,
tin_note: None,
};
if let Some(tins) = tins {
match party_id.map(tins) {
None => line.tin_note = Some("no party for this vendor".into()),
Some(Err(why)) => line.tin_note = Some(why),
Some(Ok(t)) => {
line.tin = Some(t.formatted());
line.tin_type = Some(tin_type_key(t.kind).to_string());
}
}
}
lines.push(line);
}
Ok(Vendor1099Worksheet {
year: r.year,
threshold: r.threshold,
lines,
missing_w9,
unresolved: r.unresolved,
notes,
tins_included: tins.is_some(),
})
}
/// The worksheet as CSV (INVARIANT 4): vendor id, name, form, paid,
/// reportable, W-9 on file, and last four; `with_tins` adds the TIN, its
/// type, and the note why it's missing.
pub fn vendor_1099_csv(worksheet: &Vendor1099Worksheet, with_tins: bool) -> String {
let mut csv = String::from("vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4");
if with_tins {
csv.push_str(",tin,tin_type,tin_note");
}
csv.push('\n');
for l in &worksheet.lines {
let mut cells = vec![
l.vendor_id.to_string(),
csv_escape(&l.name),
l.form_1099.clone().unwrap_or_default(),
money(l.paid),
l.reportable.to_string(),
l.tin_on_file.to_string(),
l.tin_last4.clone().unwrap_or_default(),
];
if with_tins {
cells.push(l.tin.clone().unwrap_or_default());
cells.push(l.tin_type.clone().unwrap_or_default());
cells.push(csv_escape(l.tin_note.as_deref().unwrap_or_default()));
}
csv.push_str(&cells.join(","));
csv.push('\n');
}
csv
}
/// The worksheet CSV's file name: `1099-worksheet-<year>.csv`, or
/// `1099-worksheet-<year>-with-tins.csv`.
pub fn vendor_1099_csv_name(year: i32, with_tins: bool) -> String {
format!(
"1099-worksheet-{}{}.csv",
year,
if with_tins { "-with-tins" } else { "" }
)
}
+799
View File
@@ -0,0 +1,799 @@
//! The CPA package and the 1099 worksheet, built over a small set of books
//! through both views of them (`Store`, `Snapshot`): the files, their
//! order, their contents, and the TINs, with a TIN source and without.
use chrono::NaiveDate;
use ledger_core::settings::{Company, Member};
use ledger_core::{AccountType, Currency, Form1099, Money, PartyId, Role, Side, TinFacts, TinType};
use ledger_package::{
Package, PackageBooks, PackageRequest, Settings, build_package, vendor_1099_csv,
vendor_1099_csv_name, vendor_1099_worksheet,
};
use ledger_store::{Snapshot, Store, Subject};
use rust_decimal::Decimal;
use std::io::Read;
use std::path::Path;
use std::str::FromStr;
const MEMBER_SSN: &str = "241-40-4915";
const EIN: &str = "12-3279105";
fn d(s: &str) -> NaiveDate {
NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
}
fn usd(s: &str) -> Money {
Money::new(Decimal::from_str(s).unwrap(), Currency::USD)
}
struct Books {
store: Store,
rent: u64,
sale: u64,
contribution: u64,
acme: PartyId,
tobin: PartyId,
mo: PartyId,
vendor: u64,
}
fn id_of(s: &Store, code: &str) -> ledger_core::AccountId {
s.ledger().chart().find_by_code(code).unwrap().id
}
fn post(s: &mut Store, date: &str, desc: &str, lines: &[(&str, Side, &str)]) -> u64 {
let e = s.create_entry(d(date), desc).unwrap();
for (code, side, a) in lines {
let id = id_of(s, code);
s.add_line(e, id, *side, usd(a)).unwrap();
}
s.post_entry(e).unwrap();
e
}
/// Two members (owner, with TIN facts; Mo, without), a contractor paid
/// past 2026's threshold, a receipt filed on the rent and one linked to
/// the sale, a company document, a loose receipt, and a withdrawn file.
fn books(dir: &Path) -> Books {
let mut s = Store::create(&dir.join("books-data"), Currency::USD).unwrap();
for (code, name, ty) in [
("1000", "Checking", AccountType::Asset),
("3000", "Capital, Tobin", AccountType::Equity),
("3001", "Capital, Mo", AccountType::Equity),
("4000", "Sales", AccountType::Revenue),
("6000", "Rent", AccountType::Expense),
("6100", "Contract labor", AccountType::Expense),
] {
s.add_account(code, name, ty, None, false).unwrap();
}
let tobin = s.add_party("Tobin Example", &[Role::Member]).unwrap();
let mo = s.add_party("Mo Example", &[Role::Member]).unwrap();
s.set_party_tin(
tobin,
Some(TinFacts {
tin_type: TinType::Ssn,
last4: "4915".into(),
vault_record: None,
purged: None,
}),
)
.unwrap();
let vendor = s.add_vendor("Acme, \"Design\"").unwrap();
let acme = s.ledger().party_of_vendor(vendor).unwrap();
s.set_vendor_1099(vendor, Some(Form1099::Nec), true)
.unwrap();
s.set_party_tin(
acme,
Some(TinFacts {
tin_type: TinType::Ein,
last4: "9105".into(),
vault_record: None,
purged: None,
}),
)
.unwrap();
let contribution = post(
&mut s,
"2026-01-02",
"Members' contributions",
&[
("1000", Side::Debit, "10000.00"),
("3000", Side::Credit, "5000.00"),
("3001", Side::Credit, "5000.00"),
],
);
let sale = post(
&mut s,
"2026-02-01",
"Sale",
&[
("1000", Side::Debit, "4000"),
("4000", Side::Credit, "4000"),
],
);
let rent = post(
&mut s,
"2026-03-01",
"Rent, \"March\"",
&[
("6000", Side::Debit, "1200"),
("1000", Side::Credit, "1200"),
],
);
let e = s.create_entry(d("2026-04-01"), "Acme design work").unwrap();
s.add_line(e, id_of(&s, "6100"), Side::Debit, usd("2500"))
.unwrap();
s.add_line(e, id_of(&s, "1000"), Side::Credit, usd("2500"))
.unwrap();
s.set_entry_vendor(e, Some(vendor)).unwrap();
s.post_entry(e).unwrap();
// After as_of: not in the package.
post(
&mut s,
"2027-01-05",
"Next year's rent",
&[
("6000", Side::Debit, "1200"),
("1000", Side::Credit, "1200"),
],
);
s.attach_receipt(
Some(rent),
"rent/march.pdf",
"application/pdf",
b"%PDF rent",
None,
)
.unwrap();
let (linked, _, _) = s
.attach_receipt(None, "invoice.pdf", "application/pdf", b"%PDF sale", None)
.unwrap();
s.link_attachment(linked, Subject::Entry(sale)).unwrap();
s.file_document(
"legal",
"operating agreement.pdf",
"application/pdf",
b"%PDF agreement",
None,
)
.unwrap();
s.attach_receipt(None, "loose.pdf", "application/pdf", b"%PDF loose", None)
.unwrap();
let (gone, _, _) = s
.file_document("tax", "mistake.pdf", "application/pdf", b"%PDF oops", None)
.unwrap();
s.withdraw_attachment(gone, "filed by mistake").unwrap();
Books {
store: s,
rent,
sale,
contribution,
acme,
tobin,
mo,
vendor,
}
}
fn company(members: bool) -> Company {
let member = |name: &str, capital: &str| Member {
name: name.into(),
share: Decimal::from_str("0.5").unwrap(),
capital_account: capital.into(),
distribution_account: None,
files_jointly_with: None,
};
Company {
name: "Example Labs LLC".into(),
basis: "Cash".into(),
members: if members {
// Spouses: each names the other (settings require both).
let mut tobin = member("Tobin Example", "3000");
tobin.files_jointly_with = Some("Mo Example".into());
let mut mo = member("Mo Example", "3001");
mo.files_jointly_with = Some("Tobin Example".into());
vec![tobin, mo]
} else {
Vec::new()
},
..Company::default()
}
}
fn cash() -> Vec<String> {
vec!["1000".to_string()]
}
fn settings<'a>(company: &'a Company, cash: &'a [String]) -> Settings<'a> {
Settings {
company,
cash_accounts: cash,
financing_accounts: &[],
investing_accounts: &[],
}
}
fn request<'a>(
include_receipts: bool,
tins: Option<ledger_package::TinSource<'a>>,
) -> PackageRequest<'a> {
PackageRequest {
as_of: d("2026-12-31"),
prepared_on: d("2027-01-15"),
cash_accounts: &[],
financing_accounts: &[],
investing_accounts: &[],
include_receipts,
tins,
}
}
fn names(p: &Package) -> Vec<&str> {
p.files.iter().map(|(n, _)| n.as_str()).collect()
}
fn text<'p>(p: &'p Package, name: &str) -> &'p str {
let (_, bytes) = p
.files
.iter()
.find(|(n, _)| n == name)
.unwrap_or_else(|| panic!("{name} in {:?}", names(p)));
std::str::from_utf8(bytes).unwrap()
}
/// `files` with each XLSX as its parts, its creation stamp
/// (docProps/core.xml's dcterms:created and dcterms:modified, the clock's
/// time when it is written) blanked: the only bytes of a package that
/// depend on when it is built.
fn stable(files: &[(String, Vec<u8>)]) -> Vec<(String, Vec<u8>)> {
files
.iter()
.map(|(name, bytes)| {
if !name.ends_with(".xlsx") {
return (name.clone(), bytes.clone());
}
let mut x = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut out = Vec::new();
for i in 0..x.len() {
let mut part = x.by_index(i).unwrap();
let part_name = part.name().unwrap().to_string();
let mut b = Vec::new();
part.read_to_end(&mut b).unwrap();
if part_name == "docProps/core.xml" {
let mut t = String::from_utf8(b).unwrap();
for tag in ["dcterms:created", "dcterms:modified"] {
let open = t.find(&format!("<{tag}")).unwrap();
let start = open + t[open..].find('>').unwrap() + 1;
let end = start + t[start..].find(&format!("</{tag}>")).unwrap();
t.replace_range(start..end, "STAMP");
}
b = t.into_bytes();
}
out.extend_from_slice(part_name.as_bytes());
out.push(0);
out.extend_from_slice(&b);
out.push(0);
}
(name.clone(), out)
})
.collect()
}
/// The zip holds exactly the files, in order, byte for byte.
fn assert_zip_is_the_files(p: &Package) {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&p.zip)).unwrap();
assert_eq!(z.len(), p.files.len());
for (i, (name, bytes)) in p.files.iter().enumerate() {
let mut f = z.by_index(i).unwrap();
assert_eq!(f.name().unwrap(), name.as_str());
let mut got = Vec::new();
f.read_to_end(&mut got).unwrap();
assert_eq!(&got, bytes, "{name}");
}
}
#[test]
fn a_single_member_package_holds_the_documented_files_in_order() {
let dir = tempfile::tempdir().unwrap();
let b = books(dir.path());
let company = company(false);
let cash = cash();
let p = build_package(&b.store, &settings(&company, &cash), &request(true, None)).unwrap();
assert_eq!(p.file_name, "cpa-package-2026-12-31.zip");
let sale_date = "2026-02-01";
let want = vec![
"README.txt".to_string(),
"profit-and-loss.pdf".into(),
"profit-and-loss.xlsx".into(),
"balance-sheet.pdf".into(),
"balance-sheet.xlsx".into(),
"cash-flow.pdf".into(),
"cash-flow.xlsx".into(),
"tax-summary.pdf".into(),
"tax-summary.xlsx".into(),
"trial-balance.pdf".into(),
"trial-balance.xlsx".into(),
"general-ledger.xlsx".into(),
"general-journal.xlsx".into(),
"1099-worksheet-2026.csv".into(),
// Attachment order: the rent's receipt (its name cleaned), the
// sale's linked one, the company's document; never the loose
// receipt or the withdrawn file.
format!("receipts/2026-03-01-entry-{}-rent_march.pdf", b.rent),
format!("receipts/{sale_date}-entry-{}-invoice.pdf", b.sale),
"documents/legal/operating agreement.pdf".into(),
"receipts/index.csv".into(),
"general-journal.iif".into(),
];
assert_eq!(names(&p), want);
assert_eq!(p.receipt_count, 2);
assert!(p.tin_gaps.is_empty());
for (name, bytes) in &p.files {
if name.ends_with(".pdf") && !name.contains('/') {
assert!(bytes.starts_with(b"%PDF-"), "{name}");
}
if name.ends_with(".xlsx") {
assert!(bytes.starts_with(b"PK"), "{name}");
}
}
let rent_sha = ledger_store::journal::sha256_hex(b"%PDF rent");
let sale_sha = ledger_store::journal::sha256_hex(b"%PDF sale");
assert_eq!(
text(&p, "receipts/index.csv"),
format!(
"entry_id,date,description,amount,file,sha256\n\
{rent},2026-03-01,\"Rent, \"\"March\"\"\",1200.00,receipts/2026-03-01-entry-{rent}-rent_march.pdf,{rent_sha}\n\
{sale},2026-02-01,\"Sale\",4000.00,receipts/2026-02-01-entry-{sale}-invoice.pdf,{sale_sha}\n",
rent = b.rent,
sale = b.sale,
)
);
assert_eq!(
text(&p, "1099-worksheet-2026.csv"),
"vendor,form,w9_on_file,paid,reportable\n\"Acme, \"\"Design\"\"\",1099-NEC,true,2500.00,true\n"
);
let readme = text(&p, "README.txt");
assert!(readme.starts_with("Example Labs LLC — books package for the CPA\n\nFiscal year to date: 2026-01-01 through 2026-12-31. Cash basis.\n"), "{readme}");
assert!(
readme.contains(&format!("({} events, verified)", b.store.journal_seq())),
"{readme}"
);
assert!(readme.contains("receipts/: the 2 receipts behind the period's entries"));
assert!(readme.contains("cash flow, tax summary, trial balance."));
assert!(!readme.contains("k1-members.csv") && !readme.contains("full taxpayer"));
assert!(!names(&p).contains(&"k1-members.csv"));
let iif = text(&p, "general-journal.iif");
assert!(iif.contains("Checking") && iif.contains("BANK"), "{iif}");
// Every posted entry, the year after as_of's included.
assert!(iif.contains("Next year's rent"), "{iif}");
assert_zip_is_the_files(&p);
}
#[test]
fn without_receipts_the_package_holds_no_evidence() {
let dir = tempfile::tempdir().unwrap();
let b = books(dir.path());
let company = company(false);
let cash = cash();
let with = build_package(&b.store, &settings(&company, &cash), &request(true, None)).unwrap();
let p = build_package(&b.store, &settings(&company, &cash), &request(false, None)).unwrap();
assert!(
!names(&p)
.iter()
.any(|n| n.starts_with("receipts/") || n.starts_with("documents/")),
"{:?}",
names(&p)
);
assert_eq!(p.receipt_count, 0);
// Everything else is the same, byte for byte, but the README's
// evidence lines.
let rest = |p: &Package| -> Vec<(String, Vec<u8>)> {
p.files
.iter()
.filter(|(n, _)| {
n != "README.txt" && !n.starts_with("receipts/") && !n.starts_with("documents/")
})
.cloned()
.collect()
};
assert_eq!(stable(&rest(&p)), stable(&rest(&with)));
let readme = text(&p, "README.txt");
assert!(!readme.contains("receipts/") && !readme.contains("documents/"));
assert_eq!(
text(&with, "README.txt").replace(
"receipts/: the 2 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",
""
),
readme
);
assert_zip_is_the_files(&p);
}
/// Tobin's and Acme's TINs from the source; Mo's isn't there.
fn tin_source(b: &Books) -> impl Fn(PartyId) -> Result<ledger_vault::Tin, String> + '_ {
move |id| {
if id == b.tobin {
Ok(ledger_vault::Tin::new(MEMBER_SSN, ledger_vault::TinType::Ssn).unwrap())
} else if id == b.acme {
Ok(ledger_vault::Tin::new(EIN, ledger_vault::TinType::Ein).unwrap())
} else {
Err("TIN not in the vault: record it with books.set_tin".into())
}
}
}
#[test]
fn members_get_the_k1_worksheet_with_full_tins_only_from_a_source() {
let dir = tempfile::tempdir().unwrap();
let b = books(dir.path());
let company = company(true);
let cash = cash();
let source = tin_source(&b);
let with = build_package(
&b.store,
&settings(&company, &cash),
&request(true, Some(&source)),
)
.unwrap();
let without =
build_package(&b.store, &settings(&company, &cash), &request(true, None)).unwrap();
for p in [&with, &without] {
let n = names(p);
assert!(n.contains(&"members-equity.pdf") && n.contains(&"members-equity.xlsx"));
// The K-1 worksheet comes after the evidence, before the IIF.
assert_eq!(&n[n.len() - 2..], ["k1-members.csv", "general-journal.iif"]);
assert_zip_is_the_files(p);
}
// Capital: 5000 in each; income 4000 - 1200 - 2500 = 300, half each.
assert_eq!(
text(&with, "k1-members.csv"),
format!(
"member,party_id,share,capital_account,tin,tin_type,tin_note,beginning_capital,contributions_cash,contributions_property,net_income,distributions,other,ending_capital\n\
Tobin Example,{s},0.50,3000,{MEMBER_SSN},ssn,,0.00,5000.00,0.00,150.00,0.00,0.00,5150.00\n\
Mo Example,{k},0.50,3001,,,TIN not in the vault: record it with books.set_tin,0.00,5000.00,0.00,150.00,0.00,0.00,5150.00\n\
\"Total, all members (not a K-1)\",,1.00,,,,,0.00,10000.00,0.00,300.00,0.00,0.00,10300.00\n",
s = b.tobin,
k = b.mo,
)
);
assert_eq!(
text(&without, "k1-members.csv"),
format!(
"member,party_id,share,capital_account,tin_last4,tin_type,beginning_capital,contributions_cash,contributions_property,net_income,distributions,other,ending_capital\n\
Tobin Example,{s},0.50,3000,4915,ssn,0.00,5000.00,0.00,150.00,0.00,0.00,5150.00\n\
Mo Example,{k},0.50,3001,,,0.00,5000.00,0.00,150.00,0.00,0.00,5150.00\n\
\"Total, all members (not a K-1)\",,1.00,,,,0.00,10000.00,0.00,300.00,0.00,0.00,10300.00\n",
s = b.tobin,
k = b.mo,
)
);
assert_eq!(
text(&with, "1099-worksheet-2026.csv"),
format!(
"vendor,form,w9_on_file,paid,reportable,party_id,tin_last4,tin,tin_type,tin_note\n\
\"Acme, \"\"Design\"\"\",1099-NEC,true,2500.00,true,{},9105,{EIN},ein,\n",
b.acme
)
);
assert!(!text(&without, "1099-worksheet-2026.csv").contains(EIN));
// The gap: Mo's, in tin_gaps and the README.
assert_eq!(
with.tin_gaps,
["member Mo Example: TIN not in the vault: record it with books.set_tin"]
);
assert!(without.tin_gaps.is_empty());
let readme = text(&with, "README.txt");
assert!(readme.contains("cash flow, members' equity, tax summary"));
assert!(readme.contains(", with each payee's TIN.\n"));
assert!(readme.contains("with their taxpayer ID; the last row totals"));
// Said once for the couple.
assert_eq!(
readme
.matches("file a joint individual return: each receives their own Schedule K-1; both are reported on the one Form 1040 (Schedule E, Part II).\n")
.count(),
1
);
assert!(readme.contains("\nTobin Example and Mo Example file a joint individual return"));
assert!(readme.contains(
"\nThis package holds full taxpayer identification numbers: keep it out of email and shared folders, and delete copies once filed.\nTINs not included:\n- member Mo Example: TIN not in the vault: record it with books.set_tin\n"
));
let plain = text(&without, "README.txt");
assert!(plain.contains("with their taxpayer ID's last four;"));
assert!(!plain.contains("full taxpayer") && !plain.contains("payee's TIN"));
for (_, bytes) in &without.files {
let s = String::from_utf8_lossy(bytes);
assert!(!s.contains(MEMBER_SSN) && !s.contains(EIN));
}
let _ = b.contribution;
}
#[test]
fn a_store_and_a_snapshot_of_the_same_books_build_the_same_package() {
let dir = tempfile::tempdir().unwrap();
let b = books(dir.path());
let snap = Snapshot::load(&dir.path().join("books-data")).unwrap();
assert_eq!(
PackageBooks::journal_seq(&snap),
PackageBooks::journal_seq(&b.store)
);
for a in b.store.list_attachments(None).unwrap() {
let mut from_store: Vec<Subject> = b.store.links_of(a.id).unwrap();
from_store.retain(|s| a.entry_id.map(Subject::Entry) != Some(*s));
assert_eq!(
snap.links_of(a.id).unwrap(),
from_store,
"attachment {}",
a.id
);
}
let company = company(true);
let cash = cash();
let source = tin_source(&b);
for tins in [None, Some(&source as ledger_package::TinSource<'_>)] {
for receipts in [true, false] {
let s = settings(&company, &cash);
let from_store = build_package(&b.store, &s, &request(receipts, tins)).unwrap();
let from_snap = build_package(&snap, &s, &request(receipts, tins)).unwrap();
assert_eq!(stable(&from_store.files), stable(&from_snap.files));
assert_eq!(from_store.tin_gaps, from_snap.tin_gaps);
assert_eq!(from_store.receipt_count, from_snap.receipt_count);
}
}
}
#[test]
fn a_package_needs_a_cash_account() {
let dir = tempfile::tempdir().unwrap();
let b = books(dir.path());
let company = company(false);
let err = build_package(&b.store, &settings(&company, &[]), &request(true, None)).unwrap_err();
assert_eq!(
err,
"cash_accounts is required for the cash flow statement (pass it, or set the books' cash accounts setting: books.settings shows it)"
);
// A request's own accounts override the settings'.
let mut req = request(true, None);
let bad = vec!["9999".to_string()];
req.cash_accounts = &bad;
let err = build_package(&b.store, &settings(&company, &cash()), &req).unwrap_err();
assert_eq!(err, "unknown cash account code: 9999");
}
#[test]
fn the_1099_worksheet_carries_tins_only_from_a_source() {
let dir = tempfile::tempdir().unwrap();
let b = books(dir.path());
let company = company(false);
let cash = cash();
let s = settings(&company, &cash);
let ledger = b.store.ledger();
let plain = vendor_1099_worksheet(ledger, &s, 2026, None, None).unwrap();
assert_eq!(plain.year, 2026);
assert_eq!(plain.threshold, Decimal::from(2000));
assert!(!plain.tins_included);
assert_eq!(plain.lines.len(), 1);
let l = &plain.lines[0];
assert_eq!(l.vendor_id, b.vendor);
assert_eq!(l.party_id, Some(b.acme));
assert_eq!(l.paid, Decimal::from(2500));
assert!(l.reportable && l.tin_on_file);
assert_eq!(l.tin_last4.as_deref(), Some("9105"));
assert_eq!((&l.tin, &l.tin_type, &l.tin_note), (&None, &None, &None));
assert!(plain.missing_w9.is_empty());
// Five posted entries in 2026, one tagged.
assert_eq!(
plain.notes,
[
"Only entries tagged with a vendor count (books.set_entry_vendor; books.pay_bill tags automatically).",
"Corporations are generally exempt, except attorneys' fees and medical payments; royalties report at $10. Confirm with the CPA.",
"3 posted entries in 2026 have no vendor tag; review any that paid contractors or rent.",
]
);
assert_eq!(
vendor_1099_csv(&plain, false),
format!(
"vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4\n{},\"Acme, \"\"Design\"\"\",1099-NEC,2500.00,true,true,9105\n",
b.vendor
)
);
let source = tin_source(&b);
let full = vendor_1099_worksheet(ledger, &s, 2026, None, Some(&source)).unwrap();
assert!(full.tins_included);
assert_eq!(full.lines[0].tin.as_deref(), Some(EIN));
assert_eq!(full.lines[0].tin_type.as_deref(), Some("ein"));
assert_eq!(
vendor_1099_csv(&full, true),
format!(
"vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4,tin,tin_type,tin_note\n{},\"Acme, \"\"Design\"\"\",1099-NEC,2500.00,true,true,9105,{EIN},ein,\n",
b.vendor
)
);
// A source without the payee's TIN: the note says why.
let none = |_: PartyId| -> Result<ledger_vault::Tin, String> {
Err("the TIN vault is locked, \"for now\"".into())
};
let locked = vendor_1099_worksheet(ledger, &s, 2026, None, Some(&none)).unwrap();
assert_eq!(
vendor_1099_csv(&locked, true).lines().nth(1).unwrap(),
format!(
"{},\"Acme, \"\"Design\"\"\",1099-NEC,2500.00,true,true,9105,,,\"the TIN vault is locked, \"\"for now\"\"\"",
b.vendor
)
);
assert_eq!(vendor_1099_csv_name(2026, false), "1099-worksheet-2026.csv");
assert_eq!(
vendor_1099_csv_name(2026, true),
"1099-worksheet-2026-with-tins.csv"
);
// A year whose threshold isn't known is refused, never guessed; given
// one, it is used.
let far = vendor_1099_worksheet(ledger, &s, 2031, None, None);
assert!(far.is_err(), "{far:?}");
let given = vendor_1099_worksheet(ledger, &s, 2031, Some(Decimal::from(2100)), None).unwrap();
assert_eq!(given.threshold, Decimal::from(2100));
assert!(
given
.notes
.iter()
.any(|n| n.contains("indexed for inflation after 2026; confirm the 2031 amount"))
);
}
/// Small books with no members: a checking account and rent, one rent
/// payment, and whatever the test files.
fn plain_books(dir: &Path) -> (Store, u64) {
let mut s = Store::create(&dir.join("books-data"), Currency::USD).unwrap();
for (code, name, ty) in [
("1000", "Checking", AccountType::Asset),
("3000", "Capital", AccountType::Equity),
("6000", "Rent", AccountType::Expense),
] {
s.add_account(code, name, ty, None, false).unwrap();
}
let rent = post(
&mut s,
"2026-03-01",
"March rent",
&[
("6000", Side::Debit, "1200.00"),
("1000", Side::Credit, "1200.00"),
],
);
(s, rent)
}
#[test]
fn files_of_the_same_name_each_get_their_own_place() {
let dir = tempfile::tempdir().unwrap();
let (mut s, rent) = plain_books(dir.path());
// Two receipts named alike on one entry, the second in other case; two
// documents named alike in one category.
s.attach_receipt(
Some(rent),
"r.pdf",
"application/pdf",
b"%PDF-1.4 one",
None,
)
.unwrap();
s.attach_receipt(
Some(rent),
"R.pdf",
"application/pdf",
b"%PDF-1.4 two",
None,
)
.unwrap();
s.file_document(
"legal",
"deed.pdf",
"application/pdf",
b"%PDF-1.4 deed one",
None,
)
.unwrap();
s.file_document(
"legal",
"deed.pdf",
"application/pdf",
b"%PDF-1.4 deed two",
None,
)
.unwrap();
let company = company(false);
let cash = cash();
let p = build_package(&s, &settings(&company, &cash), &request(true, None)).unwrap();
let receipt = |suffix: &str| format!("receipts/2026-03-01-entry-{rent}-{suffix}");
let all = names(&p);
for name in [
receipt("r.pdf"),
receipt("R (2).pdf"),
"documents/legal/deed.pdf".to_string(),
"documents/legal/deed (2).pdf".to_string(),
] {
assert!(all.contains(&name.as_str()), "{name} in {all:?}");
}
assert_eq!(text(&p, &receipt("r.pdf")), "%PDF-1.4 one");
assert_eq!(text(&p, &receipt("R (2).pdf")), "%PDF-1.4 two");
assert_eq!(
text(&p, "documents/legal/deed (2).pdf"),
"%PDF-1.4 deed two"
);
// The index names each receipt as it is stored.
let index = text(&p, "receipts/index.csv");
assert!(
index.contains(&receipt("r.pdf")) && index.contains(&receipt("R (2).pdf")),
"{index}"
);
assert_eq!(p.receipt_count, 2);
assert_zip_is_the_files(&p);
// And the README says the documents are there.
assert!(text(&p, "README.txt").contains("documents/:"));
}
#[test]
fn the_readme_names_only_what_the_package_holds() {
let dir = tempfile::tempdir().unwrap();
let (mut s, rent) = plain_books(dir.path());
s.attach_receipt(
Some(rent),
"r.pdf",
"application/pdf",
b"%PDF-1.4 one",
None,
)
.unwrap();
let company = company(false);
let cash = cash();
let p = build_package(&s, &settings(&company, &cash), &request(true, None)).unwrap();
let readme = text(&p, "README.txt");
assert!(!readme.contains("documents/"), "no documents: {readme}");
assert!(readme.contains("1099-worksheet-2026.csv:"), "{readme}");
assert!(names(&p).contains(&"1099-worksheet-2026.csv"));
// A year whose 1099 threshold isn't known: the worksheet is left out,
// and the README says what is there instead.
let mut late = request(true, None);
late.as_of = d("2031-06-30");
let p = build_package(&s, &settings(&company, &cash), &late).unwrap();
let readme = text(&p, "README.txt");
assert!(
names(&p).contains(&"1099-worksheet-2031-NOT-INCLUDED.txt"),
"{:?}",
names(&p)
);
assert!(
readme.contains("1099-worksheet-2031-NOT-INCLUDED.txt:"),
"{readme}"
);
assert!(!readme.contains("1099-worksheet-2031.csv"), "{readme}");
}
#[test]
fn a_single_members_readme_promises_no_totals_row() {
let dir = tempfile::tempdir().unwrap();
let (s, _) = plain_books(dir.path());
let mut solo = company(false);
solo.members = vec![Member {
name: "Solo Owner".into(),
share: Decimal::ONE,
capital_account: "3000".into(),
distribution_account: None,
files_jointly_with: None,
}];
let cash = cash();
let p = build_package(&s, &settings(&solo, &cash), &request(false, None)).unwrap();
let readme = text(&p, "README.txt");
assert!(readme.contains("k1-members.csv:"), "{readme}");
assert!(!readme.contains("the last row totals"), "{readme}");
let k1 = text(&p, "k1-members.csv");
assert_eq!(k1.lines().count(), 2, "a header and the one member: {k1}");
// Two members: the totals row, and the README says so.
let two = company(true);
let b = books(dir.path().join("two").as_path());
let p = build_package(&b.store, &settings(&two, &cash), &request(false, None)).unwrap();
assert!(text(&p, "README.txt").contains("the last row totals"));
}