Files
wanderbooks/crates/ledger-cli/tests/spec_1099.rs
T
swaitsandClaude Opus 5.5 7741e96eb3
check / check (push) Canceled after 0s
refactor!: one way to serve books: the stdio server is gone (D-44)
The owner (2026-10-23): "We should probably just delete the stdio path
altogether. THere's no point in that. And it sitting there unused will
just lead to bitrot. Nuke it?"

Books are served only by the books server (`wanderbooks server`),
reached by agents through the bridge (`wanderbooks mcp --book ID`),
which stays.

Removed: `wanderbooks dev-stdio`, the `ledger-mcp` binary, and
`ledger_mcp::run` (stdio and `--http` over a bare data dir), with all
that only that mode had:
- files the books make coming back inline: `HandedFile.content_base64`
  is gone, and `download` and `expires_at` are always there;
- books with no access log or backups: `books.access_log`,
  `books.backup` and `books.back_up_now` are always offered, by scope;
- the settings file read and reloaded (`legacy_files`), the vault key
  named by a file or `--vault-key-file`, and the books dir's
  key-inside-the-books and git checks (with `paths.rs`);
- `wire.rs` (the bridge frames an agent's lines itself);
- `books.settings`' file fields and `books.start`'s `settings_source`.

`BooksServer` is built one way, from `Served` (a book as the books
server serves it), in production and in the unit tests. An unknown tool
is now a protocol error (mcp-tools.md). Inline file input is unchanged.

Then the owner's three decisions (2026-10-23, the question tool):
(a) `books.import_settings` is removed entirely (118 tools): old books
come in whole by `wanderbooks books adopt`, which reads their settings
file once; ledger-config keeps its settings-file parsing only as far as
adoption uses it (`load` and the path-reading `effective_receipt_policy`
are gone), and `books.chart_template` gives its settings as
`books.set_setting` changes. (b) The viewer's loose-books-folder mode is
removed (`--data-dir`, `--books-toml`, `--vault-key-file`, the settings
file and config.json followed): it opens only books of the data root,
reading settings from the journal and the vault from the book's own
`vault/` and `keys/`. (c) `wanderbooks dev-stdio` has no stub: it is an
unknown command like any other word. Also: an upload's expiry past the
calendar is an error, never an empty string.

Tests: every suite that drove the stdio server moved to
crates/ledger-cli/tests/ and drives a real `wanderbooks server` on a
data root of its own (tests/common), handed files by their download;
mcp_stdio.rs is mcp_journeys.rs. Tests of what is gone are deleted and
listed in docs/testing.md. Docs: plan D-44 (D-18 superseded), STATUS,
testing, rules, mcp-interface, the handbook, README; justfile recipes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-23 19:45:18 -06:00

901 lines
32 KiB
Rust

//! The 1099 decision and payment methods through the server (plan P3,
//! defects 12, 18 and 23; D-40), driven through a real books server as
//! an agent reaches it (common.rs): every success validates against the tool's outputSchema, and
//! every refusal leaves `journal.jsonl` byte-identical (the harness is
//! `tests/spec_parties.rs`'s). Every name is fictional; the reasons are
//! the ones the first company's cutover records.
use common::{Place, Wire};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
mod common;
const TIMEOUT: Duration = common::TIMEOUT;
// ---------------------------------------------------------------- harness
#[derive(Debug, Clone)]
enum Out {
Ok(Value),
ToolErr(String),
RpcErr(Value),
}
impl Out {
fn refused(&self) -> bool {
!matches!(self, Out::Ok(_))
}
fn err_text(&self) -> String {
match self {
Out::Ok(v) => panic!("expected a refusal, got {v}"),
Out::ToolErr(t) => t.clone(),
Out::RpcErr(e) => e.to_string(),
}
}
}
struct Mcp {
wire: Wire,
next_id: u64,
data: PathBuf,
validators: HashMap<String, jsonschema::Validator>,
/// The test's place, when the session owns it (its server is closed
/// and its home removed after the session).
books_dir: Option<TestDir>,
}
/// A test's place (common.rs): its home, its server, its books.
type TestDir = Place;
fn test_dir(name: &str) -> TestDir {
Place::new(name)
}
/// `wanderbooks.toml` with a company name and extra `[books]` TOML. The
/// receipt threshold is high so ordinary test entries need no receipt.
fn books_toml(dir: &Path, company: &str, books_extra: &str) -> PathBuf {
let path = dir.join("wanderbooks.toml");
std::fs::write(
&path,
format!(
"[company]\nname = \"{company}\"\nfiscal_year_start = \"01-01\"\n\
[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n{books_extra}"
),
)
.unwrap();
path
}
impl Mcp {
fn start(dir: &TestDir, toml: &Path) -> Self {
let wire = dir.start(Some(&std::fs::read_to_string(toml).unwrap()));
let mut mcp = Self {
books_dir: None,
wire,
next_id: 1,
data: dir.books_data(),
validators: HashMap::new(),
};
let init = mcp.rpc(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "spec-1099", "version": "0.1.0"},
}),
);
assert!(init.get("result").is_some(), "initialize failed: {init}");
mcp.send_line(
&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}).to_string(),
);
let list = mcp.rpc("tools/list", json!({}));
for tool in list["result"]["tools"].as_array().expect("tools") {
let name = tool["name"].as_str().unwrap().to_string();
let validator = jsonschema::validator_for(&tool["outputSchema"])
.unwrap_or_else(|e| panic!("{name}'s outputSchema is not valid: {e}"));
mcp.validators.insert(name, validator);
}
mcp
}
fn journal(&self) -> Vec<u8> {
std::fs::read(self.data.join("journal.jsonl")).unwrap_or_default()
}
/// The journal's line types, in order.
fn journal_types(&self) -> Vec<String> {
String::from_utf8(self.journal())
.unwrap()
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| {
let v: Value = serde_json::from_str(l).unwrap();
v["type"]
.as_str()
.or_else(|| v["line_type"].as_str())
.unwrap_or_default()
.to_string()
})
.collect()
}
fn send_line(&mut self, line: &str) {
self.wire.send_line(line);
}
fn recv(&mut self) -> Option<Value> {
self.wire.recv(TIMEOUT)
}
fn rpc(&mut self, method: &str, params: Value) -> Value {
let id = self.next_id;
self.next_id += 1;
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
self.send_line(&msg.to_string());
let resp = self
.recv()
.unwrap_or_else(|| panic!("no response to {method} (id {id})"));
assert_eq!(resp["id"], json!(id), "response id mismatch: {resp}");
resp
}
/// Call a tool; a refusal must leave the journal unchanged, a success
/// must match the outputSchema.
fn tool(&mut self, name: &str, args: Value) -> Out {
let before = self.journal();
let resp = self.rpc("tools/call", json!({"name": name, "arguments": args}));
let out = if let Some(err) = resp.get("error") {
Out::RpcErr(err.clone())
} else {
let result = &resp["result"];
if result.get("isError") == Some(&json!(true)) {
Out::ToolErr(
result["content"]
.as_array()
.and_then(|c| c.iter().find_map(|b| b["text"].as_str()))
.unwrap_or_default()
.to_string(),
)
} else {
let sc = result["structuredContent"].clone();
let v = self
.validators
.get(name)
.unwrap_or_else(|| panic!("{name} is not in tools/list"));
let errors: Vec<String> = v.iter_errors(&sc).map(|e| e.to_string()).collect();
assert!(
errors.is_empty(),
"{name} violates its outputSchema: {errors:?}\n{sc}"
);
Out::Ok(sc)
}
};
if out.refused() {
assert!(
self.journal() == before,
"refused {name} {args} changed the journal ({out:?})"
);
}
out
}
fn ok(&mut self, name: &str, args: Value) -> Value {
let a = args.to_string();
match self.tool(name, args) {
Out::Ok(v) => v,
other => panic!("{name} {a}: expected success, got {other:?}"),
}
}
/// `tool`, expecting a refusal that says why.
fn refuse(&mut self, name: &str, args: Value) -> String {
let a = args.to_string();
let out = self.tool(name, args);
assert!(out.refused(), "{name} {a} should be refused, got {out:?}");
let why = out.err_text();
assert!(!why.trim().is_empty(), "{name} {a}: a refusal says why");
why
}
/// `ok`, and the journal must not change (read-only tools).
fn read_only(&mut self, name: &str, args: Value) -> Value {
let before = self.journal();
let v = self.ok(name, args);
assert!(
self.journal() == before,
"read-only {name} changed the journal"
);
v
}
fn accounts(&mut self, accts: &[(&str, &str, &str)]) {
for (code, name, ty) in accts {
self.ok(
"books.add_account",
json!({"code": code, "name": name, "account_type": ty}),
);
}
}
/// Draft an entry with `args` extra (party, vendor) and lines; the id.
fn draft(&mut self, date: &str, extra: Value, lines: &[(&str, &str, &str)]) -> u64 {
let mut args = json!({"date": date, "description": format!("entry {date}")});
for (k, v) in extra.as_object().unwrap() {
args[k] = v.clone();
}
let id = self.ok("books.draft_entry", args)["entry_id"]
.as_u64()
.unwrap();
for (account, side, amount) in lines {
self.ok(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
);
}
id
}
/// `draft`, then post it (high receipt threshold: no receipt needed).
fn post(&mut self, date: &str, extra: Value, lines: &[(&str, &str, &str)]) -> u64 {
let id = self.draft(date, extra, lines);
self.ok(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
id
}
fn add_party(&mut self, name: &str, roles: &[&str]) -> u64 {
self.ok("books.add_party", json!({"name": name, "roles": roles}))["party_id"]
.as_u64()
.unwrap()
}
fn party(&mut self, party: &str, year: i32) -> Value {
self.read_only("books.party", json!({"party": party, "year": year}))
}
/// How many journal lines have this type.
fn count(&self, line_type: &str) -> usize {
self.journal_types()
.iter()
.filter(|t| t.as_str() == line_type)
.count()
}
}
const CHART: &[(&str, &str, &str)] = &[
("1000", "Bank", "asset"),
("1300", "Prepaid Expenses", "asset"),
("2050", "Card", "liability"),
("2100", "Due to Members", "liability"),
("3000", "Capital", "equity"),
("4000", "Revenue", "revenue"),
("6000", "Contract Labor", "expense"),
("6999", "General Expenses", "expense"),
];
/// Books with the chart; cash is 1000, financing 2100.
fn books(name: &str) -> Mcp {
books_in(name, false)
}
/// [`books`], as P2 left them when `p2`: journal format 2 with none of
/// P3's features, books an older version wrote, waiting for their format
/// upgrade (rules.md B-27).
fn books_in(name: &str, p2: bool) -> Mcp {
let dir = test_dir(name);
if p2 {
ledger_store::Store::create_in_format(
&dir.books_data(),
ledger_core::Currency::USD,
&ledger_store::JournalFormat {
format: 2,
features: Vec::new(),
},
)
.unwrap();
}
let toml = books_toml(&dir, "Spec Co", "financing_accounts = [\"2100\"]\n");
let mut m = Mcp::start(&dir, &toml);
m.accounts(CHART);
m.books_dir = Some(dir);
m
}
impl Mcp {
/// The owner's go-ahead, as the app gives it (ledger-gui owner.rs,
/// after the passphrase and a verified backup; no tool can): the
/// server stopped, the books moved to this version's format, and
/// served again.
fn upgraded(mut self) -> Mcp {
let dir = self.books_dir.take().expect("books_dir");
drop(self);
dir.stop();
ledger_store::Store::open(&dir.books_data())
.unwrap()
.upgrade_format()
.unwrap()
.expect("P2's books wait for the upgrade");
let mut m = Mcp::start(&dir, &dir.join("wanderbooks.toml"));
m.books_dir = Some(dir);
m
}
/// The same books, served again (a restart).
fn restart(mut self) -> Mcp {
let dir = self.books_dir.take().expect("books_dir");
drop(self);
let mut m = Mcp::start(&dir, &dir.join("wanderbooks.toml"));
m.books_dir = Some(dir);
m
}
}
/// A vendor's decision as books.list_vendors shows it today.
fn decision(m: &mut Mcp, vendor: &str) -> Value {
let all = m.read_only("books.list_vendors", json!({}));
all["vendors"]
.as_array()
.unwrap()
.iter()
.find(|v| v["name"] == vendor)
.unwrap_or_else(|| panic!("{vendor} not listed: {all}"))
.clone()
}
/// A vendor's line on the 2026 worksheet.
fn line_2026(m: &mut Mcp, vendor: &str) -> Value {
let w = m.read_only("books.vendor_1099", json!({"year": 2026}));
w["vendors"]
.as_array()
.unwrap()
.iter()
.find(|v| v["name"] == vendor)
.unwrap_or_else(|| panic!("{vendor} not on the worksheet: {w}"))
.clone()
}
/// Defect 23, from its reproduction (P3 brief 1.1): "none" stored the same
/// null as a vendor nobody decided on. Now "none" is refused with what to
/// say instead, not_reportable is kept with its reason and who decided
/// when, a fresh vendor reads undetermined with no one named, and both
/// survive a restart.
#[test]
fn a_vendor_marked_no_1099_is_not_undecided() {
let mut m = books("decided");
m.ok("books.add_vendor", json!({"name": "Bigco"}));
m.ok("books.add_vendor", json!({"name": "Newco"}));
for none in ["none", "", "None", "no_1099"] {
let why = m.refuse(
"books.set_vendor_1099",
json!({"vendor": "Bigco", "form_1099": none, "decided_by": "Riley Chen"}),
);
assert!(
why.contains("not_reportable") && why.contains("undetermined"),
"{why}"
);
assert!(why.contains("Paid by card is not a reason"), "{why}");
}
let v = m.ok(
"books.set_vendor_1099",
json!({"vendor": "Bigco", "form_1099": "not_reportable", "reason_kind": "corporation",
"reason": "a corporation by name", "decided_by": "Riley Chen",
"decided_on": "2026-10-16", "citation": "Treas. Reg. §1.6041-3(p)(1)"}),
);
assert_eq!(v["changed"], "set");
assert!(v["warnings"].as_array().unwrap().is_empty());
let check = |m: &mut Mcp| {
let b = decision(m, "Bigco");
assert!(b["form_1099"].is_null(), "{b}");
let d = &b["decision_1099"];
assert_eq!(d["value"], "not_reportable");
assert_eq!(d["reason_kind"], "corporation");
assert_eq!(d["reason"], "a corporation by name");
assert_eq!(d["authority"], "Treas. Reg. §1.6041-3(p)(1)");
assert_eq!(d["decided_by"], "Riley Chen");
assert_eq!(d["decided_on"], "2026-10-16");
assert_eq!(d["citation"], "Treas. Reg. §1.6041-3(p)(1)");
assert!(d["effective"].is_null(), "from the beginning");
assert_eq!(b["decisions_1099"].as_array().unwrap().len(), 1);
let n = decision(m, "Newco");
assert_eq!(n["decision_1099"]["value"], "undetermined");
assert!(n["decision_1099"]["decided_by"].is_null(), "nobody decided");
assert!(n["decisions_1099"].as_array().unwrap().is_empty());
};
check(&mut m);
assert_eq!(m.count("vendor.1099_set"), 1);
let mut m = m.restart();
check(&mut m);
}
/// D-40: the first company's cutover records exactly these decisions (a
/// reason kind and its words), and each comes back as given.
#[test]
fn the_cutovers_reasons_are_recorded_as_given() {
let mut m = books("cutover");
for (vendor, kind, reason, citation) in [
(
"Northwind Research, PBC",
"corporation",
"a corporation (Delaware public benefit corporation, from the public record)",
None,
),
(
"Tidewater Hosting, Inc.",
"corporation",
"a corporation by name",
Some("Treas. Reg. §1.6041-3(p)(1)"),
),
(
"Federal Parcel Service",
"united_states",
"the United States",
Some("Treas. Reg. §1.6041-3(p)(3)"),
),
] {
m.ok("books.add_vendor", json!({"name": vendor}));
let mut args = json!({"vendor": vendor, "form_1099": "not_reportable", "reason_kind": kind,
"reason": reason, "decided_by": "Riley Chen", "decided_on": "2026-10-16"});
if let Some(c) = citation {
args["citation"] = json!(c);
}
m.ok("books.set_vendor_1099", args);
let d = decision(&mut m, vendor)["decision_1099"].clone();
assert_eq!(
(
d["value"].as_str(),
d["reason_kind"].as_str(),
d["reason"].as_str()
),
(Some("not_reportable"), Some(kind), Some(reason))
);
assert_eq!(d["citation"].as_str(), citation);
}
}
/// INVARIANTS 1-3 of tools/instruments.rs: what a decision needs, each
/// refusal saying why and changing nothing.
#[test]
fn set_vendor_1099_says_what_a_decision_needs() {
let mut m = books("refusals");
m.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
let v = "Harbor Design LLC";
let by = "Riley Chen";
for (args, needle) in [
(
json!({"vendor": v, "form_1099": "not_reportable", "reason": "x", "decided_by": by}),
"reason_kind",
),
(
json!({"vendor": v, "form_1099": "not_reportable", "reason_kind": "corporation", "decided_by": by}),
"needs reason",
),
(
json!({"vendor": v, "form_1099": "not_reportable", "reason_kind": "paid_by_card", "reason": "x", "decided_by": by}),
"paid by card is not a reason",
),
(
json!({"vendor": v, "form_1099": "not_reportable", "reason_kind": "other", "reason": "x", "decided_by": by}),
"citation",
),
(
json!({"vendor": v, "form_1099": "nec", "reason": "x", "decided_by": by}),
"go with not_reportable",
),
(json!({"vendor": v, "form_1099": "nec"}), "decided_by"),
(
json!({"vendor": v, "form_1099": "nec", "effective": "2026-07-01", "decided_by": by}),
"from the beginning",
),
(
json!({"vendor": v, "form_1099": "not_reportable", "reason_kind": "corporation", "reason": "EIN 12-3456789", "decided_by": by}),
"EIN",
),
(json!({"vendor": v}), "nothing to change"),
(
json!({"vendor": v, "reason": "x", "tin_on_file": true}),
"go with form_1099",
),
(
json!({"vendor": v, "form_1099": "1099-k", "decided_by": by}),
"expected nec",
),
(
json!({"vendor": "Nobody", "form_1099": "nec", "decided_by": by}),
"vendor",
),
] {
let why = m.refuse("books.set_vendor_1099", args.clone());
assert!(why.contains(needle), "{args}: {why}");
}
assert_eq!(m.count("vendor.1099_set"), 0);
assert_eq!(
m.count("journal.format_set"),
1,
"nothing declared either: only the books' own format, from their creation"
);
}
/// P3 brief 1.5 and review R8: tin_on_file omitted keeps the W-9 flag;
/// tin_on_file alone keeps the decision and who made it.
#[test]
fn set_vendor_1099_keeps_the_w9_flag_when_omitted() {
let mut m = books("w9-flag");
m.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
let v = "Harbor Design LLC";
m.ok(
"books.set_vendor_1099",
json!({"vendor": v, "form_1099": "nec", "tin_on_file": true, "decided_by": "Riley Chen", "decided_on": "2026-03-02"}),
);
let out = m.ok(
"books.set_vendor_1099",
json!({"vendor": v, "form_1099": "misc", "decided_by": "Riley Chen", "decided_on": "2026-04-01"}),
);
assert_eq!(out["tin_on_file"], true, "omitted: kept");
assert_eq!(out["form_1099"], "1099-MISC");
let out = m.ok(
"books.set_vendor_1099",
json!({"vendor": v, "tin_on_file": false}),
);
assert_eq!(out["changed"], "unchanged");
assert_eq!(out["tin_on_file"], false);
assert_eq!(out["decision_1099"]["value"], "misc");
assert_eq!(out["decision_1099"]["decided_on"], "2026-04-01");
assert_eq!(m.count("vendor.1099_set"), 2);
assert_eq!(m.count("vendor.w9_flag_set"), 1);
// Already so: nothing journaled.
m.ok(
"books.set_vendor_1099",
json!({"vendor": v, "tin_on_file": false}),
);
assert_eq!(m.count("vendor.w9_flag_set"), 1);
}
/// P3 brief 4.4 and review R8: recording a W-9 (books.set_w9) marks the
/// vendor's W-9 on file and keeps its 1099 decision with who made it and
/// when, verbatim.
#[test]
fn set_w9_keeps_the_1099_decision_and_its_source() {
let mut m = books("set-w9");
m.add_party("Harbor Design LLC", &["contractor"]);
m.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
m.ok(
"books.set_vendor_1099",
json!({"vendor": "Harbor Design LLC", "form_1099": "nec", "decided_by": "Riley Chen",
"decided_on": "2026-01-05", "citation": "W-9 requested"}),
);
let before = decision(&mut m, "Harbor Design LLC")["decision_1099"].clone();
m.ok(
"books.set_w9",
json!({"party": "Harbor Design LLC", "received_on": "2026-03-02",
"entity_type": "LLC (partnership)", "tin_last4": "4321", "kept_at": "CPA portal"}),
);
let after = decision(&mut m, "Harbor Design LLC");
assert_eq!(after["tin_on_file"], true);
assert_eq!(after["decision_1099"], before);
assert_eq!(m.count("vendor.1099_set"), 1, "no decision re-journaled");
assert_eq!(m.count("vendor.w9_flag_set"), 1);
}
/// A member's method on an account the books don't name as financing
/// warns: the worksheet holds its untagged payments undetermined (never a
/// card's), and the settings should say what the account is.
#[test]
fn a_members_method_off_the_financing_accounts_warns() {
let mut m = books("member-method");
m.ok(
"books.add_account",
json!({"code": "2110", "name": "Due to Riley", "account_type": "liability"}),
);
let out = m.ok(
"books.add_instrument",
json!({"kind": "member_card", "label": "Riley's Visa", "account": "2110"}),
);
let w = out["warnings"].as_array().unwrap();
assert_eq!(w.len(), 1, "{out}");
assert!(
w[0].as_str()
.unwrap()
.contains("not one of the books' financing accounts"),
"{out}"
);
let out = m.ok(
"books.add_instrument",
json!({"kind": "member_cash_or_check", "label": "Riley's checks", "account": "2100"}),
);
assert!(out["warnings"].as_array().unwrap().is_empty(), "{out}");
}
/// B-27 and the format-upgrade gate (D-39): books.set_w9 journals the W-9
/// and then the vendor's W-9 flag, a line of the vendor_1099_decisions
/// feature, so it asks for the feature before the command's first line,
/// never between its lines. In books waiting for their format upgrade
/// (P2's) the whole command is refused, journaling nothing (never the W-9
/// recorded and the flag not); once the owner upgrades them, as in books
/// this version made, it journals the W-9, then the flag, and declares
/// nothing.
#[test]
fn set_w9_asks_for_the_w9_flags_feature_before_its_first_line() {
let w9 = json!({"party": "Harbor Design LLC", "received_on": "2026-03-02",
"entity_type": "LLC (partnership)", "tin_last4": "4321", "kept_at": "CPA portal"});
let mut m = books_in("set-w9-feature", true);
m.add_party("Harbor Design LLC", &["contractor"]);
m.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
let why = m.refuse("books.set_w9", w9.clone());
assert!(
why.starts_with("format_upgrade_pending:") && why.contains("vendor_1099_decisions"),
"{why}"
);
assert_eq!(m.count("party.w9_set"), 0, "nothing half done");
let mut fresh = books("set-w9-feature-new");
fresh.add_party("Harbor Design LLC", &["contractor"]);
fresh.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
for mut m in [m.upgraded(), fresh] {
let before = m.journal_types().len();
m.ok("books.set_w9", w9.clone());
let added = m.journal_types()[before..].to_vec();
assert_eq!(added, ["party.w9_set", "vendor.w9_flag_set"]);
assert_eq!(decision(&mut m, "Harbor Design LLC")["tin_on_file"], true);
}
}
/// Defect 12 end to end, restarting between steps (P3 brief 8.2): payment
/// methods added (and refused when unsound), a draft taking one, a posted
/// entry and its reversal tagged together, and the worksheet showing what
/// counts, what a card paid, and what is undetermined.
#[test]
fn payment_methods_round_trip_and_classify_payments() {
let mut m = books("methods");
m.add_party("Riley Chen", &["member"]);
let added = m.ok(
"books.add_instrument",
json!({"kind": "bank", "label": "Harbor Bank checking", "last4": "1234", "account": "1000"}),
);
assert_eq!(added["card_or_network"], false);
assert!(added["warnings"].as_array().unwrap().is_empty());
m.ok(
"books.add_instrument",
json!({"kind": "business_card", "label": "Harbor Visa", "last4": "0042", "account": "2050"}),
);
let visa = m.ok(
"books.add_instrument",
json!({"kind": "member_card", "label": "Riley's Visa", "owner_party": "Riley Chen", "account": "2100"}),
);
assert_eq!(visa["card_or_network"], true);
assert_eq!(visa["account"], "2100");
m.ok(
"books.add_instrument",
json!({"kind": "member_cash_or_check", "label": "Riley's checks", "account": "2100"}),
);
for (args, needle) in [
(
json!({"kind": "card", "label": "x", "account": "2050"}),
"kind",
),
(
json!({"kind": "bank", "label": "Savings", "last4": "12345", "account": "1000"}),
"four digits",
),
(
json!({"kind": "bank", "label": "harbor visa", "account": "1000"}),
"already named",
),
(
json!({"kind": "bank", "label": "Capital", "account": "3000"}),
"asset",
),
(
json!({"kind": "bank", "label": "Nowhere", "account": "9999"}),
"unknown account",
),
] {
let why = m.refuse("books.add_instrument", args.clone());
assert!(why.contains(needle), "{args}: {why}");
}
m.ok("books.add_vendor", json!({"name": "Dana Studio"}));
m.ok(
"books.set_vendor_1099",
json!({"vendor": "Dana Studio", "form_1099": "nec", "decided_by": "Riley Chen"}),
);
let dana = json!({"vendor": "Dana Studio", "instrument": "Riley's Visa"});
let e1 = m.post(
"2026-05-01",
dana,
&[("6000", "debit", "2500.00"), ("2100", "credit", "2500.00")],
);
assert_eq!(
m.read_only("books.get_entry", json!({"entry_id": e1}))["instrument"],
"Riley's Visa"
);
let e2 = m.post(
"2026-05-02",
json!({"vendor": "Dana Studio"}),
&[("6000", "debit", "3000.00"), ("2100", "credit", "3000.00")],
);
let e3 = m.post(
"2026-05-03",
json!({"vendor": "Dana Studio"}),
&[("6000", "debit", "400.00"), ("2050", "credit", "400.00")],
);
let mut m = m.restart();
let d = line_2026(&mut m, "Dana Studio");
assert_eq!(
(
&d["paid"],
&d["excluded_card"],
&d["undetermined"],
&d["status"]
),
(
&json!("0.00"),
&json!("2900.00"),
&json!("3000.00"),
&json!("undetermined")
)
);
let w = m.read_only("books.vendor_1099", json!({"year": 2026}));
let listed = |k: &str| -> Vec<(u64, String, Value)> {
w[k].as_array()
.unwrap()
.iter()
.map(|p| {
(
p["entry_id"].as_u64().unwrap(),
p["amount"].as_str().unwrap().to_string(),
p["instrument"].clone(),
)
})
.collect()
};
assert_eq!(
listed("excluded_card_payments"),
[
(e1, "2500.00".into(), json!("Riley's Visa")),
(e3, "400.00".into(), Value::Null)
]
);
assert_eq!(
listed("undetermined_payments"),
[(e2, "3000.00".into(), Value::Null)]
);
assert!(
w["notes"]
.as_array()
.unwrap()
.iter()
.any(|n| n.as_str().unwrap().contains("books.set_entry_instrument")),
"{w}"
);
// The member paid e2 by check, and it was reversed: tagging either
// tags both.
let rev = m.ok(
"books.reverse_entry",
json!({"entry_id": e2, "date": "2026-05-04", "description": "paid twice", "confirmed": true}),
)["reversal_id"]
.as_u64()
.unwrap();
let out = m.ok(
"books.set_entry_instrument",
json!({"entry_id": e2, "instrument": "Riley's checks"}),
);
assert_eq!(out["changed"], true);
assert_eq!(out["instrument"], "Riley's checks");
assert!(out["warnings"].as_array().unwrap().is_empty());
let mut m = m.restart();
assert_eq!(
m.read_only("books.get_entry", json!({"entry_id": rev}))["instrument"],
"Riley's checks"
);
let d = line_2026(&mut m, "Dana Studio");
assert_eq!(
(&d["paid"], &d["undetermined"], &d["status"]),
(&json!("0.00"), &json!("0.00"), &json!("under_threshold"))
);
let all = m.read_only("books.instruments", json!({}));
let labels: Vec<(&str, bool)> = all["instruments"]
.as_array()
.unwrap()
.iter()
.map(|i| {
(
i["label"].as_str().unwrap(),
i["card_or_network"].as_bool().unwrap(),
)
})
.collect();
assert_eq!(
labels,
[
("Harbor Bank checking", false),
("Harbor Visa", true),
("Riley's Visa", true),
("Riley's checks", false)
]
);
// Cleared again: back to undetermined, both of the chain.
m.ok("books.set_entry_instrument", json!({"entry_id": rev}));
assert!(m.read_only("books.get_entry", json!({"entry_id": e2}))["instrument"].is_null());
let p = m.party("Dana Studio", 2026);
assert_eq!(p["totals"]["decision_1099"], "nec");
assert_eq!(p["totals"]["form_1099"], "1099-NEC");
}
/// D-40 and P3 review R15: a decision changed for payments in a closed
/// period, or in a year whose 1099s were due (January 31 of the next
/// year), warns, naming the year and the entries; one reaching only this
/// year's open payments doesn't.
#[test]
fn a_change_reaching_a_filed_year_warns() {
let mut m = books("warns");
m.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
let v = "Harbor Design LLC";
m.ok(
"books.set_vendor_1099",
json!({"vendor": v, "form_1099": "nec", "decided_by": "Riley Chen"}),
);
let harbor = json!({"vendor": v});
let pay = |m: &mut Mcp, date: &str| {
m.post(
date,
harbor.clone(),
&[("6000", "debit", "700.00"), ("1000", "credit", "700.00")],
)
};
let e2024 = pay(&mut m, "2024-03-01");
let e2025 = pay(&mut m, "2025-03-01");
pay(&mut m, "2026-03-01");
let period = m.ok(
"books.create_period",
json!({"name": "FY2025", "start": "2025-01-01", "end": "2025-12-31"}),
)["period_id"]
.as_u64()
.unwrap();
m.ok(
"books.close_period",
json!({"period_id": period, "confirmed": true}),
);
// From July 2026 on: only an open, not-yet-due year's payments: quiet.
let out = m.ok(
"books.set_vendor_1099",
json!({"vendor": v, "form_1099": "not_reportable", "reason_kind": "corporation",
"reason": "elected to be taxed as a corporation", "effective": "2026-07-01",
"decided_by": "Riley Chen"}),
);
assert!(out["warnings"].as_array().unwrap().is_empty(), "{out}");
assert_eq!(out["decisions_1099"].as_array().unwrap().len(), 2);
// From the beginning: reaches 2024 (1099s due 2025-01-31) and the
// closed 2025.
let out = m.ok(
"books.set_vendor_1099",
json!({"vendor": v, "form_1099": "not_reportable", "reason_kind": "corporation",
"reason": "a corporation by name", "decided_by": "Riley Chen"}),
);
let warnings: Vec<String> = out["warnings"]
.as_array()
.unwrap()
.iter()
.map(|w| w.as_str().unwrap().to_string())
.collect();
assert_eq!(warnings.len(), 2, "{warnings:?}");
assert!(warnings[0].contains("for 2024") && warnings[0].contains(&format!("entries {e2024}")));
assert!(warnings[0].contains("due January 31"), "{}", warnings[0]);
assert!(warnings[1].contains("for 2025") && warnings[1].contains("closed period"));
assert!(warnings.iter().all(|w| w.contains("corrected")));
// A payment method on the closed year's payment warns too.
m.ok(
"books.add_instrument",
json!({"kind": "business_card", "label": "Harbor debit card", "account": "1000"}),
);
let out = m.ok(
"books.set_entry_instrument",
json!({"entry_id": e2025, "instrument": "Harbor debit card"}),
);
let w = out["warnings"].as_array().unwrap();
assert_eq!(w.len(), 1, "{out}");
assert!(w[0].as_str().unwrap().contains("closed period"));
}