From b48addd0e0d19edcf9972385debe1592e1700eed Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Sun, 11 Oct 2026 21:18:32 -0700 Subject: [PATCH] =?UTF-8?q?feat:=201099=20tracking=20=E2=80=94=20vendor=20?= =?UTF-8?q?forms,=20payment=20tags,=20year-end=20worksheet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu --- crates/ledger-core/src/arap.rs | 8 ++ crates/ledger-core/src/form1099.rs | 145 +++++++++++++++++++++ crates/ledger-core/src/ledger.rs | 62 ++++++++- crates/ledger-core/src/lib.rs | 2 + crates/ledger-core/tests/form1099.rs | 147 +++++++++++++++++++++ crates/ledger-mcp/src/server.rs | 184 ++++++++++++++++++++++++++- crates/ledger-mcp/src/types.rs | 88 +++++++++++++ crates/ledger-mcp/tests/mcp_stdio.rs | 98 +++++++++++++- crates/ledger-store/src/schema.rs | 12 +- crates/ledger-store/src/store.rs | 113 ++++++++++++++++ crates/ledger-store/tests/store.rs | 71 +++++++++++ docs/mcp-interface.md | 22 ++++ docs/parity.md | 2 +- docs/requirements.md | 13 +- plan/STATUS.md | 8 ++ 15 files changed, 967 insertions(+), 8 deletions(-) create mode 100644 crates/ledger-core/src/form1099.rs create mode 100644 crates/ledger-core/tests/form1099.rs diff --git a/crates/ledger-core/src/arap.rs b/crates/ledger-core/src/arap.rs index c6d27bf..8bb2608 100644 --- a/crates/ledger-core/src/arap.rs +++ b/crates/ledger-core/src/arap.rs @@ -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, + /// 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. diff --git a/crates/ledger-core/src/form1099.rs b/crates/ledger-core/src/form1099.rs new file mode 100644 index 0000000..402827a --- /dev/null +++ b/crates/ledger-core/src/form1099.rs @@ -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 { + 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, + /// 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, +} + +/// 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, +) -> Vendor1099Report { + let threshold = threshold.unwrap_or_else(|| default_threshold(year)); + let mut paid: std::collections::HashMap = 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 = 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, + } +} diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 1cd093c..568e5c9 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -104,6 +104,8 @@ pub struct Ledger { /// Recurring entries. recurring: std::collections::BTreeMap, next_recurring_id: RecurringId, + /// Who each entry paid or was billed by, for 1099 reporting. + entry_vendors: HashMap, } 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, + 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, + ) -> 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 { + self.entry_vendors.get(&entry).copied() + } + /// All customers, in id order. pub fn list_customers(&self) -> Vec<&Customer> { let mut ids: Vec = 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(()) } diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index ecd3f40..eb7665a 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -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}; diff --git a/crates/ledger-core/tests/form1099.rs b/crates/ledger-core/tests/form1099.rs new file mode 100644 index 0000000..27eeb2c --- /dev/null +++ b/crates/ledger-core/tests/form1099.rs @@ -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); +} diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 7d5c08c..0c7aa51 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -23,7 +23,7 @@ use rust_decimal::Decimal; use std::sync::{Arc, Mutex}; /// The agent operating contract, delivered via initialize. -const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post."; +const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.\n- 1099s: mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file; never store a TIN), and tag every payment to them with its vendor (books.draft_entry vendor, or books.set_entry_vendor; books.pay_bill tags automatically). books.vendor_1099 is the year-end worksheet for the CPA."; /// Max receipt/document upload: 25 MB. const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024; @@ -316,6 +316,32 @@ fn resolve_account(store: &Store, account: &str) -> Result { } /// Resolve a class by id or name (case-insensitive). +fn resolve_vendor(store: &Store, vendor: &str) -> Result { + if let Ok(id) = vendor.parse::() + && store.ledger().get_vendor(id).is_some() + { + return Ok(id); + } + let matches: Vec = store + .ledger() + .list_vendors() + .into_iter() + .filter(|v| v.name.eq_ignore_ascii_case(vendor.trim())) + .map(|v| v.id) + .collect(); + match matches.as_slice() { + [id] => Ok(*id), + [] => Err(format!( + "unknown vendor {vendor:?}: add it with books.add_vendor" + )), + _ => Err(format!("several vendors are named {vendor:?}: pass the id")), + } +} + +fn form_1099_label(form: Option) -> Option { + form.map(|f| f.label().to_string()) +} + fn resolve_class(store: &Store, class: &str) -> Result { if let Ok(id) = class.parse::() && store.ledger().get_class(id).is_some() @@ -814,9 +840,19 @@ impl BooksServer { ) -> Result, String> { let p = params.0; let mut store = self.lock()?; + let vendor = p + .vendor + .as_deref() + .map(|v| resolve_vendor(&store, v)) + .transpose()?; let id = store .create_entry(parse_date(&p.date)?, &p.description) .map_err(|e| e.to_string())?; + if vendor.is_some() { + store + .set_entry_vendor(id, vendor) + .map_err(|e| e.to_string())?; + } Ok(Json(DraftEntryOut { entry_id: id, status: "draft".to_string(), @@ -1287,6 +1323,11 @@ impl BooksServer { lines, total: money(ReceiptPolicy::entry_debits(&entry)), receipts: preview_receipts(&store, p.entry_id)?, + vendor: store + .ledger() + .entry_vendor(p.entry_id) + .and_then(|v| store.ledger().get_vendor(v)) + .map(|v| v.name.clone()), })) } @@ -1746,6 +1787,147 @@ impl BooksServer { })) } + #[tool( + name = "books.list_vendors", + description = "Vendors, with which 1099 each gets and whether its W-9 is on file.", + annotations(read_only_hint = true) + )] + async fn list_vendors(&self) -> Result, String> { + let store = self.lock()?; + Ok(Json(ListVendorsOut { + vendors: store + .ledger() + .list_vendors() + .into_iter() + .map(|v| VendorOut { + id: v.id, + name: v.name.clone(), + form_1099: form_1099_label(v.form_1099), + tin_on_file: v.tin_on_file, + }) + .collect(), + })) + } + + #[tool( + name = "books.set_vendor_1099", + description = "Mark which information return a vendor gets: \"nec\" (1099-NEC: contractors, freelancers, attorneys), \"misc\" (1099-MISC: rent, royalties, prizes), or \"none\" (corporations generally, merchants, anyone paid only by card). Record whether a W-9 is on file; never store the TIN itself. Ask the human when unsure; the CPA confirms." + )] + async fn set_vendor_1099( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let id = resolve_vendor(&store, &p.vendor)?; + let form = match p.form_1099.trim().to_lowercase().as_str() { + "none" | "" => None, + other => Some( + ledger_core::Form1099::parse(other) + .ok_or_else(|| format!("form_1099 {other:?}: expected nec, misc, or none"))?, + ), + }; + store + .set_vendor_1099(id, form, p.tin_on_file) + .map_err(|e| e.to_string())?; + let v = store.ledger().get_vendor(id).expect("exists"); + Ok(Json(VendorOut { + id, + name: v.name.clone(), + form_1099: form_1099_label(v.form_1099), + tin_on_file: v.tin_on_file, + })) + } + + #[tool( + name = "books.set_entry_vendor", + description = "Tag an entry (draft or posted) with the vendor it paid, for 1099 tracking, or omit vendor to clear the tag. Reporting metadata only: amounts never change. books.pay_bill tags its payments automatically; books.draft_entry takes a vendor too." + )] + async fn set_entry_vendor( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + { + let mut store = self.lock()?; + let vendor = p + .vendor + .as_deref() + .map(|v| resolve_vendor(&store, v)) + .transpose()?; + store + .set_entry_vendor(p.entry_id, vendor) + .map_err(|e| e.to_string())?; + } + self.get_entry(Parameters(GetEntryParams { + entry_id: p.entry_id, + })) + .await + } + + #[tool( + name = "books.vendor_1099", + description = "1099 worksheet for a calendar year: cash paid to each vendor (credits minus debits to asset accounts on entries tagged with the vendor, so card payments, which the card processor reports on 1099-K, are excluded), which vendors are reportable (marked NEC/MISC and paid at least the threshold: $600 through 2025, $2,000 from 2026), and which are missing a W-9. For the CPA to confirm.", + annotations(read_only_hint = true) + )] + async fn vendor_1099( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let threshold = p.threshold.as_deref().map(parse_amount).transpose()?; + let store = self.lock()?; + let r = ledger_core::vendor_1099_report(store.ledger(), p.year, threshold); + let missing_w9 = r + .vendors + .iter() + .filter(|v| v.reportable && !v.tin_on_file) + .map(|v| v.name.clone()) + .collect(); + let untagged = store + .ledger() + .posted_entries() + .filter(|e| { + chrono::Datelike::year(&e.date()) == p.year + && store.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 p.year > 2026 { + notes.push(format!( + "The $2,000 threshold is indexed for inflation after 2026; confirm the {} amount and pass threshold to override.", + p.year + )); + } + if untagged > 0 { + notes.push(format!( + "{untagged} posted entries in {} have no vendor tag; review any that paid contractors or rent.", + p.year + )); + } + Ok(Json(Vendor1099Out { + year: r.year, + threshold: money(r.threshold), + vendors: r + .vendors + .into_iter() + .map(|v| Vendor1099LineOut { + vendor_id: v.vendor_id, + name: v.name, + form_1099: form_1099_label(v.form), + tin_on_file: v.tin_on_file, + paid: money(v.paid), + reportable: v.reportable, + }) + .collect(), + missing_w9, + notes, + })) + } + #[tool( name = "books.chart_template", description = "A starter chart of accounts for a kind of business (sole proprietorship, partnership or multi-member LLC, S corporation, C corporation): the right equity and owner-pay accounts (draws; per-partner capital and distributions plus guaranteed payments; officer compensation and distributions; dividends and income tax), common income and expense accounts with tax categories assigned, and the wanderbooks.toml settings that go with it. A proposal: review it with the human, adjust, then books.import_chart_csv.", diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 2327438..c2bc089 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -291,6 +291,90 @@ pub struct RecordPayoutOut { pub next: String, } +/// A vendor. +#[derive(Debug, Serialize, JsonSchema)] +pub struct VendorOut { + /// Vendor id. + pub id: u64, + /// Name. + pub name: String, + /// "1099-NEC", "1099-MISC", or absent. + pub form_1099: Option, + /// W-9 on file. + pub tin_on_file: bool, +} + +/// Result of `books.list_vendors`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct ListVendorsOut { + /// Vendors, by id. + pub vendors: Vec, +} + +/// Parameters for `books.set_vendor_1099`. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SetVendor1099Params { + /// Vendor id or name. + pub vendor: String, + /// "nec" (contractors, attorneys), "misc" (rent, royalties, other), or + /// "none" (corporations, most merchants). + pub form_1099: String, + /// A W-9 is on file (the TIN itself is never stored). + #[serde(default)] + pub tin_on_file: bool, +} + +/// Parameters for `books.set_entry_vendor`. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SetEntryVendorParams { + /// Entry id (draft or posted). + pub entry_id: u64, + /// Vendor id or name; omit to clear the tag. + pub vendor: Option, +} + +/// Parameters for `books.vendor_1099`. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct Vendor1099Params { + /// Calendar year of payment. + pub year: i32, + /// Override the reporting threshold, decimal string (default: $600 + /// through 2025, $2,000 from 2026). + pub threshold: Option, +} + +/// One vendor's year. +#[derive(Debug, Serialize, JsonSchema)] +pub struct Vendor1099LineOut { + /// Vendor id. + pub vendor_id: u64, + /// Name. + pub name: String, + /// "1099-NEC", "1099-MISC", or absent. + pub form_1099: Option, + /// W-9 on file. + pub tin_on_file: bool, + /// Cash paid in the year, decimal string. + pub paid: String, + /// Marked for a 1099 and paid at least the threshold. + pub reportable: bool, +} + +/// Result of `books.vendor_1099`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct Vendor1099Out { + /// Calendar year. + pub year: i32, + /// Threshold applied, decimal string. + pub threshold: String, + /// Vendors marked for a 1099 or paid in the year. + pub vendors: Vec, + /// Reportable vendors without a W-9 on file: request one. + pub missing_w9: Vec, + /// Caveats for the human and CPA. + pub notes: Vec, +} + /// Parameters for `books.chart_template`. #[derive(Debug, Deserialize, JsonSchema)] pub struct ChartTemplateParams { @@ -638,6 +722,8 @@ pub struct DraftEntryParams { pub date: String, /// Description of what happened. pub description: String, + /// The vendor this entry pays (id or name), for 1099 tracking. + pub vendor: Option, } /// Result of `books.draft_entry`. @@ -996,6 +1082,8 @@ pub struct GetEntryOut { pub total: String, /// Attached receipts. pub receipts: Vec, + /// The vendor the entry is tagged with, for 1099 tracking. + pub vendor: Option, } // --------------------------------------------------------------------------- diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs index d477631..e81ae47 100644 --- a/crates/ledger-mcp/tests/mcp_stdio.rs +++ b/crates/ledger-mcp/tests/mcp_stdio.rs @@ -139,7 +139,7 @@ fn mcp_end_to_end_over_stdio() { // Tool listing: 23 narrow tools, chart is read-only. let list = mcp.request("tools/list", json!({})); let tools = list["result"]["tools"].as_array().unwrap(); - assert_eq!(tools.len(), 60, "expected 60 tools, got {}", tools.len()); + assert_eq!(tools.len(), 64, "expected 64 tools, got {}", tools.len()); let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap(); assert_eq!(chart["annotations"]["readOnlyHint"], true); assert!( @@ -1453,3 +1453,99 @@ fn mcp_payout_gross_up_over_stdio() { ] ); } + +#[test] +fn mcp_vendor_1099_over_stdio() { + let dir = test_dir("form1099"); + let init = |mcp: &mut Mcp| { + mcp.request( + "initialize", + json!({ + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "smoke-test", "version": "0.1.0"}, + }), + ); + mcp.notify("notifications/initialized"); + }; + let mut mcp = Mcp::spawn(&dir); + init(&mut mcp); + for (code, name, typ) in [ + ("1000", "Bank", "asset"), + ("6000", "Contract Labor", "expense"), + ] { + mcp.call( + "books.add_account", + json!({"code": code, "name": name, "account_type": typ}), + ); + } + mcp.call("books.add_vendor", json!({"name": "Dana Designs"})); + let v = mcp.call( + "books.set_vendor_1099", + json!({"vendor": "dana designs", "form_1099": "nec"}), + ); + assert_eq!(v["form_1099"], "1099-NEC"); + let err = mcp.call_err( + "books.set_vendor_1099", + json!({"vendor": "Dana Designs", "form_1099": "k"}), + ); + assert!(err.contains("nec, misc, or none"), "{err}"); + + // A 2,500.00 check to Dana, tagged at draft time. + let e = mcp.call( + "books.draft_entry", + json!({"date": "2026-05-01", "description": "Logo", "vendor": "Dana Designs"}), + ); + let id = e["entry_id"].as_u64().unwrap(); + for (account, side) in [("6000", "debit"), ("1000", "credit")] { + mcp.call( + "books.add_line", + json!({"entry_id": id, "account": account, "side": side, "amount": "2500.00"}), + ); + } + mcp.call( + "books.attach_receipt", + json!({ + "entry_id": id, + "file_name": "invoice.pdf", + "mime_type": "application/pdf", + "content_base64": base64_like(b"%PDF-1.4 invoice"), + }), + ); + mcp.call( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + ); + let got = mcp.call("books.get_entry", json!({"entry_id": id})); + assert_eq!(got["vendor"], "Dana Designs"); + + let r = mcp.call("books.vendor_1099", json!({"year": 2026})); + assert_eq!(r["threshold"], "2000.00"); + assert_eq!(r["vendors"][0]["paid"], "2500.00"); + assert_eq!(r["vendors"][0]["reportable"], true); + assert_eq!(r["missing_w9"], json!(["Dana Designs"])); + // An override above what was paid: not reportable. + let high = mcp.call( + "books.vendor_1099", + json!({"year": 2026, "threshold": "3000"}), + ); + assert_eq!(high["vendors"][0]["reportable"], false); + // W-9 arrives. + mcp.call( + "books.set_vendor_1099", + json!({"vendor": "Dana Designs", "form_1099": "nec", "tin_on_file": true}), + ); + drop(mcp); + + let mut mcp = Mcp::spawn(&dir); + init(&mut mcp); + let r = mcp.call("books.vendor_1099", json!({"year": 2026})); + assert_eq!(r["vendors"][0]["paid"], "2500.00"); + assert_eq!(r["missing_w9"], json!([])); + // Clearing the tag removes the payment. + mcp.call("books.set_entry_vendor", json!({"entry_id": id})); + let r = mcp.call("books.vendor_1099", json!({"year": 2026})); + assert_eq!(r["vendors"][0]["paid"], "0.00"); + let vendors = mcp.call("books.list_vendors", json!({})); + assert_eq!(vendors["vendors"][0]["tin_on_file"], true); +} diff --git a/crates/ledger-store/src/schema.rs b/crates/ledger-store/src/schema.rs index 69b803a..6663aee 100644 --- a/crates/ledger-store/src/schema.rs +++ b/crates/ledger-store/src/schema.rs @@ -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 +); "#, ]; diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index a3923b8..8fd5de0 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -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, + 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, + ) -> 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( diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index c3be5f0..e545922 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -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"); + } +} diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index ea8a62e..e7e8d8c 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -176,6 +176,28 @@ through the policy. naming the difference. A draft: attach the payout statement, preview, post. Platform-agnostic; format-specific statement parsers can feed it. +## Tools — 1099 tracking + +Payments are counted as cash: credits minus debits to asset accounts on +entries tagged with the vendor. Card payments move no asset (the card +processor's 1099-K covers them), so they drop out; reversals of tagged +entries carry the tag and net out. Thresholds: $600 for payments through +2025, $2,000 from 2026 (indexed for inflation after 2026); always +overridable, always for the CPA to confirm. TINs are never stored, only +whether a W-9 is on file. + +- `books.list_vendors {}` → `{ vendors: [{ id, name, form_1099?, + tin_on_file }] }`. +- `books.set_vendor_1099 { vendor, form_1099: "nec" | "misc" | "none", + tin_on_file? }` → the vendor. `vendor` is an id or a name. +- `books.set_entry_vendor { entry_id, vendor? }` → the entry (as + `books.get_entry`, which now shows `vendor`). Omitting `vendor` clears + the tag. Metadata only: posted entries may be tagged. `books.draft_entry` + takes `vendor` too; bills and their payments are tagged automatically. +- `books.vendor_1099 { year, threshold? }` → `{ year, threshold, vendors: + [{ vendor_id, name, form_1099?, tin_on_file, paid, reportable }], + missing_w9, notes }`. + ## Tools — receipts & documents - `books.attach_receipt { entry_id?, file_name, mime_type, content_base64 }` diff --git a/docs/parity.md b/docs/parity.md index 67ba12e..fe8ed9c 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -29,7 +29,6 @@ No other statuses. | Canonical report snapshots (JSON) | — (our oracle format) | done | `reports::snapshot` | | Fixed-asset register + straight-line depreciation | Fixed Asset register | done | `assets.rs`; golden-covered schedule; idempotent runs; disposal with gain/loss | | Cash flow statement (indirect) | Reports → Cash Flow | done | R-FUTURE-3 | -| Period close / retained earnings | Close books | todo | R-FUTURE-2 | | Foreign-currency lines (base + foreign stored) | Multi-currency accts | done | R-FUTURE-1 | | Period close (closing entry to Retained Earnings) | Close books | done | R-FUTURE-2 | | Bank reconciliation (cleared flags + report) | Reconcile | done | — | @@ -43,6 +42,7 @@ No other statuses. | Budgets | Budgets | todo | R-FUTURE-6 (declined as bloat) | | Scheduled transactions (recurring entries → drafts) | Scheduled txns / Recurring transactions | partial | hand-verified fixture incl. month-end dates; drafts only, by design | | Payout gross-up (deposit → revenue, fees, adjustments) | Bank deposit with fee lines; Xero/QBO Stripe & Shopify integrations | partial | hand-verified fixture incl. classes and a negative payout; no statement parsers yet | +| 1099 vendor tracking + year-end worksheet | Vendors → Track payments for 1099; 1099 report | partial | hand-verified fixture (bill payments, card exclusion, reversal, year boundary); no golden file yet | | Prepaid expense + deferred revenue amortization | Amortization schedules (Xero/QBO add-ons) | partial | hand-verified fixture, to the cent; no golden file yet | | Automated GnuCash diff harness | — (our `xtask oracle`) | todo | roadmap phase 3 | diff --git a/docs/requirements.md b/docs/requirements.md index 1da802d..76d905f 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -23,7 +23,8 @@ requirement conflict, the requirement wins. organize the chart and reject postings. - **R-5 — Single currency per ledger (day one).** Every account and every journal line uses the ledger's base currency. Anything else is rejected, - not converted. Multi-currency with translation is R-FUTURE-1. + not converted, unless the line records its foreign currency, amount, and + rate (R-FUTURE-1, done: see below). - **R-6 — Line hygiene.** Zero-amount lines are rejected, not ignored. Unknown accounts are rejected. Single-line entries are rejected. @@ -104,8 +105,8 @@ requirement conflict, the requirement wins. ## Explicitly future work -- **R-FUTURE-6 — Budgets and scheduled transactions.** (Budgets explicitly - declined as bloat; scheduled transactions remain future.) +- **R-FUTURE-6 — Budgets.** Explicitly declined as bloat. (Scheduled + transactions, the other half of R-FUTURE-6, are done: see below.) ## Promoted from future (done) @@ -143,6 +144,12 @@ requirement conflict, the requirement wins. - **R-? — Payout gross-up.** Platform deposits are recorded gross: revenue, fees, and adjustments, reconciled to the deposit to the cent, as a draft that carries the payout statement as its receipt. +- **R-? — 1099 tracking.** Vendors carry the information return they get + and whether a W-9 is on file (never the TIN). Entries carry the vendor + they paid; bills, bill payments, and reversals carry it automatically. + The year's worksheet counts cash paid (asset-account credits minus + debits), so card payments are excluded, against a year-dependent, + overridable threshold. Schema v7. - **R-? — A/R and A/P workflows.** Was R-FUTURE-5. `ledger-core::arap`: customers, vendors, invoices, bills, payments, AR/AP aging. MCP tools for the full workflow. diff --git a/plan/STATUS.md b/plan/STATUS.md index ed352a9..905e653 100644 --- a/plan/STATUS.md +++ b/plan/STATUS.md @@ -241,3 +241,11 @@ interface is MCP, not a cloned GUI. The first storage + MCP slice is done attached as the receipt. Pure core logic (payouts.rs), no new journal events (it journals as a draft and lines). 60 tools. Statement parsers (Apple, Google, Stripe) wait for sample files. +- 2026-10-11: 1099 tracking. Vendors get form_1099 (NEC/MISC) and + tin_on_file (never the TIN); entries get an optional vendor tag (bills, + bill payments, and reversals inherit it). books.vendor_1099 counts cash + paid per vendor per calendar year against $600 (≤2025) / $2,000 (2026+, + OBBBA) or an override, and lists reportable vendors missing a W-9. + Journal events vendor.1099_set and entry.vendor_set. Schema v7. 64 tools. + Also: requirements and parity no longer list finished R-FUTURE work as + future (only budgets remain, declined).