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

+11 -1
View File
@@ -5,7 +5,7 @@
//! is the current version.
/// Current schema version.
pub const SCHEMA_VERSION: u32 = 6;
pub const SCHEMA_VERSION: u32 = 7;
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
pub const MIGRATIONS: &[&str] = &[
@@ -185,5 +185,15 @@ ALTER TABLE attachments ADD COLUMN withdrawn_reason TEXT;
-- and how much of it is deductible). Percent is TEXT, never REAL.
ALTER TABLE accounts ADD COLUMN tax_category TEXT;
ALTER TABLE accounts ADD COLUMN deductible_percent TEXT;
"#,
r#"
-- v7: 1099 tracking: which return each vendor gets, whether its W-9 is on
-- file (never the TIN itself), and the vendor each entry paid.
ALTER TABLE vendors ADD COLUMN form_1099 TEXT;
ALTER TABLE vendors ADD COLUMN tin_on_file INTEGER NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS entry_vendors(
entry_id INTEGER PRIMARY KEY,
vendor_id INTEGER NOT NULL
);
"#,
];
+113
View File
@@ -1952,6 +1952,51 @@ impl Store {
Ok(())
}
/// Set which 1099 a vendor gets (or none) and whether its W-9 is on
/// file. Journaled as `vendor.1099_set`.
pub fn set_vendor_1099(
&mut self,
vendor: VendorId,
form: Option<ledger_core::Form1099>,
tin_on_file: bool,
) -> Result<(), StoreError> {
self.ledger
.get_vendor(vendor)
.ok_or(LedgerError::UnknownVendor(vendor))?;
let line = self.append_journal(
"vendor.1099_set",
json!({ "vendor_id": vendor, "form_1099": form, "tin_on_file": tin_on_file }),
)?;
self.ledger.set_vendor_1099(vendor, form, tin_on_file)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Tag an entry with the vendor it paid, or clear the tag. Journaled as
/// `entry.vendor_set`.
pub fn set_entry_vendor(
&mut self,
entry: EntryId,
vendor: Option<VendorId>,
) -> Result<(), StoreError> {
// Validate first, so nothing invalid is journaled.
self.ledger
.get_entry(entry)
.ok_or(LedgerError::UnknownEntry(entry))?;
if let Some(v) = vendor {
self.ledger
.get_vendor(v)
.ok_or(LedgerError::UnknownVendor(v))?;
}
let line = self.append_journal(
"entry.vendor_set",
json!({ "entry_id": entry, "vendor_id": vendor }),
)?;
self.ledger.set_entry_vendor(entry, vendor)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Link a previously filed (unlinked) receipt to an entry. A receipt
/// already linked elsewhere must be unlinked first, so moving evidence
/// off an entry always goes through [`Store::unlink_receipt`] and its
@@ -2948,6 +2993,27 @@ fn replay_line_into_ledger(
)));
}
}
"vendor.1099_set" => {
let form = match p.get("form_1099").and_then(|v| v.as_str()) {
Some(s) => Some(ledger_core::Form1099::parse(s).ok_or_else(|| {
StoreError::JournalCorrupt("vendor.1099_set form_1099".into())
})?),
None => None,
};
ledger.set_vendor_1099(
get_u64(p, "vendor_id")?,
form,
p.get("tin_on_file")
.and_then(|v| v.as_bool())
.unwrap_or(false),
)?;
}
"entry.vendor_set" => {
ledger.set_entry_vendor(
get_u64(p, "entry_id")?,
p.get("vendor_id").and_then(|v| v.as_u64()),
)?;
}
"account.tax_set" => {
let pct = match p.get("deductible_percent").and_then(|v| v.as_str()) {
Some(s) => Some(s.parse().map_err(|_| {
@@ -3363,6 +3429,12 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
}
}
"entry.reversed" => {
// A reversed payment un-pays the vendor: carry the tag.
conn.execute(
"INSERT OR REPLACE INTO entry_vendors(entry_id, vendor_id) \
SELECT ?, vendor_id FROM entry_vendors WHERE entry_id = ?",
params![get_u64(p, "id")? as i64, get_u64(p, "reverses")? as i64],
)?;
conn.execute(
"INSERT INTO entries(id, date, description, status, reversal_of) VALUES (?,?,?,'posted',?)",
params![
@@ -3424,6 +3496,32 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
],
)?;
}
"vendor.1099_set" => {
conn.execute(
"UPDATE vendors SET form_1099 = ?, tin_on_file = ? WHERE id = ?",
params![
p.get("form_1099").and_then(|v| v.as_str()),
p.get("tin_on_file")
.and_then(|v| v.as_bool())
.unwrap_or(false),
get_u64(p, "vendor_id")? as i64,
],
)?;
}
"entry.vendor_set" => match p.get("vendor_id").and_then(|v| v.as_u64()) {
Some(v) => {
conn.execute(
"INSERT OR REPLACE INTO entry_vendors(entry_id, vendor_id) VALUES (?, ?)",
params![get_u64(p, "entry_id")? as i64, v as i64],
)?;
}
None => {
conn.execute(
"DELETE FROM entry_vendors WHERE entry_id = ?",
params![get_u64(p, "entry_id")? as i64],
)?;
}
},
"account.tax_set" => {
conn.execute(
"UPDATE accounts SET tax_category = ?, deductible_percent = ? WHERE id = ?",
@@ -3591,6 +3689,13 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
get_u64(p, "entry_id")? as i64,
],
)?;
conn.execute(
"INSERT OR REPLACE INTO entry_vendors(entry_id, vendor_id) VALUES (?, ?)",
params![
get_u64(p, "entry_id")? as i64,
get_u64(p, "vendor_id")? as i64
],
)?;
}
"invoice.payment_received" => {
let amount: Decimal = get_str(p, "amount")?
@@ -3603,6 +3708,14 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
.parse()
.map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?;
apply_document_payment(conn, "bills", get_u64(p, "bill_id")?, amount)?;
conn.execute(
"INSERT OR REPLACE INTO entry_vendors(entry_id, vendor_id) \
SELECT ?, vendor_id FROM bills WHERE id = ?",
params![
get_u64(p, "entry_id")? as i64,
get_u64(p, "bill_id")? as i64
],
)?;
}
"fx.rate_set" => {
conn.execute(
+71
View File
@@ -612,3 +612,74 @@ fn schedules_and_recurring_entries_replay_exactly() {
assert!(store.run_schedules(date(2, 28)).unwrap().is_empty());
assert!(store.run_recurring(date(2, 28)).unwrap().is_empty());
}
#[test]
fn vendor_1099_tags_replay_and_project() {
use ledger_core::arap::DocumentLine;
use ledger_core::{Form1099, vendor_1099_report};
let dir = test_dir("form1099");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let labor = store
.add_account("6000", "Contract Labor", AccountType::Expense, None, false)
.unwrap();
let dana = store.add_vendor("Dana").unwrap();
store
.set_vendor_1099(dana, Some(Form1099::Nec), true)
.unwrap();
let d = |m, day| NaiveDate::from_ymd_opt(2026, m, day).unwrap();
let bill = store
.create_bill(
dana,
d(3, 1),
d(3, 31),
vec![DocumentLine {
description: "Design".into(),
amount: Decimal::from(1500),
}],
)
.unwrap();
store
.pay_bill(bill, d(3, 20), Decimal::from(1500), cash)
.unwrap();
let e = store.create_entry(d(6, 1), "Dana, June").unwrap();
store.add_line(e, labor, Side::Debit, usd(700)).unwrap();
store.add_line(e, cash, Side::Credit, usd(700)).unwrap();
store.post_entry(e).unwrap();
store.set_entry_vendor(e, Some(dana)).unwrap();
let dup = store.create_entry(d(6, 2), "Dana, duplicate").unwrap();
store.add_line(dup, labor, Side::Debit, usd(700)).unwrap();
store.add_line(dup, cash, Side::Credit, usd(700)).unwrap();
store.post_entry(dup).unwrap();
store.set_entry_vendor(dup, Some(dana)).unwrap();
store.reverse_entry(dup, d(6, 3), "duplicate").unwrap();
let before = vendor_1099_report(store.ledger(), 2026, None);
assert_eq!(before.vendors[0].paid, Decimal::from(2200));
assert!(before.vendors[0].reportable);
drop(store);
// Reopen (replay), then rebuild the projection from scratch.
for rebuild in [false, true] {
if rebuild {
std::fs::remove_file(dir.join(ledger_store::DB_FILE)).unwrap();
}
let store = Store::open(&dir).unwrap();
assert_eq!(vendor_1099_report(store.ledger(), 2026, None), before);
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
let tagged: i64 = conn
.query_row(
"SELECT COUNT(*) FROM entry_vendors WHERE vendor_id = ?",
[dana as i64],
|r| r.get(0),
)
.unwrap();
// Bill, its payment, June, the duplicate, and its reversal.
assert_eq!(tagged, 5);
let form: String = conn
.query_row("SELECT form_1099 FROM vendors", [], |r| r.get(0))
.unwrap();
assert_eq!(form, "nec");
}
}