Attachments, statements, reconciliations, TIN vault, counterparty policy

MCP: books.link_attachment, unlink_attachment, attachments; link_to on
attach_receipt and file_document; record_statement, statements;
books.reconcile previews and, confirmed against a recorded statement,
records the reconciliation; void_reconciliation, reconciliations;
import_bank_csv links its source file; digest lists unreconciled
accounts.

TIN vault: books.set_w9 takes the TIN and the form into the encrypted
vault (last four in the books); books.vendor_1099 include_tins and
save_csv_to for the CPA's copy; books.get_w9; books.purge_tin. A leak
test scans every tool output, the journal, the database, and the blobs.

[books] parties = off | suggest | require (default suggest): every
preview names the entry's party or suggests likely ones; require refuses
to post until it names one or is marked as having none (entry.no_party).

Viewer: documents on every subject page; statements and reconciliation
history per account; reconciliation pages; reconcile and
no-counterparty attention items; party money by period (paid out,
received in, net, what for, by month); Needs attention gets its own
full-width section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-15 15:42:37 -06:00
1 parent a299077991
commit 08ce558ed3
41 files changed
+8463 -465

No files matched your search

+1
View File
@@ -15,6 +15,7 @@ ledger-core = { path = "../ledger-core" }
ledger-store = { path = "../ledger-store" }
ledger-import = { path = "../ledger-import" }
ledger-config = { path = "../ledger-config" }
ledger-vault = { path = "../ledger-vault" }
base64 = "0.22"
mime_guess = "2"
rust_decimal = "1"
File diff suppressed because it is too large. Load diff
+16
View File
@@ -50,9 +50,23 @@ pub struct Settings {
/// The prefix of generated asset tags (`[books] asset_tag_prefix`, or
/// the company's initials).
pub asset_tag_prefix: String,
/// `[vault] key_file`: the TIN vault's key file (unset: the vault's
/// default, `ledger_vault::default_key_file`).
pub vault_key_file: Option<PathBuf>,
/// `[books] parties`: suggest, require, or never mention each entry's
/// counterparty.
pub parties: ledger_config::PartyPolicy,
}
impl Settings {
/// The vault key file in force: `[vault] key_file`, else the default
/// outside the books (`~/.config/wanderbooks/vault.key`).
pub fn key_file(&self) -> PathBuf {
self.vault_key_file
.clone()
.unwrap_or_else(ledger_vault::default_key_file)
}
/// The prefix for generated asset tags ("FA" when none is set).
pub fn tag_prefix(&self) -> &str {
if self.asset_tag_prefix.is_empty() {
@@ -96,6 +110,8 @@ impl Source {
let policy = ledger_config::effective_receipt_policy(&self.config_json, &books)?;
Ok(Settings {
asset_tag_prefix: books.asset_tag_prefix(),
vault_key_file: books.vault_key_file.clone(),
parties: books.parties,
policy,
roles: CashRoles {
cash: books.cash_accounts,
+453 -11
View File
@@ -356,6 +356,16 @@ pub struct Vendor1099Params {
/// Override the reporting threshold, decimal string (default: $600
/// through 2025, $2,000 from 2026).
pub threshold: Option<String>,
/// True: each line carries its payee's full TIN, read from the TIN
/// vault (or a note saying why not). The output then holds full TINs:
/// for the CPA only, when the human asks for it. Default false (last
/// four digits only).
#[serde(default)]
pub include_tins: bool,
/// Also write the worksheet as CSV to this local path (TINs included
/// only with include_tins). Never inside the books dir; an existing
/// file is refused.
pub save_csv_to: Option<String>,
}
/// One vendor's year.
@@ -373,6 +383,18 @@ pub struct Vendor1099LineOut {
pub paid: String,
/// Marked for a 1099 and paid at least the threshold.
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.
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.
pub tin: Option<String>,
/// The TIN's type (ssn, ein, itin): only with include_tins.
pub tin_type: Option<String>,
/// With include_tins, why `tin` is missing (not in the vault, the vault
/// locked, a record that doesn't match the books).
pub tin_note: Option<String>,
}
/// Result of `books.vendor_1099`.
@@ -388,6 +410,11 @@ pub struct Vendor1099Out {
pub missing_w9: Vec<String>,
/// Caveats for the human and CPA.
pub notes: Vec<String>,
/// With include_tins: this output holds full TINs. For the CPA only;
/// never paste it anywhere else.
pub warning: Option<String>,
/// The CSV written with save_csv_to, if any.
pub saved_csv: Option<String>,
}
/// Parameters for `books.verify`.
@@ -436,6 +463,9 @@ pub struct ShowParams {
pub asset: Option<String>,
/// A party, by id or name (as `books.party`).
pub party: Option<String>,
/// A reconciliation (id from `books.reconciliations`): its account's
/// page, with the reconciliation and its statement.
pub reconciliation: Option<u64>,
/// Search the transactions list (description, account, vendor, amount).
pub search: Option<String>,
/// A page: overview, cash, profit, transactions, reports, accounts,
@@ -548,6 +578,8 @@ pub struct SettingsOut {
pub basis: String,
/// The receipt rule in force.
pub receipt_rule: String,
/// `[books] parties`: "off", "suggest", or "require".
pub parties: String,
/// `[[company.members]]`.
pub members: Vec<MemberSettingOut>,
/// `[books] cash_accounts`.
@@ -823,6 +855,11 @@ pub struct ImportBankCsvParams {
/// "month_first" (mm/dd/yyyy, US banks) or "day_first" (dd/mm/yyyy).
#[serde(default)]
pub date_order: Option<String>,
/// The CSV as filed (`books.file_document` category import_source, or
/// any stored attachment): linked to the bank account imported into,
/// so the import traces to its source. Never linked to the drafts: a
/// bank export is not a receipt.
pub source_attachment_id: Option<u64>,
}
/// One imported bank transaction (as a draft entry).
@@ -847,6 +884,11 @@ pub struct ImportBankCsvOut {
pub imported: Vec<ImportedTxnOut>,
/// Number of transactions imported.
pub count: usize,
/// The source attachment, if one was given.
pub source_attachment_id: Option<u64>,
/// True when this call linked it to the account (false when it already
/// was, or none was given).
pub source_linked: bool,
}
/// Parameters for `books.import_chart_csv`.
@@ -946,6 +988,10 @@ pub struct DraftEntryParams {
/// party with a vendor record also tags the entry's vendor; given with
/// `vendor`, both must name the same vendor.
pub party: Option<String>,
/// Why the entry has no counterparty (a transfer between the company's
/// own accounts, an accrual), when `[books] parties = "require"` and
/// none applies. Not with `party` or `vendor`.
pub no_party_reason: Option<String>,
}
/// Result of `books.draft_entry`.
@@ -1061,6 +1107,34 @@ pub struct PostEntryOut {
/// receipt threshold (same date, description, and debit accounts).
/// Empty when no split is detected.
pub split_detected: Vec<u64>,
/// `[books] parties`: "off", "suggest", or "require".
pub party_policy: String,
/// The entry's counterparty, when tagged.
pub party_id: Option<u64>,
/// Its name.
pub party: Option<String>,
/// Why it has none, when marked so.
pub no_party_reason: Option<String>,
/// True when the entry names no party and isn't marked as having none
/// (always false when the policy is "off"). Under "require", posting
/// with confirmed=true fails in this state.
pub party_missing: bool,
/// Likely counterparties, best first, when `party_missing`: tag one
/// with `books.set_entry_party`, or add the party with
/// `books.add_party` first.
pub party_suggestions: Vec<PartySuggestionOut>,
}
/// A likely counterparty for an entry.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct PartySuggestionOut {
/// The party's id.
pub party_id: u64,
/// Its name.
pub name: String,
/// Why: "named in the description", "named in a receipt", or "tagged
/// on entry N with the same description".
pub why: String,
}
/// Parameters for `books.reverse_entry`.
@@ -1107,6 +1181,11 @@ pub struct AttachReceiptParams {
pub content_base64: String,
/// Optional description.
pub description: Option<String>,
/// More subjects to link it to in the same call (each journaled as its
/// own `attachment.linked`). Every subject is checked before anything
/// is filed: one bad subject files nothing.
#[serde(default)]
pub link_to: Vec<SubjectParam>,
}
/// Result of `books.attach_receipt`.
@@ -1118,12 +1197,15 @@ pub struct AttachReceiptOut {
pub sha256: String,
/// True when identical bytes were already stored (deduplicated).
pub deduplicated: bool,
/// Everything it is linked to now (the entry it is filed on first).
pub links: Vec<SubjectOut>,
}
/// Parameters for `books.file_document`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct FileDocumentParams {
/// One of: formation, legal, tax, accounting, other.
/// One of: formation, legal, tax, accounting, statement, contract,
/// photo, warranty, import_source, other.
pub category: String,
/// Original file name.
pub file_name: String,
@@ -1133,6 +1215,11 @@ pub struct FileDocumentParams {
pub content_base64: String,
/// Optional description.
pub description: Option<String>,
/// Subjects to link it to in the same call (each journaled as its own
/// `attachment.linked`). Every subject is checked before anything is
/// filed: one bad subject files nothing.
#[serde(default)]
pub link_to: Vec<SubjectParam>,
}
/// Parameters for `books.link_receipt`.
@@ -1221,6 +1308,8 @@ pub struct ReceiptOut {
pub withdrawn: bool,
/// Why it was withdrawn, if it was.
pub withdrawn_reason: Option<String>,
/// Everything it is linked to (the entry it is filed on first).
pub links: Vec<SubjectOut>,
}
/// Result of `books.list_receipts`.
@@ -1255,6 +1344,10 @@ pub struct GetReceiptOut {
pub mime_type: String,
/// Size in bytes.
pub size_bytes: u64,
/// Description, if any.
pub description: Option<String>,
/// Everything it is linked to (the entry it is filed on first).
pub links: Vec<SubjectOut>,
/// File bytes, base64-encoded.
pub content_base64: String,
}
@@ -1421,6 +1514,25 @@ pub struct DigestOut {
/// didn't load, or a setting that disagrees with the chart. Fix each
/// with the human, then check books.settings.
pub settings_problems: Vec<String>,
/// Cash and card accounts (`[books] cash_accounts`, and liabilities
/// with a recorded statement) with activity not reconciled through the
/// end of last month: record each statement and reconcile it.
pub unreconciled_accounts: Vec<UnreconciledAccountOut>,
}
/// An account due for reconciliation (`books.digest`).
#[derive(Debug, Serialize, JsonSchema)]
pub struct UnreconciledAccountOut {
/// Account code.
pub code: String,
/// Account name.
pub name: String,
/// The last statement date it is reconciled through, if any.
pub reconciled_through: Option<String>,
/// The date it should be reconciled through: the end of last month.
pub due_through: String,
/// The calls that complete it.
pub next_step: String,
}
/// A torn journal tail set aside on open.
@@ -1976,20 +2088,32 @@ pub struct MarkClearedOut {
pub ok: bool,
}
/// Parameters for `books.reconcile`.
/// Parameters for `books.reconcile`. Either a recorded statement
/// (`statement_attachment`), or `account`, `statement_date`, and
/// `statement_balance` for a check against any balance.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ReconcileParams {
/// Account id or code (e.g. "1000") to reconcile.
pub account: String,
/// Statement date, YYYY-MM-DD.
pub statement_date: String,
/// Statement balance, decimal string.
pub statement_balance: String,
/// Account id or code (e.g. "1000") to reconcile. With
/// statement_attachment, optional (it must be the statement's).
pub account: Option<String>,
/// Statement date, YYYY-MM-DD. With statement_attachment, optional
/// (it must be the statement's period end).
pub statement_date: Option<String>,
/// Statement balance, decimal string. With statement_attachment,
/// optional (it must be the statement's closing balance).
pub statement_balance: Option<String>,
/// The attachment holding the statement, recorded with
/// `books.record_statement`: reconcile to what it says.
pub statement_attachment: Option<u64>,
/// True: record the reconciliation (needs statement_attachment and a
/// difference of exactly zero). Without it, a preview that records
/// nothing.
pub confirmed: Option<bool>,
}
/// One uncleared line in the reconciliation report.
/// One line in the reconciliation report.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct UnclearedLineOut {
pub struct ReconcileLineOut {
/// Entry id.
pub entry_id: u64,
/// Line index within the entry.
@@ -2007,8 +2131,14 @@ pub struct UnclearedLineOut {
/// Output of `books.reconcile`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ReconcileOut {
/// "preview" (nothing recorded) or "reconciled" (recorded).
pub status: String,
/// Account code.
pub account_code: String,
/// The statement date reconciled through, YYYY-MM-DD.
pub statement_date: String,
/// The statement's attachment, when reconciling to a recorded one.
pub statement_attachment: Option<u64>,
/// Book balance (all posted lines), decimal string.
pub book_balance: String,
/// Cleared balance (cleared lines on/before statement date), decimal string.
@@ -2018,7 +2148,14 @@ pub struct ReconcileOut {
/// cleared_balance - statement_balance. Zero means reconciled.
pub difference: String,
/// Uncleared lines on/before the statement date.
pub uncleared_lines: Vec<UnclearedLineOut>,
pub uncleared_lines: Vec<ReconcileLineOut>,
/// Cleared lines on/before the statement date: what a confirmed
/// reconciliation records.
pub cleared_lines: Vec<ReconcileLineOut>,
/// Why confirmed=true would be refused now (empty: it would record).
pub problems: Vec<String>,
/// The recorded reconciliation (status "reconciled" only).
pub reconciliation: Option<ReconciliationOut>,
}
// ---------------------------------------------------------------------------
@@ -2437,6 +2574,116 @@ pub struct SetW9Params {
/// True: clear the W-9 facts (give no other fact).
#[serde(default)]
pub clear: bool,
/// The full TIN (nine digits, hyphens allowed), stored only in the
/// encrypted TIN vault, never in the books; the books keep its last
/// four. Needs tin_type. Never echoed back.
pub tin: Option<SecretTin>,
/// The TIN's type: ssn, ein, or itin. Required with tin.
pub tin_type: Option<String>,
/// A local PDF, PNG, or JPEG of the signed W-9: stored only in the
/// vault (it shows the full TIN), never with the receipts and
/// documents. Read it back with `books.get_w9`.
pub w9_file_path: Option<String>,
}
/// A full TIN as a tool takes it: text (or a JSON number) whose value is
/// never printed: its Debug, and any error deserializing it, say nothing
/// of the digits.
#[derive(Clone, JsonSchema)]
#[schemars(transparent)]
pub struct SecretTin(pub String);
impl std::fmt::Debug for SecretTin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SecretTin(..)")
}
}
impl<'de> Deserialize<'de> for SecretTin {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct V;
impl serde::de::Visitor<'_> for V {
type Value = SecretTin;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("a TIN: nine digits as a string")
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<SecretTin, E> {
Ok(SecretTin(v.to_string()))
}
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<SecretTin, E> {
Ok(SecretTin(v.to_string()))
}
fn visit_i64<E: serde::de::Error>(self, _: i64) -> Result<SecretTin, E> {
Err(E::custom("tin must be nine digits"))
}
fn visit_f64<E: serde::de::Error>(self, _: f64) -> Result<SecretTin, E> {
Err(E::custom("tin must be nine digits"))
}
}
d.deserialize_any(V)
}
}
/// Parameters for `books.get_w9`.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct GetW9Params {
/// The party: id or name.
pub party: String,
/// Where to write the W-9 file: a local path outside the books dir
/// (the file shows the full TIN; it must never be committed with the
/// books). An existing file is refused.
pub save_to: String,
}
/// Result of `books.get_w9`: where the file went, never its contents.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct GetW9Out {
/// The party.
pub party_id: u64,
/// The path written.
pub path: String,
/// The file's original name.
pub file_name: String,
/// Its size in bytes.
pub size_bytes: u64,
/// What to tell the human.
pub note: String,
}
/// Parameters for `books.purge_tin`.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct PurgeTinParams {
/// The party: id or name.
pub party: String,
/// Why (retention period over, the payee asked, a wrong TIN):
/// journaled with the W-9 facts.
pub reason: String,
/// True: delete the vault records and journal it. Without it, a
/// preview that changes nothing.
pub confirmed: Option<bool>,
}
/// Result of `books.purge_tin`.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct PurgeTinOut {
/// "preview" or "purged".
pub status: String,
/// The party.
pub party_id: u64,
/// Its name.
pub name: String,
/// Whether the vault holds (preview) or held (purged) its TIN.
pub tin_in_vault: bool,
/// Whether the vault holds (held) its W-9 file.
pub w9_file_in_vault: bool,
/// The TIN's last four digits, which the books keep.
pub tin_last4: Option<String>,
/// True when the purge is journaled (`party.w9_set` with the vault
/// record cleared and kept_at saying why); false when the party has no
/// W-9 facts to update.
pub journaled: bool,
/// What happens (preview) or happened.
pub note: String,
}
/// Parameters for `books.set_w4`: what a W-4 said, never the SSN. Either
@@ -2490,6 +2737,10 @@ pub struct SetEntryPartyParams {
/// in `books.vendor_1099`); one without, or clearing, clears the vendor
/// tag too.
pub party: Option<String>,
/// With `party` null: mark the entry as having no counterparty, with
/// why (journaled as `entry.no_party`). Satisfies `[books] parties =
/// "require"`.
pub no_party_reason: Option<String>,
}
/// A party's W-9 facts.
@@ -2507,6 +2758,9 @@ pub struct W9Out {
pub tin_last4: String,
/// Where the form is kept.
pub kept_at: String,
/// True when the books record a TIN vault record for it (the full TIN
/// is in the vault, never here).
pub in_vault: bool,
}
/// A party's W-4 facts.
@@ -2713,3 +2967,191 @@ pub struct PartyDetailOut {
/// Payroll lines naming it, by run.
pub payroll: Vec<PartyPayrollOut>,
}
// ---------------------------------------------------------------------------
// Attachment links, statements, reconciliations
// ---------------------------------------------------------------------------
/// A reference: a number or text (a code, tag, or name).
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum RefId {
/// A number (an id; for an account, its code or id).
Number(u64),
/// Text: an account code, an asset tag, a party name, or an id.
Text(String),
}
/// What an attachment is linked to.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct SubjectParam {
/// entry, account, asset, party, invoice, bill, period, payroll_run,
/// schedule, reconciliation, or company.
pub kind: String,
/// Which one: an entry, invoice, bill, period, payroll run, schedule,
/// or reconciliation id; an account's code (or id); an asset's tag (or
/// id); a party's id or name. Omitted for the company.
pub id: Option<RefId>,
}
/// A subject in a tool's output.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct SubjectOut {
/// Its kind (as in SubjectParam).
pub kind: String,
/// Its id (absent for the company). For an account, the account id;
/// `label` gives the code.
pub id: Option<u64>,
/// Human words, e.g. "account 1000 Bank", "entry #5 January rent".
pub label: String,
}
/// Parameters for `books.link_attachment`.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct LinkAttachmentParams {
/// A stored attachment (receipt or document), not withdrawn.
pub attachment_id: u64,
/// What to link it to.
pub subject: SubjectParam,
}
/// Parameters for `books.unlink_attachment`.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct UnlinkAttachmentParams {
/// The attachment.
pub attachment_id: u64,
/// The subject to take it off.
pub subject: SubjectParam,
/// Why, recorded in the journal.
pub reason: String,
}
/// Result of `books.link_attachment` / `books.unlink_attachment`.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct AttachmentLinksOut {
/// The attachment.
pub attachment_id: u64,
/// Everything it is linked to now (the entry it is filed on first).
pub links: Vec<SubjectOut>,
}
/// Parameters for `books.attachments`.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct AttachmentsParams {
/// The subject whose files to list.
pub subject: SubjectParam,
}
/// Result of `books.attachments`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct AttachmentsOut {
/// The subject.
pub subject: SubjectOut,
/// Its files (for an entry: filed on it or linked to it), not
/// withdrawn, by id, each with all its links.
pub attachments: Vec<ReceiptOut>,
}
/// Parameters for `books.record_statement`.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct RecordStatementParams {
/// The filed statement (`books.file_document` category statement).
pub attachment_id: u64,
/// The bank, cash, or card account it is for: code or id.
pub account: String,
/// First day it covers, YYYY-MM-DD.
pub period_start: String,
/// Last day it covers (the statement date), YYYY-MM-DD.
pub period_end: String,
/// Opening balance as printed, in the account's normal direction
/// (what the bank holds for a bank account; what is owed for a card),
/// decimal string, cents at most.
pub opening_balance: String,
/// Closing balance as printed, same direction, decimal string.
pub closing_balance: String,
}
/// A recorded statement.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct StatementOut {
/// The attachment holding it.
pub attachment_id: u64,
/// Its file name.
pub file_name: String,
/// Account code.
pub account_code: String,
/// Account name.
pub account_name: String,
/// First day, YYYY-MM-DD.
pub period_start: String,
/// Last day (statement date), YYYY-MM-DD.
pub period_end: String,
/// Opening balance, decimal string.
pub opening_balance: String,
/// Closing balance, decimal string.
pub closing_balance: String,
/// The reconciliation (not voided) made against it, if any.
pub reconciliation_id: Option<u64>,
}
/// Parameters for `books.statements` and `books.reconciliations`.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct AccountFilterParams {
/// Only this account's (code or id). Omit for every account.
pub account: Option<String>,
}
/// Result of `books.statements`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct StatementsOut {
/// Recorded statements, by statement date.
pub statements: Vec<StatementOut>,
}
/// A cleared line a reconciliation recorded.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ClearedRefOut {
/// Entry id.
pub entry_id: u64,
/// Line index within the entry.
pub line_index: usize,
}
/// A recorded reconciliation.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ReconciliationOut {
/// Its id.
pub reconciliation_id: u64,
/// Account code.
pub account_code: String,
/// Account name.
pub account_name: String,
/// The statement date it reconciles through, YYYY-MM-DD.
pub statement_date: String,
/// The statement's closing balance, decimal string.
pub statement_balance: String,
/// The statement's attachment.
pub statement_attachment: u64,
/// The lines it found cleared.
pub cleared: Vec<ClearedRefOut>,
/// True when voided.
pub voided: bool,
/// Why it was voided, if it was.
pub voided_reason: Option<String>,
}
/// Parameters for `books.void_reconciliation`.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct VoidReconciliationParams {
/// The reconciliation's id.
pub reconciliation_id: u64,
/// Why, recorded in the journal.
pub reason: String,
}
/// Result of `books.reconciliations`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ReconciliationsOut {
/// Reconciliations (voided ones included, marked), by date.
pub reconciliations: Vec<ReconciliationOut>,
}
+1 -1
View File
@@ -147,7 +147,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(), 82, "expected 82 tools, got {}", tools.len());
assert_eq!(tools.len(), 91, "expected 91 tools, got {}", tools.len());
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
assert_eq!(chart["annotations"]["readOnlyHint"], true);
assert!(
File diff suppressed because it is too large. Load diff
+102 -4
View File
@@ -175,6 +175,14 @@ fn test_dir(name: &str) -> PathBuf {
dir
}
/// A directory beside the books dir `dir`, outside it (vault keys, files
/// with TINs).
fn outside(dir: &Path) -> PathBuf {
let d = PathBuf::from(format!("{}-outside", dir.display()));
std::fs::create_dir_all(&d).unwrap();
d
}
/// Write a `wanderbooks.toml` with the given receipt threshold and extra
/// TOML (appended under `[books]` unless it opens its own table).
fn books_toml(dir: &Path, threshold: &str, extra: &str) -> PathBuf {
@@ -201,6 +209,9 @@ fn spawn_child(dir: &Path, toml: &Path) -> Child {
// books.show writes a viewer request here; keep it out of the
// user's real runtime dir.
.env("XDG_RUNTIME_DIR", &run)
// The TIN vault's default key file is under XDG_CONFIG_HOME: never
// the user's real one (the happy path names its own anyway).
.env("XDG_CONFIG_HOME", outside(dir).join("config"))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
@@ -572,9 +583,13 @@ fn happy_path(name: &str) -> Mcp {
let toml = books_toml(
&dir,
"1000000.00",
"financing_accounts = [\"2100\"]\n\
&format!(
"financing_accounts = [\"2100\"]\n\
[[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
[[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n",
[[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\
[vault]\nkey_file = \"{}\"\n",
outside(&dir).join("vault.key").display()
),
);
let mut m = Mcp::start(&dir, &toml);
@@ -675,6 +690,18 @@ fn happy_path(name: &str) -> Mcp {
json!({"party": "Landlord", "received_on": "2026-01-03", "entity_type": "LLC (partnership)",
"tin_last4": "4321", "kept_at": "CPA portal"}),
);
let w9_pdf = outside(&dir).join("landlord-w9.pdf");
std::fs::write(&w9_pdf, b"%PDF-1.4 W-9 EIN 79-9714321").unwrap();
m.ok(
"books.set_w9",
json!({"party": "Landlord", "received_on": "2026-01-03", "entity_type": "LLC (partnership)",
"tin": "79-9714321", "tin_type": "ein", "w9_file_path": w9_pdf.to_str().unwrap()}),
);
m.ok(
"books.get_w9",
json!({"party": "Landlord",
"save_to": outside(&dir).join("landlord-w9-copy.pdf").to_str().unwrap()}),
);
m.ok("books.parties", json!({"role": "landlord", "year": 2026}));
// Owner contribution: draft, lines, receipt, preview, post.
@@ -748,6 +775,25 @@ fn happy_path(name: &str) -> Mcp {
)["attachment_id"]
.as_u64()
.unwrap();
let filed = m.ok(
"books.file_document",
json!({"category": "contract", "file_name": "lease.txt", "mime_type": "text/plain",
"content_base64": b64(b"lease"), "link_to": [{"kind": "company"}]}),
)["attachment_id"]
.as_u64()
.unwrap();
m.ok(
"books.link_attachment",
json!({"attachment_id": filed, "subject": {"kind": "party", "id": "Landlord"}}),
);
m.ok(
"books.attachments",
json!({"subject": {"kind": "party", "id": "Landlord"}}),
);
m.ok(
"books.unlink_attachment",
json!({"attachment_id": filed, "subject": {"kind": "company"}, "reason": "a lease, not a formation document"}),
);
let local = dir.join("local-receipt.txt");
std::fs::write(&local, b"a local receipt").unwrap();
m.ok(
@@ -899,15 +945,54 @@ fn happy_path(name: &str) -> Mcp {
);
// Bank import and reconciliation.
let csv = "Date,Description,Amount\n2026-03-05,Coffee,-4.50\n";
let source = m.ok(
"books.file_document",
json!({"category": "import_source", "file_name": "bank.csv", "mime_type": "text/csv",
"content_base64": b64(csv.as_bytes())}),
)["attachment_id"]
.as_u64()
.unwrap();
m.ok(
"books.import_bank_csv",
json!({"csv_text": "Date,Description,Amount\n2026-03-05,Coffee,-4.50\n",
"bank_account_code": "1000", "default_offset_code": "6999"}),
json!({"csv_text": csv, "bank_account_code": "1000", "default_offset_code": "6999",
"source_attachment_id": source}),
);
m.ok(
"books.reconcile",
json!({"account": "1000", "statement_date": "2026-12-31", "statement_balance": "100.00"}),
);
// January's statement: the contribution and the rent cleared.
m.ok(
"books.mark_cleared",
json!({"entry_id": e1, "line_index": 0, "cleared": true}),
);
let stmt = m.ok(
"books.file_document",
json!({"category": "statement", "file_name": "2026-01.pdf", "mime_type": "application/pdf",
"content_base64": b64(b"%PDF statement")}),
)["attachment_id"]
.as_u64()
.unwrap();
m.ok(
"books.record_statement",
json!({"attachment_id": stmt, "account": "1000", "period_start": "2026-01-01",
"period_end": "2026-01-31", "opening_balance": "0.00", "closing_balance": "9900.00"}),
);
m.ok("books.statements", json!({}));
m.ok("books.reconcile", json!({"statement_attachment": stmt}));
let rec = m.ok(
"books.reconcile",
json!({"statement_attachment": stmt, "confirmed": true}),
)["reconciliation"]["reconciliation_id"]
.as_u64()
.unwrap();
m.ok("books.reconciliations", json!({"account": "1000"}));
m.ok("books.show", json!({"reconciliation": rec}));
m.ok(
"books.void_reconciliation",
json!({"reconciliation_id": rec, "reason": "redo after the March import"}),
);
// Periods.
let p = m.ok(
@@ -943,6 +1028,19 @@ fn happy_path(name: &str) -> Mcp {
m.ok("books.digest", json!({"since": "2026-01-01"}));
m.ok("books.search_entries", json!({"text": "rent", "limit": 10}));
m.ok("books.vendor_1099", json!({"year": 2026}));
m.ok(
"books.vendor_1099",
json!({"year": 2026, "include_tins": true,
"save_csv_to": outside(&dir).join("1099.csv").to_str().unwrap()}),
);
m.ok(
"books.purge_tin",
json!({"party": "Landlord", "reason": "test purge"}),
);
m.ok(
"books.purge_tin",
json!({"party": "Landlord", "reason": "test purge", "confirmed": true}),
);
m.ok("books.party", json!({"party": "Landlord", "year": 2026}));
m.ok("books.party", json!({"party": "Pat", "year": 2026}));
m.ok("books.party", json!({"party": "Client"}));
+166
View File
@@ -1147,3 +1147,169 @@ fn no_party_output_holds_a_full_tin() {
assert!(digits.iter().all(|&n| n < 9), "{text}");
}
}
// ---------------------------------------------------------------- [books] parties
/// Books with the chart and extra `[books]` TOML.
fn books_with(name: &str, extra: &str) -> Mcp {
let dir = test_dir(name);
let toml = books_toml(&dir, "Spec Co", extra);
let mut m = Mcp::start(&dir, &toml);
m.accounts(CHART);
m
}
/// Oracle: PostEntryOut party fields and the "suggest" default
/// (docs/parties-and-attachments.md "Counterparties"): an untagged preview
/// names likely parties (description, then the same description on an
/// earlier tagged entry); nothing is refused.
#[test]
fn suggest_names_likely_parties_and_refuses_nothing() {
let mut m = books("suggest");
assert_eq!(
m.read_only("books.settings", json!({}))["parties"],
"suggest"
);
let apple = m.add_party("Apple Inc.", &[]);
let id = m.ok(
"books.draft_entry",
json!({"date": "2026-03-01", "description": "APPLE.COM/BILL iCloud"}),
)["entry_id"]
.as_u64()
.unwrap();
m.ok(
"books.add_line",
json!({"entry_id": id, "account": "6999", "side": "debit", "amount": "2.99"}),
);
m.ok(
"books.add_line",
json!({"entry_id": id, "account": "2050", "side": "credit", "amount": "2.99"}),
);
let pv = m.ok("books.post_entry", json!({"entry_id": id}));
assert_eq!(pv["party_policy"], "suggest");
assert_eq!(pv["party_missing"], true);
assert_eq!(pv["party_suggestions"][0]["party_id"], apple);
assert_eq!(
pv["party_suggestions"][0]["why"],
"named in the description"
);
m.ok(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
// Tagged: the next entry with the same description is suggested from it.
m.ok(
"books.set_entry_party",
json!({"entry_id": id, "party": apple.to_string()}),
);
let other = m.add_party("Zed", &[]);
let _ = other;
let id2 = m.ok(
"books.draft_entry",
json!({"date": "2026-04-01", "description": "APPLE.COM/BILL iCloud"}),
)["entry_id"]
.as_u64()
.unwrap();
m.ok(
"books.add_line",
json!({"entry_id": id2, "account": "6999", "side": "debit", "amount": "2.99"}),
);
m.ok(
"books.add_line",
json!({"entry_id": id2, "account": "2050", "side": "credit", "amount": "2.99"}),
);
let pv = m.ok("books.post_entry", json!({"entry_id": id2}));
let s = pv["party_suggestions"].as_array().unwrap();
assert_eq!(s.len(), 1, "one party, listed once: {s:?}");
assert_eq!(s[0]["party_id"], apple);
}
/// Oracle: "require": posting is refused (party_required) and journals
/// nothing until the entry names a party or is marked as having none;
/// no_party_reason is refused alongside a party.
#[test]
fn require_refuses_until_tagged_or_marked_none() {
let mut m = books_with("require", "parties = \"require\"\n");
assert_eq!(
m.read_only("books.settings", json!({}))["parties"],
"require"
);
let bank = m.add_party("Bluevine", &["financial_institution"]);
let id = m.draft(
"2026-03-01",
json!({}),
&[("6999", "debit", "5.00"), ("1000", "credit", "5.00")],
);
let before = m.journal();
let e = m.refuse(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
assert!(e.contains("party_required"), "{e}");
assert_eq!(m.journal(), before, "refusal journals nothing");
m.ok(
"books.set_entry_party",
json!({"entry_id": id, "party": bank.to_string()}),
);
let pv = m.ok(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
assert_eq!(pv["status"], "posted");
assert_eq!(pv["party_id"], bank);
// Marked none at draft time: posts, and the reason is journaled.
let t = m.draft(
"2026-03-02",
json!({"no_party_reason": "transfer between own accounts"}),
&[("1300", "debit", "1.00"), ("1000", "credit", "1.00")],
);
let pv = m.ok(
"books.post_entry",
json!({"entry_id": t, "confirmed": true}),
);
assert_eq!(pv["no_party_reason"], "transfer between own accounts");
assert_eq!(pv["party_missing"], false);
assert_eq!(m.count("entry.no_party"), 1);
// 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"}),
);
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"}),
);
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"}),
);
m.ok(
"books.set_entry_party",
json!({"entry_id": t, "no_party_reason": "own-account transfer"}),
);
let r = m.read_only("books.verify", json!({}));
assert_eq!(r["status"], "ok", "{r}");
}
/// Oracle: "off": the preview never raises a party; a bad value is a
/// settings error.
#[test]
fn off_never_raises_it() {
let mut m = books_with("off", "parties = \"off\"\n");
m.add_party("Apple", &[]);
let id = m.draft(
"2026-03-01",
json!({}),
&[("6999", "debit", "5.00"), ("1000", "credit", "5.00")],
);
let pv = m.ok("books.post_entry", json!({"entry_id": id}));
assert_eq!(pv["party_missing"], false);
assert!(pv["party_suggestions"].as_array().unwrap().is_empty());
m.ok(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
}
File diff suppressed because it is too large. Load diff