P3: dated 1099 decisions, payment methods and the card exception (defects 23, 12, 18, D-40)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
A vendor's 1099 decision is its own dated fact (files NEC or MISC, not reportable with its reason, or undetermined), applied by each payment's date. Payment methods are recorded; card and network payments are left out and listed; a member's check through financing counts; a bank payment with no method counts (D-40); $2,000 for payments after 2025. One worksheet for the tool, the viewer and the CPA package. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
77 files changed
+11455
-970
No files matched your search
@@ -178,7 +178,7 @@ fn mcp_end_to_end_over_stdio() {
|
||||
// Tool listing: the narrow tools; chart is read-only.
|
||||
let list = mcp.request("tools/list", json!({}));
|
||||
let tools = list["result"]["tools"].as_array().unwrap();
|
||||
assert_eq!(tools.len(), 109, "expected 109 tools, got {}", tools.len());
|
||||
assert_eq!(tools.len(), 112, "expected 112 tools, got {}", tools.len());
|
||||
// Files come in as tool input: books.begin_upload, never a local path.
|
||||
assert!(tools.iter().any(|t| t["name"] == "books.begin_upload"));
|
||||
assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file"));
|
||||
@@ -820,6 +820,11 @@ fn tin_bearing_files_are_refused_when_given_back_to_be_filed() {
|
||||
"books.add_account",
|
||||
json!({"code": "1000", "name": "Cash", "account_type": "asset"}),
|
||||
);
|
||||
// The 1099 worksheet counts payments out of the books' cash accounts.
|
||||
mcp.call(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "books.cash_accounts", "value": ["1000"]}], "decided_by": "Test Owner"}),
|
||||
);
|
||||
mcp.call(
|
||||
"books.add_party",
|
||||
json!({"name": "Acme", "roles": ["contractor"]}),
|
||||
@@ -1298,6 +1303,13 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
|
||||
json!({"as_of": "2026-10-31", "include_tins": false}),
|
||||
);
|
||||
assert!(err.contains("cash_accounts"), "{err}");
|
||||
// The package's 1099 worksheet is books.vendor_1099's for the
|
||||
// package's accounts: the request's, else the settings' (here both
|
||||
// name checking).
|
||||
mcp.call(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "books.cash_accounts", "value": ["1000"]}], "decided_by": "Test Owner"}),
|
||||
);
|
||||
let pkg = mcp.call(
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-10-31", "cash_accounts": ["1000"], "include_tins": false}),
|
||||
@@ -2191,17 +2203,26 @@ fn mcp_vendor_1099_over_stdio() {
|
||||
json!({"code": code, "name": name, "account_type": typ}),
|
||||
);
|
||||
}
|
||||
// The worksheet counts payments out of the books' cash accounts.
|
||||
mcp.call(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "books.cash_accounts", "value": ["1000"]}], "decided_by": "Test Owner"}),
|
||||
);
|
||||
mcp.call("books.add_vendor", json!({"name": "Dana Designs"}));
|
||||
let v = mcp.call(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "dana designs", "form_1099": "nec"}),
|
||||
json!({"vendor": "dana designs", "form_1099": "nec", "decided_by": "Test Owner"}),
|
||||
);
|
||||
assert_eq!(v["form_1099"], "1099-NEC");
|
||||
assert_eq!(v["decision_1099"]["decided_by"], "Test Owner");
|
||||
let err = mcp.call_err(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Dana Designs", "form_1099": "k"}),
|
||||
json!({"vendor": "Dana Designs", "form_1099": "k", "decided_by": "Test Owner"}),
|
||||
);
|
||||
assert!(
|
||||
err.contains("expected nec, misc, not_reportable, or undetermined"),
|
||||
"{err}"
|
||||
);
|
||||
assert!(err.contains("nec, misc, or none"), "{err}");
|
||||
|
||||
// A 2,500.00 check to Dana, tagged at draft time.
|
||||
let e = mcp.call(
|
||||
@@ -2243,10 +2264,10 @@ fn mcp_vendor_1099_over_stdio() {
|
||||
json!({"year": 2026, "threshold": "3000"}),
|
||||
);
|
||||
assert_eq!(high["vendors"][0]["reportable"], false);
|
||||
// W-9 arrives.
|
||||
// W-9 arrives: the flag alone, the decision kept.
|
||||
mcp.call(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Dana Designs", "form_1099": "nec", "tin_on_file": true}),
|
||||
json!({"vendor": "Dana Designs", "tin_on_file": true}),
|
||||
);
|
||||
drop(mcp);
|
||||
|
||||
|
||||
@@ -485,9 +485,9 @@ fn rich_books(s: &mut Store) -> Rich {
|
||||
let coach = s.add_class("Coach").unwrap();
|
||||
let landlord = s.add_vendor("Landlord").unwrap();
|
||||
let contractor = s.add_vendor("Contractor").unwrap();
|
||||
s.set_vendor_1099(landlord, Some(Form1099::Misc), true)
|
||||
s.set_vendor_1099(landlord, &decided(Form1099::Misc), Some(true))
|
||||
.unwrap();
|
||||
s.set_vendor_1099(contractor, Some(Form1099::Nec), false)
|
||||
s.set_vendor_1099(contractor, &decided(Form1099::Nec), Some(false))
|
||||
.unwrap();
|
||||
s.set_account_tax(id_of(s, "6000"), Some("rent_property"), None)
|
||||
.unwrap();
|
||||
@@ -626,6 +626,52 @@ fn rich_books(s: &mut Store) -> Rich {
|
||||
const START: &str = "2025-01-01";
|
||||
const END: &str = "2025-06-30";
|
||||
|
||||
/// A 1099 decision from the beginning, decided by the owner.
|
||||
fn decided(form: Form1099) -> ledger_core::Dated1099 {
|
||||
ledger_core::Dated1099::from_beginning(
|
||||
ledger_core::Decision1099::Files(form),
|
||||
Some(ledger_core::settings::Source {
|
||||
decided_by: "Oracle Owner".into(),
|
||||
decided_on: d("2025-01-01"),
|
||||
citation: None,
|
||||
note: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// The 1099 report the server makes for these books: payments out of
|
||||
/// cash 1000 and financing 2100 (books_toml), no member parties (the
|
||||
/// rich books name none, so no shares resolve against members).
|
||||
fn core_1099(
|
||||
ledger: &Ledger,
|
||||
year: i32,
|
||||
threshold: Option<Decimal>,
|
||||
) -> ledger_core::Vendor1099Report {
|
||||
core_1099_through(ledger, year, threshold, None)
|
||||
}
|
||||
|
||||
/// [`core_1099`], payments dated after `through` left out.
|
||||
fn core_1099_through(
|
||||
ledger: &Ledger,
|
||||
year: i32,
|
||||
threshold: Option<Decimal>,
|
||||
through: Option<chrono::NaiveDate>,
|
||||
) -> ledger_core::Vendor1099Report {
|
||||
let id = |code: &str| ledger.chart().find_by_code(code).unwrap().id;
|
||||
ledger_core::vendor_1099_report(
|
||||
ledger,
|
||||
year,
|
||||
threshold,
|
||||
&ledger_core::PaymentAccounts {
|
||||
cash: vec![id("1000")],
|
||||
financing: vec![id("2100")],
|
||||
},
|
||||
&[],
|
||||
through,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ------------------------------------------------- statements: csv + json
|
||||
|
||||
/// Trial balance CSV and JSON equal `trial_balance_as_of`, line by line.
|
||||
@@ -1748,10 +1794,13 @@ fn pnl_by_class_excludes_closing_entries() {
|
||||
|
||||
// ------------------------------------------------- 1099
|
||||
|
||||
/// The worksheet equals `vendor_1099_report`; `missing_w9` is the
|
||||
/// reportable vendors without a W-9; the untagged-entries note counts the
|
||||
/// year's posted entries with no vendor; the indexing note appears only
|
||||
/// after 2026. Kills: vendor_1099's year/tag filter and both note guards.
|
||||
/// The worksheet equals `vendor_1099_report` (for the server's cash and
|
||||
/// financing accounts); `missing_w9` is the vendors a 1099 is or may be
|
||||
/// due for without a W-9; the untagged-entries note counts the year's
|
||||
/// untagged entries that paid money out for something (the report's
|
||||
/// untagged_payments, listed as untagged_entries); the indexing note
|
||||
/// appears only after 2026. Kills: vendor_1099's year/tag filter and both
|
||||
/// note guards.
|
||||
#[test]
|
||||
fn vendor_1099_equals_the_core_report_and_counts_untagged_entries() {
|
||||
let dir = test_dir("1099");
|
||||
@@ -1769,11 +1818,9 @@ fn vendor_1099_equals_the_core_report_and_counts_untagged_entries() {
|
||||
let ledger = s.ledger().clone();
|
||||
drop(s);
|
||||
let untagged = |year: i32| {
|
||||
ledger
|
||||
.posted_entries()
|
||||
.filter(|e| chrono::Datelike::year(&e.date()) == year)
|
||||
.filter(|e| ledger.entry_vendor(e.id()).is_none())
|
||||
.count()
|
||||
core_1099(&ledger, year, Some(Decimal::ZERO))
|
||||
.untagged_payments
|
||||
.len()
|
||||
};
|
||||
assert!(untagged(2025) > 0 && untagged(2025) != untagged(2024));
|
||||
let mut m = Mcp::start(&dir, "1000000.00");
|
||||
@@ -1784,8 +1831,9 @@ fn vendor_1099_equals_the_core_report_and_counts_untagged_entries() {
|
||||
args["threshold"] = json!(t);
|
||||
}
|
||||
let out = m.ok("books.vendor_1099", args);
|
||||
let want = ledger_core::vendor_1099_report(&ledger, year, threshold.map(dec)).unwrap();
|
||||
let want = core_1099(&ledger, year, threshold.map(dec));
|
||||
assert_eq!(out["year"], json!(want.year));
|
||||
assert_eq!(out["untagged_entries"], json!(want.untagged_payments));
|
||||
assert_eq!(jamt(&out["threshold"]), want.threshold);
|
||||
let vendors = out["vendors"].as_array().unwrap();
|
||||
assert_eq!(vendors.len(), want.vendors.len(), "{year} vendors");
|
||||
@@ -1793,14 +1841,29 @@ fn vendor_1099_equals_the_core_report_and_counts_untagged_entries() {
|
||||
assert_eq!(g["vendor_id"], json!(w.vendor_id));
|
||||
assert_eq!(g["name"], w.name.as_str());
|
||||
assert_eq!(jamt(&g["paid"]), w.paid, "{year} {}", w.name);
|
||||
assert_eq!(jamt(&g["excluded_card"]), w.excluded_card);
|
||||
assert_eq!(jamt(&g["undetermined"]), w.undetermined);
|
||||
assert_eq!(jamt(&g["not_reportable"]), w.not_reportable);
|
||||
assert_eq!(g["status"], w.status.key());
|
||||
assert_eq!(g["decision_1099"]["value"], w.decision.key());
|
||||
assert_eq!(g["reportable"], json!(w.reportable));
|
||||
assert_eq!(g["tin_on_file"], json!(w.tin_on_file));
|
||||
}
|
||||
let missing: Vec<String> = want
|
||||
.vendors
|
||||
.iter()
|
||||
.filter(|v| v.reportable && !v.tin_on_file)
|
||||
.map(|v| v.name.clone())
|
||||
.filter(|v| !v.tin_on_file)
|
||||
.filter_map(|v| {
|
||||
if v.reportable {
|
||||
Some(v.name.clone())
|
||||
} else if v.status == ledger_core::Status1099::Undetermined
|
||||
&& v.decisions.iter().any(|d| d.decision.form().is_some())
|
||||
{
|
||||
Some(format!("{} (may be reportable)", v.name))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(out["missing_w9"], json!(missing), "{year} missing W-9s");
|
||||
|
||||
@@ -2757,7 +2820,7 @@ fn receipts_and_documents_round_trip() {
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": e, "file_name": "first.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64e(first), "sha256": sha256(first),
|
||||
"description": "Receipt ID 1007; Paid from: Bluevine"}),
|
||||
"description": "Receipt ID 1007; Paid from: Quillmere"}),
|
||||
);
|
||||
let b = m.ok(
|
||||
"books.file_document",
|
||||
@@ -2787,7 +2850,7 @@ fn receipts_and_documents_round_trip() {
|
||||
"first.pdf",
|
||||
"application/pdf",
|
||||
Some(e),
|
||||
"Receipt ID 1007; Paid from: Bluevine",
|
||||
"Receipt ID 1007; Paid from: Quillmere",
|
||||
),
|
||||
(
|
||||
&b,
|
||||
@@ -3560,31 +3623,88 @@ fn cpa_package_holds_the_year_and_its_evidence() {
|
||||
// 1099 worksheet.
|
||||
let w = csv_rows(
|
||||
std::str::from_utf8(&files["1099-worksheet-2025.csv"]).unwrap(),
|
||||
&["vendor", "form", "w9_on_file", "paid", "reportable"],
|
||||
&[
|
||||
"vendor_id",
|
||||
"name",
|
||||
"decision_1099",
|
||||
"form_1099",
|
||||
"reason_kind",
|
||||
"reason",
|
||||
"decided_by",
|
||||
"decided_on",
|
||||
"effective",
|
||||
"decisions_in_year",
|
||||
"paid",
|
||||
"excluded_card",
|
||||
"undetermined",
|
||||
"not_reportable",
|
||||
"status",
|
||||
"reportable",
|
||||
"w9_on_file",
|
||||
"tin_last4",
|
||||
],
|
||||
);
|
||||
let want = ledger_core::vendor_1099_report_with_members(
|
||||
&ledger,
|
||||
2025,
|
||||
Some(d(END)),
|
||||
None,
|
||||
&ledger_core::PaymentAccounts::default(),
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
// Through as_of (rules.md B-33), out of the package's cash account
|
||||
// and the settings' financing account (one list each per package).
|
||||
let want = core_1099_through(&ledger, 2025, None, Some(d(END)));
|
||||
assert_eq!(w.len(), want.vendors.len());
|
||||
let contractor = w.iter().find(|row| row[0] == "Contractor").unwrap();
|
||||
let contractor = w.iter().find(|row| row[1] == "Contractor").unwrap();
|
||||
assert_eq!(
|
||||
amt(&contractor[3]),
|
||||
amt(&contractor[10]),
|
||||
dec("900.00"),
|
||||
"the payment after as_of is left out"
|
||||
);
|
||||
for (row, v) in w.iter().zip(&want.vendors) {
|
||||
assert_eq!(row[0], v.name);
|
||||
assert_eq!(row[1], v.form.map(|f| f.label()).unwrap_or(""));
|
||||
assert_eq!(row[2], v.tin_on_file.to_string());
|
||||
assert_eq!(amt(&row[3]), v.paid);
|
||||
assert_eq!(row[4], v.reportable.to_string());
|
||||
assert_eq!(row[0], v.vendor_id.to_string());
|
||||
assert_eq!(row[1], v.name);
|
||||
assert_eq!(row[2], v.decision.key());
|
||||
assert_eq!(row[3], v.form().map(|f| f.label()).unwrap_or(""));
|
||||
assert_eq!(
|
||||
row[6],
|
||||
v.decided.as_ref().map_or("", |s| s.decided_by.as_str())
|
||||
);
|
||||
assert_eq!(amt(&row[10]), v.paid);
|
||||
assert_eq!(amt(&row[11]), v.excluded_card);
|
||||
assert_eq!(amt(&row[12]), v.undetermined);
|
||||
assert_eq!(amt(&row[13]), v.not_reportable);
|
||||
assert_eq!(row[14], v.status.key());
|
||||
assert_eq!(row[15], v.reportable.to_string());
|
||||
assert_eq!(row[16], v.tin_on_file.to_string());
|
||||
}
|
||||
// The package's 1099 worksheet is books.vendor_1099's CSV for the
|
||||
// package's accounts and its as_of, byte for byte (ledger_package
|
||||
// INVARIANT 7, docs/mcp-interface.md): the tool given the same cash
|
||||
// account (the financing account the settings', as the package's)
|
||||
// and through as_of hands over the same file; without through, the
|
||||
// payment after as_of counts, and the files differ.
|
||||
let mut tool_csv = |args: Value| {
|
||||
let out = m.ok("books.vendor_1099", args);
|
||||
handed(&out["csv"])
|
||||
};
|
||||
let same =
|
||||
tool_csv(json!({"year": 2025, "csv": true, "cash_accounts": ["1000"], "through": END}));
|
||||
assert_eq!(same, files["1099-worksheet-2025.csv"]);
|
||||
let named = tool_csv(json!({
|
||||
"year": 2025, "csv": true, "cash_accounts": ["1000"],
|
||||
"financing_accounts": ["2100"], "through": END
|
||||
}));
|
||||
assert_eq!(
|
||||
named, files["1099-worksheet-2025.csv"],
|
||||
"the settings' financing, named"
|
||||
);
|
||||
let whole_year = tool_csv(json!({"year": 2025, "csv": true, "cash_accounts": ["1000"]}));
|
||||
assert_ne!(whole_year, files["1099-worksheet-2025.csv"]);
|
||||
// A through outside the year, or an account the chart lacks: refused.
|
||||
let why = m.refuse(
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2025, "through": "2026-01-01"}),
|
||||
);
|
||||
assert!(why.contains("is a day of 2025"), "{why}");
|
||||
let why = m.refuse(
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2025, "cash_accounts": ["1999"]}),
|
||||
);
|
||||
assert!(why.contains("no account 1999"), "{why}");
|
||||
|
||||
// Without receipts: no evidence at all.
|
||||
let out = m.ok(
|
||||
|
||||
@@ -0,0 +1,929 @@
|
||||
//! The 1099 decision and payment methods through the server (plan P3,
|
||||
//! defects 12, 18 and 23; D-40), driven over stdio against the real
|
||||
//! binary: 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 serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(180);
|
||||
|
||||
// ---------------------------------------------------------------- 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 {
|
||||
stdin: ChildStdin,
|
||||
rx: Receiver<String>,
|
||||
child: Child,
|
||||
next_id: u64,
|
||||
data: PathBuf,
|
||||
validators: HashMap<String, jsonschema::Validator>,
|
||||
/// The books dir when the server owns it: removed after the server
|
||||
/// stops (fields drop after `Drop::drop`).
|
||||
books_dir: Option<TestDir>,
|
||||
}
|
||||
|
||||
impl Drop for Mcp {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
static DIR_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = std::path::Path;
|
||||
fn deref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<std::path::Path> for TestDir {
|
||||
fn as_ref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ledger-mcp-spec-1099-{}-{name}-{n}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
}
|
||||
|
||||
/// `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: &Path, toml: &Path) -> Self {
|
||||
let run = dir.join("run");
|
||||
std::fs::create_dir_all(&run).unwrap();
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
|
||||
.arg("--data-dir")
|
||||
.arg(dir.join("books-data"))
|
||||
.arg("--books-toml")
|
||||
.arg(toml)
|
||||
.env("XDG_RUNTIME_DIR", &run)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn ledger-mcp");
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
let (tx, rx) = channel();
|
||||
std::thread::spawn(move || {
|
||||
for line in stdout.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut mcp = Self {
|
||||
books_dir: None,
|
||||
stdin,
|
||||
rx,
|
||||
child,
|
||||
next_id: 1,
|
||||
data: dir.join("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) {
|
||||
writeln!(self.stdin, "{line}").unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
}
|
||||
|
||||
fn recv(&mut self) -> Option<Value> {
|
||||
loop {
|
||||
match self.rx.recv_timeout(TIMEOUT) {
|
||||
Ok(line) => {
|
||||
let v: Value = serde_json::from_str(&line)
|
||||
.unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}"));
|
||||
if v.get("id").is_none() && v.get("method").is_some() {
|
||||
continue;
|
||||
}
|
||||
return Some(v);
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
let dir = test_dir(name);
|
||||
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 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"), 0, "nothing declared either");
|
||||
}
|
||||
|
||||
/// 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 on books that
|
||||
/// never used the vendor_1099_decisions feature journals the W-9 and then
|
||||
/// the vendor's W-9 flag, a line of that feature, so the feature is
|
||||
/// declared before the command's first line, never between its lines
|
||||
/// (where a refused upgrade would leave the W-9 recorded and the flag
|
||||
/// not).
|
||||
#[test]
|
||||
fn set_w9_declares_the_w9_flags_feature_before_its_first_line() {
|
||||
let mut m = books("set-w9-feature");
|
||||
m.add_party("Harbor Design LLC", &["contractor"]);
|
||||
m.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
|
||||
let before = m.journal_types().len();
|
||||
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 added = m.journal_types()[before..].to_vec();
|
||||
assert_eq!(
|
||||
added,
|
||||
["journal.format_set", "party.w9_set", "vendor.w9_flag_set"],
|
||||
"the declaration comes first"
|
||||
);
|
||||
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"));
|
||||
}
|
||||
@@ -783,9 +783,15 @@ fn happy_path(name: &str) -> Mcp {
|
||||
.unwrap();
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Landlord", "form_1099": "misc", "tin_on_file": true}),
|
||||
json!({"vendor": "Landlord", "form_1099": "misc", "tin_on_file": true, "decided_by": "Test Owner"}),
|
||||
);
|
||||
m.ok("books.list_vendors", json!({}));
|
||||
// Payment methods (plan P3, defect 12).
|
||||
m.ok(
|
||||
"books.add_instrument",
|
||||
json!({"kind": "business_card", "label": "Harbor Visa", "last4": "0042", "account": "1000"}),
|
||||
);
|
||||
m.ok("books.instruments", json!({}));
|
||||
m.ok(
|
||||
"books.add_party",
|
||||
json!({"name": "Pat", "roles": ["employee"]}),
|
||||
@@ -872,6 +878,12 @@ fn happy_path(name: &str) -> Mcp {
|
||||
"books.set_entry_party",
|
||||
json!({"entry_id": e2, "party": "Landlord"}),
|
||||
);
|
||||
// Paid with the debit card, then the tag cleared.
|
||||
m.ok(
|
||||
"books.set_entry_instrument",
|
||||
json!({"entry_id": e2, "instrument": "Harbor Visa"}),
|
||||
);
|
||||
m.ok("books.set_entry_instrument", json!({"entry_id": e2}));
|
||||
// Replace the receipt: attach new, unlink old, retire it.
|
||||
m.attach(Some(e2), b"rent receipt v2");
|
||||
m.ok(
|
||||
@@ -2154,7 +2166,7 @@ fn inv6_ambiguous_vendor_reference_refused() {
|
||||
let amb = acme.to_string();
|
||||
m.refuse(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": amb, "form_1099": "nec", "tin_on_file": false}),
|
||||
json!({"vendor": amb, "form_1099": "nec", "tin_on_file": false, "decided_by": "Test Owner"}),
|
||||
);
|
||||
m.refuse(
|
||||
"books.draft_entry",
|
||||
@@ -2169,7 +2181,7 @@ fn inv6_ambiguous_vendor_reference_refused() {
|
||||
// that name).
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Acme", "form_1099": "nec", "tin_on_file": false}),
|
||||
json!({"vendor": "Acme", "form_1099": "nec", "tin_on_file": false, "decided_by": "Test Owner"}),
|
||||
);
|
||||
let out = m.ok(
|
||||
"books.set_entry_vendor",
|
||||
|
||||
@@ -761,7 +761,7 @@ fn set_entry_party_tags_the_vendor_so_vendor_1099_counts_it() {
|
||||
m.ok("books.add_vendor", json!({"name": "Landlord LLC"}));
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Landlord LLC", "form_1099": "misc"}),
|
||||
json!({"vendor": "Landlord LLC", "form_1099": "misc", "decided_by": "Test Owner"}),
|
||||
);
|
||||
let rent = m.post(
|
||||
"2026-02-01",
|
||||
@@ -862,7 +862,7 @@ fn party_totals_match_a_hand_computed_year() {
|
||||
.unwrap();
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Acme Design", "form_1099": "nec"}),
|
||||
json!({"vendor": "Acme Design", "form_1099": "nec", "decided_by": "Test Owner"}),
|
||||
);
|
||||
let acme = json!({"party": "Acme Design"});
|
||||
// Last year: not in 2026.
|
||||
@@ -1122,7 +1122,7 @@ fn parties_lists_by_id_and_filters_by_role() {
|
||||
let mut m = books("list");
|
||||
let a = m.add_party("Acme Design", &["contractor"]);
|
||||
let i = m.add_party("IRS", &["tax_agency"]);
|
||||
let b = m.add_party("Bluevine", &["financial_institution"]);
|
||||
let b = m.add_party("Quillmere", &["financial_institution"]);
|
||||
let u = m.add_party("Utah Division of Corporations", &["government_agency"]);
|
||||
let all = m.read_only("books.parties", json!({}));
|
||||
assert!(all["year"].is_i64(), "defaults to this year");
|
||||
@@ -1284,7 +1284,7 @@ fn require_refuses_until_tagged_or_marked_none() {
|
||||
m.read_only("books.settings", json!({}))["parties"],
|
||||
"require"
|
||||
);
|
||||
let bank = m.add_party("Bluevine", &["financial_institution"]);
|
||||
let bank = m.add_party("Quillmere", &["financial_institution"]);
|
||||
let id = m.draft(
|
||||
"2026-03-01",
|
||||
json!({}),
|
||||
@@ -1323,18 +1323,18 @@ fn require_refuses_until_tagged_or_marked_none() {
|
||||
// Both at once is refused.
|
||||
let e = m.refuse(
|
||||
"books.draft_entry",
|
||||
json!({"date": "2026-03-03", "description": "x", "party": "Bluevine", "no_party_reason": "none"}),
|
||||
json!({"date": "2026-03-03", "description": "x", "party": "Quillmere", "no_party_reason": "none"}),
|
||||
);
|
||||
assert!(e.contains("no_party_reason"), "{e}");
|
||||
let e = m.refuse(
|
||||
"books.set_entry_party",
|
||||
json!({"entry_id": t, "party": "Bluevine", "no_party_reason": "none"}),
|
||||
json!({"entry_id": t, "party": "Quillmere", "no_party_reason": "none"}),
|
||||
);
|
||||
assert!(e.contains("not both"), "{e}");
|
||||
// Tagging later clears the mark; marking again needs no party.
|
||||
m.ok(
|
||||
"books.set_entry_party",
|
||||
json!({"entry_id": t, "party": "Bluevine"}),
|
||||
json!({"entry_id": t, "party": "Quillmere"}),
|
||||
);
|
||||
m.ok(
|
||||
"books.set_entry_party",
|
||||
|
||||
@@ -639,6 +639,27 @@ fn defect24_creditor_of_round_trips_and_a_card_fee_is_not_a_payment() {
|
||||
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||||
}
|
||||
|
||||
/// rules.md B-27: `books.update_party` declares the `party_creditors`
|
||||
/// feature before the first line it writes, never between them: a name
|
||||
/// change with the first creditor is journaled as the declaration, then
|
||||
/// the name, then the creditor (a refused format upgrade then leaves
|
||||
/// nothing half done).
|
||||
#[test]
|
||||
fn update_party_declares_the_creditor_feature_before_its_first_line() {
|
||||
let mut m = books("feature-first");
|
||||
let start = m.journal_types().len();
|
||||
let p = m.ok(
|
||||
"books.update_party",
|
||||
json!({"party": "Harbor Bank", "notes": "the card's issuer", "creditor_of": ["2050"]}),
|
||||
);
|
||||
assert_eq!(p["creditor_of"], json!(["2050"]));
|
||||
assert_eq!(
|
||||
m.journal_types()[start..].to_vec(),
|
||||
["journal.format_set", "party.updated", "party.creditor_set"],
|
||||
);
|
||||
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||||
}
|
||||
|
||||
/// What `books.post_entry` previews is what it posts: each party's
|
||||
/// `resolved`, `direction`, `cash` and `in_kind` in the preview are
|
||||
/// `books.get_entry`'s once confirmed (a derived split is then journaled as
|
||||
|
||||
@@ -427,7 +427,7 @@ fn books_with(name: &str, extra: &str) -> Mcp {
|
||||
m.ok("books.add_vendor", json!({"name": "Acme Design"}));
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Acme Design", "form_1099": "nec"}),
|
||||
json!({"vendor": "Acme Design", "form_1099": "nec", "decided_by": "Test Owner"}),
|
||||
);
|
||||
m.add_party("Rent Co", &["landlord"]);
|
||||
m.books_dir = Some(dir);
|
||||
@@ -1019,7 +1019,7 @@ fn the_1099_worksheet_carries_full_tins_only_when_asked() {
|
||||
m.ok("books.add_vendor", json!({"name": "Rent Co"}));
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Rent Co", "form_1099": "misc"}),
|
||||
json!({"vendor": "Rent Co", "form_1099": "misc", "decided_by": "Test Owner"}),
|
||||
);
|
||||
m.w9(
|
||||
"Rent Co",
|
||||
@@ -1072,7 +1072,9 @@ fn the_1099_worksheet_carries_full_tins_only_when_asked() {
|
||||
assert_eq!(out["csv"]["mime_type"], "text/csv");
|
||||
let text = String::from_utf8(handed(&out["csv"])).unwrap();
|
||||
assert!(text.contains(SSN), "{text}");
|
||||
assert!(text.starts_with("vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4,tin"));
|
||||
assert!(text.starts_with(
|
||||
"vendor_id,name,decision_1099,form_1099,reason_kind,reason,decided_by,decided_on,effective,decisions_in_year,paid,excluded_card,undetermined,not_reportable,status,reportable,w9_on_file,tin_last4,tin,tin_type,tin_note\n"
|
||||
));
|
||||
let rows = csv_rows(text.as_bytes());
|
||||
let acme = rows.iter().find(|r| r["name"] == "Acme Design").unwrap();
|
||||
assert_eq!(acme["tin"], SSN);
|
||||
@@ -1087,7 +1089,7 @@ fn the_1099_worksheet_carries_full_tins_only_when_asked() {
|
||||
assert!(!holds_tin(&bytes, SSN_DIGITS));
|
||||
let text = String::from_utf8(bytes).unwrap();
|
||||
assert!(
|
||||
text.starts_with("vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4\n"),
|
||||
text.starts_with("vendor_id,name,decision_1099,form_1099,reason_kind,reason,decided_by,decided_on,effective,decisions_in_year,paid,excluded_card,undetermined,not_reportable,status,reportable,w9_on_file,tin_last4\n"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
@@ -1190,7 +1192,7 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
|
||||
m.ok("books.add_vendor", json!({"name": "Rent Co"}));
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Rent Co", "form_1099": "misc"}),
|
||||
json!({"vendor": "Rent Co", "form_1099": "misc", "decided_by": "Test Owner"}),
|
||||
);
|
||||
m.pay("Acme Design", "2500.00");
|
||||
m.pay("Rent Co", "3000.00");
|
||||
@@ -1802,7 +1804,7 @@ fn the_cpa_package_carries_the_k1_and_1099_worksheets_with_tins() {
|
||||
}
|
||||
// The 1099 worksheet: the payee's full TIN.
|
||||
let w = csv_rows(&pkg["1099-worksheet-2026.csv"]);
|
||||
let acme = w.iter().find(|r| r["vendor"] == "Acme Design").unwrap();
|
||||
let acme = w.iter().find(|r| r["name"] == "Acme Design").unwrap();
|
||||
assert_eq!(acme["tin"], EIN);
|
||||
assert_eq!(acme["tin_type"], "ein");
|
||||
assert_eq!(acme["tin_last4"], "9105");
|
||||
@@ -2100,6 +2102,10 @@ fn idempotent_tools_change_nothing_when_repeated() {
|
||||
.to_string();
|
||||
let settings_file =
|
||||
std::fs::read_to_string(m.data.parent().unwrap().join("wanderbooks.toml")).unwrap();
|
||||
m.ok(
|
||||
"books.add_instrument",
|
||||
json!({"kind": "member_card", "label": "Riley's Visa", "account": "1000"}),
|
||||
);
|
||||
let calls: Vec<(&str, Value)> = vec![
|
||||
("books.verify", json!({"blobs": true})),
|
||||
(
|
||||
@@ -2118,12 +2124,16 @@ fn idempotent_tools_change_nothing_when_repeated() {
|
||||
("books.run_recurring", json!({"through": "2026-03-15"})),
|
||||
(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Acme Design", "form_1099": "misc"}),
|
||||
json!({"vendor": "Acme Design", "form_1099": "misc", "decided_by": "Test Owner"}),
|
||||
),
|
||||
(
|
||||
"books.set_entry_vendor",
|
||||
json!({"entry_id": entry, "vendor": "Acme Design"}),
|
||||
),
|
||||
(
|
||||
"books.set_entry_instrument",
|
||||
json!({"entry_id": entry, "instrument": "Riley's Visa"}),
|
||||
),
|
||||
(
|
||||
"books.label_asset",
|
||||
json!({"asset": asset.to_string(), "serial": "SN-1"}),
|
||||
|
||||
Reference in new issue
Block a user