feat: 1099 tracking — vendor forms, payment tags, year-end worksheet

Vendors carry form_1099 (NEC/MISC) and tin_on_file (never the TIN).
Entries carry the vendor they paid; bills, bill payments, and reversals
are tagged automatically. books.vendor_1099 counts cash paid per vendor
per calendar year (card payments excluded) against $600 through 2025 and
$2,000 from 2026, or an override, and flags missing W-9s. Four new tools
(64), two journal events, schema v7. Docs: finished R-FUTURE work is no
longer listed as future.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-11 21:18:32 -07:00
1 parent d65f9aa840
commit b48addd0e0
15 files changed
+967 -8

No files matched your search

+8
View File
@@ -42,6 +42,14 @@ pub struct Vendor {
pub id: VendorId,
/// Display name.
pub name: String,
/// The information return this vendor gets, if any (see
/// [`crate::form1099`]).
#[serde(default)]
pub form_1099: Option<crate::form1099::Form1099>,
/// A W-9 (taxpayer identification number) is on file. The number
/// itself is never stored here.
#[serde(default)]
pub tin_on_file: bool,
}
/// Payment status of an invoice or bill.
+145
View File
@@ -0,0 +1,145 @@
//! 1099 tracking: what was paid to each vendor in a calendar year.
//!
//! A vendor is marked with the information return it gets (1099-NEC for
//! nonemployee compensation, 1099-MISC for rents and the like) and whether
//! its W-9 is on file. Entries are tagged with the vendor they paid
//! (`pay_bill` tags automatically). The amount paid is the cash that left
//! asset accounts on the vendor's tagged entries: credits minus debits to
//! asset accounts. A card payment (Dr expense, Cr credit card) moves no
//! asset, so it is excluded, as it should be: card payments are reported by
//! the card processor on 1099-K, not by the payer.
//!
//! Thresholds are set by law and change; [`default_threshold`] encodes
//! them and the report takes an override. Confirm with the CPA.
use crate::{AccountType, Ledger, VendorId};
use chrono::Datelike;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
/// The information return a vendor gets.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Form1099 {
/// 1099-NEC: nonemployee compensation (contractors, attorneys' fees).
Nec,
/// 1099-MISC: rents, royalties, prizes, other income.
Misc,
}
impl Form1099 {
/// Parse "nec", "1099-NEC", "misc", "1099-MISC".
pub fn parse(s: &str) -> Option<Form1099> {
let s = s.trim().to_lowercase();
match s.strip_prefix("1099-").unwrap_or(&s) {
"nec" => Some(Form1099::Nec),
"misc" => Some(Form1099::Misc),
_ => None,
}
}
/// "1099-NEC" or "1099-MISC".
pub fn label(self) -> &'static str {
match self {
Form1099::Nec => "1099-NEC",
Form1099::Misc => "1099-MISC",
}
}
}
/// The reporting threshold for payments made in `year`: $600 through 2025;
/// $2,000 from 2026 (One Big Beautiful Bill Act), indexed for inflation
/// after 2026, so later years return $2,000 until updated. Royalties
/// (1099-MISC) have a $10 threshold this doesn't model. Pass an explicit
/// threshold to override.
pub fn default_threshold(year: i32) -> Decimal {
if year <= 2025 {
Decimal::new(600, 0)
} else {
Decimal::new(2000, 0)
}
}
/// One vendor's year.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Vendor1099Line {
/// The vendor.
pub vendor_id: VendorId,
/// Name.
pub name: String,
/// The return it gets, if marked.
pub form: Option<Form1099>,
/// W-9 on file.
pub tin_on_file: bool,
/// Cash paid in the year (net of refunds and reversals).
pub paid: Decimal,
/// Marked for a 1099 and paid at least the threshold.
pub reportable: bool,
}
/// The year's 1099 report.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Vendor1099Report {
/// Calendar year.
pub year: i32,
/// The threshold applied.
pub threshold: Decimal,
/// Every vendor marked for a 1099 or paid in the year, by name.
pub vendors: Vec<Vendor1099Line>,
}
/// Cash paid per vendor in calendar `year`, from posted entries tagged
/// with the vendor.
pub fn vendor_1099_report(
ledger: &Ledger,
year: i32,
threshold: Option<Decimal>,
) -> Vendor1099Report {
let threshold = threshold.unwrap_or_else(|| default_threshold(year));
let mut paid: std::collections::HashMap<VendorId, Decimal> = Default::default();
for entry in ledger.posted_entries() {
if entry.date().year() != year {
continue;
}
let Some(vendor) = ledger.entry_vendor(entry.id()) else {
continue;
};
let cash: Decimal = entry
.lines()
.iter()
.filter(|l| {
ledger
.chart()
.get(l.account)
.is_some_and(|a| a.account_type == AccountType::Asset)
})
.map(|l| match l.side {
crate::Side::Credit => l.amount.amount,
crate::Side::Debit => -l.amount.amount,
})
.sum();
*paid.entry(vendor).or_default() += cash;
}
let mut vendors: Vec<Vendor1099Line> = ledger
.list_vendors()
.into_iter()
.filter(|v| v.form_1099.is_some() || paid.get(&v.id).is_some_and(|p| !p.is_zero()))
.map(|v| {
let amount = paid.get(&v.id).copied().unwrap_or_default();
Vendor1099Line {
vendor_id: v.id,
name: v.name.clone(),
form: v.form_1099,
tin_on_file: v.tin_on_file,
paid: amount,
reportable: v.form_1099.is_some() && amount >= threshold,
}
})
.collect();
vendors.sort_by(|a, b| a.name.cmp(&b.name).then(a.vendor_id.cmp(&b.vendor_id)));
Vendor1099Report {
year,
threshold,
vendors,
}
}
+61 -1
View File
@@ -104,6 +104,8 @@ pub struct Ledger {
/// Recurring entries.
recurring: std::collections::BTreeMap<RecurringId, RecurringEntry>,
next_recurring_id: RecurringId,
/// Who each entry paid or was billed by, for 1099 reporting.
entry_vendors: HashMap<EntryId, VendorId>,
}
impl Ledger {
@@ -138,6 +140,7 @@ impl Ledger {
next_schedule_id: 1,
recurring: std::collections::BTreeMap::new(),
next_recurring_id: 1,
entry_vendors: HashMap::new(),
}
}
@@ -635,6 +638,10 @@ impl Ledger {
.ok_or(LedgerError::UnknownEntry(id))?
.mark_reversal_of(entry);
self.post_entry(id)?;
// A reversed payment un-pays the vendor: carry the tag.
if let Some(v) = self.entry_vendor(entry) {
self.entry_vendors.insert(id, v);
}
Ok(id)
}
@@ -1127,6 +1134,8 @@ impl Ledger {
Vendor {
id,
name: name.to_string(),
form_1099: None,
tin_on_file: false,
},
);
Ok(id)
@@ -1142,6 +1151,53 @@ impl Ledger {
self.vendors.get(&id)
}
/// Set which information return a vendor gets (or none), and whether
/// their W-9 is on file.
pub fn set_vendor_1099(
&mut self,
id: VendorId,
form: Option<crate::form1099::Form1099>,
tin_on_file: bool,
) -> Result<(), LedgerError> {
let v = self
.vendors
.get_mut(&id)
.ok_or(LedgerError::UnknownVendor(id))?;
v.form_1099 = form;
v.tin_on_file = tin_on_file;
Ok(())
}
/// Tag an entry (draft or posted) with the vendor it paid or was billed
/// by, or clear the tag. The tag is reporting metadata, like a receipt
/// link: it changes no amounts, so posted entries may be tagged.
pub fn set_entry_vendor(
&mut self,
entry: EntryId,
vendor: Option<VendorId>,
) -> Result<(), LedgerError> {
if !self.entries.contains_key(&entry) {
return Err(LedgerError::UnknownEntry(entry));
}
match vendor {
Some(v) => {
if !self.vendors.contains_key(&v) {
return Err(LedgerError::UnknownVendor(v));
}
self.entry_vendors.insert(entry, v);
}
None => {
self.entry_vendors.remove(&entry);
}
}
Ok(())
}
/// The vendor an entry is tagged with.
pub fn entry_vendor(&self, entry: EntryId) -> Option<VendorId> {
self.entry_vendors.get(&entry).copied()
}
/// All customers, in id order.
pub fn list_customers(&self) -> Vec<&Customer> {
let mut ids: Vec<CustomerId> = self.customers.keys().copied().collect();
@@ -1254,6 +1310,7 @@ impl Ledger {
entry_id: entry,
},
);
self.entry_vendors.insert(entry, vendor_id);
Ok(id)
}
@@ -1336,6 +1393,7 @@ impl Ledger {
let b = self.bills.get_mut(&bill_id).expect("bill exists");
b.amount_paid += amount;
b.status = arap::derive_status(b.amount_paid, b.total());
self.entry_vendors.insert(entry, bill.vendor_id);
Ok(entry)
}
@@ -1403,6 +1461,7 @@ impl Ledger {
entry_id,
},
);
self.entry_vendors.insert(entry_id, vendor_id);
Ok(())
}
@@ -1437,13 +1496,14 @@ impl Ledger {
amount: Decimal,
entry_id: EntryId,
) -> Result<(), LedgerError> {
let _ = entry_id;
let bill = self
.bills
.get_mut(&bill_id)
.ok_or(LedgerError::UnknownBill(bill_id))?;
bill.amount_paid += amount;
bill.status = arap::derive_status(bill.amount_paid, bill.total());
let vendor = bill.vendor_id;
self.entry_vendors.insert(entry_id, vendor);
Ok(())
}
+2
View File
@@ -21,6 +21,7 @@ pub mod arap;
pub mod assets;
pub mod classes;
pub mod error;
pub mod form1099;
pub mod journal;
pub mod ledger;
pub mod money;
@@ -42,6 +43,7 @@ pub use assets::{
};
pub use classes::{Class, ClassId};
pub use error::LedgerError;
pub use form1099::{Form1099, Vendor1099Line, Vendor1099Report, vendor_1099_report};
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
pub use ledger::{Ledger, ReconciliationReport, UnclearedLine};
pub use money::{Currency, Money};