P3: dated 1099 decisions, payment methods and the card exception (defects 23, 12, 18, D-40)
check / check (push) Canceled after 0s

A vendor's 1099 decision is its own dated fact (files NEC or MISC, not
reportable with its reason, or undetermined), applied by each payment's
date. Payment methods are recorded; card and network payments are left
out and listed; a member's check through financing counts; a bank payment
with no method counts (D-40); $2,000 for payments after 2025. One
worksheet for the tool, the viewer and the CPA package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-21 12:12:26 -06:00
1 parent c2ceabbae9
commit 1304b9c0ad
77 files changed
+11455 -970

No files matched your search

+3 -2
View File
@@ -41,6 +41,7 @@ pub use reports::{
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,
VENDOR_1099_CSV_HEADER, Vendor1099Worksheet, Vendor1099WorksheetLine, form_1099_label,
payment_accounts, vendor_1099_csv, vendor_1099_csv_name, vendor_1099_report,
vendor_1099_worksheet,
};
+31 -76
View File
@@ -15,18 +15,20 @@
//! XLSX, for the fiscal year containing as_of, through as_of; the
//! fixed asset register when an asset was bought by as_of; the
//! general ledger and journal (XLSX); the journal through as_of as
//! IIF; the 1099 worksheet for as_of's calendar year through as_of
//! (or why it can't be made); with members, the K-1 worksheet; a
//! README first.
//! IIF; the 1099 worksheet for as_of's calendar year through as_of,
//! books.vendor_1099's for the package's cash and financing accounts
//! (INVARIANT 7), 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.
//! one. The 1099 worksheet is books.vendor_1099's CSV
//! (crate::vendor_1099_csv), each payee's TIN and type added 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).
//! a 1099 is or may be due for).
//! 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
@@ -62,7 +64,7 @@ use crate::reports::{
};
use crate::text::{csv_escape, money, share_text};
use crate::tins::{TinSource, tin_type_key};
use crate::vendor_1099::vendor_1099_report;
use crate::vendor_1099::{vendor_1099_csv, vendor_1099_csv_name, vendor_1099_worksheet};
/// What the package is built for.
#[derive(Clone, Copy)]
@@ -95,8 +97,8 @@ pub struct Package {
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).
/// With a TIN source: each member, or 1099 payee a 1099 is or may be
/// due for, 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,
@@ -138,16 +140,8 @@ pub fn build_package<B: PackageBooks + ?Sized>(
let pdf = |st: &ledger_import::Statement| statement_pdf(st, &meta).map_err(|e| e.to_string());
let members = report_members(company);
// One list of each per package (INVARIANT 7).
let cash_codes = if req.cash_accounts.is_empty() {
settings.cash_accounts
} else {
req.cash_accounts
};
let financing_codes = if req.financing_accounts.is_empty() {
settings.financing_accounts
} else {
req.financing_accounts
};
let payments = settings.with_accounts(req.cash_accounts, req.financing_accounts);
let (cash_codes, financing_codes) = (payments.cash_accounts, payments.financing_accounts);
// The financial statements, each as PDF and XLSX.
let mut statements: Vec<(&str, ledger_import::Statement)> = vec![
@@ -373,68 +367,29 @@ pub fn build_package<B: PackageBooks + ?Sized>(
));
// 1099 worksheet for the calendar year of as_of, through as_of
// (INVARIANT 6): the payments from January 1, out of the package's
// cash and financing accounts (INVARIANT 7).
// cash and financing accounts (INVARIANT 7), the same worksheet and
// CSV books.vendor_1099 and the viewer hand over for those accounts
// and that day (one builder, one figure).
let year = chrono::Datelike::year(&as_of);
let mut worksheet_1099 = false;
let payments = Settings {
cash_accounts: cash_codes,
financing_accounts: financing_codes,
..*settings
};
match vendor_1099_report(ledger, &payments, year, Some(as_of), 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.tin_last4())
.map(str::to_string);
let got = match party {
None => Err("TIN not in the vault: no party for this vendor".into()),
Some(id) => tins(id),
};
let (tin, kind, note) = match got {
Ok(t) => (
t.formatted(),
tin_type_key(t.kind).to_string(),
String::new(),
),
Err(why) => {
if v.reportable {
tin_gaps.push(format!("1099 payee {}: {why}", v.name));
}
(String::new(), String::new(), why)
}
};
w.push_str(&format!(
",{},{},{tin},{kind},{}",
party.map(|id| id.to_string()).unwrap_or_default(),
last4.unwrap_or_default(),
csv_escape(&note)
));
match vendor_1099_worksheet(ledger, &payments, year, None, Some(as_of), tins) {
Ok(w) => {
// A payee a 1099 is or may be due for, whose TIN the package
// lacks (INVARIANT 3).
for l in w.lines.iter().filter(|l| l.may_file()) {
if let Some(why) = &l.tin_note {
tin_gaps.push(format!("1099 payee {}: {why}", l.name));
}
w.push('\n');
}
files.push((format!("1099-worksheet-{year}.csv"), w.into_bytes()));
files.push((
vendor_1099_csv_name(year, false),
vendor_1099_csv(&w, tins.is_some()).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.
// The worksheet can't be made (the year's threshold isn't known,
// or the package names no cash accounts): never guess (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(),
@@ -611,7 +566,7 @@ pub fn build_package<B: PackageBooks + ?Sized>(
},
worksheet = if worksheet_1099 {
format!(
"1099-worksheet-{year}.csv: cash paid per vendor marked for a 1099{}, against the year's threshold{}.\n",
"1099-worksheet-{year}.csv: each vendor paid in {year}{} or decided for a 1099: what counts toward its 1099 against the year's threshold, what was paid by card or payment network (reported by the processor on Form 1099-K, Treas. Reg. §1.6041-1(a)(1)(iv)), what can't be classified until its payment method is recorded, and its 1099 decision with the reason, who made it, and when it took effect{}.\n",
if year_end {
String::new()
} else {
+23
View File
@@ -48,6 +48,29 @@ pub struct Settings<'a> {
pub investing_accounts: &'a [String],
}
impl<'a> Settings<'a> {
/// These settings with the cash and financing accounts a request
/// names: each list the request's when it gives one, else these
/// settings'. The one rule the CPA package (one list of each per
/// package, for every file in it: crate::package INVARIANT 7) and
/// `books.vendor_1099` read, so the package's 1099 worksheet is the
/// tool's for the same accounts.
pub fn with_accounts(
&self,
cash_accounts: &'a [String],
financing_accounts: &'a [String],
) -> Settings<'a> {
let or = |given: &'a [String], kept: &'a [String]| {
if given.is_empty() { kept } else { given }
};
Settings {
cash_accounts: or(cash_accounts, self.cash_accounts),
financing_accounts: or(financing_accounts, self.financing_accounts),
..*self
}
}
}
/// Resolve a statement's date range (INVARIANT 1): the dates as given,
/// each omitted one the books' default for `company` on `today`
/// (rules.md B-30).
+417 -62
View File
@@ -1,26 +1,36 @@
//! 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.
//! CSV, what the CPA package carries, and what the viewer saves and shows,
//! 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
//! GOAL: the CPA gets one 1099 worksheet, the same from the server, the
//! package and the viewer: per vendor what counts toward its 1099,
//! what a card or payment network paid, what is unknown, its 1099
//! decision with the reason and who made it, who lacks a W-9, and
//! each payee's TIN.
//! SOURCE: ledger_core::form1099 (the one report: payments out of the cash
//! and financing accounts, classified by payment method; an entry's
//! shares resolved with the members; dated decisions); 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.
//! 1. Payments come from the cash and financing accounts of the
//! `Settings` given (codes the chart lacks are skipped): the books'
//! settings, or the CPA package's and `books.vendor_1099`'s request
//! accounts (`Settings::with_accounts`), an entry's shares resolved
//! with the company's members; with no cash accounts named,
//! refused.
//! 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.
//! 5. The notes are quiet: the untagged entries are one count and a
//! tool hint, each list of payments one line.
//! ```
use ledger_core::{EntryId, Ledger, PartyId, VendorId};
use chrono::NaiveDate;
use ledger_core::{Decision1099, EntryId, Ledger, PartyId, Payment1099, Status1099, VendorId};
use rust_decimal::Decimal;
use crate::reports::Settings;
@@ -43,29 +53,29 @@ pub fn payment_accounts(ledger: &Ledger, settings: &Settings<'_>) -> ledger_core
}
}
/// 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. Given `through`, only the
/// payments from January 1 through it (the CPA package as of a day,
/// rules.md B-33).
/// The 1099 report for `year`, the one every tool, the worksheet, the
/// CPA package and the viewer use: payments out of [`payment_accounts`],
/// an entry's shares resolved with the settings' members. Given
/// `through`, only the payments from January 1 through it (the CPA
/// package as of a day, rules.md B-33).
pub fn vendor_1099_report(
ledger: &Ledger,
settings: &Settings<'_>,
year: i32,
through: Option<chrono::NaiveDate>,
threshold: Option<Decimal>,
through: Option<NaiveDate>,
) -> Result<ledger_core::Vendor1099Report, ledger_core::LedgerError> {
ledger_core::vendor_1099_report_with_members(
ledger_core::vendor_1099_report(
ledger,
year,
through,
threshold,
&payment_accounts(ledger, settings),
&settings.company.party_members(ledger),
through,
)
}
/// A 1099 form's label ("1099-NEC", "1099-MISC"), if the vendor is marked.
/// A 1099 form's label ("1099-NEC", "1099-MISC"), if the vendor files one.
pub fn form_1099_label(form: Option<ledger_core::Form1099>) -> Option<String> {
form.map(|f| f.label().to_string())
}
@@ -77,18 +87,41 @@ pub struct Vendor1099WorksheetLine {
pub vendor_id: VendorId,
/// Its name.
pub name: String,
/// "1099-NEC", "1099-MISC", or none.
/// "1099-NEC", "1099-MISC", or none: the form its year-end decision
/// files.
pub form_1099: Option<String>,
/// Its decision at the end of the year: "nec", "misc",
/// "not_reportable" or "undetermined".
pub decision_1099: String,
/// The decision itself, with its reason when not reportable.
pub decision: Decision1099,
/// Who decided it and when (none: never decided, or decided before
/// decisions were kept).
pub decided: Option<ledger_core::settings::Source>,
/// The date it took effect (none: from the beginning).
pub effective: Option<NaiveDate>,
/// Every decision in force during the year, in date order.
pub decisions: Vec<ledger_core::Dated1099>,
/// W-9 on file.
pub tin_on_file: bool,
/// Cash paid in the year.
/// What counts toward its 1099.
pub paid: Decimal,
/// Marked for a 1099 and paid at least the threshold.
/// Paid by card or payment network: reported on Form 1099-K.
pub excluded_card: Decimal,
/// Paid with no payment method recorded where it matters.
pub undetermined: Decimal,
/// Paid while it was decided not reportable.
pub not_reportable: Decimal,
/// A decision is needed for some of the year's payments.
pub decision_needed: bool,
/// Where it stands.
pub status: Status1099,
/// File a 1099 (status reportable).
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.
/// The TIN's last four ([`ledger_core::Party::tin_last4`]: its TIN
/// facts, else its W-9 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.
@@ -99,6 +132,17 @@ pub struct Vendor1099WorksheetLine {
pub tin_note: Option<String>,
}
impl Vendor1099WorksheetLine {
/// Whether a 1099 may be due: reportable, or undetermined while decided
/// for one some time in the year (P3 review R16: its W-9 and TIN
/// matter as much).
pub fn may_file(&self) -> bool {
self.reportable
|| (self.status == Status1099::Undetermined
&& self.decisions.iter().any(|d| d.decision.form().is_some()))
}
}
/// The 1099 worksheet for a calendar year.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Vendor1099Worksheet {
@@ -106,58 +150,145 @@ pub struct Vendor1099Worksheet {
pub year: i32,
/// The threshold applied.
pub threshold: Decimal,
/// One line per vendor marked for a 1099 or paid in the year, by name.
/// The last day counted, when it stops before December 31.
pub through: Option<NaiveDate>,
/// One line per vendor decided for a 1099 or paid in the year, by name.
pub lines: Vec<Vendor1099WorksheetLine>,
/// Reportable vendors with no W-9 on file.
/// Vendors a 1099 is or may be due for with no W-9 on file (" (may be
/// reportable)" after those undetermined).
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>,
/// Payments by card or payment network, excluded.
pub excluded_card_payments: Vec<Payment1099>,
/// Payments whose method must be recorded before they can be
/// classified.
pub undetermined_payments: Vec<Payment1099>,
/// Money received from a vendor for something else, not netted.
pub other_receipts: Vec<Payment1099>,
/// Posted entries that paid money out with no vendor tag.
pub untagged_payments: Vec<EntryId>,
/// What to check: what counts, exemptions, the threshold's indexing,
/// untagged entries, unresolved shares.
/// untagged entries, card payments, unknown methods, decisions needed,
/// 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, None, threshold).map_err(|e| e.to_string())?;
let missing_w9 = r
.vendors
/// "entry 4" or "entries 4, 9".
fn entries_text(ids: &[EntryId]) -> String {
let list = ids
.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();
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(", ");
if ids.len() == 1 {
format!("entry {list}")
} else {
format!("entries {list}")
}
}
/// "1 payment" or "3 payments", with their total.
fn payments_text(list: &[Payment1099]) -> String {
let total: Decimal = list.iter().map(|p| p.amount).sum();
let n = list.len();
format!(
"{n} payment{} totaling {}",
if n == 1 { "" } else { "s" },
money(total)
)
}
/// The worksheet's notes (INVARIANT 5).
fn notes_for(ledger: &Ledger, r: &ledger_core::Vendor1099Report) -> Vec<String> {
let year = r.year;
let name = |id: VendorId| {
ledger
.get_vendor(id)
.map_or_else(|| format!("vendor {id}"), |v| v.name.clone())
};
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(),
"Each vendor's 1099 decision says why when none is due (a corporation, the United States or a state: Treas. Reg. §1.6041-3); payments to a corporation for legal or medical services stay reportable, and royalties report at $10. Confirm with the CPA.".to_string(),
];
if let Some(t) = r.through {
notes.push(format!(
"Payments from {year}-01-01 through {t} only: later payments in {year} aren't counted."
));
}
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 {
if !r.excluded_card_payments.is_empty() {
notes.push(format!(
"{untagged} posted entries in {year} have no vendor tag; review any that paid contractors or rent."
"{} by card or payment network are excluded: the processor reports them on Form 1099-K, whoever's card it was (Treas. Reg. §1.6041-1(a)(1)(iv)).",
payments_text(&r.excluded_card_payments)
));
}
if !r.undetermined_payments.is_empty() {
let ids: Vec<EntryId> = r.undetermined_payments.iter().map(|p| p.entry_id).collect();
notes.push(format!(
"{} through a financing account (or a member's or a payment platform's) have no payment method recorded ({}), so whether they count is undetermined: a card or the platform's goods-and-services payment is excluded, cash, a check or a friends-and-family transfer counts. Record how each was paid with books.set_entry_instrument (books.instruments lists the methods).",
payments_text(&r.undetermined_payments),
entries_text(&ids)
));
}
let undecided: Vec<&str> = r
.vendors
.iter()
.filter(|v| v.decision_needed)
.map(|v| v.name.as_str())
.collect();
if !undecided.is_empty() {
notes.push(format!(
"No 1099 decision for payments that could count: {}. Record one with books.set_vendor_1099.",
undecided.join(", ")
));
}
for v in &r.vendors {
let mut forms: Vec<&'static str> = v
.decisions
.iter()
.filter_map(|d| d.decision.form().map(|f| f.label()))
.collect();
forms.dedup();
if forms.len() > 1 {
notes.push(format!(
"{}'s 1099 form changed during {year} ({}): each payment goes on the form for what it paid for. Confirm with the CPA.",
v.name,
forms.join(", then ")
));
}
}
for p in &r.other_receipts {
// Money in is negative; its reversal gives it back.
let what = if p.amount < Decimal::ZERO {
format!("received {} from", money(-p.amount))
} else {
format!("reversed {} received from", money(p.amount))
};
notes.push(format!(
"Entry {} {what} {} for something else (a sale, its payment of an invoice, a disposal, a loan): not netted against its payments.",
p.entry_id,
name(p.vendor_id)
));
}
let untagged = &r.untagged_payments;
if !untagged.is_empty() {
let n = untagged.len();
let which = if n <= 10 {
format!(" ({})", entries_text(untagged))
} else {
String::new()
};
notes.push(format!(
"{n} posted entr{} in {year} paid money out with no vendor tag{which}; tag any that paid a contractor or landlord with books.set_entry_vendor.",
if n == 1 { "y" } else { "ies" }
));
}
if !r.unresolved.is_empty() {
@@ -170,8 +301,28 @@ pub fn vendor_1099_worksheet(
.join(", ")
));
}
notes
}
/// The 1099 worksheet for `year` (threshold: the IRS figure to use when
/// the year's isn't known, else none), entries dated after `through`
/// left out, 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>,
through: Option<NaiveDate>,
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, through)
.map_err(|e| e.to_string())?;
let notes = notes_for(ledger, &r);
let mut lines = Vec::new();
for v in r.vendors {
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))
@@ -179,10 +330,20 @@ pub fn vendor_1099_worksheet(
.map(str::to_string);
let mut line = Vendor1099WorksheetLine {
vendor_id: v.vendor_id,
name: v.name,
form_1099: form_1099_label(v.form),
name: v.name.clone(),
form_1099: form_1099_label(v.form()),
decision_1099: v.decision.key().to_string(),
decision: v.decision.clone(),
decided: v.decided.clone(),
effective: v.effective,
decisions: v.decisions.clone(),
tin_on_file: v.tin_on_file,
paid: v.paid,
excluded_card: v.excluded_card,
undetermined: v.undetermined,
not_reportable: v.not_reportable,
decision_needed: v.decision_needed,
status: v.status,
reportable: v.reportable,
party_id,
tin_last4,
@@ -202,32 +363,94 @@ pub fn vendor_1099_worksheet(
}
lines.push(line);
}
let missing_w9 = lines
.iter()
.filter(|l| l.may_file() && !l.tin_on_file)
.map(|l| {
if l.reportable {
l.name.clone()
} else {
format!("{} (may be reportable)", l.name)
}
})
.collect();
Ok(Vendor1099Worksheet {
year: r.year,
threshold: r.threshold,
through: r.through,
lines,
missing_w9,
unresolved: r.unresolved,
excluded_card_payments: r.excluded_card_payments,
undetermined_payments: r.undetermined_payments,
other_receipts: r.other_receipts,
untagged_payments: r.untagged_payments,
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.
/// A dated decision for the CSV: its key, with its kind when not
/// reportable, and its date when it has one ("nec",
/// "not_reportable:corporation from 2026-12-30").
fn decision_cell(d: &ledger_core::Dated1099) -> String {
let mut s = d.decision.key().to_string();
if let Decision1099::NotReportable { kind, .. } = &d.decision {
s.push(':');
s.push_str(kind.key());
}
if let Some(e) = d.effective {
s.push_str(&format!(" from {e}"));
}
s
}
/// The worksheet's CSV header (INVARIANT 4).
pub const VENDOR_1099_CSV_HEADER: &str = "vendor_id,name,decision_1099,form_1099,reason_kind,reason,decided_by,decided_on,effective,decisions_in_year,paid,excluded_card,undetermined,not_reportable,status,reportable,w9_on_file,tin_last4";
/// The worksheet as CSV (INVARIANT 4): per vendor its id and name, its
/// year-end 1099 decision (key, form, reason kind and words, who decided,
/// when, the date it took effect) and every decision in force during the
/// year, what counts, what was paid by card or network, what is
/// undetermined, what was paid while not reportable, its status, whether
/// it is reportable, its W-9 flag 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");
let mut csv = String::from(VENDOR_1099_CSV_HEADER);
if with_tins {
csv.push_str(",tin,tin_type,tin_note");
}
csv.push('\n');
for l in &worksheet.lines {
let (kind, reason) = match &l.decision {
Decision1099::NotReportable { kind, reason } => (kind.key(), reason.as_str()),
_ => ("", ""),
};
let mut cells = vec![
l.vendor_id.to_string(),
csv_escape(&l.name),
l.decision_1099.clone(),
l.form_1099.clone().unwrap_or_default(),
kind.to_string(),
csv_escape(reason),
csv_escape(l.decided.as_ref().map_or("", |s| s.decided_by.as_str())),
l.decided
.as_ref()
.map(|s| s.decided_on.to_string())
.unwrap_or_default(),
l.effective.map(|e| e.to_string()).unwrap_or_default(),
csv_escape(
&l.decisions
.iter()
.map(decision_cell)
.collect::<Vec<_>>()
.join("; "),
),
money(l.paid),
money(l.excluded_card),
money(l.undetermined),
money(l.not_reportable),
l.status.key().to_string(),
l.reportable.to_string(),
l.tin_on_file.to_string(),
l.tin_last4.clone().unwrap_or_default(),
@@ -252,3 +475,135 @@ pub fn vendor_1099_csv_name(year: i32, with_tins: bool) -> String {
if with_tins { "-with-tins" } else { "" }
)
}
#[cfg(test)]
mod tests {
use super::*;
use ledger_core::{AccountType, Currency, Money, Side};
fn dec(s: &str) -> Decimal {
s.parse().unwrap()
}
fn date(s: &str) -> NaiveDate {
s.parse().unwrap()
}
/// The untagged note (P3 review R7), by hand. Bank 1000 (cash), Due to
/// Members 2100 (financing), card 2050 (a business card's), capital
/// 3000, labor 6000, prepaid 1300, equipment 1500. Untagged, in 2026:
/// 1. a contribution, Dr 1000 / Cr 3000 500: money in, not counted;
/// 2. a member reimbursed, Dr 2100 / Cr 1000 80: nets to zero;
/// 3. a property contribution, Dr 1500 / Cr 3000: no payment line;
/// 4. the card bill, Dr 2050 / Cr 1000 300: nets to zero;
/// 5. a distribution, Dr 3000 / Cr 1000 200: paid out, but for no
/// expense or asset: not counted;
/// 6. a fee a member paid, Dr 6000 / Cr 2100 25: counted;
/// 7. a prepayment, Dr 1300 / Cr 1000 120: counted;
/// 8. a payment, Dr 6000 / Cr 1000 40, and 9 its reversal: both in
/// the year, so neither counts.
///
/// Two entries: 6 and 7.
#[test]
fn the_untagged_note_counts_only_money_out() {
let mut l = Ledger::new(Currency::USD);
let mut acct = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
let bank = acct("1000", AccountType::Asset);
let prepaid = acct("1300", AccountType::Asset);
let equipment = acct("1500", AccountType::Asset);
let card = acct("2050", AccountType::Liability);
let due = acct("2100", AccountType::Liability);
let capital = acct("3000", AccountType::Equity);
let labor = acct("6000", AccountType::Expense);
let post = |l: &mut Ledger, dr, cr, amt: &str| {
let e = l.create_entry(date("2026-05-01"), "x");
let m = Money::new(dec(amt), Currency::USD);
l.add_line(e, dr, Side::Debit, m).unwrap();
l.add_line(e, cr, Side::Credit, m).unwrap();
l.post_entry(e).unwrap();
e
};
post(&mut l, bank, capital, "500");
post(&mut l, due, bank, "80");
post(&mut l, equipment, capital, "900");
post(&mut l, card, bank, "300");
post(&mut l, capital, bank, "200");
let fee = post(&mut l, labor, due, "25");
let pre = post(&mut l, prepaid, bank, "120");
let wrong = post(&mut l, labor, bank, "40");
l.reverse_entry(wrong, date("2026-05-02"), "oops").unwrap();
l.add_instrument(
ledger_core::InstrumentKind::BusinessCard,
"Card",
None,
None,
card,
)
.unwrap();
let r = ledger_core::vendor_1099_report(
&l,
2026,
None,
&ledger_core::PaymentAccounts {
cash: vec![bank],
financing: vec![due],
},
&[],
None,
)
.unwrap();
assert_eq!(r.untagged_payments, vec![fee, pre]);
let notes = notes_for(&l, &r);
assert!(
notes.contains(&format!(
"2 posted entries in 2026 paid money out with no vendor tag (entries {fee}, {pre}); tag any that paid a contractor or landlord with books.set_entry_vendor."
)),
"{notes:?}"
);
}
/// The note on money received from a vendor for something else says
/// what each entry did: the sale received the money, its reversal
/// gave it back (never "received -500.00").
#[test]
fn the_other_receipts_note_says_what_each_entry_did() {
let mut l = Ledger::new(Currency::USD);
let bank = l
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let sales = l
.add_account("4000", "Sales", AccountType::Revenue, None, false)
.unwrap();
let dana = l.add_vendor("Dana").unwrap();
let e = l.create_entry(date("2026-04-01"), "laptop sold to Dana");
let m = Money::new(dec("500.00"), Currency::USD);
l.add_line(e, bank, Side::Debit, m).unwrap();
l.add_line(e, sales, Side::Credit, m).unwrap();
l.post_entry(e).unwrap();
l.set_entry_vendor(e, Some(dana)).unwrap();
let undo = l.reverse_entry(e, date("2026-04-02"), "not Dana").unwrap();
let r = ledger_core::vendor_1099_report(
&l,
2026,
None,
&ledger_core::PaymentAccounts {
cash: vec![bank],
financing: vec![],
},
&[],
None,
)
.unwrap();
let notes = notes_for(&l, &r);
for want in [
format!(
"Entry {e} received 500.00 from Dana for something else (a sale, its payment of an invoice, a disposal, a loan): not netted against its payments."
),
format!(
"Entry {undo} reversed 500.00 received from Dana for something else (a sale, its payment of an invoice, a disposal, a loan): not netted against its payments."
),
] {
assert!(notes.contains(&want), "{want} not in {notes:?}");
}
}
}