refactor!: files only by handle: uploads in, downloads out (D-46)
check / check (push) Canceled after 0s

The owner (2026-10-23, via the question tool): "Uploads only"; and
before it: "What we would DEFINITELY want to avoid is passing a bunch
of BLOBs through AI context windows. That's just a pure waste." No file
passes through an agent's context, in either direction.

In: the inline arguments are gone with the 256 KiB limit (INLINE_MAX,
take_file's inline branch): content_base64 and its sha256 on
books.attach_receipt and books.file_document, w9_content_base64 and
w9_sha256 on books.set_w9, csv_text on the two CSV imports. A file comes
only by books.begin_upload and `wanderbooks upload`, named by its
upload_id; existing_attachment_id still names bytes stored already. The
five filing tools deny unknown fields, and any inline argument to any
tool is refused before it runs as an unknown argument pointing to
books.begin_upload (access log: refused inline_file).

Out: books.export (every format), books.asset_labels and
books.get_receipt view bytes hand the file over (HandedFile: download
command and expiry, D-29), as the CPA package and the W-9 do. Resource
reads stay text. A token keeps up to 64 handed files (was 16). The
access log's digest no longer exempts file bytes.

Docs: handbook files-in-out and parties-and-tins, mcp-interface.md,
rules.md, receipt-store.md, parties-and-attachments.md, plan/served.md
(D-46; the inline decisions marked superseded), STATUS.md, testing.md
(D-46: tests migrated to uploads and downloads, rewritten, removed,
added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-24 09:18:14 -06:00
1 parent d7fa16e974
commit ebb5fcb46f
38 files changed
+1702 -1172

No files matched your search

+4 -5
View File
@@ -1,8 +1,7 @@
# Files in and out
- A file comes in one of two ways, named in the tool that files it (books.attach_receipt, books.file_document, books.set_w9, books.import_bank_csv, books.import_chart_csv):
- an upload, for any file up to 25 MiB: books.begin_upload with the file's sha256 (`sha256sum`), size, type, name and purpose; run the command it returns with the file's path; then give its upload_id. Its bytes never pass through you. An upload lasts 15 minutes, is used once, and only by the token that began it.
- inline, for a small file (at most 256 KiB): content_base64 with the file's sha256, so a byte lost on the way is caught.
- A file never passes through you, in either direction: no tool takes a file's bytes or text, and none gives them back.
- A file comes in by upload alone, named by its upload_id in the tool that files it (books.attach_receipt, books.file_document, books.set_w9 w9_upload_id, books.import_bank_csv, books.import_chart_csv): books.begin_upload with the file's sha256 (`sha256sum`), size (up to 25 MiB), type, name and purpose; run the command it returns (`wanderbooks upload`) with the file's path; then give the upload_id. An upload lasts 15 minutes, is used once, and only by the token that began it.
- If begin_upload says the bytes are stored already (existing_attachment_id), link that attachment instead of filing a copy.
- Files the books make (books.export_cpa_package, books.vendor_1099 csv, books.get_w9) are handed over: run the download command each returns to save it in a folder. They are kept 15 minutes, for the token that asked. No file with a full TIN goes into the books, however it is filed: the server refuses every file it read or wrote with full TINs (the W-9 books.set_w9 took, and each of those outputs, or a file taken out of a CPA package), and any text file holding one: a TIN the vault keeps, written as nine digits or in the usual groups (3-2-4 with hyphens, spaces or dots; 2-7 with a hyphen or a space), or a number printed as an SSN or EIN is (3-2-4 or 2-7 digits joined by hyphens, not part of a longer number). Other numbers pass, so bank exports (routing and account numbers, long references) go in. It can't recognize an altered copy of a scan, or a TIN the vault doesn't keep written any other way, so never file a W-9 or a TIN worksheet yourself.
- Receipts and documents come back out through books.get_receipt: view bytes gives a file of at most 256 KiB inline; a larger one gives the command (wanderbooks download) that saves a copy in a folder you name.
- A file goes out by download alone. Files the books make (books.export in every format, books.asset_labels, books.export_cpa_package, books.vendor_1099 csv, books.get_w9) are handed over: run the download command each returns to save it in a folder. They are kept 15 minutes, for the token that asked. No file with a full TIN goes into the books, however it is filed: the server refuses every file it read or wrote with full TINs (the W-9 books.set_w9 took, and each of those outputs, or a file taken out of a CPA package), and any text file holding one: a TIN the vault keeps, written as nine digits or in the usual groups (3-2-4 with hyphens, spaces or dots; 2-7 with a hyphen or a space), or a number printed as an SSN or EIN is (3-2-4 or 2-7 digits joined by hyphens, not part of a longer number). Other numbers pass, so bank exports (routing and account numbers, long references) go in. It can't recognize an altered copy of a scan, or a TIN the vault doesn't keep written any other way, so never file a W-9 or a TIN worksheet yourself.
- Receipts and documents come back out through books.get_receipt view bytes, handed over the same way: run its download command to save a copy in a folder you name.
@@ -10,6 +10,6 @@
- Name whose each card, loan and advance is: books.update_party creditor_of, the issuer for its card, the lender for its loan, each member for what the members advanced. Then a fee on the card, interest on the loan, or a distribution credited to what the members are owed never reads as a payment to them; it counts when paid. A distribution declared to distributions payable counts when it is paid, too. Record a reimbursement and a distribution as two entries, and a member's cash either as a loan (through the members' financing, tagged with the payee it paid) or as capital, not both.
- Every post_entry preview says where the entry stands (party_policy, party_missing, party_suggestions): under the `[books] parties` setting "suggest" (the default), when party_missing, name the likely party to the human and tag it (or add it) before confirming; under "require", posting is refused (party_required) until the entry names a party or is marked as having none (books.set_entry_party with party null and no_party_reason, or draft_entry no_party_reason: only for an entry with truly no counterparty, like a transfer between the company's own accounts); under "off", don't raise it.
- Record W-9 facts (books.set_w9) for contractors and landlords before paying them, and W-4 facts (books.set_w4) for employees before their first payroll; name the employee party on every books.run_payroll line.
- Never put a full TIN or SSN anywhere in the books, only the last four digits: the engine refuses any nine-digit number in a party field. The full TIN goes only to the encrypted TIN vault, and every member's and every 1099 payee's belongs there: books.set_tin (party, tin, tin_type: ssn, ein, itin; any role, so each member's SSN for their K-1) and books.set_w9 with tin and tin_type for a payee's W-9, and the signed form with w9_upload_id (books.begin_upload purpose w9), or w9_content_base64 with w9_sha256 for one of at most 256 KiB (never books.file_document or books.attach_receipt: the form shows the TIN).
- Never put a full TIN or SSN anywhere in the books, only the last four digits: the engine refuses any nine-digit number in a party field. The full TIN goes only to the encrypted TIN vault, and every member's and every 1099 payee's belongs there: books.set_tin (party, tin, tin_type: ssn, ein, itin; any role, so each member's SSN for their K-1) and books.set_w9 with tin and tin_type for a payee's W-9, and the signed form with w9_upload_id (books.begin_upload purpose w9; never books.file_document or books.attach_receipt: the form shows the TIN).
- A full TIN lives in the vault: keep it out of descriptions, notes, file names and version-history messages. books.get_w9 hands the signed form over (run its download command to save it for the human); books.purge_tin (preview, then confirmed, with a reason) deletes a TIN no longer needed.
- books.parties lists the directory (each party's TIN last four from its TIN or W-9 facts), books.party shows one party with the year's totals, and books.show party opens it in the viewer. Archive (books.update_party archived) parties no longer dealt with; never delete history.
+2
View File
@@ -96,6 +96,8 @@ pub(crate) const INSUFFICIENT_SCOPE: &str = "insufficient_scope";
pub(crate) const CLOSING: &str = "closing";
/// The server had stopped.
pub(crate) const STOPPED: &str = "stopped";
/// A file's bytes or text came as an argument (D-46).
pub(crate) const INLINE_FILE: &str = "inline_file";
/// What a refused call is told when its record couldn't be kept, or a
/// call that ran is told instead of its answer (INVARIANT 3).
+3 -3
View File
@@ -1,9 +1,9 @@
//! `ledger-mcp`: the AI-first bookkeeping interface. It is served one way
//! (plan D-44): by the books server, `wanderbooks server` ([`local`]),
//! every book of the data root on the root's local sockets, which agents
//! reach through the bridge `wanderbooks mcp --book ID`. Files come in as
//! tool input (inline, at most 256 KiB) or as uploads, and the files the
//! books make go out as downloads.
//! reach through the bridge `wanderbooks mcp --book ID`. Files come in only
//! as uploads and go out only as downloads: never through an agent's
//! context (D-46).
pub mod access;
pub mod admin;
+2 -2
View File
@@ -86,8 +86,8 @@ pub const SERVER_LOCK: &str = "server.lock";
/// What the server prints on stdout when it is ready (INVARIANT 5).
pub const READY: &str = "ready";
/// How large an MCP request may be (plan 3.2): files of any size come
/// through uploads (`/b/{book}/u/{upload}`); inline, at most 256 KiB.
/// How large an MCP request may be (plan 3.2): files come only through
/// uploads (`/b/{book}/u/{upload}`), never in a request (D-46).
pub const MAX_BODY: usize = 1024 * 1024;
/// How long the admin socket waits for a request to arrive.
+94 -96
View File
@@ -317,12 +317,8 @@ fn named(
mime_type: Option<String>,
file: &files::TakenFile,
) -> Result<(String, String), String> {
let name = file_name
.or_else(|| file.file_name.clone())
.ok_or("file_name is needed")?;
let mime = mime_type
.or_else(|| file.mime.clone())
.ok_or("mime_type is needed")?;
let name = file_name.unwrap_or_else(|| file.file_name.clone());
let mime = mime_type.unwrap_or_else(|| file.mime.clone());
if let Some(why) = ledger_store::uploads::file_name_problem(&name)
.or_else(|| ledger_store::uploads::mime_problem(&mime))
{
@@ -939,35 +935,30 @@ impl BooksServer {
.collect()
}
/// The signed W-9 a call gives (an upload, or inline), for the vault:
/// The signed W-9 a call gives (its upload, `upload_id`), for the vault:
/// a PDF, PNG, or JPEG by its own first bytes, whatever it is called.
fn w9_file(
&self,
p: &SetW9Params,
upload_id: &str,
) -> Result<(ledger_vault::W9File, Option<ledger_store::uploads::Claim>), String> {
let file = self.take_file(files::Given {
upload_id: p.w9_upload_id.as_deref(),
content_base64: p.w9_content_base64.as_deref(),
sha256: p.w9_sha256.as_deref(),
upload_id,
purposes: files::W9,
})?;
// Not recorded here: set_w9 records it once the call can only
// succeed, just before the vault write (tin_files.rs INVARIANT 1),
// so a refused call never marks an ordinary file.
let (mime_type, ext) = if file.bytes.starts_with(b"%PDF-") {
("application/pdf", "pdf")
let mime_type = if file.bytes.starts_with(b"%PDF-") {
"application/pdf"
} else if file.bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
("image/png", "png")
"image/png"
} else if file.bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
("image/jpeg", "jpg")
"image/jpeg"
} else {
return Err("the W-9 is a PDF, PNG, or JPEG".into());
};
let file_name = p
.w9_file_name
.clone()
.or(file.file_name)
.unwrap_or_else(|| format!("w9.{ext}"));
let file_name = p.w9_file_name.clone().unwrap_or(file.file_name);
if let Some(why) = ledger_store::uploads::file_name_problem(&file_name) {
return Err(why);
}
@@ -981,29 +972,19 @@ impl BooksServer {
))
}
/// A CSV's text, given inline or as an upload (books.begin_upload),
/// A CSV's text, from its upload (books.begin_upload purpose import),
/// and the upload's hold.
fn csv_given(
&self,
text: Option<&str>,
upload_id: Option<&str>,
upload_id: &str,
) -> Result<(String, Option<ledger_store::uploads::Claim>), String> {
match (text, upload_id) {
(Some(_), Some(_)) => Err("give csv_text or upload_id, not both".into()),
(Some(t), None) => Ok((t.to_string(), None)),
(None, Some(id)) => {
let file = self.take_file(files::Given {
upload_id: Some(id),
content_base64: None,
sha256: None,
purposes: files::IMPORTED,
})?;
let text = String::from_utf8(file.bytes)
.map_err(|_| "the CSV isn't UTF-8 text".to_string())?;
Ok((text, file.claim))
}
(None, None) => Err("give the CSV: csv_text, or an upload_id".into()),
}
let file = self.take_file(files::Given {
upload_id,
purposes: files::IMPORTED,
})?;
let text =
String::from_utf8(file.bytes).map_err(|_| "the CSV isn't UTF-8 text".to_string())?;
Ok((text, file.claim))
}
/// Record a file read or written with full TINs (tin_files.rs
@@ -3517,7 +3498,7 @@ impl BooksServer {
#[tool(
name = "books.attach_receipt",
title = "Attach a receipt",
description = "Attach a receipt to an entry, or file it unlinked and link later with books.link_receipt. The bytes: upload_id (books.begin_upload), or content_base64 with its sha256 for a file of at most 256 KiB. Files are content-addressed and deduplicated. link_to links it to more subjects in the same call (another entry it also supports, an asset, a party, a project, ...: see books.link_attachment); every subject is checked first, so one bad subject files nothing. A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.",
description = "Attach a receipt to an entry, or file it unlinked and link later with books.link_receipt. The bytes: upload_id, from books.begin_upload and `wanderbooks upload` (a file never passes through you). Files are content-addressed and deduplicated. link_to links it to more subjects in the same call (another entry it also supports, an asset, a party, a project, ...: see books.link_attachment); every subject is checked first, so one bad subject files nothing. A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.",
annotations(
read_only_hint = false,
destructive_hint = false,
@@ -3538,9 +3519,7 @@ impl BooksServer {
let link_to = check_link_to(&store, p.entry_id, &p.link_to)?;
// Taken once everything else is known good.
let file = self.take_file(files::Given {
upload_id: p.upload_id.as_deref(),
content_base64: p.content_base64.as_deref(),
sha256: p.sha256.as_deref(),
upload_id: &p.upload_id,
purposes: files::FILED,
})?;
let (file_name, mime_type) = named(p.file_name, p.mime_type, &file)?;
@@ -3572,7 +3551,7 @@ impl BooksServer {
#[tool(
name = "books.file_document",
title = "File a document",
description = "File a document: category formation, legal, tax, accounting, statement (a bank or card statement: then books.record_statement), contract, photo, warranty, import_source (a bank export), or other. link_to links it to subjects in the same call (an account, an asset, a party, a project, the company: see books.link_attachment), all checked first. The bytes: upload_id (books.begin_upload), or content_base64 with its sha256 (at most 256 KiB). Never a W-9 (books.set_w9 w9_upload_id puts it in the vault). A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.",
description = "File a document: category formation, legal, tax, accounting, statement (a bank or card statement: then books.record_statement), contract, photo, warranty, import_source (a bank export), or other. link_to links it to subjects in the same call (an account, an asset, a party, a project, the company: see books.link_attachment), all checked first. The bytes: upload_id, from books.begin_upload and `wanderbooks upload` (a file never passes through you). Never a W-9 (books.set_w9 w9_upload_id puts it in the vault). A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.",
annotations(
read_only_hint = false,
destructive_hint = false,
@@ -3597,9 +3576,7 @@ impl BooksServer {
let mut store = self.lock()?;
let link_to = check_link_to(&store, None, &p.link_to)?;
let file = self.take_file(files::Given {
upload_id: p.upload_id.as_deref(),
content_base64: p.content_base64.as_deref(),
sha256: p.sha256.as_deref(),
upload_id: &p.upload_id,
purposes: files::FILED,
})?;
let (file_name, mime_type) = named(p.file_name, p.mime_type, &file)?;
@@ -3890,7 +3867,7 @@ impl BooksServer {
#[tool(
name = "books.get_receipt",
title = "Get a receipt",
description = "A receipt or document: what it is and everything it is linked to. view bytes adds its bytes, base64 and hash-verified, for a file of at most 256 KiB; a larger one gives the command (wanderbooks download) that saves a copy in a folder you name.",
description = "A receipt or document: what it is and everything it is linked to. view bytes adds the file, handed over: run its download (wanderbooks download) to save a copy in a folder you name; its bytes never come back here.",
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn get_receipt(
@@ -3910,33 +3887,17 @@ impl BooksServer {
};
let store = self.lock()?;
let meta = find_attachment(&store, p.attachment_id)?;
let book = store
.data_dir()
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let fits = meta.size_bytes as usize <= files::INLINE_MAX;
let content_base64 = if bytes_wanted && fits {
// Handed over, read hash-verified (D-46): never its bytes here.
let file = if bytes_wanted {
let bytes = store
.attachment_bytes(p.attachment_id)
.map_err(|e| e.to_string())?;
Some(base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
&bytes,
))
Some(self.hand_over(&book_name(&store), &meta.file_name, &meta.mime_type, bytes)?)
} else {
None
};
// Too large to come back here: the command that saves a copy.
let download = (bytes_wanted && !fits).then(|| {
format!(
"wanderbooks download --book {book}{} {} --to <a folder>",
files::token_flag(),
meta.id
)
});
Ok(Json(GetReceiptOut {
download,
file,
links: links_out(&store, meta.id)?,
description: meta.description.clone(),
attachment_id: meta.id,
@@ -3946,7 +3907,6 @@ impl BooksServer {
file_name: meta.file_name,
mime_type: meta.mime_type,
size_bytes: meta.size_bytes,
content_base64,
}))
}
@@ -4512,7 +4472,7 @@ impl BooksServer {
#[tool(
name = "books.import_bank_csv",
title = "Import a bank CSV as drafts",
description = "Import a QuickBooks-style bank/credit-card CSV: 3 columns Date,Description,Amount (payments negative), or 4 columns Date,Description, money out, money in (Intuit heads them Credit,Debit). Slash dates must settle month-first or day-first from the file itself or from date_order (\"month_first\" for mm/dd, \"day_first\" for dd/mm); ask the human when the tool says the dates read either way. Creates one DRAFT entry per transaction, all or none: bank account on one side, categorized offset on the other (the longest matching rule pattern, else default_offset_code). Give the CSV as csv_text, or upload it (books.begin_upload purpose import) and give upload_id. Review with books.digest, then post. File the export too (books.file_document category import_source) and pass source_attachment_id: it is linked to the bank account, so the import traces to its source (never to the drafts: a bank export is not a receipt).",
description = "Import a QuickBooks-style bank/credit-card CSV: 3 columns Date,Description,Amount (payments negative), or 4 columns Date,Description, money out, money in (Intuit heads them Credit,Debit). Slash dates must settle month-first or day-first from the file itself or from date_order (\"month_first\" for mm/dd, \"day_first\" for dd/mm); ask the human when the tool says the dates read either way. Creates one DRAFT entry per transaction, all or none: bank account on one side, categorized offset on the other (the longest matching rule pattern, else default_offset_code). Give the CSV as upload_id: upload it with books.begin_upload purpose import and `wanderbooks upload` (a file never passes through you). Review with books.digest, then post. File the export too (books.file_document category import_source) and pass source_attachment_id: it is linked to the bank account, so the import traces to its source (never to the drafts: a bank export is not a receipt).",
annotations(
read_only_hint = false,
destructive_hint = false,
@@ -4534,7 +4494,7 @@ impl BooksServer {
));
}
};
let (csv, claim) = self.csv_given(p.csv_text.as_deref(), p.upload_id.as_deref())?;
let (csv, claim) = self.csv_given(&p.upload_id)?;
let txns = ledger_import::parse_bank_csv_with(&csv, order).map_err(|e| e.to_string())?;
let mut store = self.lock()?;
let bank = store
@@ -5683,14 +5643,13 @@ impl BooksServer {
#[tool(
name = "books.asset_labels",
title = "Asset label sheet",
description = "A printable sheet of asset labels (PDF, base64): each label shows the company, the tag, a QR code of the tag, the description, and the serial number. Avery 5160/8160 (US letter, 30 labels of 1\" x 2-5/8\"); skip reuses a partly used sheet. Omit assets for every active asset with a tag. Save the file and tell the human to print at 100% scale.",
description = "A printable sheet of asset labels (a PDF, handed over: run its download to save it): each label shows the company, the tag, a QR code of the tag, the description, and the serial number. Avery 5160/8160 (US letter, 30 labels of 1\" x 2-5/8\"); skip reuses a partly used sheet. Omit assets for every active asset with a tag. Save the file and tell the human to print it at 100% scale.",
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn asset_labels(
&self,
params: Parameters<AssetLabelsParams>,
) -> Result<Json<AssetLabelsOut>, String> {
use base64::Engine as _;
let p = params.0;
let store = self.lock()?;
let ledger = store.ledger();
@@ -5729,9 +5688,14 @@ impl BooksServer {
p.skip.unwrap_or(0) as usize,
)
.map_err(|e| e.to_string())?;
let file = self.hand_over(
&book_name(&store),
&format!("asset-labels-{}.pdf", today()),
"application/pdf",
pdf,
)?;
Ok(Json(AssetLabelsOut {
file_name: format!("asset-labels-{}.pdf", today()),
content_base64: base64::engine::general_purpose::STANDARD.encode(pdf),
file,
tags: labels.into_iter().map(|l| l.tag).collect(),
}))
}
@@ -5866,7 +5830,7 @@ impl BooksServer {
#[tool(
name = "books.import_chart_csv",
title = "Import a chart of accounts",
description = "Import a chart of accounts from CSV (header: code,name,type,parent_code,tax_category; the last is optional): csv_text, or upload_id (books.begin_upload purpose import). Types: asset, liability, equity, revenue, expense. Parents must come before children. Fails on the first bad row; nothing is half-imported. A revenue or expense row without a tax_category is listed in warnings, or, under policy.require_tax_category = refuse, refuses the file.",
description = "Import a chart of accounts from CSV (header: code,name,type,parent_code,tax_category; the last is optional), as upload_id: upload it with books.begin_upload purpose import and `wanderbooks upload` (a file never passes through you). Types: asset, liability, equity, revenue, expense. Parents must come before children. Fails on the first bad row; nothing is half-imported. A revenue or expense row without a tax_category is listed in warnings, or, under policy.require_tax_category = refuse, refuses the file.",
annotations(
read_only_hint = false,
destructive_hint = false,
@@ -5878,7 +5842,7 @@ impl BooksServer {
params: Parameters<ImportChartCsvParams>,
) -> Result<Json<ImportChartCsvOut>, String> {
let p = params.0;
let (csv, claim) = self.csv_given(p.csv_text.as_deref(), p.upload_id.as_deref())?;
let (csv, claim) = self.csv_given(&p.upload_id)?;
let rows = ledger_import::parse_chart_csv(&csv).map_err(|e| e.to_string())?;
let mut store = self.lock()?;
let parse_type = |t: &str| match t {
@@ -5996,7 +5960,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), 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).",
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). The file is handed over (run its download to save it; never its bytes). Formats: pdf and xlsx 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> {
@@ -6025,11 +5989,11 @@ impl BooksServer {
let as_of = self.as_of(p.as_of.as_deref())?;
let iif = self.export_iif(as_of, &p.cash_accounts)?;
let warnings = iif.warnings();
let file = self.hand_over_export(&report, &format, iif.text.into_bytes())?;
return Ok(Json(ExportOut {
format,
report,
content: iif.text,
content_encoding: None,
file,
warnings,
}));
}
@@ -6039,11 +6003,11 @@ impl BooksServer {
} else {
self.export_pdf(&report, &p)?
};
let file = self.hand_over_export(&report, &format, bytes)?;
return Ok(Json(ExportOut {
format,
report,
content: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes),
content_encoding: Some("base64".to_string()),
file,
warnings: Vec::new(),
}));
}
@@ -6066,15 +6030,40 @@ impl BooksServer {
));
}
};
let file = self.hand_over_export(&report, &format, content.into_bytes())?;
Ok(Json(ExportOut {
format,
report,
content,
content_encoding: None,
file,
warnings: Vec::new(),
}))
}
/// An export's file, handed over (files.rs INVARIANT 6, D-46): named
/// for its report and today, typed by its format.
fn hand_over_export(
&self,
report: &str,
format: &str,
bytes: Vec<u8>,
) -> Result<HandedFile, String> {
let mime = match format {
"csv" => "text/csv",
"json" => "application/json",
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"pdf" => "application/pdf",
// IIF is tab-separated ASCII text.
_ => "text/plain",
};
let book = book_name(&*self.lock()?);
self.hand_over(
&book,
&format!("{report}-{}.{format}", today()),
mime,
bytes,
)
}
/// Lay out a financial statement for rendering, from export params.
fn layout_statement(
&self,
@@ -7555,7 +7544,7 @@ impl BooksServer {
#[tool(
name = "books.set_w9",
title = "Record W-9 facts",
description = "Record what a contractor's or landlord's W-9 says, or clear it (clear: true alone): the date received (received_on), line 3 entity type, line 4 exempt payee and FATCA codes, the TIN's last four digits, and where the form is kept. Give the full TIN as tin with tin_type (ssn, ein, itin) and it goes only to the encrypted TIN vault (its key kept apart from it, created on first use): the books keep its last four (tin_last4 is then optional, and refused if it disagrees) and a hash of the vault record, in the W-9 facts and the party's TIN facts alike (party.tin_set, as books.set_tin), and kept_at defaults to \"wanderbooks vault\". The CPA package carries the full TIN to the CPA. Give the signed form (a PDF, PNG, or JPEG) as w9_upload_id (books.begin_upload), or w9_content_base64 with w9_sha256 when at most 256 KiB, and it goes to the vault too, never to the receipts and documents; read it back with books.get_w9. Never put a TIN in any other field, note, description, or file: the engine refuses any nine-digit number, and no tool output echoes one. The party needs a 1099 role (contractor or landlord: books.set_party_roles). Record it before paying them. A party with a vendor record also gets its W-9 marked on file there (books.vendor_1099 missing_w9). Clearing W-9 facts whose TIN is in the vault needs books.purge_tin first. Journaled as party.w9_set. Returns the party (the TIN's last four only).",
description = "Record what a contractor's or landlord's W-9 says, or clear it (clear: true alone): the date received (received_on), line 3 entity type, line 4 exempt payee and FATCA codes, the TIN's last four digits, and where the form is kept. Give the full TIN as tin with tin_type (ssn, ein, itin) and it goes only to the encrypted TIN vault (its key kept apart from it, created on first use): the books keep its last four (tin_last4 is then optional, and refused if it disagrees) and a hash of the vault record, in the W-9 facts and the party's TIN facts alike (party.tin_set, as books.set_tin), and kept_at defaults to \"wanderbooks vault\". The CPA package carries the full TIN to the CPA. Give the signed form (a PDF, PNG, or JPEG) as w9_upload_id (books.begin_upload purpose w9, then `wanderbooks upload`: a file never passes through you), and it goes to the vault too, never to the receipts and documents; read it back with books.get_w9. Never put a TIN in any other field, note, description, or file: the engine refuses any nine-digit number, and no tool output echoes one. The party needs a 1099 role (contractor or landlord: books.set_party_roles). Record it before paying them. A party with a vendor record also gets its W-9 marked on file there (books.vendor_1099 missing_w9). Clearing W-9 facts whose TIN is in the vault needs books.purge_tin first. Journaled as party.w9_set. Returns the party (the TIN's last four only).",
annotations(
read_only_hint = false,
destructive_hint = true,
@@ -7564,12 +7553,9 @@ impl BooksServer {
)]
async fn set_w9(&self, params: Parameters<SetW9Params>) -> Result<Json<PartyOut>, String> {
let p = params.0;
let w9_given = p.w9_upload_id.is_some() || p.w9_content_base64.is_some();
let w9_given = p.w9_upload_id.is_some();
if p.w9_file_name.is_some() && !w9_given {
return Err(
"w9_file_name names the W-9 given with it (w9_upload_id or w9_content_base64)"
.into(),
);
return Err("w9_file_name names the W-9 given with it (w9_upload_id)".into());
}
let vault_input = p.tin.is_some() || p.tin_type.is_some() || w9_given;
let tin = match (&p.tin, &p.tin_type) {
@@ -7624,8 +7610,8 @@ impl BooksServer {
vault_record: None,
})
};
let (w9_file, mut w9_claim) = if w9_given {
let (file, claim) = self.w9_file(&p)?;
let (w9_file, mut w9_claim) = if let Some(upload_id) = &p.w9_upload_id {
let (file, claim) = self.w9_file(&p, upload_id)?;
(Some(file), claim)
} else {
(None, None)
@@ -9879,7 +9865,14 @@ impl ServerHandler for BooksServer {
.join(", ")
))),
)),
_ => None,
// A file's bytes or text given as an argument: refused as an
// unknown argument, before it runs (files.rs INVARIANT 5).
_ => files::inline_refusal(&request.name, request.arguments.as_ref()).map(|why| {
(
access::INLINE_FILE,
Err(rmcp::ErrorData::invalid_params(why, None)),
)
}),
};
if let Some((code, answer)) = refusal {
call.digested(refused_digest(request.arguments.clone()).await);
@@ -10271,11 +10264,11 @@ mod tests {
)
}
/// An upload's limit, 25 MiB exactly; inline, 256 KiB (plan 2.1, 4.7).
/// An upload's limit, 25 MiB exactly (plan 4.7); nothing comes inline
/// (D-46).
#[test]
fn the_file_limits_are_the_plans() {
assert_eq!(ledger_store::uploads::MAX_UPLOAD, 26_214_400);
assert_eq!(files::INLINE_MAX, 262_144);
// Plan 4.7: an upload lasts 15 minutes; 16 under way per token.
assert_eq!(ledger_store::uploads::UPLOAD_TTL, 900);
assert_eq!(ledger_store::uploads::MAX_LIVE, 16);
@@ -10920,7 +10913,7 @@ mod tests {
}
/// No tool takes a path on the server's computer (plan 4.7, D-28,
/// D-29): files come in by upload or inline, and go out as exports.
/// D-29, D-46): files come in by upload, and go out as exports.
#[test]
fn no_tool_takes_a_path() {
fn names(v: &serde_json::Value, out: &mut Vec<String>) {
@@ -11001,7 +10994,7 @@ mod tests {
"read the file",
"open the file",
"cat ",
// Gone in P2 slice 5: files come by upload or inline.
// Gone in P2 slice 5: files come by upload.
"import_local_file",
"w9_file_path",
"import folder",
@@ -11009,6 +11002,11 @@ mod tests {
"put_url",
"human_url",
"fl-0001",
// Gone with D-46: a file never passes through the agent.
"content_base64",
"csv_text",
"base64",
"256 kib",
];
// Whole words only: "digit" and "committee" are fine.
const NEVER_WORDS: &[&str] = &["git", "commit"];
+110 -82
View File
@@ -1,19 +1,20 @@
//! Files in (plan 4.7, P2 slice 5, D-28): `books.begin_upload`, and the
//! bytes a tool is given, as an upload or inline.
//! Files in and out (plan 4.7, P2 slice 5, D-28, D-29, D-46):
//! `books.begin_upload`, the upload a tool is given, and the files the
//! books hand over.
//!
//! ```text
//! GOAL: an agent files a document of any size up to the limit without
//! its bytes passing through its context, and a small one inline,
//! both exactly as the agent declared them.
//! SOURCE: plan 4.7 (begin_upload; "content_base64 (≤256 KiB + required
//! sha256)"; guard base64_corrupt), 2.1 ("256 KiB with a mandatory
//! matching sha256"), D-28 (the agent does it all).
//! GOAL: an agent files a document of any size up to the limit, and takes
//! away any file the books make, without the file's bytes passing
//! through its context, in either direction (D-46).
//! SOURCE: plan 4.7 (begin_upload), D-28 (the agent does it all), D-29
//! (files the books make are handed over by `wanderbooks
//! download`), D-46 ("files never pass through an agent's context,
//! in either direction": uploads only, no inline bytes).
//! INVARIANTS:
//! 1. A tool's bytes are an upload made by the same token, or inline
//! base64 of at most INLINE_MAX bytes whose SHA-256 is the one given;
//! never both, never neither. An upload is used up only by the call
//! that files it (TakenFile::filed); a call that fails leaves it for
//! the next.
//! 1. A tool's bytes are an upload made by the same token, named by its
//! upload_id; nothing else. An upload is used up only by the call that
//! files it (TakenFile::filed); a call that fails leaves it for the
//! next.
//! 2. begin_upload makes nothing for a receipt or document whose bytes
//! are stored already: it names the attachment (not withdrawn) that
//! holds them.
@@ -23,8 +24,9 @@
//! 4. An upload is filed only by a tool of the purpose it was begun for
//! (a receipt or document, a CSV import, a W-9); another tool's
//! refusal leaves it for the right one.
//! 5. Inline text longer than INLINE_MAX bytes' base64 is refused
//! before it is decoded.
//! 5. A call that gives a file's bytes or text as an argument (one of
//! INLINE_ARGS) is refused before it runs, as an unknown argument
//! whose words name books.begin_upload (D-46).
//! 6. A file the books make for an agent (D-29) is handed over as an
//! export the caller's token fetches with `wanderbooks download`;
//! never inline, never by a path.
@@ -34,20 +36,41 @@ use super::*;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// The most bytes given inline (plan 2.1: 256 KiB).
pub(crate) const INLINE_MAX: usize = 256 * 1024;
/// The arguments that once carried a file's bytes or text inline, before
/// D-46 (INVARIANT 5): refused whatever tool they come to.
pub(crate) const INLINE_ARGS: [&str; 5] = [
"content_base64",
"sha256",
"csv_text",
"w9_content_base64",
"w9_sha256",
];
/// Why `args` can't be taken by `tool` (INVARIANT 5): an argument that
/// carries a file's bytes or text. books.begin_upload's own sha256 is the
/// file's hash, not its bytes.
pub(crate) fn inline_refusal(
tool: &str,
args: Option<&serde_json::Map<String, serde_json::Value>>,
) -> Option<String> {
let given = args?.keys().find(|k| INLINE_ARGS.contains(&k.as_str()))?;
if tool == "books.begin_upload" && given == "sha256" {
return None;
}
Some(format!(
"unknown argument {given:?}: a file never passes through you (D-46). Send it with books.begin_upload and the command it returns (`wanderbooks upload`), then name the upload_id here"
))
}
/// Why a call with no token begins, takes or is handed no file
/// (INVARIANT 3).
const NO_CALLER: &str =
"this call came without a token for these books: a file is kept only for the token that asked";
/// What the bytes of a file a tool files came as (INVARIANT 1), and the
/// upload purposes the tool takes (INVARIANT 4).
/// The upload a tool files (INVARIANT 1), and the upload purposes the
/// tool takes (INVARIANT 4).
pub(crate) struct Given<'a> {
pub upload_id: Option<&'a str>,
pub content_base64: Option<&'a str>,
pub sha256: Option<&'a str>,
pub upload_id: &'a str,
pub purposes: &'a [&'a str],
}
@@ -61,8 +84,8 @@ pub(crate) const W9: &[&str] = &["w9"];
/// The bytes, and the name and type an upload declared.
pub(crate) struct TakenFile {
pub bytes: Vec<u8>,
pub file_name: Option<String>,
pub mime: Option<String>,
pub file_name: String,
pub mime: String,
/// The upload's hold, if it came as one (INVARIANT 1).
pub claim: Option<ledger_store::uploads::Claim>,
}
@@ -196,65 +219,32 @@ impl BooksServer {
CALLER.with(|c| c.borrow().as_ref().map(|(id, _)| id.clone()))
}
/// The bytes a tool was given (INVARIANT 1).
/// The bytes of the upload a tool was given (INVARIANT 1).
pub(crate) fn take_file(&self, given: Given<'_>) -> Result<TakenFile, String> {
match (given.upload_id, given.content_base64) {
(Some(_), Some(_)) => Err("give upload_id or content_base64, not both".into()),
(None, None) => Err(
"the file's bytes are missing: upload it (books.begin_upload), or give content_base64 with its sha256 (at most 256 KiB)"
.into(),
),
(Some(id), None) => {
// INVARIANT 3.
let token = self.caller_token().ok_or(NO_CALLER)?;
let got = self
.uploads
.take(id.trim(), &token, chrono::Utc::now().timestamp())
.map_err(|e| e.to_string())?;
// INVARIANT 4: dropped, the claim gives it back.
if !given.purposes.contains(&got.grant.purpose.as_str()) {
return Err(format!(
"that upload was begun for {}: this tool takes one begun for {}",
got.grant.purpose,
given.purposes.join(" or ")
));
}
Ok(TakenFile {
bytes: got.bytes,
file_name: Some(got.grant.file_name),
mime: Some(got.grant.mime),
claim: Some(got.claim),
})
}
(None, Some(b64)) => {
let Some(sha) = given.sha256 else {
return Err("content_base64 comes with its sha256 (the file's SHA-256, hex), so a byte lost on the way is caught".into());
};
// INVARIANT 5.
if b64.len() > INLINE_MAX.div_ceil(3) * 4 {
return Err(format!(
"inline is at most {INLINE_MAX} bytes: upload it with books.begin_upload"
));
}
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64)
.map_err(|_| "content_base64 is not valid base64 (guard base64_corrupt): send the file with books.begin_upload".to_string())?;
if bytes.len() > INLINE_MAX {
return Err(format!(
"the file is {} bytes; inline is at most {INLINE_MAX}: upload it with books.begin_upload",
bytes.len()
));
}
if ledger_store::journal::sha256_hex(&bytes) != sha.trim().to_ascii_lowercase() {
return Err("content_base64 doesn't have the sha256 given: some of it was lost on the way (guard base64_corrupt). Send the file with books.begin_upload instead".into());
}
Ok(TakenFile {
bytes,
file_name: None,
mime: None,
claim: None,
})
}
// INVARIANT 3.
let token = self.caller_token().ok_or(NO_CALLER)?;
let got = self
.uploads
.take(
given.upload_id.trim(),
&token,
chrono::Utc::now().timestamp(),
)
.map_err(|e| e.to_string())?;
// INVARIANT 4: dropped, the claim gives it back.
if !given.purposes.contains(&got.grant.purpose.as_str()) {
return Err(format!(
"that upload was begun for {}: this tool takes one begun for {}",
got.grant.purpose,
given.purposes.join(" or ")
));
}
Ok(TakenFile {
bytes: got.bytes,
file_name: got.grant.file_name,
mime: got.grant.mime,
claim: Some(got.claim),
})
}
}
@@ -342,7 +332,45 @@ impl BooksServer {
#[cfg(test)]
mod tests {
use super::{CALLER, plain_file_name, token_flag};
use super::{CALLER, INLINE_ARGS, inline_refusal, plain_file_name, token_flag};
/// INVARIANT 5: every argument that once carried a file inline is
/// refused, on any tool, naming itself and books.begin_upload; but
/// books.begin_upload's own sha256 (the file's hash), and the
/// arguments that name an upload.
#[test]
fn an_inline_file_argument_is_refused_on_every_tool() {
let args = |key: &str| {
let mut m = serde_json::Map::new();
m.insert(key.into(), serde_json::json!("JVBERi0xLjQ="));
m
};
for tool in [
"books.attach_receipt",
"books.file_document",
"books.set_w9",
"books.import_bank_csv",
"books.import_chart_csv",
"books.draft_entry",
] {
for key in INLINE_ARGS {
let why = inline_refusal(tool, Some(&args(key)))
.unwrap_or_else(|| panic!("{tool} took {key}"));
assert!(why.contains(&format!("{key:?}")), "{why}");
assert!(why.contains("books.begin_upload"), "{why}");
assert!(why.starts_with("unknown argument"), "{why}");
}
for key in ["upload_id", "w9_upload_id", "file_name", "mime_type"] {
assert_eq!(inline_refusal(tool, Some(&args(key))), None, "{tool} {key}");
}
}
assert_eq!(
inline_refusal("books.begin_upload", Some(&args("sha256"))),
None
);
assert!(inline_refusal("books.begin_upload", Some(&args("content_base64"))).is_some());
assert_eq!(inline_refusal("books.attach_receipt", None), None);
}
/// A name kept before names were checked is handed over cleaned, never
/// refused (a W-9 sealed earlier, say).
+50 -73
View File
@@ -1271,16 +1271,16 @@ pub struct CashFlowOut {
pub warnings: Vec<CashFlowWarningOut>,
}
/// Parameters for `books.import_bank_csv`.
/// Parameters for `books.import_bank_csv`. No other argument is taken: a
/// file's text never comes as one (D-46).
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ImportBankCsvParams {
/// The CSV text (QBO bank upload layout: Date,Description,Amount, or
/// Date,Description, money out, money in)…
#[serde(default)]
pub csv_text: Option<String>,
/// …or an upload of it made with books.begin_upload (purpose import).
#[serde(default)]
pub upload_id: Option<String>,
/// The CSV (QBO bank upload layout: Date,Description,Amount, or
/// Date,Description, money out, money in): its upload, made with
/// books.begin_upload (purpose import) and `wanderbooks upload`. A
/// file never passes through the agent (D-46).
pub upload_id: String,
/// Account code for the bank account (the offset side of each entry).
pub bank_account_code: String,
/// Account code for the default offset (e.g. an expense account).
@@ -1331,15 +1331,15 @@ pub struct ImportBankCsvOut {
pub source_linked: bool,
}
/// Parameters for `books.import_chart_csv`.
/// Parameters for `books.import_chart_csv`. No other argument is taken: a
/// file's text never comes as one (D-46).
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ImportChartCsvParams {
/// The CSV text: code,name,type,parent_code with a header row…
#[serde(default)]
pub csv_text: Option<String>,
/// …or an upload of it made with books.begin_upload (purpose import).
#[serde(default)]
pub upload_id: Option<String>,
/// The CSV (code,name,type,parent_code,tax_category with a header
/// row): its upload, made with books.begin_upload (purpose import) and
/// `wanderbooks upload`. A file never passes through the agent (D-46).
pub upload_id: String,
}
/// Result of `books.import_chart_csv`.
@@ -1679,8 +1679,10 @@ pub struct ReverseEntryOut {
// Receipts and documents
// ---------------------------------------------------------------------------
/// Parameters for `books.attach_receipt`.
/// Parameters for `books.attach_receipt`. No other argument is taken: a
/// file's bytes never come as one (D-46).
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AttachReceiptParams {
/// Entry id to attach to. Omit to file unlinked, then link later with
/// `books.link_receipt`.
@@ -1692,16 +1694,9 @@ pub struct AttachReceiptParams {
/// MIME type, e.g. "image/png" (an upload's own, if not given).
#[serde(default)]
pub mime_type: Option<String>,
/// The file's bytes: an upload made with books.begin_upload (its id)…
#[serde(default)]
pub upload_id: Option<String>,
/// …or, for a file of at most 256 KiB, its bytes base64-encoded, with
/// sha256.
#[serde(default)]
pub content_base64: Option<String>,
/// The SHA-256 of the bytes in content_base64, hex.
#[serde(default)]
pub sha256: Option<String>,
/// The file: its upload, made with books.begin_upload and `wanderbooks
/// upload` (its id). A file never passes through the agent (D-46).
pub upload_id: String,
/// Optional description.
pub description: Option<String>,
/// More subjects to link it to in the same call (each journaled as its
@@ -1724,8 +1719,10 @@ pub struct AttachReceiptOut {
pub links: Vec<SubjectOut>,
}
/// Parameters for `books.file_document`.
/// Parameters for `books.file_document`. No other argument is taken: a
/// file's bytes never come as one (D-46).
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct FileDocumentParams {
/// One of: formation, legal, tax, accounting, statement, contract,
/// photo, warranty, import_source, other.
@@ -1736,16 +1733,9 @@ pub struct FileDocumentParams {
/// MIME type (an upload's own, if not given).
#[serde(default)]
pub mime_type: Option<String>,
/// The file's bytes: an upload made with books.begin_upload (its id)…
#[serde(default)]
pub upload_id: Option<String>,
/// …or, for a file of at most 256 KiB, its bytes base64-encoded, with
/// sha256.
#[serde(default)]
pub content_base64: Option<String>,
/// The SHA-256 of the bytes in content_base64, hex.
#[serde(default)]
pub sha256: Option<String>,
/// The file: its upload, made with books.begin_upload and `wanderbooks
/// upload` (its id). A file never passes through the agent (D-46).
pub upload_id: String,
/// Optional description.
pub description: Option<String>,
/// Subjects to link it to in the same call (each journaled as its own
@@ -1860,15 +1850,14 @@ pub struct GetReceiptParams {
/// Attachment id.
pub attachment_id: u64,
/// What to return: "meta" (default: what it is and what it's linked
/// to), or "bytes" (also its bytes, base64, for a file of at most
/// 256 KiB; a larger one comes with `wanderbooks download`).
/// to), or "bytes" (also the file, handed over: the command that
/// saves a copy, never its bytes; D-46).
#[serde(default)]
pub view: Option<String>,
}
/// Result of `books.get_receipt`: what it is and what it's linked to, and
/// with view `bytes`, its bytes (hash-verified on read) or the command that
/// saves a copy.
/// with view `bytes`, the file handed over (read hash-verified).
#[derive(Debug, Serialize, JsonSchema)]
pub struct GetReceiptOut {
/// Attachment id.
@@ -1889,14 +1878,10 @@ pub struct GetReceiptOut {
pub description: Option<String>,
/// Everything it is linked to (the entry it is filed on first).
pub links: Vec<SubjectOut>,
/// Its bytes, base64-encoded: with view `bytes`, for a file of at most
/// 256 KiB.
/// With view `bytes`: the file, handed over: run its `download` to
/// save a copy in a folder you name.
#[serde(skip_serializing_if = "Option::is_none")]
pub content_base64: Option<String>,
/// With view `bytes`, for a file too large to come back here: the
/// command that saves a copy in a folder you name.
#[serde(skip_serializing_if = "Option::is_none")]
pub download: Option<String>,
pub file: Option<HandedFile>,
}
// ---------------------------------------------------------------------------
@@ -2140,8 +2125,8 @@ pub struct StaleRule {
/// Parameters for `books.export`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ExportParams {
/// "csv", "json", "xlsx", "pdf", or "iif". XLSX and PDF are
/// base64-encoded; PDF covers every report but general_ledger and
/// "csv", "json", "xlsx", "pdf", or "iif": the file is handed over,
/// never its bytes (D-46). 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,
@@ -2203,12 +2188,9 @@ pub struct ExportOut {
pub format: String,
/// The requested report.
pub report: String,
/// The exported content. Base64-encoded when `content_encoding` is
/// "base64" (XLSX, PDF); otherwise raw text.
pub content: String,
/// "base64" for XLSX and PDF, otherwise absent.
#[serde(skip_serializing_if = "Option::is_none")]
pub content_encoding: Option<String>,
/// The exported file, handed over: run its `download` to save it in a
/// folder you name. Never its bytes (D-46).
pub file: HandedFile,
/// IIF: what the file writes differently from the books to fit
/// QuickBooks Desktop (plain ASCII; account names at most 31
/// characters a level, numbers 7, memos 4,095; no two names or numbers
@@ -2546,11 +2528,10 @@ pub struct AssetLabelsParams {
/// Result of `books.asset_labels`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct AssetLabelsOut {
/// A file name for the sheet.
pub file_name: String,
/// The label sheet as PDF, base64. Avery 5160/8160 (US letter, 30
/// labels of 1" x 2-5/8"); print at 100% scale, not "fit to page".
pub content_base64: String,
/// The label sheet, a PDF, handed over: run its `download` to save it.
/// Avery 5160/8160 (US letter, 30 labels of 1" x 2-5/8"); print at
/// 100% scale, not "fit to page".
pub file: HandedFile,
/// The tags printed, in order.
pub tags: Vec<String>,
}
@@ -3752,8 +3733,10 @@ pub struct SetPartyRolesParams {
}
/// Parameters for `books.set_w9`: what a W-9 said, never the number
/// itself. Either `clear: true` alone, or every required fact.
/// itself. Either `clear: true` alone, or every required fact. No other
/// argument is taken: the form's bytes never come as one (D-46).
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SetW9Params {
/// The party: id or name. Needs a 1099 role (contractor or landlord).
pub party: String,
@@ -3784,19 +3767,13 @@ pub struct SetW9Params {
/// The TIN's type: ssn, ein, or itin. Required with tin.
pub tin_type: Option<String>,
/// The signed W-9 itself (a PDF, PNG, or JPEG): an upload made with
/// books.begin_upload. Stored only in the vault (it shows the full
/// TIN), never with the receipts and documents.
/// books.begin_upload purpose w9 and `wanderbooks upload` (its id; a
/// file never passes through the agent, D-46). Stored only in the
/// vault (it shows the full TIN), never with the receipts and
/// documents.
#[serde(default)]
pub w9_upload_id: Option<String>,
/// …or, for one of at most 256 KiB, its bytes base64-encoded, with
/// w9_sha256.
#[serde(default)]
pub w9_content_base64: Option<String>,
/// The SHA-256 of the bytes in w9_content_base64, hex.
#[serde(default)]
pub w9_sha256: Option<String>,
/// The W-9 file's name (default: the upload's, else w9.pdf, w9.png
/// or w9.jpg by its type).
/// The W-9 file's name (default: the upload's).
#[serde(default)]
pub w9_file_name: Option<String>,
}