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:
1 parent
b26c099a68
commit
5bc0633cdd
77 files changed
+5833
-2427
No files matched your search
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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(¬e)
|
||||
));
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 { "" }
|
||||
)
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
Reference in new issue
Block a user