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,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 { "" }
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user