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:
15 files changed
+967
-8
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//! 1099 tracking. Numbers computed by hand.
|
||||
//!
|
||||
//! 2026 payments:
|
||||
//! - Dana (1099-NEC, W-9): bill 1,500 paid by check + 700 direct = 2,200,
|
||||
//! over the 2,000 threshold. A 300 payment on 2025-12-30 counts in 2025.
|
||||
//! - Ace Rentals (1099-MISC): 1,200 twice, one reversed = 1,200, under.
|
||||
//! - Sam (1099-NEC): 5,000 by credit card = 0 cash: the card processor's
|
||||
//! 1099-K covers it.
|
||||
//! - Bigco (no 1099): 9,000, listed but never reportable.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::arap::DocumentLine;
|
||||
use ledger_core::form1099::default_threshold;
|
||||
use ledger_core::{
|
||||
AccountType, Currency, Form1099, Ledger, LedgerError, Money, Side, vendor_1099_report,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn books() -> Ledger {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let bank = l
|
||||
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let card = l
|
||||
.add_account("2100", "Credit Card", AccountType::Liability, None, false)
|
||||
.unwrap();
|
||||
let contract = l
|
||||
.add_account("6000", "Contract Labor", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let rent = l
|
||||
.add_account("6600", "Rent", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let dana = l.add_vendor("Dana").unwrap();
|
||||
let ace = l.add_vendor("Ace Rentals").unwrap();
|
||||
let sam = l.add_vendor("Sam").unwrap();
|
||||
let bigco = l.add_vendor("Bigco").unwrap();
|
||||
l.set_vendor_1099(dana, Some(Form1099::Nec), true).unwrap();
|
||||
l.set_vendor_1099(ace, Some(Form1099::Misc), false).unwrap();
|
||||
l.set_vendor_1099(sam, Some(Form1099::Nec), true).unwrap();
|
||||
let pay = |l: &mut Ledger, date, vendor, dr, cr, amt: &str| {
|
||||
let e = l.create_entry(date, "payment");
|
||||
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();
|
||||
l.set_entry_vendor(e, Some(vendor)).unwrap();
|
||||
e
|
||||
};
|
||||
let bill = l
|
||||
.create_bill(
|
||||
dana,
|
||||
d(2026, 3, 1),
|
||||
d(2026, 3, 31),
|
||||
vec![DocumentLine {
|
||||
description: "Design work".into(),
|
||||
amount: dec("1500"),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
l.pay_bill(bill, d(2026, 3, 20), dec("1500"), bank).unwrap();
|
||||
pay(&mut l, d(2026, 6, 1), dana, contract, bank, "700");
|
||||
pay(&mut l, d(2025, 12, 30), dana, contract, bank, "300");
|
||||
pay(&mut l, d(2026, 1, 1), ace, rent, bank, "1200");
|
||||
let wrong = pay(&mut l, d(2026, 2, 1), ace, rent, bank, "1200");
|
||||
l.reverse_entry(wrong, d(2026, 2, 2), "duplicate").unwrap();
|
||||
pay(&mut l, d(2026, 4, 1), sam, contract, card, "5000");
|
||||
pay(&mut l, d(2026, 5, 1), bigco, contract, bank, "9000");
|
||||
// Untagged spending is not anyone's 1099.
|
||||
let e = l.create_entry(d(2026, 7, 1), "untagged");
|
||||
let m = Money::new(dec("50"), Currency::USD);
|
||||
l.add_line(e, contract, Side::Debit, m).unwrap();
|
||||
l.add_line(e, bank, Side::Credit, m).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
l
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cash_paid_per_vendor_and_who_is_reportable() {
|
||||
let l = books();
|
||||
let r = vendor_1099_report(&l, 2026, None);
|
||||
assert_eq!(r.threshold, dec("2000"));
|
||||
let rows: Vec<(&str, Decimal, bool)> = r
|
||||
.vendors
|
||||
.iter()
|
||||
.map(|v| (v.name.as_str(), v.paid, v.reportable))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
rows,
|
||||
[
|
||||
("Ace Rentals", dec("1200"), false),
|
||||
("Bigco", dec("9000"), false),
|
||||
("Dana", dec("2200"), true),
|
||||
("Sam", dec("0"), false),
|
||||
]
|
||||
);
|
||||
let ace = &r.vendors[0];
|
||||
assert_eq!(ace.form, Some(Form1099::Misc));
|
||||
assert!(!ace.tin_on_file);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn years_and_thresholds() {
|
||||
let l = books();
|
||||
assert_eq!(default_threshold(2025), dec("600"));
|
||||
assert_eq!(default_threshold(2026), dec("2000"));
|
||||
let y2025 = vendor_1099_report(&l, 2025, None);
|
||||
let dana = y2025.vendors.iter().find(|v| v.name == "Dana").unwrap();
|
||||
assert_eq!(dana.paid, dec("300"));
|
||||
assert!(!dana.reportable, "300 < 600");
|
||||
// An override: at 1,000, Ace's 1,200 is reportable.
|
||||
let r = vendor_1099_report(&l, 2026, Some(dec("1000")));
|
||||
assert!(
|
||||
r.vendors
|
||||
.iter()
|
||||
.any(|v| v.name == "Ace Rentals" && v.reportable)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_need_real_entries_and_vendors() {
|
||||
let mut l = books();
|
||||
assert!(matches!(
|
||||
l.set_entry_vendor(999, Some(1)),
|
||||
Err(LedgerError::UnknownEntry(999))
|
||||
));
|
||||
assert!(matches!(
|
||||
l.set_entry_vendor(1, Some(999)),
|
||||
Err(LedgerError::UnknownVendor(999))
|
||||
));
|
||||
// The bill and its payment were tagged automatically.
|
||||
assert_eq!(l.entry_vendor(1), Some(1));
|
||||
assert_eq!(l.entry_vendor(2), Some(1));
|
||||
l.set_entry_vendor(2, None).unwrap();
|
||||
assert_eq!(l.entry_vendor(2), None);
|
||||
assert_eq!(Form1099::parse("1099-NEC"), Some(Form1099::Nec));
|
||||
assert_eq!(Form1099::parse("misc"), Some(Form1099::Misc));
|
||||
assert_eq!(Form1099::parse("K"), None);
|
||||
}
|
||||
Reference in new issue
Block a user