feat(core,mcp,gui): never apply a legal rule past what wanderbooks knows
Stephen (2026-10-13): when what wanderbooks knows is stale, check the date, research it or ask the owner or CPA, and update the rule for everyone with a pull request (best) or an issue (worst). - ledger_core::rules: a registry of rules set by law that change over time. Each says the years it's known for, its rules.md entry, its vendored official source, and a review date. stale_rules(today) is pure; the engine has no clock. - The 1099 threshold is the first: $600 through 2025, $2,000 in 2026, then indexed for inflation (26 U.S.C. §6041(h)). default_threshold and vendor_1099_report now return Result; for 2027+ they refuse with LedgerError::RuleNotKnown, whose message says what to do: get the figure from the official source or the owner/CPA, pass it explicitly, then open a PR (or issue) at github.com/swaits/wanderbooks. Before, 2027+ silently used $2,000. - MCP: books.vendor_1099 relays that refusal; books.digest gains stale_rules; the server instructions tell the agent the procedure (and how to handle control-account conflicts: explain, never renumber). The CPA package writes a NOT-INCLUDED note instead of a guessed 1099 worksheet. - Viewer: "Needs attention" shows each stale rule with a ready prompt for the agent; the 1099 page explains instead of guessing. Review date for the 1099 rule: 2026-11-01, when the IRS usually publishes next year's inflation adjustments (CONVENTION, marked as such in the registry). Tests: an_unknown_year_threshold_is_never_guessed, dated_rules_go_stale_on_their_review_date (dates passed in, so no clock in the test), MCP stdio: 2027 refused with guidance, explicit threshold accepted, digest carries stale_rules (asserted date-independently). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
1 parent
e2bebce051
commit
df38dcccf9
15 files changed
+338
-36
No files matched your search
@@ -102,6 +102,27 @@ pub enum LedgerError {
|
||||
placeholder: bool,
|
||||
},
|
||||
|
||||
/// A rule set by law (a dollar threshold, a rate) isn't known to
|
||||
/// wanderbooks for the year asked; the engine never guesses (see
|
||||
/// [`crate::rules`]).
|
||||
#[error(
|
||||
"wanderbooks knows the {rule} only through {known_through}, not for {year}. \
|
||||
Find the {year} figure from the official source (see {spec}) or ask the books' \
|
||||
owner or CPA, and pass it explicitly. Then update the rule for everyone: open a \
|
||||
pull request (or at least an issue) at https://github.com/swaits/wanderbooks with \
|
||||
the new value and its official source."
|
||||
)]
|
||||
RuleNotKnown {
|
||||
/// The rule's plain name.
|
||||
rule: String,
|
||||
/// The year asked for.
|
||||
year: i32,
|
||||
/// The last year the rule is known for.
|
||||
known_through: i32,
|
||||
/// The vendored official source of the rule.
|
||||
spec: String,
|
||||
},
|
||||
|
||||
/// The referenced journal entry does not exist.
|
||||
#[error("entry {0} does not exist")]
|
||||
UnknownEntry(u64),
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//! 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 crate::{AccountType, Ledger, LedgerError, VendorId};
|
||||
use chrono::Datelike;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -47,17 +47,40 @@ impl Form1099 {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// The reporting threshold for payments made in `year`.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: the dollar amount at which a vendor's payments become reportable.
|
||||
/// SOURCE: docs/spec/rules.md B-19 (26 U.S.C. §6041(a), (h), §6041A(a)(2),
|
||||
/// Pub. L. 119-21 §70433; sources/irs-1099-thresholds.md).
|
||||
/// SCENARIOS:
|
||||
/// 1. IF `year` <= 2025: SHOULD be $600.
|
||||
/// 2. IF `year` == 2026: SHOULD be $2,000.
|
||||
/// 3. IF `year` > rules::THRESHOLD_1099.known_through:
|
||||
/// - SHOULD refuse with RuleNotKnown
|
||||
/// - NEVER guess $2,000
|
||||
/// BECAUSE the amount is indexed for inflation after 2026 and not
|
||||
/// yet known (Stephen, 2026-10-13: never apply stale rules).
|
||||
/// ```
|
||||
///
|
||||
/// Royalties (1099-MISC, $10) and attorneys' gross proceeds ($600) are not
|
||||
/// modeled. Pass an explicit threshold to [`vendor_1099_report`] to
|
||||
/// override.
|
||||
pub fn default_threshold(year: i32) -> Result<Decimal, LedgerError> {
|
||||
let rule = crate::rules::THRESHOLD_1099;
|
||||
if year > rule.known_through {
|
||||
return Err(LedgerError::RuleNotKnown {
|
||||
rule: rule.name.to_string(),
|
||||
year,
|
||||
known_through: rule.known_through,
|
||||
spec: rule.source.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(if year <= 2025 {
|
||||
Decimal::new(600, 0)
|
||||
} else {
|
||||
Decimal::new(2000, 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// One vendor's year.
|
||||
@@ -94,8 +117,11 @@ pub fn vendor_1099_report(
|
||||
ledger: &Ledger,
|
||||
year: i32,
|
||||
threshold: Option<Decimal>,
|
||||
) -> Vendor1099Report {
|
||||
let threshold = threshold.unwrap_or_else(|| default_threshold(year));
|
||||
) -> Result<Vendor1099Report, LedgerError> {
|
||||
let threshold = match threshold {
|
||||
Some(t) => t,
|
||||
None => default_threshold(year)?,
|
||||
};
|
||||
let mut paid: std::collections::HashMap<VendorId, Decimal> = Default::default();
|
||||
for entry in ledger.posted_entries() {
|
||||
if entry.date().year() != year {
|
||||
@@ -137,9 +163,9 @@ pub fn vendor_1099_report(
|
||||
})
|
||||
.collect();
|
||||
vendors.sort_by(|a, b| a.name.cmp(&b.name).then(a.vendor_id.cmp(&b.vendor_id)));
|
||||
Vendor1099Report {
|
||||
Ok(Vendor1099Report {
|
||||
year,
|
||||
threshold,
|
||||
vendors,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -33,6 +33,7 @@ pub mod periods;
|
||||
#[cfg(kani)]
|
||||
mod proofs;
|
||||
pub mod reports;
|
||||
pub mod rules;
|
||||
pub mod schedules;
|
||||
pub mod tax;
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Rules set by law that change over time, and how far wanderbooks knows
|
||||
//! them.
|
||||
//!
|
||||
//! The engine has no clock, so it never decides on its own that a rule is
|
||||
//! out of date. Instead each dated rule says which years it is known for
|
||||
//! and when it is due for review; callers that know today's date (the MCP
|
||||
//! server, the viewer) ask [`stale_rules`] and surface what is due.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: never apply a legal amount for a year it isn't known for, and say
|
||||
//! so before anyone needs it.
|
||||
//! SOURCE: DECISION (Stephen, 2026-10-13): when what wanderbooks knows is
|
||||
//! stale, research it or ask the owner or CPA, and update the rule
|
||||
//! for everyone (a pull request, or at least an issue, on
|
||||
//! github.com/swaits/wanderbooks).
|
||||
//! INVARIANTS:
|
||||
//! 1. NEVER returns a rule's value for a year past `known_through`.
|
||||
//! 2. MUST report a rule as stale from its review date on.
|
||||
//! ```
|
||||
//!
|
||||
//! To update a rule: vendor the new official text beside the old in its
|
||||
//! `source` file (docs/spec/README.md, "Vendoring rules"), extend the
|
||||
//! rule's table and `known_through`, move `review_after` to when the next
|
||||
//! change is expected, and say so in the commit.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
|
||||
/// A rule set by law whose value changes over time.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct DatedRule {
|
||||
/// Plain name, e.g. "1099 reporting threshold".
|
||||
pub name: &'static str,
|
||||
/// Its entry in docs/spec/rules.md, e.g. "B-19".
|
||||
pub catalog: &'static str,
|
||||
/// The vendored official text it comes from.
|
||||
pub source: &'static str,
|
||||
/// The last year wanderbooks knows the rule for.
|
||||
pub known_through: i32,
|
||||
/// When to look again, as (year, month, day): the day the next value
|
||||
/// is expected to be published or to take effect.
|
||||
pub review: (i32, u32, u32),
|
||||
/// Why that date, and where the next value will come from.
|
||||
pub review_why: &'static str,
|
||||
}
|
||||
|
||||
impl DatedRule {
|
||||
/// The review date.
|
||||
pub fn review_after(&self) -> NaiveDate {
|
||||
let (y, m, d) = self.review;
|
||||
NaiveDate::from_ymd_opt(y, m, d).expect("rule review dates are valid")
|
||||
}
|
||||
|
||||
/// Whether the rule is due for review on `today`.
|
||||
pub fn is_stale(&self, today: NaiveDate) -> bool {
|
||||
today >= self.review_after()
|
||||
}
|
||||
}
|
||||
|
||||
/// The 1099-NEC / 1099-MISC reporting threshold (rules.md B-19): $600 for
|
||||
/// payments through 2025, $2,000 in 2026, then indexed for inflation.
|
||||
pub const THRESHOLD_1099: DatedRule = DatedRule {
|
||||
name: "1099 reporting threshold",
|
||||
catalog: "B-19",
|
||||
source: "docs/spec/sources/irs-1099-thresholds.md",
|
||||
known_through: 2026,
|
||||
review: (2026, 11, 1),
|
||||
review_why: "26 U.S.C. §6041(h) indexes the threshold for calendar years after 2026, \
|
||||
rounded to the nearest $100; the IRS publishes each year's inflation \
|
||||
adjustments late in the year before (CONVENTION: the usual timing, not a \
|
||||
cited date), so look for the 2027 figure from November 2026.",
|
||||
};
|
||||
|
||||
/// Every dated rule the engine applies.
|
||||
pub const DATED_RULES: &[DatedRule] = &[THRESHOLD_1099];
|
||||
|
||||
/// The dated rules due for review on `today`.
|
||||
pub fn stale_rules(today: NaiveDate) -> Vec<&'static DatedRule> {
|
||||
DATED_RULES.iter().filter(|r| r.is_stale(today)).collect()
|
||||
}
|
||||
@@ -86,7 +86,7 @@ fn books() -> Ledger {
|
||||
#[test]
|
||||
fn cash_paid_per_vendor_and_who_is_reportable() {
|
||||
let l = books();
|
||||
let r = vendor_1099_report(&l, 2026, None);
|
||||
let r = vendor_1099_report(&l, 2026, None).unwrap();
|
||||
assert_eq!(r.threshold, dec("2000"));
|
||||
let rows: Vec<(&str, Decimal, bool)> = r
|
||||
.vendors
|
||||
@@ -110,14 +110,15 @@ fn cash_paid_per_vendor_and_who_is_reportable() {
|
||||
#[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);
|
||||
// SOURCE: rules.md B-19 (sources/irs-1099-thresholds.md).
|
||||
assert_eq!(default_threshold(2025), Ok(dec("600")));
|
||||
assert_eq!(default_threshold(2026), Ok(dec("2000")));
|
||||
let y2025 = vendor_1099_report(&l, 2025, None).unwrap();
|
||||
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")));
|
||||
let r = vendor_1099_report(&l, 2026, Some(dec("1000"))).unwrap();
|
||||
assert!(
|
||||
r.vendors
|
||||
.iter()
|
||||
@@ -145,3 +146,60 @@ fn tags_need_real_entries_and_vendors() {
|
||||
assert_eq!(Form1099::parse("misc"), Some(Form1099::Misc));
|
||||
assert_eq!(Form1099::parse("K"), None);
|
||||
}
|
||||
|
||||
/// Spec (Stephen, 2026-10-13): a rule set by law that wanderbooks doesn't
|
||||
/// know for a year is never guessed. 26 U.S.C. §6041(h) indexes the $2,000
|
||||
/// threshold for calendar years after 2026; until the indexed amount is in
|
||||
/// the rule table, asking for it is an error that says what to do.
|
||||
#[test]
|
||||
fn an_unknown_year_threshold_is_never_guessed() {
|
||||
let err = default_threshold(2027).unwrap_err();
|
||||
let LedgerError::RuleNotKnown {
|
||||
rule,
|
||||
year,
|
||||
known_through,
|
||||
spec,
|
||||
} = &err
|
||||
else {
|
||||
panic!("expected RuleNotKnown, got {err:?}");
|
||||
};
|
||||
assert_eq!(
|
||||
(rule.as_str(), *year, *known_through),
|
||||
("1099 reporting threshold", 2027, 2026)
|
||||
);
|
||||
assert!(spec.ends_with("irs-1099-thresholds.md"), "{spec}");
|
||||
let why = err.to_string();
|
||||
for needle in [
|
||||
"2027",
|
||||
"official source",
|
||||
"CPA",
|
||||
"github.com/swaits/wanderbooks",
|
||||
] {
|
||||
assert!(why.contains(needle), "missing {needle:?} in: {why}");
|
||||
}
|
||||
let l = books();
|
||||
assert!(matches!(
|
||||
vendor_1099_report(&l, 2027, None),
|
||||
Err(LedgerError::RuleNotKnown { .. })
|
||||
));
|
||||
// An explicit threshold (from the IRS, or the owner or CPA) works.
|
||||
let r = vendor_1099_report(&l, 2027, Some(dec("2100"))).unwrap();
|
||||
assert_eq!(r.threshold, dec("2100"));
|
||||
}
|
||||
|
||||
/// Spec: a dated rule is due for review from its review date, and stays
|
||||
/// due; before it, nothing is stale. The engine has no clock: callers pass
|
||||
/// today.
|
||||
#[test]
|
||||
fn dated_rules_go_stale_on_their_review_date() {
|
||||
use ledger_core::rules::{THRESHOLD_1099, stale_rules};
|
||||
assert!(stale_rules(d(2026, 10, 13)).is_empty());
|
||||
let due = THRESHOLD_1099.review_after();
|
||||
assert!(stale_rules(due.pred_opt().unwrap()).is_empty());
|
||||
assert_eq!(stale_rules(due).len(), 1);
|
||||
assert_eq!(
|
||||
stale_rules(d(2027, 1, 1))[0].name,
|
||||
"1099 reporting threshold"
|
||||
);
|
||||
assert_eq!(stale_rules(d(2030, 6, 1)).len(), 1);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! What the books need: drafts left unposted, missing receipts, schedules
|
||||
//! and recurring entries due, open past periods, uncategorized tax lines,
|
||||
//! 1099 vendors without a W-9, overdue bills and invoices, money owed to
|
||||
//! owners. Each item says what's wrong, where to look, and the prompt that
|
||||
//! owners, and legal rules (like the 1099 threshold) due for an update. Each item says what's wrong, where to look, and the prompt that
|
||||
//! asks the agent to fix it (the GUI never changes the books itself).
|
||||
|
||||
use crate::books::{Attention, Books, Severity};
|
||||
@@ -176,12 +176,34 @@ pub fn find(b: &Books) -> Vec<Attention> {
|
||||
}
|
||||
}
|
||||
|
||||
// Rules set by law that wanderbooks may no longer know (rules.md;
|
||||
// Stephen, 2026-10-13): research or ask, then update them for everyone.
|
||||
for rule in ledger_core::rules::stale_rules(today) {
|
||||
out.push(Attention {
|
||||
severity: Severity::Todo,
|
||||
title: format!("Check the {}", rule.name),
|
||||
detail: format!(
|
||||
"wanderbooks knows it through {}. {}",
|
||||
rule.known_through, rule.review_why
|
||||
),
|
||||
target: None,
|
||||
prompt: format!(
|
||||
"wanderbooks' {} is known only through {}. Find the current figure from the official source (see {} in the wanderbooks repo), or ask me or our CPA, and tell me what you found. Then update the rule for everyone: open a pull request at https://github.com/swaits/wanderbooks with the new value and its official source (or at least an issue).",
|
||||
rule.name, rule.known_through, rule.source
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let mut years = vec![today.year()];
|
||||
if today.month() == 1 {
|
||||
years.insert(0, today.year() - 1);
|
||||
}
|
||||
for year in years {
|
||||
let r = vendor_1099_report(&b.ledger, year, None);
|
||||
// A year whose threshold isn't known is flagged by the stale-rule
|
||||
// item above; never guess it here.
|
||||
let Ok(r) = vendor_1099_report(&b.ledger, year, None) else {
|
||||
continue;
|
||||
};
|
||||
for v in r.vendors.iter().filter(|v| v.reportable && !v.tin_on_file) {
|
||||
out.push(Attention {
|
||||
severity: Severity::Problem,
|
||||
|
||||
@@ -413,7 +413,7 @@ fn recurring(ui: &mut Ui, b: &Books) {
|
||||
fn parties(ui: &mut Ui, b: &Books) {
|
||||
let p = pal(ui.ctx());
|
||||
let year = chrono::Datelike::year(&b.today);
|
||||
let r1099 = vendor_1099_report(&b.ledger, year, None);
|
||||
let r1099 = vendor_1099_report(&b.ledger, year, None).ok();
|
||||
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
|
||||
0 => {
|
||||
widgets::titled_card(
|
||||
@@ -427,8 +427,8 @@ fn parties(ui: &mut Ui, b: &Books) {
|
||||
}
|
||||
for v in vendors {
|
||||
let paid = r1099
|
||||
.vendors
|
||||
.iter()
|
||||
.flat_map(|r| &r.vendors)
|
||||
.find(|x| x.vendor_id == v.id)
|
||||
.map(|x| x.paid)
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -1777,7 +1777,15 @@ fn gl(ui: &mut Ui, b: &Books, s: NaiveDate, e: NaiveDate, shell: &mut Shell<'_>)
|
||||
|
||||
fn vendor_1099(ui: &mut Ui, b: &Books, year: i32, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
let r = vendor_1099_report(&b.ledger, year, None);
|
||||
let r = match vendor_1099_report(&b.ledger, year, None) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
// Never guess a threshold (rules.md B-19): say why there's no
|
||||
// worksheet, in the engine's words.
|
||||
ui.label(RichText::new(e.to_string()).color(p.muted));
|
||||
return;
|
||||
}
|
||||
};
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"Calendar {year}. Threshold {} ($600 through 2025; $2,000 from 2026, indexed after). Counts cash paid on entries tagged with the vendor; card payments are reported by the card processor (1099-K) and drop out.",
|
||||
|
||||
@@ -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.\n- The viewer: the human usually has the read-only viewer open beside you (`just gui`). When they ask to see something, and after you post, call books.show so it's on their screen.\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.";
|
||||
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- The viewer: the human usually has the read-only viewer open beside you (`just gui`). When they ask to see something, and after you post, call books.show so it's on their screen.\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.\n- Rules that change with the law: wanderbooks knows some legal amounts (like the 1099 threshold) only through a given year and never guesses past it. When a tool refuses with \"wanderbooks knows the ... only through\", or books.digest lists stale_rules: check today's date, find the current figure from the official source (the refusal names the vendored source file) or ask the human (owner, CPA), confirm it with them, and pass it explicitly. Then update the rule for everyone: open a pull request at https://github.com/swaits/wanderbooks with the new value and its official text (best), or at least an issue; if you can't reach GitHub, give the human the issue text to file.\n- Control accounts: invoices, bills, payroll, and closes post to accounts found by code (1200, 2000, 4000, 6999, 3999, 6100-2150). If the chart already uses one of those codes for a different kind of account, the engine refuses and says which. Never rename or renumber the human's accounts on your own: explain the conflict and let them decide.";
|
||||
|
||||
/// Max receipt/document upload: 25 MB.
|
||||
const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
|
||||
@@ -1613,10 +1613,21 @@ impl BooksServer {
|
||||
}
|
||||
}
|
||||
totals.sort_by(|a, b| a.code.cmp(&b.code));
|
||||
let stale_rules = ledger_core::rules::stale_rules(today)
|
||||
.into_iter()
|
||||
.map(|r| StaleRule {
|
||||
name: r.name.to_string(),
|
||||
known_through: r.known_through,
|
||||
catalog: r.catalog.to_string(),
|
||||
source: r.source.to_string(),
|
||||
why: r.review_why.to_string(),
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(DigestOut {
|
||||
since: since.to_string(),
|
||||
entries,
|
||||
totals_by_account: totals,
|
||||
stale_rules,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -2058,7 +2069,7 @@ impl BooksServer {
|
||||
|
||||
#[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.",
|
||||
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 in 2026, indexed for inflation after that), and which are missing a W-9. For a year whose threshold wanderbooks doesn't know yet it refuses rather than guess: get the figure from the IRS or the human, pass it as `threshold`, and update the rule for everyone (pull request or issue at github.com/swaits/wanderbooks). For the CPA to confirm.",
|
||||
annotations(read_only_hint = true)
|
||||
)]
|
||||
async fn vendor_1099(
|
||||
@@ -2068,7 +2079,10 @@ impl BooksServer {
|
||||
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);
|
||||
// A year past what wanderbooks knows is refused with guidance
|
||||
// (research or ask, pass `threshold`, then update the rule).
|
||||
let r = ledger_core::vendor_1099_report(store.ledger(), p.year, threshold)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let missing_w9 = r
|
||||
.vendors
|
||||
.iter()
|
||||
@@ -3574,7 +3588,8 @@ impl BooksServer {
|
||||
));
|
||||
// 1099 worksheet for the calendar year of as_of.
|
||||
let year = chrono::Datelike::year(&as_of);
|
||||
let r1099 = ledger_core::vendor_1099_report(ledger, year, None);
|
||||
match ledger_core::vendor_1099_report(ledger, year, None) {
|
||||
Ok(r1099) => {
|
||||
let mut w = String::from("vendor,form,w9_on_file,paid,reportable\n");
|
||||
for v in &r1099.vendors {
|
||||
w.push_str(&format!(
|
||||
@@ -3587,6 +3602,14 @@ impl BooksServer {
|
||||
));
|
||||
}
|
||||
files.push((format!("1099-worksheet-{year}.csv"), w.into_bytes()));
|
||||
}
|
||||
// The threshold isn't known for this year: never guess it
|
||||
// (rules.md B-19); say so in the package instead.
|
||||
Err(e) => files.push((
|
||||
format!("1099-worksheet-{year}-NOT-INCLUDED.txt"),
|
||||
format!("{e}\n").into_bytes(),
|
||||
)),
|
||||
}
|
||||
// The evidence: every current receipt on the year's posted entries,
|
||||
// and the company's documents, with an index tying them together.
|
||||
let mut receipt_count = 0;
|
||||
|
||||
@@ -1247,6 +1247,27 @@ pub struct DigestOut {
|
||||
pub entries: Vec<DigestEntry>,
|
||||
/// Net movement per account over the period.
|
||||
pub totals_by_account: Vec<DigestTotal>,
|
||||
/// Rules set by law (like the 1099 threshold) that wanderbooks may no
|
||||
/// longer know as of today. For each: research the current value from
|
||||
/// the official source or ask the human (owner, CPA), then update it
|
||||
/// for everyone with a pull request (or at least an issue) at
|
||||
/// https://github.com/swaits/wanderbooks.
|
||||
pub stale_rules: Vec<StaleRule>,
|
||||
}
|
||||
|
||||
/// A dated rule due for review (see `ledger_core::rules`).
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct StaleRule {
|
||||
/// Plain name, e.g. "1099 reporting threshold".
|
||||
pub name: String,
|
||||
/// The last year wanderbooks knows it for.
|
||||
pub known_through: i32,
|
||||
/// Its entry in docs/spec/rules.md.
|
||||
pub catalog: String,
|
||||
/// The vendored official text in the wanderbooks repo.
|
||||
pub source: String,
|
||||
/// Why it's due, and where the next value comes from.
|
||||
pub why: String,
|
||||
}
|
||||
|
||||
/// Parameters for `books.export`.
|
||||
|
||||
@@ -251,6 +251,14 @@ fn mcp_end_to_end_over_stdio() {
|
||||
let digest = mcp.call("books.digest", json!({"since": "2026-01-01"}));
|
||||
assert_eq!(digest["entries"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(digest["entries"][0]["receipt_missing"], false);
|
||||
// Stale legal rules ride along with the digest. Which ones depends on
|
||||
// the server's clock, so assert only what holds on any date.
|
||||
for rule in digest["stale_rules"]
|
||||
.as_array()
|
||||
.expect("stale_rules is a list")
|
||||
{
|
||||
assert!(rule["name"].is_string() && rule["source"].is_string());
|
||||
}
|
||||
let search = mcp.call("books.search_entries", json!({"text": "rent"}));
|
||||
assert_eq!(search["entries"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(search["entries"][0]["receipt_count"], 1);
|
||||
@@ -1578,6 +1586,21 @@ fn mcp_vendor_1099_over_stdio() {
|
||||
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");
|
||||
// A year whose threshold isn't known yet: refused, with what to do
|
||||
// (rules.md B-19). An explicit threshold works.
|
||||
let why = mcp.call_err("books.vendor_1099", json!({"year": 2027}));
|
||||
for needle in [
|
||||
"only through 2026",
|
||||
"official source",
|
||||
"github.com/swaits/wanderbooks",
|
||||
] {
|
||||
assert!(why.contains(needle), "missing {needle:?} in: {why}");
|
||||
}
|
||||
let r = mcp.call(
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2027, "threshold": "2100"}),
|
||||
);
|
||||
assert_eq!(r["threshold"], "2100.00");
|
||||
let vendors = mcp.call("books.list_vendors", json!({}));
|
||||
assert_eq!(vendors["vendors"][0]["tin_on_file"], true);
|
||||
// Show the entry in the viewer: a request outside the books.
|
||||
|
||||
@@ -655,7 +655,7 @@ fn vendor_1099_tags_replay_and_project() {
|
||||
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);
|
||||
let before = vendor_1099_report(store.ledger(), 2026, None).unwrap();
|
||||
assert_eq!(before.vendors[0].paid, Decimal::from(2200));
|
||||
assert!(before.vendors[0].reportable);
|
||||
drop(store);
|
||||
@@ -666,7 +666,10 @@ fn vendor_1099_tags_replay_and_project() {
|
||||
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);
|
||||
assert_eq!(
|
||||
vendor_1099_report(store.ledger(), 2026, None).unwrap(),
|
||||
before
|
||||
);
|
||||
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
|
||||
let tagged: i64 = conn
|
||||
.query_row(
|
||||
|
||||
+10
-4
@@ -107,9 +107,13 @@ below.
|
||||
accounts (required); the three sections always sum to their net change.
|
||||
Disposal gains/losses move from operating to investing automatically.
|
||||
- `books.digest { since: "2026-10-01" }`
|
||||
→ `{ entries_posted: [...], totals_by_account: [...] }`. The human review
|
||||
surface: "what did the AI do with my books lately." The digest is the
|
||||
answer to "trust, but verify."
|
||||
→ `{ entries_posted: [...], totals_by_account: [...], stale_rules: [...] }`.
|
||||
The human review surface: "what did the AI do with my books lately." The
|
||||
digest is the answer to "trust, but verify." `stale_rules` lists legal
|
||||
amounts wanderbooks may no longer know as of today (`{ name,
|
||||
known_through, catalog, source, why }`): research the current value or
|
||||
ask the human, then update it upstream (pull request or issue at
|
||||
github.com/swaits/wanderbooks).
|
||||
|
||||
## Tools — fixed assets
|
||||
|
||||
@@ -220,7 +224,9 @@ whether a W-9 is on file.
|
||||
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 }`.
|
||||
missing_w9, notes }`. For a year whose threshold wanderbooks doesn't know
|
||||
(after 2026 until the indexed amount is added) it refuses with what to
|
||||
do; pass `threshold` explicitly once the figure is confirmed.
|
||||
|
||||
## Tools — receipts & documents
|
||||
|
||||
|
||||
@@ -40,6 +40,13 @@ human to confirm or overturn; nothing else is.
|
||||
- When an authority changes (the IRS revises a publication; a statute is
|
||||
amended), add the new text beside the old with its effective date. Rules
|
||||
that depend on dates (1099 thresholds) must say which text applies when.
|
||||
- Rules that change with the law are registered in
|
||||
`crates/ledger-core/src/rules.rs` with the years they are known for and
|
||||
a review date. Past what it knows, the engine refuses rather than
|
||||
guesses; from the review date, the MCP digest and the viewer flag the
|
||||
rule. Whoever updates one (human or agent) vendors the new official
|
||||
text here, extends the rule, and sends the change upstream: a pull
|
||||
request at https://github.com/swaits/wanderbooks (best), or an issue.
|
||||
|
||||
## Open: sources we could not verify
|
||||
|
||||
|
||||
+7
-3
@@ -289,9 +289,13 @@ years after 2026, indexed to inflation and rounded to the nearest $100
|
||||
(§6041(h)). Card payments are reported on Form 1099-K, not 1099-NEC/MISC.
|
||||
Attorneys' gross proceeds stay at $600 (not modeled).
|
||||
|
||||
**Enforced:** `form1099::default_threshold`. **Open:** for 2027 and later
|
||||
the indexed amount is not yet published; the engine must not silently
|
||||
assume $2,000 (see the open-questions list in the strategy note).
|
||||
**Enforced:** `form1099::default_threshold`, a dated rule
|
||||
(`ledger_core::rules::THRESHOLD_1099`, known through 2026, review from
|
||||
2026-11-01). For a later year the engine refuses with `RuleNotKnown`
|
||||
rather than guess; the MCP digest and the viewer's "Needs attention" list
|
||||
flag the rule once it is due (DECISION, Stephen, 2026-10-13). **Checked:**
|
||||
`an_unknown_year_threshold_is_never_guessed`,
|
||||
`dated_rules_go_stale_on_their_review_date`, MCP stdio test.
|
||||
|
||||
## B-20 Every transaction has supporting documents; records are kept
|
||||
|
||||
|
||||
Reference in new issue
Block a user