Files
wanderbooks/crates/ledger-package/tests/seams/books.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

701 lines
25 KiB
Rust

//! The P3 seam review's books (shared by `ledger-package`'s
//! `tests/p3_seams.rs`, `ledger-gui`'s `tests/p3_seams.rs` and
//! `ledger-cli`'s `tests/p3_seams.rs`, which include this file): one year
//! of fictional books for a two-member LLC, Ana Example and Ben Example,
//! 50/50, built through the real [`Store`], with the settings the server
//! and the viewer read written beside them (`wanderbooks.toml`). Every
//! name and amount is made up.
//!
//! What happens, by month (2026):
//! - Jan: the members contribute 7,500.00 each in cash.
//! - Feb: Ana contributes her personal laptop (value 1,800.00, her basis
//! 2,400.00: a personal-use conversion, D-41); Delta Studio LLC, a
//! 1099-NEC contractor, is paid 1,500.00 by check.
//! - Mar: Delta 700.00 on the business card; a year of insurance,
//! 1,095.00, prepaid from March 15 on a daily schedule; supplies
//! 300.25 on the card; Ana pays a 120.00 subscription on her own card
//! (through Due to Members).
//! - Apr: an 8,000.00 bank loan; a 40.00 refund from the supplies
//! vendor; the card paid, 1,000.25; a camera bought, 3,600.00, in
//! service April 15; Ana reimbursed.
//! - May: Ben pays Delta 900.00 with his own check (Due to Members).
//! - Jun: a loan payment; Ben reimbursed; May's camera run reversed on
//! June 3 (caught up by July's run); a 2,000.00 distribution declared
//! to Distributions Payable on June 30.
//! - Jul: the distribution paid; a loan payment.
//! - Aug 1: Delta elects corporate taxation (not reportable from then).
//! - Sep: Delta paid 1,200.00 by check.
//! - Nov: November's camera run made, then the camera sold on November 20
//! for 2,900.00 (the disposal credits back the run's last ten days).
//! - Dec: a 1,000.00 distribution paid directly.
//! - Every month: rent 450.00, revenue with odd cents, depreciation, the
//! insurance schedule, and a close to the members.
#![allow(dead_code)]
use chrono::NaiveDate;
use ledger_core::settings::{Company, Member, Source};
use ledger_core::{
AccountId, AccountType, AssetBasis, BasisRule, CloseTo, Currency, Dated1099, Decision1099,
Form1099, InstrumentKind, MemberClose, Money, NewAsset, NewSchedule, PartyId, PartyShare, Role,
ScheduleConvention, ScheduleKind, Sec704cMethod, Side,
};
use ledger_store::Store;
use rust_decimal::Decimal;
use std::str::FromStr;
pub fn d(s: &str) -> NaiveDate {
NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
}
pub fn dec(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
pub fn usd(s: &str) -> Money {
Money::new(dec(s), Currency::USD)
}
fn src(on: &str) -> Source {
Source {
decided_by: "Ana Example".into(),
decided_on: d(on),
citation: None,
note: None,
}
}
pub const CASH: &str = "1000";
pub const FINANCING: [&str; 3] = ["2050", "2100", "2300"];
/// The scenario's books and the ids the checks need.
pub struct Books {
pub store: Store,
pub company: Company,
pub cash: Vec<String>,
pub financing: Vec<String>,
pub ana: PartyId,
pub ben: PartyId,
pub delta: u64,
pub delta_party: PartyId,
pub paper_party: PartyId,
pub laptop: u64,
pub camera: u64,
}
pub fn id(s: &Store, code: &str) -> AccountId {
s.ledger().chart().find_by_code(code).unwrap().id
}
fn post(s: &mut Store, date: &str, desc: &str, lines: &[(&str, Side, &str)]) -> u64 {
let e = s.create_entry(d(date), desc).unwrap();
for (code, side, a) in lines {
let acct = id(s, code);
s.add_line(e, acct, *side, usd(a)).unwrap();
}
e
}
fn done(s: &mut Store, e: u64) -> u64 {
s.post_entry(e).unwrap();
e
}
fn with_party(s: &mut Store, e: u64, p: PartyId) -> u64 {
s.set_entry_party(e, Some(p)).unwrap();
done(s, e)
}
fn with_vendor(s: &mut Store, e: u64, v: u64) -> u64 {
s.set_entry_vendor(e, Some(v)).unwrap();
done(s, e)
}
fn both(s: &mut Store, e: u64, ana: PartyId, ben: PartyId, each: &str) -> u64 {
let share = ledger_core::shares::Share::Amount(dec(each));
s.set_entry_parties(
e,
&[
PartyShare { party: ana, share },
PartyShare { party: ben, share },
],
)
.unwrap();
done(s, e)
}
/// The settings of these books, written beside `books-data` as an older
/// books dir keeps them (adoption reads them once; a test that opens the
/// books itself journals them): [`company`] and the account roles, the
/// receipt threshold high so no entry needs a receipt.
pub const SETTINGS_TOML: &str = "[company]
name = \"Example Trails LLC\"
basis = \"Cash\"
fiscal_year_start = \"01-01\"
[[company.members]]
name = \"Ana Example\"
share = \"50%\"
capital_account = \"3000\"
distribution_account = \"3010\"
files_jointly_with = \"Ben Example\"
[[company.members]]
name = \"Ben Example\"
share = \"50%\"
capital_account = \"3001\"
distribution_account = \"3011\"
files_jointly_with = \"Ana Example\"
[books]
receipt_threshold = \"1000000.00\"
cash_accounts = [\"1000\"]
financing_accounts = [\"2050\", \"2100\", \"2300\"]
";
/// The company as [`SETTINGS_TOML`] gives it.
pub fn company() -> Company {
let member = |name: &str, capital: &str, draws: &str, other: &str| Member {
name: name.into(),
share: dec("0.5"),
capital_account: capital.into(),
distribution_account: Some(draws.into()),
files_jointly_with: Some(other.into()),
};
Company {
name: "Example Trails LLC".into(),
basis: "Cash".into(),
members: vec![
member("Ana Example", "3000", "3010", "Ben Example"),
member("Ben Example", "3001", "3011", "Ana Example"),
],
..Company::default()
}
}
pub const MONTHS: [&str; 12] = [
"2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06", "2026-07", "2026-08",
"2026-09", "2026-10", "2026-11", "2026-12",
];
pub fn month_end(m: &str) -> NaiveDate {
let first = d(&format!("{m}-01"));
let next = first.checked_add_months(chrono::Months::new(1)).unwrap();
next.pred_opt().unwrap()
}
pub fn books(dir: &std::path::Path) -> Books {
let mut s = Store::create(&dir.join("books-data"), Currency::USD).unwrap();
for (code, name, ty) in [
("1000", "Checking", AccountType::Asset),
("1300", "Prepaid Insurance", AccountType::Asset),
("1500", "Camera", AccountType::Asset),
(
"1510",
"Accumulated Depreciation - Camera",
AccountType::Asset,
),
("1520", "Laptop", AccountType::Asset),
(
"1530",
"Accumulated Depreciation - Laptop",
AccountType::Asset,
),
("2050", "Business Card", AccountType::Liability),
("2100", "Due to Members", AccountType::Liability),
("2200", "Distributions Payable", AccountType::Liability),
("2300", "Bank Loan", AccountType::Liability),
("3000", "Capital - Ana", AccountType::Equity),
("3001", "Capital - Ben", AccountType::Equity),
("3010", "Distributions - Ana", AccountType::Equity),
("3011", "Distributions - Ben", AccountType::Equity),
("4000", "Guiding Revenue", AccountType::Revenue),
("6000", "Rent", AccountType::Expense),
("6150", "Software", AccountType::Expense),
("6200", "Supplies", AccountType::Expense),
("6300", "Insurance", AccountType::Expense),
("6400", "Contract Labor", AccountType::Expense),
("6500", "Depreciation", AccountType::Expense),
("6900", "Interest Expense", AccountType::Expense),
("7000", "Gain or Loss on Disposal", AccountType::Revenue),
] {
s.add_account(code, name, ty, None, false).unwrap();
}
let ana = s.add_party("Ana Example", &[Role::Member]).unwrap();
let ben = s.add_party("Ben Example", &[Role::Member]).unwrap();
for m in [ana, ben] {
s.set_party_creditor(m, id(&s, "2100"), true).unwrap();
}
let client = s.add_customer("Summit Client Co").unwrap();
let client = s.ledger().party_of_customer(client).unwrap();
let lender = s
.add_party("Example Savings Bank", &[Role::FinancialInstitution])
.unwrap();
s.set_party_creditor(lender, id(&s, "2300"), true).unwrap();
let delta = s.add_vendor("Delta Studio LLC").unwrap();
let delta_party = s.ledger().party_of_vendor(delta).unwrap();
// Delta files a 1099-NEC, then elects corporate taxation effective
// August 1 (Form 8832): not reportable from that day (D-40).
s.set_vendor_1099(
delta,
&Dated1099::from_beginning(Decision1099::Files(Form1099::Nec), Some(src("2026-01-02"))),
Some(true),
)
.unwrap();
s.set_vendor_1099(
delta,
&Dated1099 {
effective: Some(d("2026-08-01")),
decision: Decision1099::not_reportable(
ledger_core::ExemptReason::Corporation,
"elected corporate taxation, Form 8832 effective 2026-08-01",
)
.unwrap(),
source: Some(src("2026-08-03")),
},
Some(true),
)
.unwrap();
let paper = s.add_vendor("Paper Goods Co").unwrap();
let paper_party = s.ledger().party_of_vendor(paper).unwrap();
let cloud = s.add_vendor("Cloud Tools Inc").unwrap();
let lens = s.add_vendor("Lens Shop Inc").unwrap();
let insurer = s.add_vendor("Safe Insurance Inc").unwrap();
let landlord = s.add_vendor("Trailhead Rentals LLC").unwrap();
let card = s
.add_instrument(
InstrumentKind::BusinessCard,
"Business card",
Some("4242"),
None,
id(&s, "2050"),
)
.unwrap();
let ana_card = s
.add_instrument(
InstrumentKind::MemberCard,
"Ana's personal card",
Some("1111"),
Some(ana),
id(&s, "2100"),
)
.unwrap();
let ben_check = s
.add_instrument(
InstrumentKind::MemberCashOrCheck,
"Ben's personal checks",
None,
Some(ben),
id(&s, "2100"),
)
.unwrap();
let members: Vec<MemberClose> = company()
.members
.iter()
.map(|m| MemberClose {
capital: id(&s, &m.capital_account),
share: m.share,
distributions: m.distribution_account.as_deref().map(|c| id(&s, c)),
})
.collect();
let mut laptop = 0;
let mut camera = 0;
// Odd-cent revenue so the closes split odd cents.
let revenue = [
"2100.01", "1850.37", "2433.33", "2950.05", "3100.11", "3333.33", "2875.99", "2600.01",
"2999.97", "3050.03", "2200.21", "1999.99",
];
for (i, m) in MONTHS.iter().enumerate() {
let end = month_end(m);
let day = |n: u32| format!("{m}-{n:02}");
match i {
0 => {
let e = post(
&mut s,
&day(5),
"Members' opening contributions",
&[
("1000", Side::Debit, "15000.00"),
("3000", Side::Credit, "7500.00"),
("3001", Side::Credit, "7500.00"),
],
);
both(&mut s, e, ana, ben, "7500.00");
}
1 => {
// Ana contributes the laptop she used personally (D-41).
let e = post(
&mut s,
&day(1),
"Ana contributes her laptop",
&[
("1520", Side::Debit, "1800.00"),
("3000", Side::Credit, "1800.00"),
],
);
let e = with_party(&mut s, e, ana);
laptop = s
.register_asset(NewAsset {
description: "Laptop".into(),
in_service_date: d("2026-02-01"),
cost: usd("1800.00"),
salvage_value: usd("0.00"),
useful_life_months: 36,
cost_account: id(&s, "1520"),
accum_depr_account: id(&s, "1530"),
expense_account: id(&s, "6500"),
})
.unwrap();
s.set_asset_acquisition(laptop, e).unwrap();
s.set_asset_basis(
laptop,
AssetBasis {
rule: BasisRule::PersonalUseConversion,
contributor_adjusted_basis: Some(dec("2400.00")),
fmv_at_contribution: Some(dec("1800.00")),
sec704c_method: Some(Sec704cMethod::Traditional),
source: Some(src("2026-02-01")),
},
)
.unwrap();
// Delta paid by check from the bank: no method recorded,
// presumed a check (D-40).
let e = post(
&mut s,
&day(10),
"Delta Studio: trail maps",
&[
("6400", Side::Debit, "1500.00"),
("1000", Side::Credit, "1500.00"),
],
);
with_vendor(&mut s, e, delta);
}
2 => {
let e = post(
&mut s,
&day(12),
"Delta Studio: logo work, on the card",
&[
("6400", Side::Debit, "700.00"),
("2050", Side::Credit, "700.00"),
],
);
s.set_entry_instrument(e, Some(card)).unwrap();
with_vendor(&mut s, e, delta);
let e = post(
&mut s,
&day(15),
"Liability insurance, a year from March 15",
&[
("1300", Side::Debit, "1095.00"),
("1000", Side::Credit, "1095.00"),
],
);
with_vendor(&mut s, e, insurer);
s.register_schedule(NewSchedule {
description: "Liability insurance".into(),
kind: ScheduleKind::PrepaidExpense,
total: usd("1095.00"),
start: d("2026-03-15"),
months: 12,
balance_account: id(&s, "1300"),
pl_account: id(&s, "6300"),
convention: ScheduleConvention::Daily,
})
.unwrap();
let e = post(
&mut s,
&day(20),
"Paper Goods: maps paper, on the card",
&[
("6200", Side::Debit, "300.25"),
("2050", Side::Credit, "300.25"),
],
);
s.set_entry_instrument(e, Some(card)).unwrap();
with_vendor(&mut s, e, paper);
// Ana pays a software subscription on her own card.
let e = post(
&mut s,
&day(22),
"Cloud Tools: route planner, Ana's card",
&[
("6150", Side::Debit, "120.00"),
("2100", Side::Credit, "120.00"),
],
);
s.set_entry_instrument(e, Some(ana_card)).unwrap();
with_vendor(&mut s, e, cloud);
}
3 => {
let e = post(
&mut s,
&day(1),
"Equipment loan",
&[
("1000", Side::Debit, "8000.00"),
("2300", Side::Credit, "8000.00"),
],
);
with_party(&mut s, e, lender);
let e = post(
&mut s,
&day(5),
"Paper Goods refund: damaged paper",
&[
("1000", Side::Debit, "40.00"),
("6200", Side::Credit, "40.00"),
],
);
with_vendor(&mut s, e, paper);
let e = post(
&mut s,
&day(8),
"Card payment",
&[
("2050", Side::Debit, "1000.25"),
("1000", Side::Credit, "1000.25"),
],
);
s.set_entry_no_party(e, "paying the company's own card")
.unwrap();
done(&mut s, e);
let e = post(
&mut s,
&day(10),
"Lens Shop: trail camera",
&[
("1500", Side::Debit, "3600.00"),
("1000", Side::Credit, "3600.00"),
],
);
let e = with_vendor(&mut s, e, lens);
camera = s
.register_asset(NewAsset {
description: "Trail camera".into(),
in_service_date: d("2026-04-15"),
cost: usd("3600.00"),
salvage_value: usd("0.00"),
useful_life_months: 48,
cost_account: id(&s, "1500"),
accum_depr_account: id(&s, "1510"),
expense_account: id(&s, "6500"),
})
.unwrap();
s.set_asset_acquisition(camera, e).unwrap();
s.set_asset_basis(
camera,
AssetBasis {
rule: BasisRule::Purchase,
contributor_adjusted_basis: None,
fmv_at_contribution: None,
sec704c_method: None,
source: Some(src("2026-04-10")),
},
)
.unwrap();
let e = post(
&mut s,
&day(25),
"Ana reimbursed for the route planner",
&[
("2100", Side::Debit, "120.00"),
("1000", Side::Credit, "120.00"),
],
);
with_party(&mut s, e, ana);
}
4 => {
// Ben pays Delta with his own check, at the company's
// direction: the company is the payor (B-19).
let e = post(
&mut s,
&day(20),
"Delta Studio: guidebook layout, Ben's check",
&[
("6400", Side::Debit, "900.00"),
("2100", Side::Credit, "900.00"),
],
);
s.set_entry_instrument(e, Some(ben_check)).unwrap();
with_vendor(&mut s, e, delta);
}
5 => {
let e = post(
&mut s,
&day(1),
"Loan payment",
&[
("2300", Side::Debit, "300.00"),
("6900", Side::Debit, "40.00"),
("1000", Side::Credit, "340.00"),
],
);
with_party(&mut s, e, lender);
let e = post(
&mut s,
&day(15),
"Ben reimbursed for the guidebook layout",
&[
("2100", Side::Debit, "900.00"),
("1000", Side::Credit, "900.00"),
],
);
with_party(&mut s, e, ben);
let e = post(
&mut s,
&day(30),
"Distribution declared",
&[
("3010", Side::Debit, "1000.00"),
("3011", Side::Debit, "1000.00"),
("2200", Side::Credit, "2000.00"),
],
);
both(&mut s, e, ana, ben, "1000.00");
}
6 => {
let e = post(
&mut s,
&day(10),
"Distribution paid",
&[
("2200", Side::Debit, "2000.00"),
("1000", Side::Credit, "2000.00"),
],
);
both(&mut s, e, ana, ben, "1000.00");
let e = post(
&mut s,
&day(1),
"Loan payment",
&[
("2300", Side::Debit, "302.00"),
("6900", Side::Debit, "38.00"),
("1000", Side::Credit, "340.00"),
],
);
with_party(&mut s, e, lender);
}
8 => {
// After Delta's election: not reportable (D-40).
let e = post(
&mut s,
&day(10),
"Delta Studio: signage",
&[
("6400", Side::Debit, "1200.00"),
("1000", Side::Credit, "1200.00"),
],
);
with_vendor(&mut s, e, delta);
}
10 => {
// November's run is made before the camera's sale is
// recorded: the disposal takes back the days after it.
s.run_depreciation(camera, m).unwrap();
let e = s
.dispose_asset(
camera,
d("2026-11-20"),
usd("2900.00"),
id(&s, "1000"),
id(&s, "7000"),
)
.unwrap();
let buyer = lens_buyer(&mut s);
s.set_entry_party(e, Some(buyer)).unwrap();
}
11 => {
let e = post(
&mut s,
&day(15),
"Year-end distribution",
&[
("3010", Side::Debit, "500.00"),
("3011", Side::Debit, "500.00"),
("1000", Side::Credit, "1000.00"),
],
);
both(&mut s, e, ana, ben, "500.00");
}
_ => {}
}
let e = post(
&mut s,
&day(3),
"Trail office rent",
&[
("6000", Side::Debit, "450.00"),
("1000", Side::Credit, "450.00"),
],
);
with_vendor(&mut s, e, landlord);
let e = post(
&mut s,
&end.to_string(),
"Guided hikes",
&[
("1000", Side::Debit, revenue[i]),
("4000", Side::Credit, revenue[i]),
],
);
with_party(&mut s, e, client);
// Month end: depreciation, the schedules, the close.
for asset in [laptop, camera] {
if asset == 0 {
continue;
}
let a = s.ledger().get_asset(asset).unwrap().clone();
if matches!(a.status, ledger_core::AssetStatus::Disposed { .. }) {
continue;
}
if s.ledger().live_depreciation_run(asset, m).is_none() {
s.run_depreciation(asset, m).unwrap();
}
}
if i == 5 {
let may = s
.ledger()
.live_depreciation_run(camera, "2026-05")
.unwrap()
.entry;
s.reverse_entry(
may,
d("2026-06-03"),
"May's camera run reversed: wrong asset",
)
.unwrap();
// June's run (made above) came first; reversing May leaves
// May's depreciation for the next run to catch up.
}
s.run_schedules(end).unwrap();
let p = s.create_period(m, d(&format!("{m}-01")), end).unwrap();
s.close_period_to(p, &CloseTo::Members(members.clone()))
.unwrap();
}
std::fs::write(dir.join("wanderbooks.toml"), SETTINGS_TOML).unwrap();
Books {
store: s,
company: company(),
cash: vec![CASH.to_string()],
financing: FINANCING.iter().map(|c| c.to_string()).collect(),
ana,
ben,
delta,
delta_party,
paper_party,
laptop,
camera,
}
}
fn lens_buyer(s: &mut Store) -> PartyId {
let c = s.add_customer("Ridge Outfitters Inc").unwrap();
s.ledger().party_of_customer(c).unwrap()
}