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

+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 { "" }
)
}