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,
|
pub id: VendorId,
|
||||||
/// Display name.
|
/// Display name.
|
||||||
pub name: String,
|
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.
|
/// 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 entries.
|
||||||
recurring: std::collections::BTreeMap<RecurringId, RecurringEntry>,
|
recurring: std::collections::BTreeMap<RecurringId, RecurringEntry>,
|
||||||
next_recurring_id: RecurringId,
|
next_recurring_id: RecurringId,
|
||||||
|
/// Who each entry paid or was billed by, for 1099 reporting.
|
||||||
|
entry_vendors: HashMap<EntryId, VendorId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Ledger {
|
impl Ledger {
|
||||||
@@ -138,6 +140,7 @@ impl Ledger {
|
|||||||
next_schedule_id: 1,
|
next_schedule_id: 1,
|
||||||
recurring: std::collections::BTreeMap::new(),
|
recurring: std::collections::BTreeMap::new(),
|
||||||
next_recurring_id: 1,
|
next_recurring_id: 1,
|
||||||
|
entry_vendors: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,6 +638,10 @@ impl Ledger {
|
|||||||
.ok_or(LedgerError::UnknownEntry(id))?
|
.ok_or(LedgerError::UnknownEntry(id))?
|
||||||
.mark_reversal_of(entry);
|
.mark_reversal_of(entry);
|
||||||
self.post_entry(id)?;
|
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)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1127,6 +1134,8 @@ impl Ledger {
|
|||||||
Vendor {
|
Vendor {
|
||||||
id,
|
id,
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
|
form_1099: None,
|
||||||
|
tin_on_file: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
Ok(id)
|
Ok(id)
|
||||||
@@ -1142,6 +1151,53 @@ impl Ledger {
|
|||||||
self.vendors.get(&id)
|
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.
|
/// All customers, in id order.
|
||||||
pub fn list_customers(&self) -> Vec<&Customer> {
|
pub fn list_customers(&self) -> Vec<&Customer> {
|
||||||
let mut ids: Vec<CustomerId> = self.customers.keys().copied().collect();
|
let mut ids: Vec<CustomerId> = self.customers.keys().copied().collect();
|
||||||
@@ -1254,6 +1310,7 @@ impl Ledger {
|
|||||||
entry_id: entry,
|
entry_id: entry,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
self.entry_vendors.insert(entry, vendor_id);
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1336,6 +1393,7 @@ impl Ledger {
|
|||||||
let b = self.bills.get_mut(&bill_id).expect("bill exists");
|
let b = self.bills.get_mut(&bill_id).expect("bill exists");
|
||||||
b.amount_paid += amount;
|
b.amount_paid += amount;
|
||||||
b.status = arap::derive_status(b.amount_paid, b.total());
|
b.status = arap::derive_status(b.amount_paid, b.total());
|
||||||
|
self.entry_vendors.insert(entry, bill.vendor_id);
|
||||||
Ok(entry)
|
Ok(entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1403,6 +1461,7 @@ impl Ledger {
|
|||||||
entry_id,
|
entry_id,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
self.entry_vendors.insert(entry_id, vendor_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1437,13 +1496,14 @@ impl Ledger {
|
|||||||
amount: Decimal,
|
amount: Decimal,
|
||||||
entry_id: EntryId,
|
entry_id: EntryId,
|
||||||
) -> Result<(), LedgerError> {
|
) -> Result<(), LedgerError> {
|
||||||
let _ = entry_id;
|
|
||||||
let bill = self
|
let bill = self
|
||||||
.bills
|
.bills
|
||||||
.get_mut(&bill_id)
|
.get_mut(&bill_id)
|
||||||
.ok_or(LedgerError::UnknownBill(bill_id))?;
|
.ok_or(LedgerError::UnknownBill(bill_id))?;
|
||||||
bill.amount_paid += amount;
|
bill.amount_paid += amount;
|
||||||
bill.status = arap::derive_status(bill.amount_paid, bill.total());
|
bill.status = arap::derive_status(bill.amount_paid, bill.total());
|
||||||
|
let vendor = bill.vendor_id;
|
||||||
|
self.entry_vendors.insert(entry_id, vendor);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub mod arap;
|
|||||||
pub mod assets;
|
pub mod assets;
|
||||||
pub mod classes;
|
pub mod classes;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod form1099;
|
||||||
pub mod journal;
|
pub mod journal;
|
||||||
pub mod ledger;
|
pub mod ledger;
|
||||||
pub mod money;
|
pub mod money;
|
||||||
@@ -42,6 +43,7 @@ pub use assets::{
|
|||||||
};
|
};
|
||||||
pub use classes::{Class, ClassId};
|
pub use classes::{Class, ClassId};
|
||||||
pub use error::LedgerError;
|
pub use error::LedgerError;
|
||||||
|
pub use form1099::{Form1099, Vendor1099Line, Vendor1099Report, vendor_1099_report};
|
||||||
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
|
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
|
||||||
pub use ledger::{Ledger, ReconciliationReport, UnclearedLine};
|
pub use ledger::{Ledger, ReconciliationReport, UnclearedLine};
|
||||||
pub use money::{Currency, Money};
|
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);
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ use rust_decimal::Decimal;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
/// The agent operating contract, delivered via initialize.
|
/// 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.
|
/// Max receipt/document upload: 25 MB.
|
||||||
const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
|
const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
|
||||||
@@ -316,6 +316,32 @@ fn resolve_account(store: &Store, account: &str) -> Result<AccountId, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a class by id or name (case-insensitive).
|
/// Resolve a class by id or name (case-insensitive).
|
||||||
|
fn resolve_vendor(store: &Store, vendor: &str) -> Result<u64, String> {
|
||||||
|
if let Ok(id) = vendor.parse::<u64>()
|
||||||
|
&& store.ledger().get_vendor(id).is_some()
|
||||||
|
{
|
||||||
|
return Ok(id);
|
||||||
|
}
|
||||||
|
let matches: Vec<u64> = 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<ledger_core::Form1099>) -> Option<String> {
|
||||||
|
form.map(|f| f.label().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn resolve_class(store: &Store, class: &str) -> Result<u32, String> {
|
fn resolve_class(store: &Store, class: &str) -> Result<u32, String> {
|
||||||
if let Ok(id) = class.parse::<u32>()
|
if let Ok(id) = class.parse::<u32>()
|
||||||
&& store.ledger().get_class(id).is_some()
|
&& store.ledger().get_class(id).is_some()
|
||||||
@@ -814,9 +840,19 @@ impl BooksServer {
|
|||||||
) -> Result<Json<DraftEntryOut>, String> {
|
) -> Result<Json<DraftEntryOut>, String> {
|
||||||
let p = params.0;
|
let p = params.0;
|
||||||
let mut store = self.lock()?;
|
let mut store = self.lock()?;
|
||||||
|
let vendor = p
|
||||||
|
.vendor
|
||||||
|
.as_deref()
|
||||||
|
.map(|v| resolve_vendor(&store, v))
|
||||||
|
.transpose()?;
|
||||||
let id = store
|
let id = store
|
||||||
.create_entry(parse_date(&p.date)?, &p.description)
|
.create_entry(parse_date(&p.date)?, &p.description)
|
||||||
.map_err(|e| e.to_string())?;
|
.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 {
|
Ok(Json(DraftEntryOut {
|
||||||
entry_id: id,
|
entry_id: id,
|
||||||
status: "draft".to_string(),
|
status: "draft".to_string(),
|
||||||
@@ -1287,6 +1323,11 @@ impl BooksServer {
|
|||||||
lines,
|
lines,
|
||||||
total: money(ReceiptPolicy::entry_debits(&entry)),
|
total: money(ReceiptPolicy::entry_debits(&entry)),
|
||||||
receipts: preview_receipts(&store, p.entry_id)?,
|
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<Json<ListVendorsOut>, 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<SetVendor1099Params>,
|
||||||
|
) -> Result<Json<VendorOut>, 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<SetEntryVendorParams>,
|
||||||
|
) -> Result<Json<GetEntryOut>, 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<Vendor1099Params>,
|
||||||
|
) -> Result<Json<Vendor1099Out>, 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(
|
#[tool(
|
||||||
name = "books.chart_template",
|
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.",
|
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.",
|
||||||
|
|||||||
@@ -291,6 +291,90 @@ pub struct RecordPayoutOut {
|
|||||||
pub next: String,
|
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<String>,
|
||||||
|
/// 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<VendorOut>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
/// 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<Vendor1099LineOut>,
|
||||||
|
/// Reportable vendors without a W-9 on file: request one.
|
||||||
|
pub missing_w9: Vec<String>,
|
||||||
|
/// Caveats for the human and CPA.
|
||||||
|
pub notes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Parameters for `books.chart_template`.
|
/// Parameters for `books.chart_template`.
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
pub struct ChartTemplateParams {
|
pub struct ChartTemplateParams {
|
||||||
@@ -638,6 +722,8 @@ pub struct DraftEntryParams {
|
|||||||
pub date: String,
|
pub date: String,
|
||||||
/// Description of what happened.
|
/// Description of what happened.
|
||||||
pub description: String,
|
pub description: String,
|
||||||
|
/// The vendor this entry pays (id or name), for 1099 tracking.
|
||||||
|
pub vendor: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of `books.draft_entry`.
|
/// Result of `books.draft_entry`.
|
||||||
@@ -996,6 +1082,8 @@ pub struct GetEntryOut {
|
|||||||
pub total: String,
|
pub total: String,
|
||||||
/// Attached receipts.
|
/// Attached receipts.
|
||||||
pub receipts: Vec<PreviewReceipt>,
|
pub receipts: Vec<PreviewReceipt>,
|
||||||
|
/// The vendor the entry is tagged with, for 1099 tracking.
|
||||||
|
pub vendor: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ fn mcp_end_to_end_over_stdio() {
|
|||||||
// Tool listing: 23 narrow tools, chart is read-only.
|
// Tool listing: 23 narrow tools, chart is read-only.
|
||||||
let list = mcp.request("tools/list", json!({}));
|
let list = mcp.request("tools/list", json!({}));
|
||||||
let tools = list["result"]["tools"].as_array().unwrap();
|
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();
|
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
|
||||||
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
||||||
assert!(
|
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);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
//! is the current version.
|
//! is the current version.
|
||||||
|
|
||||||
/// Current schema 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`.
|
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
|
||||||
pub const MIGRATIONS: &[&str] = &[
|
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.
|
-- 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 tax_category TEXT;
|
||||||
ALTER TABLE accounts ADD COLUMN deductible_percent 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
|
||||||
|
);
|
||||||
"#,
|
"#,
|
||||||
];
|
];
|
||||||
@@ -1952,6 +1952,51 @@ impl Store {
|
|||||||
Ok(())
|
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
|
/// Link a previously filed (unlinked) receipt to an entry. A receipt
|
||||||
/// already linked elsewhere must be unlinked first, so moving evidence
|
/// already linked elsewhere must be unlinked first, so moving evidence
|
||||||
/// off an entry always goes through [`Store::unlink_receipt`] and its
|
/// 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" => {
|
"account.tax_set" => {
|
||||||
let pct = match p.get("deductible_percent").and_then(|v| v.as_str()) {
|
let pct = match p.get("deductible_percent").and_then(|v| v.as_str()) {
|
||||||
Some(s) => Some(s.parse().map_err(|_| {
|
Some(s) => Some(s.parse().map_err(|_| {
|
||||||
@@ -3363,6 +3429,12 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"entry.reversed" => {
|
"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(
|
conn.execute(
|
||||||
"INSERT INTO entries(id, date, description, status, reversal_of) VALUES (?,?,?,'posted',?)",
|
"INSERT INTO entries(id, date, description, status, reversal_of) VALUES (?,?,?,'posted',?)",
|
||||||
params![
|
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" => {
|
"account.tax_set" => {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE accounts SET tax_category = ?, deductible_percent = ? WHERE id = ?",
|
"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,
|
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" => {
|
"invoice.payment_received" => {
|
||||||
let amount: Decimal = get_str(p, "amount")?
|
let amount: Decimal = get_str(p, "amount")?
|
||||||
@@ -3603,6 +3708,14 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
|
|||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?;
|
.map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?;
|
||||||
apply_document_payment(conn, "bills", get_u64(p, "bill_id")?, amount)?;
|
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" => {
|
"fx.rate_set" => {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@@ -612,3 +612,74 @@ fn schedules_and_recurring_entries_replay_exactly() {
|
|||||||
assert!(store.run_schedules(date(2, 28)).unwrap().is_empty());
|
assert!(store.run_schedules(date(2, 28)).unwrap().is_empty());
|
||||||
assert!(store.run_recurring(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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -176,6 +176,28 @@ through the policy.
|
|||||||
naming the difference. A draft: attach the payout statement, preview,
|
naming the difference. A draft: attach the payout statement, preview,
|
||||||
post. Platform-agnostic; format-specific statement parsers can feed it.
|
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
|
## Tools — receipts & documents
|
||||||
|
|
||||||
- `books.attach_receipt { entry_id?, file_name, mime_type, content_base64 }`
|
- `books.attach_receipt { entry_id?, file_name, mime_type, content_base64 }`
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,6 @@ No other statuses.
|
|||||||
| Canonical report snapshots (JSON) | — (our oracle format) | done | `reports::snapshot` |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Period close (closing entry to Retained Earnings) | Close books | done | R-FUTURE-2 |
|
||||||
| Bank reconciliation (cleared flags + report) | Reconcile | done | — |
|
| Bank reconciliation (cleared flags + report) | Reconcile | done | — |
|
||||||
@@ -43,6 +42,7 @@ No other statuses.
|
|||||||
| Budgets | Budgets | todo | R-FUTURE-6 (declined as bloat) |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Automated GnuCash diff harness | — (our `xtask oracle`) | todo | roadmap phase 3 |
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -23,7 +23,8 @@ requirement conflict, the requirement wins.
|
|||||||
organize the chart and reject postings.
|
organize the chart and reject postings.
|
||||||
- **R-5 — Single currency per ledger (day one).** Every account and every
|
- **R-5 — Single currency per ledger (day one).** Every account and every
|
||||||
journal line uses the ledger's base currency. Anything else is rejected,
|
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.
|
- **R-6 — Line hygiene.** Zero-amount lines are rejected, not ignored.
|
||||||
Unknown accounts are rejected. Single-line entries are rejected.
|
Unknown accounts are rejected. Single-line entries are rejected.
|
||||||
|
|
||||||
@@ -104,8 +105,8 @@ requirement conflict, the requirement wins.
|
|||||||
|
|
||||||
## Explicitly future work
|
## Explicitly future work
|
||||||
|
|
||||||
- **R-FUTURE-6 — Budgets and scheduled transactions.** (Budgets explicitly
|
- **R-FUTURE-6 — Budgets.** Explicitly declined as bloat. (Scheduled
|
||||||
declined as bloat; scheduled transactions remain future.)
|
transactions, the other half of R-FUTURE-6, are done: see below.)
|
||||||
|
|
||||||
## Promoted from future (done)
|
## Promoted from future (done)
|
||||||
|
|
||||||
@@ -143,6 +144,12 @@ requirement conflict, the requirement wins.
|
|||||||
- **R-? — Payout gross-up.** Platform deposits are recorded gross:
|
- **R-? — Payout gross-up.** Platform deposits are recorded gross:
|
||||||
revenue, fees, and adjustments, reconciled to the deposit to the cent,
|
revenue, fees, and adjustments, reconciled to the deposit to the cent,
|
||||||
as a draft that carries the payout statement as its receipt.
|
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.
|
- **R-? — A/R and A/P workflows.** Was R-FUTURE-5.
|
||||||
`ledger-core::arap`: customers, vendors, invoices, bills, payments,
|
`ledger-core::arap`: customers, vendors, invoices, bills, payments,
|
||||||
AR/AP aging. MCP tools for the full workflow.
|
AR/AP aging. MCP tools for the full workflow.
|
||||||
|
|||||||
@@ -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
|
attached as the receipt. Pure core logic (payouts.rs), no new journal
|
||||||
events (it journals as a draft and lines). 60 tools. Statement parsers
|
events (it journals as a draft and lines). 60 tools. Statement parsers
|
||||||
(Apple, Google, Stripe) wait for sample files.
|
(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).
|
||||||
Reference in new issue
Block a user