Split kernel::entry_flows into entry_movements and flows_of so the reversal-mirror proof fits CI; prove runs the fast harnesses, prove-slow the 8 slow ones (own job in scheduled CI), prove-all both. Laws in verification.md renumbered 1-31 with a table of all 55 harnesses. MCP docs checked against the 115 tools (about 60 fixes) with a test that every tool is documented; rules.md cross-references fixed; bookspec statuses and loader tightened; viewer notes wrap and accounts sort by code; STATUS.md says P3 is built, cutover pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
49 files changed
+963
-292
No files matched your search
@@ -11,7 +11,7 @@
|
||||
//! — and postings go through the normal entry machinery, so every
|
||||
//! accounting invariant holds.
|
||||
//!
|
||||
//! Two figures of accumulated depreciation, never confused (rules.md B-24):
|
||||
//! Two figures of accumulated depreciation, never confused (rules.md B-15 rule 8):
|
||||
//! what the schedule says is due through a date
|
||||
//! ([`accumulated_scheduled_through`]) and what the books carry, the runs
|
||||
//! posted and not reversed ([`crate::Ledger::accumulated_posted`]). Book
|
||||
@@ -743,7 +743,7 @@ fn schedule_months(asset: &Asset, through: NaiveDate) -> Result<Vec<MonthLine>,
|
||||
/// (inclusive), exact to the minor unit ([`depreciation_schedule`]): the
|
||||
/// tools' `accumulated_scheduled_through`. What the books carry is
|
||||
/// [`crate::Ledger::accumulated_posted`]; the two agree once every month
|
||||
/// due is posted (rules.md B-24).
|
||||
/// due is posted (rules.md B-15 rule 8).
|
||||
pub fn accumulated_scheduled_through(
|
||||
asset: &Asset,
|
||||
as_of: NaiveDate,
|
||||
|
||||
@@ -680,7 +680,17 @@ pub fn apply_cash<T: Amount>(cash: T, movements: &[T], applied: &mut [T]) -> Opt
|
||||
/// operating, investing, financing, each up to its own movement; what is
|
||||
/// left of a section's movement is the part no cash settled. `None` when
|
||||
/// the lines don't net to zero (never for a posted entry) or on overflow.
|
||||
/// It is [`entry_movements`] then [`flows_of`], so each is proved on its
|
||||
/// own (docs/verification.md, law 10).
|
||||
pub fn entry_flows<T: Amount>(lines: &[(Flow, Side, T)]) -> Option<EntryFlows<T>> {
|
||||
let (cash, moved) = entry_movements(lines)?;
|
||||
flows_of(cash, moved)
|
||||
}
|
||||
|
||||
/// An entry's net movement on its cash lines and on each section's,
|
||||
/// `(cash, [operating, investing, financing])`, debit-positive. `None`
|
||||
/// on overflow.
|
||||
pub fn entry_movements<T: Amount>(lines: &[(Flow, Side, T)]) -> Option<(T, [T; 3])> {
|
||||
let mut cash = T::ZERO;
|
||||
let mut moved = [T::ZERO; 3];
|
||||
for &(flow, side, amount) in lines {
|
||||
@@ -693,6 +703,13 @@ pub fn entry_flows<T: Amount>(lines: &[(Flow, Side, T)]) -> Option<EntryFlows<T>
|
||||
};
|
||||
*slot = slot.checked_add(delta)?;
|
||||
}
|
||||
Some((cash, moved))
|
||||
}
|
||||
|
||||
/// The cash flows of an entry whose cash lines moved `cash` and whose
|
||||
/// sections moved `moved` ([`entry_flows`]). `None` when they don't net
|
||||
/// to zero, or on overflow.
|
||||
pub fn flows_of<T: Amount>(cash: T, moved: [T; 3]) -> Option<EntryFlows<T>> {
|
||||
// Balanced lines only: every part below rests on it.
|
||||
let mut total = cash;
|
||||
for &m in &moved {
|
||||
|
||||
@@ -2534,7 +2534,7 @@ impl Ledger {
|
||||
/// ```text
|
||||
/// GOAL: a run the books record is the entry that posted it, no more
|
||||
/// and no less, so accumulated_posted is what the books carry.
|
||||
/// SOURCE: rules.md B-24; B-28 (new commands are bounded; replay checks
|
||||
/// SOURCE: rules.md B-15 rules 6 and 8, B-24; B-28 (new commands are bounded; replay checks
|
||||
/// only what every journaled run passed:
|
||||
/// record_depreciation_run).
|
||||
/// ```
|
||||
|
||||
@@ -85,7 +85,7 @@ pub type PayoutPlanLine = (AccountId, Side, Decimal, Option<ClassId>);
|
||||
/// collected; fees debit expense accounts), and the payout reconciles to
|
||||
/// the cent.
|
||||
/// A zero deposit (fees and adjustments ate the sales exactly) has no
|
||||
/// deposit line: every planned line is positive (B-17, docs/spec/rules.md).
|
||||
/// deposit line: every planned line is positive (B-2, docs/spec/rules.md).
|
||||
pub fn payout_plan(
|
||||
payout: &Payout,
|
||||
chart: &ChartOfAccounts,
|
||||
|
||||
@@ -1031,12 +1031,17 @@ fn entry_flows_are_bounded() {
|
||||
|
||||
/// A reversal's cash flows are its original's, negated, part by part:
|
||||
/// what lets an entry and its reversal in one period drop out of the
|
||||
/// statement together (rules.md B-14 rule 6).
|
||||
/// statement together (rules.md B-14 rule 6). End to end, on entries of
|
||||
/// up to two lines: at three (`NF`) one run went past half an hour at
|
||||
/// four jobs on a busy machine (2026-10-21), so three lines are proved in
|
||||
/// two halves, `entry_movements_of_a_reversal_mirror` and
|
||||
/// `flows_of_mirrors`, and Lean `flows_reversal` proves it at any size.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(5)]
|
||||
fn entry_flows_of_a_reversal_mirror() {
|
||||
let all = any_flow_lines();
|
||||
let n = any_flow_len();
|
||||
kani::assume(n <= 2);
|
||||
let lines = &all[..n];
|
||||
// Element by element (see `kani_symbolic_copy_canary`).
|
||||
let mut rev = [(Flow::Cash, Side::Debit, 0i64); NF];
|
||||
@@ -1046,6 +1051,67 @@ fn entry_flows_of_a_reversal_mirror() {
|
||||
let a = kernel::entry_flows(lines);
|
||||
let b = kernel::entry_flows(&rev[..n]);
|
||||
assert_eq!(a.is_some(), b.is_some());
|
||||
if let (Some(a), Some(b)) = (a, b) {
|
||||
assert_eq!(b.cash, -a.cash);
|
||||
for k in 0..3 {
|
||||
assert_eq!(b.flows[k], -a.flows[k]);
|
||||
assert_eq!(b.noncash[k], -a.noncash[k]);
|
||||
}
|
||||
kani::cover!(a.flows[1] != 0, "cash settling investing, reversed");
|
||||
}
|
||||
}
|
||||
|
||||
/// The two halves of `entry_flows_of_a_reversal_mirror`, each for every
|
||||
/// input in its bounds: `kernel::entry_flows` is `entry_movements` then
|
||||
/// `flows_of`, so a reversal mirrors when its movements are its
|
||||
/// original's negated (this harness, up to `NF` lines each in any of the
|
||||
/// four places) and `flows_of` is odd (the next, for any movements three
|
||||
/// lines can make). Together they prove the end-to-end harness's claim,
|
||||
/// whose single run past half an hour at four jobs on a busy machine
|
||||
/// (2026-10-21) moved it to `just prove-slow`.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(5)]
|
||||
fn entry_movements_of_a_reversal_mirror() {
|
||||
let all = any_flow_lines();
|
||||
let n = any_flow_len();
|
||||
let lines = &all[..n];
|
||||
// Element by element (see `kani_symbolic_copy_canary`).
|
||||
let mut rev = [(Flow::Cash, Side::Debit, 0i64); NF];
|
||||
for i in 0..n {
|
||||
rev[i] = kernel::reverse_line(lines[i]);
|
||||
}
|
||||
let a = kernel::entry_movements(lines);
|
||||
let b = kernel::entry_movements(&rev[..n]);
|
||||
assert_eq!(a.is_some(), b.is_some());
|
||||
if let (Some((ca, ma)), Some((cb, mb))) = (a, b) {
|
||||
assert_eq!(cb, -ca);
|
||||
for k in 0..3 {
|
||||
assert_eq!(mb[k], -ma[k]);
|
||||
}
|
||||
kani::cover!(
|
||||
n == NF && ca != 0 && ma[1] != 0,
|
||||
"three lines, cash and investing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `kernel::flows_of` is odd: the negated movements give the negated
|
||||
/// flows, part by part, and succeed exactly when the original's do; for
|
||||
/// any cash and section movements up to `NF` lines' worth.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(5)]
|
||||
fn flows_of_mirrors() {
|
||||
let wide_amount = || {
|
||||
let a: i64 = kani::any();
|
||||
// What up to NF in-range lines can sum to (`in_line_range`: 2^40).
|
||||
kani::assume(a.unsigned_abs() <= (NF as u64) << 40);
|
||||
a
|
||||
};
|
||||
let cash = wide_amount();
|
||||
let moved: [i64; 3] = core::array::from_fn(|_| wide_amount());
|
||||
let a = kernel::flows_of(cash, moved);
|
||||
let b = kernel::flows_of(-cash, [-moved[0], -moved[1], -moved[2]]);
|
||||
assert_eq!(a.is_some(), b.is_some());
|
||||
if let (Some(a), Some(b)) = (a, b) {
|
||||
assert_eq!(b.cash, -a.cash);
|
||||
for k in 0..3 {
|
||||
|
||||
@@ -743,9 +743,14 @@ fn bookspec_source(id: &str) -> Source {
|
||||
fn decision(ex: &str, what: &str, kind: Option<&str>, reason: Option<&str>) -> Decision1099 {
|
||||
match what {
|
||||
"not_reportable" => {
|
||||
let kind = kind.and_then(ExemptReason::parse).unwrap_or_else(|| {
|
||||
panic!("{ex}: not_reportable needs a reason_kind, got {kind:?}")
|
||||
});
|
||||
// Exactly as the README spells it: the engine's parse forgives
|
||||
// case and spaces, an example doesn't.
|
||||
let kind = kind
|
||||
.and_then(ExemptReason::parse)
|
||||
.filter(|k| Some(k.key()) == kind)
|
||||
.unwrap_or_else(|| {
|
||||
panic!("{ex}: not_reportable needs a reason_kind, got {kind:?}")
|
||||
});
|
||||
let reason = reason.unwrap_or_else(|| panic!("{ex}: not_reportable needs a reason"));
|
||||
Decision1099::not_reportable(kind, reason).unwrap_or_else(|e| panic!("{ex}: {e}"))
|
||||
}
|
||||
@@ -1058,7 +1063,7 @@ impl<'a> Books<'a> {
|
||||
for p in &s.payees {
|
||||
let what = format!("{}: payee {}", ex.id, p.name);
|
||||
let form = Form1099::parse(&p.form)
|
||||
.filter(|_| p.form.starts_with("1099-"))
|
||||
.filter(|f| f.label() == p.form)
|
||||
.unwrap_or_else(|| panic!("{what}: form {:?}", p.form));
|
||||
// A payee in the directory with the vendor role is that
|
||||
// party's vendor record.
|
||||
@@ -1099,6 +1104,7 @@ impl<'a> Books<'a> {
|
||||
for i in &s.instruments {
|
||||
let what = format!("{} instrument {}", ex.id, i.label);
|
||||
let kind = InstrumentKind::parse(&i.kind)
|
||||
.filter(|k| k.key() == i.kind)
|
||||
.unwrap_or_else(|| panic!("{what}: kind {:?}", i.kind));
|
||||
let account = id(store.ledger(), &i.account);
|
||||
let owner = i.owner.as_ref().map(|o| {
|
||||
@@ -1959,7 +1965,11 @@ fn check_party_totals(b: &Books<'_>, x: &Expect) {
|
||||
let basis = x
|
||||
.basis
|
||||
.as_deref()
|
||||
.map(|k| Basis::parse(k).unwrap_or_else(|| panic!("{}: basis {k:?}", ex.id)))
|
||||
.map(|k| {
|
||||
Basis::parse(k)
|
||||
.filter(|b| b.key() == k)
|
||||
.unwrap_or_else(|| panic!("{}: basis {k:?}", ex.id))
|
||||
})
|
||||
.unwrap_or_else(|| panic!("{}: party_totals names its basis", ex.id));
|
||||
let party = expect_party(b, x);
|
||||
let a = ledger_core::parties::activity_with(
|
||||
@@ -2035,6 +2045,11 @@ fn check_k1(ex: &Example, l: &Ledger, members: &[MemberCapital], x: &Expect) {
|
||||
"{}: k1 needs the members and their shares",
|
||||
ex.id
|
||||
);
|
||||
assert!(
|
||||
!ex.setup.cash_accounts.is_empty(),
|
||||
"{}: k1 needs cash_accounts (what is money rather than property)",
|
||||
ex.id
|
||||
);
|
||||
let cash = codes(l, &ex.setup.cash_accounts, &ex.id);
|
||||
let rows = ledger_core::k1::k1_worksheet(l, from, to, members, &cash)
|
||||
.unwrap_or_else(|e| panic!("{}: {e}", ex.id));
|
||||
@@ -3427,6 +3442,37 @@ fn a_payment_method_not_in_the_setup_is_refused() {
|
||||
});
|
||||
}
|
||||
|
||||
/// A value from a closed list is spelled exactly as bookspec/README.md
|
||||
/// spells it: the engine's own parse forgives case and spaces, the
|
||||
/// loader doesn't (a spec has one spelling).
|
||||
#[test]
|
||||
#[should_panic(expected = "form \"1099-nec\"")]
|
||||
fn a_form_in_another_case_is_refused() {
|
||||
run_edited("1099-a-dated-status-applies-by-payment-date.toml", |t| {
|
||||
t.replacen("form = \"1099-NEC\"", "form = \"1099-nec\"", 1)
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "kind \"Business_Card\"")]
|
||||
fn a_payment_method_kind_in_another_case_is_refused() {
|
||||
run_edited("1099-card-payments-are-excluded.toml", |t| {
|
||||
t.replacen("kind = \"business_card\"", "kind = \"Business_Card\"", 1)
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "not_reportable needs a reason_kind")]
|
||||
fn a_reason_kind_in_another_case_is_refused() {
|
||||
run_edited("1099-a-dated-status-applies-by-payment-date.toml", |t| {
|
||||
t.replacen(
|
||||
"reason_kind = \"corporation\"",
|
||||
"reason_kind = \"Corporation\"",
|
||||
1,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/// An event the example says the engine refuses must be refused: one it
|
||||
/// takes fails the example (March's run reversed on its own day is taken).
|
||||
#[test]
|
||||
|
||||
@@ -787,7 +787,7 @@ fn cash_flow_reconciles_to_change_in_cash() {
|
||||
|
||||
// Operating, by the direct method: collected 1,500 (class (a)); paid
|
||||
// 2,500 for equipment (not a registered asset: operating, rules.md
|
||||
// B-14's limit R4) and 800 rent (class (d)) = -1,800. Reconciled:
|
||||
// B-14 rule 12's limits) and 800 rent (class (d)) = -1,800. Reconciled:
|
||||
// 3,200 net income - 2,500 increase in AR (4,000 billed - 1,500
|
||||
// collected) - 2,500 increase in equipment = -1,800.
|
||||
// Financing: +10,000 owner contribution. Sum = 8,200. Reconciles.
|
||||
|
||||
@@ -596,7 +596,7 @@ mod p3 {
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(500))]
|
||||
|
||||
/// B-15 INVARIANT 5 (defect 22): accumulated depreciation never falls,
|
||||
/// depreciation_schedule INVARIANT 5 (rules.md B-15; defect 22): accumulated depreciation never falls,
|
||||
/// never exceeds cost − salvage, is 0 before the in-service date and
|
||||
/// cost − salvage from the life's last day on; each schedule's lines
|
||||
/// sum to its accumulated and none is negative. Sampled through: every
|
||||
@@ -719,7 +719,7 @@ proptest! {
|
||||
}
|
||||
}
|
||||
|
||||
/// B-15 INVARIANT 6 (P3 depreciation brief 2.3, R1, R8): through every
|
||||
/// B-15 rule 4 (depreciation_schedule INVARIANT 6; P3 depreciation brief 2.3, R1, R8): through every
|
||||
/// month end of the life, the schedule is the pre-P3 running total
|
||||
/// (USD, a two-place currency), except where the exact cumulative is a
|
||||
/// half-cent tie, which the old 28-digit Decimal quotients could round
|
||||
|
||||
@@ -647,7 +647,7 @@ fn defect24_an_accrual_through_another_liability_counts_once() {
|
||||
assert_eq!(b.totals(c, Basis::All), (Decimal::ZERO, d("1200.00")));
|
||||
}
|
||||
|
||||
/// Defect 24 and B-35 item 1: a credit to what the company owes a party
|
||||
/// Defect 24 and B-35 item 6: a credit to what the company owes a party
|
||||
/// itself never counts as paying it. A fee on the issuer's own card is a
|
||||
/// bill from the issuer (counted when the card is paid); a distribution
|
||||
/// still owed to a member counts when it is paid; equipment bought on the
|
||||
|
||||
@@ -78,7 +78,7 @@ pub fn show_chart(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
(AccountType::Revenue, "Income"),
|
||||
(AccountType::Expense, "Expenses"),
|
||||
] {
|
||||
let accounts: Vec<_> = b
|
||||
let mut accounts: Vec<_> = b
|
||||
.ledger
|
||||
.chart()
|
||||
.all()
|
||||
@@ -86,6 +86,8 @@ pub fn show_chart(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
a.account_type == t && (shell.state.accounts.show_empty || used.contains(&a.id))
|
||||
})
|
||||
.collect();
|
||||
// In the chart's order: by code, as every report lists them.
|
||||
accounts.sort_by(|x, y| x.code.cmp(&y.code));
|
||||
if accounts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1881,6 +1881,26 @@ fn render(ui: &mut Ui, t: &Table, pdf: &PdfInfo, shell: &mut Shell<'_>) {
|
||||
Stroke::new(1.0, p.border),
|
||||
);
|
||||
for (ri, r) in t.rows.iter().enumerate() {
|
||||
// A note wraps over the table's width, never cut short:
|
||||
// it says how to read the figures above it.
|
||||
if r.kind == Kind::Note {
|
||||
let mut job = egui::text::LayoutJob::single_section(
|
||||
r.label.clone(),
|
||||
egui::TextFormat::simple(theme::regular(12.0), p.muted),
|
||||
);
|
||||
job.wrap.max_width = (width - 8.0).max(10.0);
|
||||
let galley = ui.painter().layout_job(job);
|
||||
let (rr, _) = ui.allocate_exact_size(
|
||||
vec2(width, galley.size().y + 14.0),
|
||||
Sense::hover(),
|
||||
);
|
||||
ui.painter().galley(
|
||||
egui::pos2(rr.left() + 4.0, rr.top() + 7.0),
|
||||
galley,
|
||||
p.muted,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let h = match r.kind {
|
||||
Kind::Section => 36.0,
|
||||
Kind::Grand => 38.0,
|
||||
|
||||
@@ -221,7 +221,7 @@ pub struct YearEarnings {
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: the balance sheet as printed, footing and balancing.
|
||||
/// SOURCE: rules.md B-4/B-5 (assets = liabilities + equity); footing.
|
||||
/// SOURCE: rules.md B-7 (the accounting equation); footing.
|
||||
/// INVARIANTS:
|
||||
/// 1. Asset lines sum to "Total assets", liability lines to "Total
|
||||
/// liabilities", equity lines (accounts and the earnings lines) to the
|
||||
@@ -425,7 +425,7 @@ pub fn cash_flow(cf: &CashFlowStatement) -> Statement {
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: the trial balance as printed, footing.
|
||||
/// SOURCE: rules.md B-2 (debits equal credits); footing.
|
||||
/// SOURCE: rules.md B-6 (the trial balance balances; B-1); footing.
|
||||
/// INVARIANTS:
|
||||
/// 1. Each column's lines sum to its total; the totals are equal for
|
||||
/// balanced books.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Prepaid expenses, deferred revenue and recurring entries
|
||||
|
||||
- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation).
|
||||
- A new schedule spreads by the day from its start (the [books] schedule_convention setting, default daily): a policy starting mid-month posts a small first and last month, so 12 months from March 15 is 13 month ends. Equal months (monthly_even) start on the 1st, so revenue is never earned early. Schedules from before keep their equal months.
|
||||
- A new schedule spreads by the day from its start (the schedule_convention setting, default daily): a policy starting mid-month posts a small first and last month, so 12 months from March 15 is 13 month ends. Equal months (monthly_even) start on the 1st, so revenue is never earned early. Schedules from before keep their equal months.
|
||||
- Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.
|
||||
@@ -3019,7 +3019,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.add_account",
|
||||
title = "Add an account",
|
||||
description = "Add an account to the chart. Codes must be unique; check books.chart first. Give a revenue or expense account its tax_category (books.tax_categories): without one the result warns, or, under policy.require_tax_category = refuse, the account is refused. A name another account under the same parent has (ignoring capitals), one holding nine digits in a row, or more than one line of 200 characters, is refused.",
|
||||
description = "Add an account to the chart. Codes must be unique; check books.chart first. Give a revenue or expense account its tax_category (books.tax_categories): without one the result warns, or, under policy.require_tax_category = refuse, the account is refused. A name another account under the same parent has (ignoring capitals), one holding nine digits in a row, or one that isn't a single line of at most 200 characters, is refused.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -3793,7 +3793,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.withdraw_attachment",
|
||||
title = "Withdraw a receipt or document",
|
||||
description = "Withdraw an unlinked receipt or a filed document, with a reason recorded in the journal. It drops out of books.list_receipts and can never be linked again. Nothing is deleted: the bytes stay in the blob store and the journal keeps the filing and the withdrawal. Unlink a linked receipt first.",
|
||||
description = "Withdraw an unlinked receipt or a filed document, with a reason recorded in the journal. It drops out of books.list_receipts and can never be linked again. Nothing is deleted: the bytes stay in the blob store and the journal keeps the filing and the withdrawal. Unlink it first (books.unlink_receipt from its entry, books.unlink_attachment from anything else).",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
@@ -4752,7 +4752,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.create_schedule",
|
||||
title = "Create an amortization schedule",
|
||||
description = "Spread a prepaid expense (an asset used up as time passes, e.g. a year of insurance paid up front) or deferred revenue (a liability earned as time passes, e.g. an annual subscription collected up front) over the time it covers, to the cent. convention: daily (default, by the day from start: a mid-month start posts a small first and last month, one month end more than its months) or monthly_even (equal months; starts on the 1st). Record the original payment first (Dr prepaid / Cr cash, with its receipt; or Dr cash / Cr deferred revenue). Returns the plan; post it with books.run_schedules.",
|
||||
description = "Spread a prepaid expense (an asset used up as time passes, e.g. a year of insurance paid up front) or deferred revenue (a liability earned as time passes, e.g. an annual subscription collected up front) over the time it covers, to the cent. convention: daily (by the day from start: a mid-month start posts a small first and last month, one month end more than its months) or monthly_even (equal months; starts on the 1st); omitted, the schedule_convention setting, else daily. Record the original payment first (Dr prepaid / Cr cash, with its receipt; or Dr cash / Cr deferred revenue). Returns the plan; post it with books.run_schedules.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -5185,7 +5185,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.set_entry_vendor",
|
||||
title = "Tag an entry's vendor",
|
||||
description = "Tag an entry (draft or posted) with the vendor it paid, for 1099 tracking, or omit vendor to clear the tag. Reporting metadata only: amounts never change. On an entry naming several parties (books.set_entry_party parties) the parties stay as they are: the vendor of a party already named is tagged with every share kept; any other vendor, or clearing the tag while its party is named, is refused (change the parties instead). An entry and its reversal are tagged together. books.pay_bill tags its payments automatically; books.draft_entry takes a vendor too.",
|
||||
description = "Tag an entry (draft or posted) with the vendor it paid, for 1099 tracking, or omit vendor to clear the tag. Reporting metadata only: amounts never change. On an entry naming one party or none, the vendor's party becomes its party; clearing also clears that party. On an entry naming several parties (books.set_entry_party parties) the parties stay as they are: the vendor of a party already named is tagged with every share kept; any other vendor, or clearing the tag while its party is named, is refused (change the parties instead). An entry and its reversal are tagged together. books.pay_bill tags its payments automatically; books.draft_entry takes a vendor too.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -5450,7 +5450,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.rename_account",
|
||||
title = "Rename an account",
|
||||
description = "Give an account a new name. Only the name changes: its id, code, type, tax category and every entry posted to it stay, so no entry, balance or statement amount changes; every report, past periods included, prints the new name, and the journal keeps the old one. Refuses a blank name, the name it has, a name another account under the same parent has (ignoring capitals), one holding nine digits in a row, or more than one line of 200 characters. QuickBooks matches accounts by name: if the CPA imported an earlier IIF, tell them. Never rename the human's accounts on your own: only when they ask.",
|
||||
description = "Give an account a new name. Only the name changes: its id, code, type, tax category and every entry posted to it stay, so no entry, balance or statement amount changes; every report, past periods included, prints the new name, and the journal keeps the old one. Refuses a blank name, the name it has, a name another account under the same parent has (ignoring capitals), one holding nine digits in a row, or one that isn't a single line of at most 200 characters. QuickBooks matches accounts by name: if the CPA imported an earlier IIF, tell them. Never rename the human's accounts on your own: only when they ask.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
@@ -6078,7 +6078,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.export",
|
||||
title = "Export a report",
|
||||
description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (direct method, with its reconciliation and noncash schedules; needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from the settings), general_ledger (by account: running balances, splits), general_journal (each entry posted in the period; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books). Formats: pdf and xlsx (base64) for the five statements and fixed_asset_register; xlsx, csv, json for every report; iif for general_journal or journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of (start and end are refused), period closes left out, DOCNUM the entry id; names, numbers and memos fit QuickBooks' limits; warnings says what is written differently from the books, memos grouped, to tell the human). Dates: start/end for period reports (omitted: the fiscal year to date), as_of for balance_sheet, trial_balance, fixed_asset_register and iif (omitted: today).",
|
||||
description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (direct method, with its reconciliation and noncash schedules; needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from the settings), tax_summary, general_ledger (by account: running balances, splits), general_journal (each entry posted in the period; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books). Formats: pdf and xlsx (base64) for the statements, tax_summary and fixed_asset_register; xlsx, csv, json for every report; iif for general_journal or journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of (start and end are refused), period closes left out, DOCNUM the entry id; names, numbers and memos fit QuickBooks' limits; warnings says what is written differently from the books, memos grouped, to tell the human). Dates: start/end for period reports (omitted: the fiscal year to date), as_of for balance_sheet, trial_balance, fixed_asset_register and iif (omitted: today).",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> {
|
||||
@@ -11290,6 +11290,68 @@ mod tests {
|
||||
assert_eq!(over, OVER, "tool descriptions over 700 characters");
|
||||
}
|
||||
|
||||
/// The tools docs/mcp-interface.md documents: a line (or a bullet)
|
||||
/// that starts with `` `books.NAME` `` or `` `books.NAME { `` , and
|
||||
/// any `` `books.NAME { `` signature within a line.
|
||||
fn documented_tools(doc: &str) -> std::collections::BTreeSet<String> {
|
||||
let name_at = |s: &str| -> Option<(String, char)> {
|
||||
let rest = s.strip_prefix("`books.")?;
|
||||
let name: String = rest
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '_')
|
||||
.collect();
|
||||
let next = rest[name.len()..].chars().next()?;
|
||||
(!name.is_empty()).then(|| (format!("books.{name}"), next))
|
||||
};
|
||||
let mut out = std::collections::BTreeSet::new();
|
||||
for line in doc.lines() {
|
||||
let t = line.trim_start();
|
||||
let t = t.strip_prefix("- ").unwrap_or(t);
|
||||
if let Some((name, next)) = name_at(t)
|
||||
&& (next == '`' || next == ' ')
|
||||
{
|
||||
out.insert(name);
|
||||
}
|
||||
for (i, _) in line.match_indices("`books.") {
|
||||
if let Some((name, ' ')) = name_at(&line[i..])
|
||||
&& line[i + 1 + name.len()..].starts_with(" {")
|
||||
{
|
||||
out.insert(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every tool the server has is documented in docs/mcp-interface.md,
|
||||
/// and every tool the doc documents is one the server has.
|
||||
#[test]
|
||||
fn every_tool_is_documented_and_every_documented_tool_exists() {
|
||||
let doc = include_str!("../../../docs/mcp-interface.md");
|
||||
let documented = documented_tools(doc);
|
||||
let tools: std::collections::BTreeSet<String> = BooksServer::tool_router()
|
||||
.list_all()
|
||||
.iter()
|
||||
.map(|t| t.name.to_string())
|
||||
.collect();
|
||||
let undocumented: Vec<&String> = tools.difference(&documented).collect();
|
||||
let unknown: Vec<&String> = documented.difference(&tools).collect();
|
||||
assert!(
|
||||
undocumented.is_empty(),
|
||||
"tools docs/mcp-interface.md doesn't document: {undocumented:?}"
|
||||
);
|
||||
assert!(
|
||||
unknown.is_empty(),
|
||||
"docs/mcp-interface.md documents tools there aren't: {unknown:?}"
|
||||
);
|
||||
// The reader catches what it should, and only that.
|
||||
let sample = "- `books.chart` → x\n `books.decisions { a? }`; \
|
||||
`books.open_items { b? }`.\nsee `books.cash_accounts`, \
|
||||
`books.show`.";
|
||||
let got: Vec<String> = documented_tools(sample).into_iter().collect();
|
||||
assert_eq!(got, ["books.chart", "books.decisions", "books.open_items"]);
|
||||
}
|
||||
|
||||
/// rules.md B-30 (defect 16): every report tool says what its omitted
|
||||
/// dates mean in the one sentence the resolver's kind gives
|
||||
/// (PeriodKind::describe), and each date parameter in the schema's
|
||||
|
||||
@@ -127,7 +127,7 @@ pub struct CreateScheduleParams {
|
||||
/// "daily" (by the day from start, like depreciation: a mid-month
|
||||
/// start posts a small first and last month, one more month end than
|
||||
/// its months) or "monthly_even" (equal months, starting on the 1st).
|
||||
/// Default: the books' `[books] schedule_convention` setting, else
|
||||
/// Default: the books' `schedule_convention` setting, else
|
||||
/// daily.
|
||||
#[serde(default)]
|
||||
pub convention: Option<String>,
|
||||
@@ -588,7 +588,8 @@ pub struct Vendor1099LineOut {
|
||||
pub reportable: bool,
|
||||
/// The vendor's party.
|
||||
pub party_id: Option<u64>,
|
||||
/// The TIN's last four digits, from the party's W-9 facts.
|
||||
/// The TIN's last four digits: from the party's TIN facts
|
||||
/// (books.set_tin), else its W-9 facts.
|
||||
pub tin_last4: Option<String>,
|
||||
/// The full TIN, formatted (123-45-6789, 12-3456789): only with
|
||||
/// include_tins, and only when the vault holds it.
|
||||
@@ -775,8 +776,8 @@ pub struct ChartTemplateOut {
|
||||
/// Chart CSV (code,name,type,parent_code,tax_category) for
|
||||
/// `books.import_chart_csv`, after review.
|
||||
pub csv: String,
|
||||
/// The `wanderbooks.toml` settings that go with it: tax form, owners,
|
||||
/// where period closes go.
|
||||
/// The settings that go with it, as settings-file TOML for
|
||||
/// `books.import_settings`: tax form, owners, where period closes go.
|
||||
pub settings_toml: String,
|
||||
/// What to decide or know.
|
||||
pub notes: Vec<String>,
|
||||
@@ -800,8 +801,10 @@ pub struct RenumberAccountOut {
|
||||
pub previous_code: String,
|
||||
/// The code it has now.
|
||||
pub code: String,
|
||||
/// Settings in wanderbooks.toml that still name the old code: change
|
||||
/// each to the new one (the server reloads the file).
|
||||
/// Settings that still name the old code (as books.set_setting keys,
|
||||
/// or the settings file's labels for books whose settings are in a
|
||||
/// file), and conventions whose template or approval rule names it:
|
||||
/// change each (books.set_setting, books.set_convention).
|
||||
pub config_mentions: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -1820,7 +1823,9 @@ pub struct ReceiptOut {
|
||||
pub sha256: String,
|
||||
/// Linked entry id, if any.
|
||||
pub entry_id: Option<u64>,
|
||||
/// Category: receipts, formation, legal, tax, accounting, other.
|
||||
/// Category: receipts (books.attach_receipt), or a document's:
|
||||
/// formation, legal, tax, accounting, statement, contract, photo,
|
||||
/// warranty, import_source, other.
|
||||
pub category: String,
|
||||
/// Original file name.
|
||||
pub file_name: String,
|
||||
@@ -2125,12 +2130,13 @@ pub struct StaleRule {
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct ExportParams {
|
||||
/// "csv", "json", "xlsx", "pdf", or "iif". XLSX and PDF are
|
||||
/// base64-encoded; PDF covers profit_and_loss, balance_sheet, and
|
||||
/// cash_flow; IIF (plain ASCII text) is only valid with report
|
||||
/// base64-encoded; PDF covers every report but general_ledger and
|
||||
/// general_journal; IIF (plain ASCII text) is only valid with report
|
||||
/// "general_journal" (alias "journal").
|
||||
pub format: String,
|
||||
/// "journal", "trial_balance", "profit_and_loss", "balance_sheet", or
|
||||
/// "cash_flow".
|
||||
/// "general_journal" (alias "journal"), "general_ledger",
|
||||
/// "trial_balance", "profit_and_loss", "balance_sheet", "cash_flow",
|
||||
/// "members_equity", "tax_summary", or "fixed_asset_register".
|
||||
pub report: String,
|
||||
/// Period start for the period reports (see [`export_start_doc`]).
|
||||
#[schemars(description = export_start_doc())]
|
||||
@@ -3406,7 +3412,8 @@ pub struct AddPartyParams {
|
||||
/// Parameters for `books.update_party`. Give at least one field to change.
|
||||
/// Every field given is checked before any is written: a bad one changes
|
||||
/// nothing. Each is journaled as its own `party.updated`, in this order:
|
||||
/// name, display_name, website, address, notes, archived.
|
||||
/// name, display_name, website, address, notes, archived; then one
|
||||
/// `party.creditor_set` per `creditor_of` account added or dropped.
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||
pub struct UpdatePartyParams {
|
||||
/// The party: id or name.
|
||||
|
||||
@@ -1303,7 +1303,7 @@ fn acquired_date_comes_from_the_acquisition_entry() {
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------- posted and scheduled (rules.md B-24)
|
||||
// ------------------------------- posted and scheduled (rules.md B-15 rule 8)
|
||||
|
||||
/// Named regression 1 (the brief's reproduction 1, its figures made up): a
|
||||
/// $1,440.00 laptop, 36 months, in service 2026-05-15, nothing run. As of
|
||||
|
||||
@@ -1175,7 +1175,7 @@ fn reversing_a_linked_acquisition_still_opens_and_verifies() {
|
||||
Store::open(&dir).unwrap().verify().unwrap();
|
||||
}
|
||||
|
||||
// ------------------------------------------------- depreciation runs (rules.md B-24)
|
||||
// ------------------------------------------------- depreciation runs (rules.md B-15 rules 6 and 8)
|
||||
|
||||
/// A run is the books' record of what they carry for an asset
|
||||
/// (Ledger::accumulated_posted): recorded live, replayed from the journal,
|
||||
|
||||
@@ -3417,7 +3417,7 @@ mod p3_properties {
|
||||
})]
|
||||
|
||||
/// rules.md B-31 (the engine-side counterpart to the Lean note,
|
||||
/// docs/verification.md 13a): on books with postings, any sequence
|
||||
/// docs/verification.md law 15): on books with postings, any sequence
|
||||
/// of renames changes no figure (the ledger's statements, without
|
||||
/// names), no entry and no earlier journal line; live equals
|
||||
/// replay; the projection equals a fresh one. The sets picked by
|
||||
|
||||
Reference in new issue
Block a user