feat: receipt unlink and attachment withdrawal
Posted entries stay immutable (R-3), but their evidence can now change the
same way the ledger does: by appending events, never by editing or
deleting. New R-13a.
- books.unlink_receipt {attachment_id, reason} journals receipt.unlinked
and leaves the receipt filed, unlinked. The receipt policy holds: an
entry that requires a receipt can't lose its last one (structured
receipt_required error), so a replacement is attach-new, then
unlink-old. Drafts and reversals are exempt, as at posting.
- books.withdraw_attachment {attachment_id, reason} journals
attachment.withdrawn for an unlinked receipt or a filed document: hidden
from list_receipts (unless include_withdrawn), never linkable again. The
blob stays in the store, hash-verified and readable.
- link_receipt now refuses an already-linked or withdrawn attachment.
Before, re-linking silently moved a receipt off a posted entry,
bypassing the policy; evidence now leaves an entry only through a
recorded unlink. dispose_asset checks its receipt ids before posting
instead of after.
- Schema v5 adds attachments.withdrawn_at/withdrawn_reason; existing
stores migrate on open and replay rebuilds the projection.
Tests: store test (errors, reopen, rebuild, verify, journal history) and
an MCP stdio test (policy refusal, replace flow, withdraw, listings,
under-threshold unlink). Dry-run and live use on the Wanderland Labs books:
8 migrated .eml attachments swapped for their PDF renders, trial balance
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
1 parent
3bfea47a59
commit
e59e8001c6
13 files changed
+580
-21
No files matched your search
@@ -21,7 +21,7 @@ use rust_decimal::Decimal;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
/// The agent operating contract, delivered via initialize.
|
/// The agent operating contract, delivered via initialize.
|
||||||
const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA.";
|
const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA.";
|
||||||
|
|
||||||
/// Max receipt/document upload: 25 MB.
|
/// Max receipt/document upload: 25 MB.
|
||||||
const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
|
const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
|
||||||
@@ -55,6 +55,43 @@ impl BooksServer {
|
|||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| "store lock poisoned".to_string())
|
.map_err(|_| "store lock poisoned".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fail with the structured `receipt_required` error when the receipt
|
||||||
|
/// policy requires this posted entry to keep at least one receipt.
|
||||||
|
/// Drafts pass (posting enforces the policy later); reversals pass (they
|
||||||
|
/// are exempt: the original carries the evidence).
|
||||||
|
fn ensure_receipt_not_required(&self, store: &Store, entry_id: u64) -> Result<(), String> {
|
||||||
|
let entry = store
|
||||||
|
.ledger()
|
||||||
|
.get_entry(entry_id)
|
||||||
|
.ok_or_else(|| format!("unknown entry {entry_id}"))?;
|
||||||
|
if entry.status() != ledger_core::EntryStatus::Posted || entry.reversal_of().is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let split_group = self.policy.split_group(store.ledger(), entry_id, entry);
|
||||||
|
if !self.policy.receipt_required(store.ledger(), entry) && split_group.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let total = ReceiptPolicy::entry_debits(entry);
|
||||||
|
Err(ReceiptRequiredError {
|
||||||
|
code: "receipt_required".to_string(),
|
||||||
|
message: format!(
|
||||||
|
"entry {entry_id} moves {} in debits ({}); unlinking its last receipt would leave it without one.",
|
||||||
|
money(total),
|
||||||
|
self.policy.describe()
|
||||||
|
),
|
||||||
|
entry_id: Some(entry_id),
|
||||||
|
asset_id: None,
|
||||||
|
debits: total.to_string(),
|
||||||
|
policy: self.policy.describe(),
|
||||||
|
remedy_tool: "books.attach_receipt".to_string(),
|
||||||
|
remedy_hint: format!(
|
||||||
|
"attach the replacement receipt to entry {entry_id} with books.attach_receipt first, then retry books.unlink_receipt"
|
||||||
|
),
|
||||||
|
split_group,
|
||||||
|
}
|
||||||
|
.to_tool_error())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -846,9 +883,65 @@ impl BooksServer {
|
|||||||
Ok(Json(LinkReceiptOut { ok: true }))
|
Ok(Json(LinkReceiptOut { ok: true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
name = "books.unlink_receipt",
|
||||||
|
description = "Detach a receipt from its entry, with a reason recorded in the journal. The receipt stays filed (unlinked); link it elsewhere or withdraw it with books.withdraw_attachment. The receipt policy still holds: an entry that requires a receipt cannot be left with none, so to replace a receipt attach the new one first, then unlink the old."
|
||||||
|
)]
|
||||||
|
async fn unlink_receipt(
|
||||||
|
&self,
|
||||||
|
params: Parameters<UnlinkReceiptParams>,
|
||||||
|
) -> Result<Json<UnlinkReceiptOut>, String> {
|
||||||
|
let p = params.0;
|
||||||
|
let mut store = self.lock()?;
|
||||||
|
let attachment = store
|
||||||
|
.list_attachments(None)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.into_iter()
|
||||||
|
.find(|a| a.id == p.attachment_id)
|
||||||
|
.ok_or_else(|| format!("unknown attachment {}", p.attachment_id))?;
|
||||||
|
if let Some(entry_id) = attachment.entry_id {
|
||||||
|
let remaining = store
|
||||||
|
.list_attachments(Some(entry_id))
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.len()
|
||||||
|
- 1;
|
||||||
|
if remaining == 0 {
|
||||||
|
self.ensure_receipt_not_required(&store, entry_id)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let entry_id = store
|
||||||
|
.unlink_receipt(p.attachment_id, &p.reason)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let remaining_receipts = store
|
||||||
|
.list_attachments(Some(entry_id))
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.len() as u64;
|
||||||
|
Ok(Json(UnlinkReceiptOut {
|
||||||
|
ok: true,
|
||||||
|
entry_id,
|
||||||
|
remaining_receipts,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
name = "books.withdraw_attachment",
|
||||||
|
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."
|
||||||
|
)]
|
||||||
|
async fn withdraw_attachment(
|
||||||
|
&self,
|
||||||
|
params: Parameters<WithdrawAttachmentParams>,
|
||||||
|
) -> Result<Json<WithdrawAttachmentOut>, String> {
|
||||||
|
let p = params.0;
|
||||||
|
let mut store = self.lock()?;
|
||||||
|
store
|
||||||
|
.withdraw_attachment(p.attachment_id, &p.reason)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(Json(WithdrawAttachmentOut { ok: true }))
|
||||||
|
}
|
||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
name = "books.list_receipts",
|
name = "books.list_receipts",
|
||||||
description = "List receipts and filed documents, optionally filtered to one entry.",
|
description = "List receipts and filed documents, optionally filtered to one entry. Withdrawn attachments are hidden unless include_withdrawn=true.",
|
||||||
annotations(read_only_hint = true)
|
annotations(read_only_hint = true)
|
||||||
)]
|
)]
|
||||||
async fn list_receipts(
|
async fn list_receipts(
|
||||||
@@ -857,10 +950,12 @@ impl BooksServer {
|
|||||||
) -> Result<Json<ListReceiptsOut>, String> {
|
) -> Result<Json<ListReceiptsOut>, String> {
|
||||||
let p = params.0;
|
let p = params.0;
|
||||||
let store = self.lock()?;
|
let store = self.lock()?;
|
||||||
|
let include_withdrawn = p.include_withdrawn.unwrap_or(false);
|
||||||
let receipts = store
|
let receipts = store
|
||||||
.list_attachments(p.entry_id)
|
.list_attachments(p.entry_id)
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
.filter(|a| include_withdrawn || a.withdrawn_at.is_none())
|
||||||
.map(|a| ReceiptOut {
|
.map(|a| ReceiptOut {
|
||||||
attachment_id: a.id,
|
attachment_id: a.id,
|
||||||
sha256: a.blob_sha,
|
sha256: a.blob_sha,
|
||||||
@@ -870,6 +965,8 @@ impl BooksServer {
|
|||||||
mime_type: a.mime_type,
|
mime_type: a.mime_type,
|
||||||
size_bytes: a.size_bytes,
|
size_bytes: a.size_bytes,
|
||||||
description: a.description,
|
description: a.description,
|
||||||
|
withdrawn: a.withdrawn_at.is_some(),
|
||||||
|
withdrawn_reason: a.withdrawn_reason,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok(Json(ListReceiptsOut { receipts }))
|
Ok(Json(ListReceiptsOut { receipts }))
|
||||||
@@ -1687,6 +1784,9 @@ impl BooksServer {
|
|||||||
}
|
}
|
||||||
.to_tool_error());
|
.to_tool_error());
|
||||||
}
|
}
|
||||||
|
for aid in &attachment_ids {
|
||||||
|
store.check_linkable(*aid).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
let entry_id = store
|
let entry_id = store
|
||||||
.dispose_asset(
|
.dispose_asset(
|
||||||
p.asset_id,
|
p.asset_id,
|
||||||
|
|||||||
@@ -503,11 +503,51 @@ pub struct LinkReceiptOut {
|
|||||||
pub ok: bool,
|
pub ok: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parameters for `books.unlink_receipt`.
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub struct UnlinkReceiptParams {
|
||||||
|
/// Attachment id of the linked receipt.
|
||||||
|
pub attachment_id: u64,
|
||||||
|
/// Why it is being unlinked, recorded in the journal (e.g. "replaced by
|
||||||
|
/// the PDF render, attachment 9").
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of `books.unlink_receipt`.
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub struct UnlinkReceiptOut {
|
||||||
|
/// True when unlinked.
|
||||||
|
pub ok: bool,
|
||||||
|
/// The entry it was unlinked from.
|
||||||
|
pub entry_id: u64,
|
||||||
|
/// Receipts still linked to that entry.
|
||||||
|
pub remaining_receipts: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters for `books.withdraw_attachment`.
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub struct WithdrawAttachmentParams {
|
||||||
|
/// Attachment id of an unlinked receipt or a filed document.
|
||||||
|
pub attachment_id: u64,
|
||||||
|
/// Why it is being withdrawn, recorded in the journal.
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of `books.withdraw_attachment`.
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub struct WithdrawAttachmentOut {
|
||||||
|
/// True when withdrawn.
|
||||||
|
pub ok: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Parameters for `books.list_receipts`.
|
/// Parameters for `books.list_receipts`.
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
pub struct ListReceiptsParams {
|
pub struct ListReceiptsParams {
|
||||||
/// Filter to one entry. Omit for all.
|
/// Filter to one entry. Omit for all.
|
||||||
pub entry_id: Option<u64>,
|
pub entry_id: Option<u64>,
|
||||||
|
/// Include withdrawn attachments. Default false.
|
||||||
|
#[serde(default)]
|
||||||
|
pub include_withdrawn: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One receipt or document.
|
/// One receipt or document.
|
||||||
@@ -529,6 +569,10 @@ pub struct ReceiptOut {
|
|||||||
pub size_bytes: u64,
|
pub size_bytes: u64,
|
||||||
/// Description, if any.
|
/// Description, if any.
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
/// True when withdrawn (only listed with include_withdrawn).
|
||||||
|
pub withdrawn: bool,
|
||||||
|
/// Why it was withdrawn, if it was.
|
||||||
|
pub withdrawn_reason: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of `books.list_receipts`.
|
/// Result of `books.list_receipts`.
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ fn mcp_end_to_end_over_stdio() {
|
|||||||
// Tool listing: 23 narrow tools, chart is read-only.
|
// Tool listing: 23 narrow tools, chart is read-only.
|
||||||
let list = mcp.request("tools/list", json!({}));
|
let list = mcp.request("tools/list", json!({}));
|
||||||
let tools = list["result"]["tools"].as_array().unwrap();
|
let tools = list["result"]["tools"].as_array().unwrap();
|
||||||
assert_eq!(tools.len(), 48, "expected 48 tools, got {}", tools.len());
|
assert_eq!(tools.len(), 50, "expected 50 tools, got {}", tools.len());
|
||||||
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
|
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
|
||||||
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -534,6 +534,141 @@ fn mcp_receipt_policy_rejects_split_entries() {
|
|||||||
assert_eq!(p3["status"], "posted");
|
assert_eq!(p3["status"], "posted");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mcp_receipt_corrections_over_stdio() {
|
||||||
|
let dir = test_dir("receipt-corrections");
|
||||||
|
let mut mcp = Mcp::spawn(&dir);
|
||||||
|
mcp.request(
|
||||||
|
"initialize",
|
||||||
|
json!({
|
||||||
|
"protocolVersion": "2025-06-18",
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mcp.notify("notifications/initialized");
|
||||||
|
for (code, name, typ) in [("1000", "Cash", "asset"), ("6000", "Software", "expense")] {
|
||||||
|
mcp.call(
|
||||||
|
"books.add_account",
|
||||||
|
json!({"code": code, "name": name, "account_type": typ}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let post = |mcp: &mut Mcp, desc: &str, amount: &str, receipt: &[u8]| -> (u64, u64) {
|
||||||
|
let e = mcp.call(
|
||||||
|
"books.draft_entry",
|
||||||
|
json!({"date": "2026-04-01", "description": desc}),
|
||||||
|
);
|
||||||
|
let id = e["entry_id"].as_u64().unwrap();
|
||||||
|
mcp.call(
|
||||||
|
"books.add_line",
|
||||||
|
json!({"entry_id": id, "account": "6000", "side": "debit", "amount": amount}),
|
||||||
|
);
|
||||||
|
mcp.call(
|
||||||
|
"books.add_line",
|
||||||
|
json!({"entry_id": id, "account": "1000", "side": "credit", "amount": amount}),
|
||||||
|
);
|
||||||
|
let att = mcp.call(
|
||||||
|
"books.attach_receipt",
|
||||||
|
json!({"entry_id": id, "file_name": "receipt.eml", "mime_type": "message/rfc822",
|
||||||
|
"content_base64": base64_like(receipt)}),
|
||||||
|
);
|
||||||
|
let posted = mcp.call(
|
||||||
|
"books.post_entry",
|
||||||
|
json!({"entry_id": id, "confirmed": true}),
|
||||||
|
);
|
||||||
|
assert_eq!(posted["status"], "posted");
|
||||||
|
(id, att["attachment_id"].as_u64().unwrap())
|
||||||
|
};
|
||||||
|
|
||||||
|
// A $100 entry whose only receipt is an .eml.
|
||||||
|
let (big, eml) = post(&mut mcp, "Annual license", "100.00", b"eml-bytes");
|
||||||
|
|
||||||
|
// Unlinking its only receipt would break the policy: refused, with the remedy.
|
||||||
|
let err = mcp.call_err(
|
||||||
|
"books.unlink_receipt",
|
||||||
|
json!({"attachment_id": eml, "reason": "replacing with PDF"}),
|
||||||
|
);
|
||||||
|
let err_json: serde_json::Value = serde_json::from_str(&err).unwrap();
|
||||||
|
assert_eq!(err_json["code"], "receipt_required");
|
||||||
|
assert_eq!(err_json["entry_id"], big);
|
||||||
|
assert_eq!(err_json["remedy_tool"], "books.attach_receipt");
|
||||||
|
|
||||||
|
// Replace: attach the PDF first, then unlink the .eml.
|
||||||
|
let pdf = mcp.call(
|
||||||
|
"books.attach_receipt",
|
||||||
|
json!({"entry_id": big, "file_name": "receipt.pdf", "mime_type": "application/pdf",
|
||||||
|
"content_base64": base64_like(b"pdf-bytes")}),
|
||||||
|
)["attachment_id"]
|
||||||
|
.as_u64()
|
||||||
|
.unwrap();
|
||||||
|
let un = mcp.call(
|
||||||
|
"books.unlink_receipt",
|
||||||
|
json!({"attachment_id": eml, "reason": "replaced by the PDF render"}),
|
||||||
|
);
|
||||||
|
assert_eq!(un["entry_id"], big);
|
||||||
|
assert_eq!(un["remaining_receipts"], 1);
|
||||||
|
|
||||||
|
// A linked receipt can't be withdrawn; an unlinked one can, with a reason.
|
||||||
|
let err = mcp.call_err(
|
||||||
|
"books.withdraw_attachment",
|
||||||
|
json!({"attachment_id": pdf, "reason": "x"}),
|
||||||
|
);
|
||||||
|
assert!(err.contains("unlink it first"), "{err}");
|
||||||
|
let err = mcp.call_err(
|
||||||
|
"books.withdraw_attachment",
|
||||||
|
json!({"attachment_id": eml, "reason": " "}),
|
||||||
|
);
|
||||||
|
assert!(err.contains("reason"), "{err}");
|
||||||
|
mcp.call(
|
||||||
|
"books.withdraw_attachment",
|
||||||
|
json!({"attachment_id": eml, "reason": "file PDFs, not .eml"}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Listings hide the withdrawn .eml unless asked; the entry keeps the PDF.
|
||||||
|
let listed = mcp.call("books.list_receipts", json!({}));
|
||||||
|
let ids: Vec<u64> = listed["receipts"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|r| r["attachment_id"].as_u64().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(ids, vec![pdf]);
|
||||||
|
let all = mcp.call("books.list_receipts", json!({"include_withdrawn": true}));
|
||||||
|
let gone = all["receipts"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|r| r["attachment_id"] == eml)
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
assert_eq!(gone["withdrawn"], true);
|
||||||
|
assert_eq!(gone["withdrawn_reason"], "file PDFs, not .eml");
|
||||||
|
assert_eq!(gone["entry_id"], serde_json::Value::Null);
|
||||||
|
let entry = mcp.call("books.get_entry", json!({"entry_id": big}));
|
||||||
|
assert_eq!(entry["receipts"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(entry["receipts"][0]["attachment_id"], pdf);
|
||||||
|
// A withdrawn attachment can never be linked again.
|
||||||
|
let err = mcp.call_err(
|
||||||
|
"books.link_receipt",
|
||||||
|
json!({"attachment_id": eml, "entry_id": big}),
|
||||||
|
);
|
||||||
|
assert!(err.contains("withdrawn"), "{err}");
|
||||||
|
|
||||||
|
// Under the threshold, the last receipt may go.
|
||||||
|
let (small, small_eml) = post(&mut mcp, "Domain renewal", "10.00", b"small-eml");
|
||||||
|
let un = mcp.call(
|
||||||
|
"books.unlink_receipt",
|
||||||
|
json!({"attachment_id": small_eml, "reason": "wrong file"}),
|
||||||
|
);
|
||||||
|
assert_eq!(un["entry_id"], small);
|
||||||
|
assert_eq!(un["remaining_receipts"], 0);
|
||||||
|
|
||||||
|
// The books never moved.
|
||||||
|
let tb = mcp.call("books.trial_balance", json!({}));
|
||||||
|
assert_eq!(tb["balanced"], true);
|
||||||
|
assert_eq!(tb["total_debits"], "110.00");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mcp_resources_over_stdio() {
|
fn mcp_resources_over_stdio() {
|
||||||
let dir = test_dir("resources");
|
let dir = test_dir("resources");
|
||||||
|
|||||||
@@ -41,6 +41,22 @@ pub enum StoreError {
|
|||||||
#[error("unknown attachment {0}")]
|
#[error("unknown attachment {0}")]
|
||||||
UnknownAttachment(u64),
|
UnknownAttachment(u64),
|
||||||
|
|
||||||
|
/// The attachment is not linked to any entry.
|
||||||
|
#[error("attachment {0} is not linked to an entry")]
|
||||||
|
AttachmentNotLinked(u64),
|
||||||
|
|
||||||
|
/// The attachment is linked to an entry; unlink it first.
|
||||||
|
#[error("attachment {0} is linked to entry {1}; unlink it first with books.unlink_receipt")]
|
||||||
|
AttachmentLinked(u64, u64),
|
||||||
|
|
||||||
|
/// The attachment was withdrawn and can no longer be linked or changed.
|
||||||
|
#[error("attachment {0} is withdrawn")]
|
||||||
|
AttachmentWithdrawn(u64),
|
||||||
|
|
||||||
|
/// A correction to the evidence trail needs a reason.
|
||||||
|
#[error("a non-empty reason is required")]
|
||||||
|
ReasonRequired,
|
||||||
|
|
||||||
/// The store's schema is newer than this binary understands.
|
/// The store's schema is newer than this binary understands.
|
||||||
#[error("schema version {0} newer than supported {1}")]
|
#[error("schema version {0} newer than supported {1}")]
|
||||||
SchemaTooNew(u32, u32),
|
SchemaTooNew(u32, u32),
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ pub struct JournalLine {
|
|||||||
pub ts: String,
|
pub ts: String,
|
||||||
/// Event type: `account.added`, `entry.created`, `entry.line_added`,
|
/// Event type: `account.added`, `entry.created`, `entry.line_added`,
|
||||||
/// `entry.posted`, `entry.reversed`, `entry.line_class_set`,
|
/// `entry.posted`, `entry.reversed`, `entry.line_class_set`,
|
||||||
/// `receipt.attached`, `receipt.linked`, `document.filed`,
|
/// `receipt.attached`, `receipt.linked`, `receipt.unlinked`,
|
||||||
|
/// `document.filed`, `attachment.withdrawn`,
|
||||||
/// `asset.registered`, `asset.depreciation_posted`, `asset.disposed`,
|
/// `asset.registered`, `asset.depreciation_posted`, `asset.disposed`,
|
||||||
/// `period.created`, `period.closed`, `line.cleared`, `customer.added`,
|
/// `period.created`, `period.closed`, `line.cleared`, `customer.added`,
|
||||||
/// `vendor.added`, `invoice.created`, `bill.created`,
|
/// `vendor.added`, `invoice.created`, `bill.created`,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! is the current version.
|
//! is the current version.
|
||||||
|
|
||||||
/// Current schema version.
|
/// Current schema version.
|
||||||
pub const SCHEMA_VERSION: u32 = 4;
|
pub const SCHEMA_VERSION: u32 = 5;
|
||||||
|
|
||||||
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
|
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
|
||||||
pub const MIGRATIONS: &[&str] = &[
|
pub const MIGRATIONS: &[&str] = &[
|
||||||
@@ -172,5 +172,12 @@ CREATE TABLE IF NOT EXISTS payroll_lines(
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_payroll_lines_run ON payroll_lines(run_id);
|
CREATE INDEX IF NOT EXISTS idx_payroll_lines_run ON payroll_lines(run_id);
|
||||||
ALTER TABLE lines ADD COLUMN class_id INTEGER;
|
ALTER TABLE lines ADD COLUMN class_id INTEGER;
|
||||||
|
"#,
|
||||||
|
r#"
|
||||||
|
-- v5: withdrawn attachments. Withdrawal retires a receipt or document from
|
||||||
|
-- listings and linking; the bytes stay in the blob store and the journal
|
||||||
|
-- keeps the filing and the withdrawal.
|
||||||
|
ALTER TABLE attachments ADD COLUMN withdrawn_at TEXT;
|
||||||
|
ALTER TABLE attachments ADD COLUMN withdrawn_reason TEXT;
|
||||||
"#,
|
"#,
|
||||||
];
|
];
|
||||||
@@ -115,6 +115,10 @@ pub struct Attachment {
|
|||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
/// UTC timestamp when filed.
|
/// UTC timestamp when filed.
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
|
/// UTC timestamp when withdrawn, if it was.
|
||||||
|
pub withdrawn_at: Option<String>,
|
||||||
|
/// Why it was withdrawn, if it was.
|
||||||
|
pub withdrawn_reason: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Input to [`Store::run_payroll`]: one employee's gross pay and every
|
/// Input to [`Store::run_payroll`]: one employee's gross pay and every
|
||||||
@@ -1757,7 +1761,10 @@ impl Store {
|
|||||||
Ok((id, sha, dedup))
|
Ok((id, sha, dedup))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Link a previously filed receipt to an entry.
|
/// Link a previously filed (unlinked) receipt to an entry. A receipt
|
||||||
|
/// already linked elsewhere must be unlinked first, so moving evidence
|
||||||
|
/// off an entry always goes through [`Store::unlink_receipt`] and its
|
||||||
|
/// recorded reason.
|
||||||
pub fn link_receipt(
|
pub fn link_receipt(
|
||||||
&mut self,
|
&mut self,
|
||||||
attachment_id: u64,
|
attachment_id: u64,
|
||||||
@@ -1766,14 +1773,7 @@ impl Store {
|
|||||||
self.ledger
|
self.ledger
|
||||||
.get_entry(entry_id)
|
.get_entry(entry_id)
|
||||||
.ok_or(LedgerError::UnknownEntry(entry_id))?;
|
.ok_or(LedgerError::UnknownEntry(entry_id))?;
|
||||||
let exists: i64 = self.conn.query_row(
|
self.check_linkable(attachment_id)?;
|
||||||
"SELECT COUNT(*) FROM attachments WHERE id = ?",
|
|
||||||
params![attachment_id as i64],
|
|
||||||
|r| r.get(0),
|
|
||||||
)?;
|
|
||||||
if exists == 0 {
|
|
||||||
return Err(StoreError::UnknownAttachment(attachment_id));
|
|
||||||
}
|
|
||||||
let line = self.append_journal(
|
let line = self.append_journal(
|
||||||
"receipt.linked",
|
"receipt.linked",
|
||||||
json!({ "attachment_id": attachment_id, "entry_id": entry_id }),
|
json!({ "attachment_id": attachment_id, "entry_id": entry_id }),
|
||||||
@@ -1782,6 +1782,89 @@ impl Store {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fail unless the attachment exists, is unlinked, and is not
|
||||||
|
/// withdrawn: the precondition of [`Store::link_receipt`], checkable
|
||||||
|
/// before posting anything that will link it.
|
||||||
|
pub fn check_linkable(&self, attachment_id: u64) -> Result<(), StoreError> {
|
||||||
|
let (entry_id, withdrawn) = self.attachment_state(attachment_id)?;
|
||||||
|
if withdrawn {
|
||||||
|
return Err(StoreError::AttachmentWithdrawn(attachment_id));
|
||||||
|
}
|
||||||
|
if let Some(eid) = entry_id {
|
||||||
|
return Err(StoreError::AttachmentLinked(attachment_id, eid));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detach a receipt from its entry, recording why. The attachment stays
|
||||||
|
/// filed (unlinked) and can be linked again or withdrawn. The journal
|
||||||
|
/// keeps both the link and the unlink: the evidence history is
|
||||||
|
/// append-only even when the current evidence set changes. Returns the
|
||||||
|
/// entry it was linked to.
|
||||||
|
///
|
||||||
|
/// Policy-free like the rest of the store: whether an entry may be left
|
||||||
|
/// without a receipt is the MCP layer's call.
|
||||||
|
pub fn unlink_receipt(
|
||||||
|
&mut self,
|
||||||
|
attachment_id: u64,
|
||||||
|
reason: &str,
|
||||||
|
) -> Result<EntryId, StoreError> {
|
||||||
|
let reason = reason.trim();
|
||||||
|
if reason.is_empty() {
|
||||||
|
return Err(StoreError::ReasonRequired);
|
||||||
|
}
|
||||||
|
let (entry_id, withdrawn) = self.attachment_state(attachment_id)?;
|
||||||
|
if withdrawn {
|
||||||
|
return Err(StoreError::AttachmentWithdrawn(attachment_id));
|
||||||
|
}
|
||||||
|
let entry_id = entry_id.ok_or(StoreError::AttachmentNotLinked(attachment_id))?;
|
||||||
|
let line = self.append_journal(
|
||||||
|
"receipt.unlinked",
|
||||||
|
json!({ "attachment_id": attachment_id, "entry_id": entry_id, "reason": reason }),
|
||||||
|
)?;
|
||||||
|
apply_line_to_sqlite(&self.conn, &line)?;
|
||||||
|
Ok(entry_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Withdraw a receipt or document, recording why: it drops out of
|
||||||
|
/// listings 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. A linked receipt must be unlinked first.
|
||||||
|
pub fn withdraw_attachment(
|
||||||
|
&mut self,
|
||||||
|
attachment_id: u64,
|
||||||
|
reason: &str,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
let reason = reason.trim();
|
||||||
|
if reason.is_empty() {
|
||||||
|
return Err(StoreError::ReasonRequired);
|
||||||
|
}
|
||||||
|
self.check_linkable(attachment_id)?;
|
||||||
|
let line = self.append_journal(
|
||||||
|
"attachment.withdrawn",
|
||||||
|
json!({ "attachment_id": attachment_id, "reason": reason }),
|
||||||
|
)?;
|
||||||
|
apply_line_to_sqlite(&self.conn, &line)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `(linked entry, withdrawn?)` for an attachment.
|
||||||
|
fn attachment_state(&self, attachment_id: u64) -> Result<(Option<EntryId>, bool), StoreError> {
|
||||||
|
self.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT entry_id, withdrawn_at IS NOT NULL FROM attachments WHERE id = ?",
|
||||||
|
params![attachment_id as i64],
|
||||||
|
|r| {
|
||||||
|
Ok((
|
||||||
|
r.get::<_, Option<i64>>(0)?.map(|v| v as EntryId),
|
||||||
|
r.get::<_, bool>(1)?,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.ok_or(StoreError::UnknownAttachment(attachment_id))
|
||||||
|
}
|
||||||
|
|
||||||
/// List attachments, optionally filtered to one entry.
|
/// List attachments, optionally filtered to one entry.
|
||||||
pub fn list_attachments(
|
pub fn list_attachments(
|
||||||
&self,
|
&self,
|
||||||
@@ -1789,7 +1872,8 @@ impl Store {
|
|||||||
) -> Result<Vec<Attachment>, StoreError> {
|
) -> Result<Vec<Attachment>, StoreError> {
|
||||||
let mut sql = String::from(
|
let mut sql = String::from(
|
||||||
"SELECT a.id, a.blob_sha, a.entry_id, a.category, b.original_name, b.mime_type, \
|
"SELECT a.id, a.blob_sha, a.entry_id, a.category, b.original_name, b.mime_type, \
|
||||||
b.size_bytes, a.description, a.created_at FROM attachments a \
|
b.size_bytes, a.description, a.created_at, a.withdrawn_at, a.withdrawn_reason \
|
||||||
|
FROM attachments a \
|
||||||
JOIN blobs b ON b.sha256 = a.blob_sha",
|
JOIN blobs b ON b.sha256 = a.blob_sha",
|
||||||
);
|
);
|
||||||
if entry_id.is_some() {
|
if entry_id.is_some() {
|
||||||
@@ -2594,7 +2678,11 @@ fn replay_line_into_ledger(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"receipt.attached" | "receipt.linked" | "document.filed" => {
|
"receipt.attached"
|
||||||
|
| "receipt.linked"
|
||||||
|
| "receipt.unlinked"
|
||||||
|
| "document.filed"
|
||||||
|
| "attachment.withdrawn" => {
|
||||||
// Not accounting state; the projection records them.
|
// Not accounting state; the projection records them.
|
||||||
}
|
}
|
||||||
"asset.registered" => {
|
"asset.registered" => {
|
||||||
@@ -2923,6 +3011,22 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
|
|||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
"receipt.unlinked" => {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE attachments SET entry_id = NULL WHERE id = ?",
|
||||||
|
params![get_u64(p, "attachment_id")? as i64],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
"attachment.withdrawn" => {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE attachments SET withdrawn_at = ?, withdrawn_reason = ? WHERE id = ?",
|
||||||
|
params![
|
||||||
|
line.ts,
|
||||||
|
get_str(p, "reason")?,
|
||||||
|
get_u64(p, "attachment_id")? as i64,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
"asset.registered" => {
|
"asset.registered" => {
|
||||||
let cost = &p["cost"];
|
let cost = &p["cost"];
|
||||||
let salvage = &p["salvage_value"];
|
let salvage = &p["salvage_value"];
|
||||||
@@ -3184,5 +3288,7 @@ fn row_to_attachment(r: &rusqlite::Row) -> rusqlite::Result<Attachment> {
|
|||||||
size_bytes: r.get::<_, i64>(6)? as u64,
|
size_bytes: r.get::<_, i64>(6)? as u64,
|
||||||
description: r.get(7)?,
|
description: r.get(7)?,
|
||||||
created_at: r.get(8)?,
|
created_at: r.get(8)?,
|
||||||
|
withdrawn_at: r.get(9)?,
|
||||||
|
withdrawn_reason: r.get(10)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use ledger_core::{AccountType, Currency, Money, Side};
|
use ledger_core::{AccountType, Currency, Money, Side};
|
||||||
use ledger_store::{JournalWriter, Store};
|
use ledger_store::{JournalWriter, Store, StoreError};
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
@@ -347,3 +347,113 @@ fn corrupt_projection_is_quarantined_and_rebuilt() {
|
|||||||
assert_eq!(tb.total_debits, Decimal::from(14000));
|
assert_eq!(tb.total_debits, Decimal::from(14000));
|
||||||
store.verify().unwrap();
|
store.verify().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn receipt_corrections_are_journaled_and_survive_rebuild() {
|
||||||
|
let dir = test_dir("receipt-corrections");
|
||||||
|
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
||||||
|
let cash = store
|
||||||
|
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let rent = store
|
||||||
|
.add_account("6000", "Rent Expense", AccountType::Expense, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let e = store
|
||||||
|
.create_entry(
|
||||||
|
NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
|
||||||
|
"February rent",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
store.add_line(e, rent, Side::Debit, usd(800)).unwrap();
|
||||||
|
store.add_line(e, cash, Side::Credit, usd(800)).unwrap();
|
||||||
|
store.post_entry(e).unwrap();
|
||||||
|
let (eml, _, _) = store
|
||||||
|
.attach_receipt(Some(e), "rent.eml", "message/rfc822", b"eml-bytes", None)
|
||||||
|
.unwrap();
|
||||||
|
let (pdf, _, _) = store
|
||||||
|
.attach_receipt(Some(e), "rent.pdf", "application/pdf", b"pdf-bytes", None)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Every correction needs a reason.
|
||||||
|
assert!(matches!(
|
||||||
|
store.unlink_receipt(eml, " "),
|
||||||
|
Err(StoreError::ReasonRequired)
|
||||||
|
));
|
||||||
|
// A linked receipt can't be withdrawn or moved: unlink it first.
|
||||||
|
assert!(matches!(
|
||||||
|
store.withdraw_attachment(eml, "dup"),
|
||||||
|
Err(StoreError::AttachmentLinked(a, b)) if a == eml && b == e
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
store.link_receipt(pdf, e),
|
||||||
|
Err(StoreError::AttachmentLinked(_, _))
|
||||||
|
));
|
||||||
|
|
||||||
|
// Swap the .eml for the PDF: unlink, then withdraw.
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.unlink_receipt(eml, "replaced by the PDF render")
|
||||||
|
.unwrap(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
store.unlink_receipt(eml, "again"),
|
||||||
|
Err(StoreError::AttachmentNotLinked(_))
|
||||||
|
));
|
||||||
|
store
|
||||||
|
.withdraw_attachment(eml, "file PDFs, not .eml")
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
store.link_receipt(eml, e),
|
||||||
|
Err(StoreError::AttachmentWithdrawn(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
store.withdraw_attachment(eml, "again"),
|
||||||
|
Err(StoreError::AttachmentWithdrawn(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
store.unlink_receipt(999, "x"),
|
||||||
|
Err(StoreError::UnknownAttachment(999))
|
||||||
|
));
|
||||||
|
|
||||||
|
let check = |store: &Store| {
|
||||||
|
let linked: Vec<u64> = store
|
||||||
|
.list_attachments(Some(e))
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|a| a.id)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(linked, vec![pdf]);
|
||||||
|
let all = store.list_attachments(None).unwrap();
|
||||||
|
let gone = all.iter().find(|a| a.id == eml).unwrap();
|
||||||
|
assert_eq!(gone.entry_id, None);
|
||||||
|
assert!(gone.withdrawn_at.is_some());
|
||||||
|
assert_eq!(
|
||||||
|
gone.withdrawn_reason.as_deref(),
|
||||||
|
Some("file PDFs, not .eml")
|
||||||
|
);
|
||||||
|
// Withdrawn is not deleted: the bytes are still there, hash-verified.
|
||||||
|
assert_eq!(store.attachment_bytes(eml).unwrap(), b"eml-bytes");
|
||||||
|
};
|
||||||
|
check(&store);
|
||||||
|
|
||||||
|
// The journal is the history: the attach, the unlink, and the withdrawal.
|
||||||
|
let journal = std::fs::read_to_string(dir.join("journal.jsonl")).unwrap();
|
||||||
|
for t in [
|
||||||
|
"receipt.attached",
|
||||||
|
"receipt.unlinked",
|
||||||
|
"attachment.withdrawn",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
journal.contains(&format!("\"type\":\"{t}\"")),
|
||||||
|
"missing {t}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(store);
|
||||||
|
let mut store = Store::open(&dir).unwrap();
|
||||||
|
check(&store);
|
||||||
|
store.rebuild().unwrap();
|
||||||
|
check(&store);
|
||||||
|
store.verify().unwrap();
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ Statuses: `covered` (built and tested), `partial` (built, known gaps listed),
|
|||||||
| 4 | Live Balance Sheet tab | engine + `books.balance_sheet` | covered |
|
| 4 | Live Balance Sheet tab | engine + `books.balance_sheet` | covered |
|
||||||
| 5 | Live Cash Flow tab | engine `cash_flow` (indirect) + `books.cash_flow` | covered |
|
| 5 | Live Cash Flow tab | engine `cash_flow` (indirect) + `books.cash_flow` | covered |
|
||||||
| 6 | Transaction Journal as append-only log | engine posting log + `books.search_entries` | covered |
|
| 6 | Transaction Journal as append-only log | engine posting log + `books.search_entries` | covered |
|
||||||
| 7 | Receipt storage in Drive | blob store + `books.attach_receipt` / `link_receipt` | covered |
|
| 7 | Receipt storage in Drive | blob store + `books.attach_receipt` / `link_receipt` / `unlink_receipt` / `withdraw_attachment` | covered |
|
||||||
| 8 | Document filing by category (01 Formation / 02 Legal / 03 Tax / 04 Accounting) | `books.file_document` | covered |
|
| 8 | Document filing by category (01 Formation / 02 Legal / 03 Tax / 04 Accounting) | `books.file_document` | covered |
|
||||||
| 9 | Ad-hoc questions ("what did we spend on X?") | `search_entries` + reports; the AI itself is the query layer | covered |
|
| 9 | Ad-hoc questions ("what did we spend on X?") | `search_entries` + reports; the AI itself is the query layer | covered |
|
||||||
| 10 | Human review of what the assistant recorded | `books.digest` | covered |
|
| 10 | Human review of what the assistant recorded | `books.digest` | covered |
|
||||||
|
|||||||
+16
-2
@@ -117,8 +117,22 @@ so the lifetime total is exactly (cost − salvage).
|
|||||||
→ `{ receipt_id, sha256, deduplicated, size_bytes }`. `entry_id` is
|
→ `{ receipt_id, sha256, deduplicated, size_bytes }`. `entry_id` is
|
||||||
optional: a receipt can be filed before its entry exists.
|
optional: a receipt can be filed before its entry exists.
|
||||||
- `books.link_receipt { receipt_id, entry_id }` → link a previously filed
|
- `books.link_receipt { receipt_id, entry_id }` → link a previously filed
|
||||||
receipt to its entry.
|
(unlinked, not withdrawn) receipt to its entry. A receipt linked elsewhere
|
||||||
- `books.list_receipts { entry_id? }` → metadata list (no bytes).
|
must be unlinked first.
|
||||||
|
- `books.unlink_receipt { attachment_id, reason }` → `{ ok, entry_id,
|
||||||
|
remaining_receipts }`. Detaches a receipt from its entry; it stays filed,
|
||||||
|
unlinked. Journaled as `receipt.unlinked` with the reason. Subject to the
|
||||||
|
receipt policy: an entry that requires a receipt cannot be left with none
|
||||||
|
(`receipt_required` error, remedy `books.attach_receipt`), so replacing a
|
||||||
|
receipt is attach-new, then unlink-old.
|
||||||
|
- `books.withdraw_attachment { attachment_id, reason }` → `{ ok }`. Retires
|
||||||
|
an unlinked receipt or a filed document: hidden from listings, never
|
||||||
|
linkable again. Journaled as `attachment.withdrawn`; the bytes stay in the
|
||||||
|
blob store. Nothing in the evidence trail is ever erased — receipts change
|
||||||
|
by new events, the same way entries change by reversals.
|
||||||
|
- `books.list_receipts { entry_id?, include_withdrawn? }` → metadata list
|
||||||
|
(no bytes), each with `withdrawn` / `withdrawn_reason`. Withdrawn
|
||||||
|
attachments are omitted unless `include_withdrawn` is true.
|
||||||
- `books.get_receipt { receipt_id }` → `{ metadata, content_base64 }`.
|
- `books.get_receipt { receipt_id }` → `{ metadata, content_base64 }`.
|
||||||
- `books.file_document { category: "formation" | "legal" | "tax" |
|
- `books.file_document { category: "formation" | "legal" | "tax" |
|
||||||
"accounting" | "other", file_name, mime_type, content_base64,
|
"accounting" | "other", file_name, mime_type, content_base64,
|
||||||
|
|||||||
+11
-1
@@ -50,7 +50,9 @@ apply is safe — on startup the applier replays every journal line with
|
|||||||
- `seq`: 1-based, gapless, assigned by the writer.
|
- `seq`: 1-based, gapless, assigned by the writer.
|
||||||
- `ts`: UTC, RFC 3339. Recorded, never trusted for ordering (`seq` orders).
|
- `ts`: UTC, RFC 3339. Recorded, never trusted for ordering (`seq` orders).
|
||||||
- `type`: `account.added`, `entry.posted`, `entry.reversed`,
|
- `type`: `account.added`, `entry.posted`, `entry.reversed`,
|
||||||
`receipt.attached`, `receipt.linked`, `document.filed`.
|
`receipt.attached`, `receipt.linked`, `receipt.unlinked`,
|
||||||
|
`document.filed`, `attachment.withdrawn` (plus the A/R, A/P, asset,
|
||||||
|
period, payroll, and class events listed on `JournalLine`).
|
||||||
- `payload`: the full domain facts (account fields, entry with lines, blob
|
- `payload`: the full domain facts (account fields, entry with lines, blob
|
||||||
sha, ...). Everything the applier needs; nothing it must look up elsewhere.
|
sha, ...). Everything the applier needs; nothing it must look up elsewhere.
|
||||||
- `prev`: hex SHA-256 of the previous line's canonical JSON (keys sorted, no
|
- `prev`: hex SHA-256 of the previous line's canonical JSON (keys sorted, no
|
||||||
@@ -99,6 +101,14 @@ before backups.
|
|||||||
plus `receipts`.
|
plus `receipts`.
|
||||||
- Documents filed via `books.file_document` and receipts via
|
- Documents filed via `books.file_document` and receipts via
|
||||||
`books.attach_receipt` share the blob store; they differ only in metadata.
|
`books.attach_receipt` share the blob store; they differ only in metadata.
|
||||||
|
- **Receipts change by events, never by edits.** `receipt.unlinked`
|
||||||
|
(`attachment_id`, `entry_id`, `reason`) detaches a receipt from its entry;
|
||||||
|
`attachment.withdrawn` (`attachment_id`, `reason`) retires an unlinked
|
||||||
|
receipt or a document (`withdrawn_at`, `withdrawn_reason` columns, schema
|
||||||
|
v5). Linking requires an unlinked, non-withdrawn attachment, so evidence
|
||||||
|
only leaves an entry through a recorded unlink. Blobs are untouched: a
|
||||||
|
withdrawn file is still hash-verified and readable, and the journal shows
|
||||||
|
when it was filed, linked, unlinked, and withdrawn, and why.
|
||||||
- The store is policy-free: it records attachments faithfully. Whether a
|
- The store is policy-free: it records attachments faithfully. Whether a
|
||||||
posting *requires* a receipt is decided by the MCP layer's receipt policy
|
posting *requires* a receipt is decided by the MCP layer's receipt policy
|
||||||
(`docs/mcp-interface.md`), which reads the `attachments` table.
|
(`docs/mcp-interface.md`), which reads the `attachments` table.
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ requirement conflict, the requirement wins.
|
|||||||
|
|
||||||
## Receipt policy (MCP phase)
|
## Receipt policy (MCP phase)
|
||||||
|
|
||||||
|
- **R-13a — Receipt corrections.** Posted entries are immutable (R-3); their
|
||||||
|
evidence is not frozen but is append-only. A receipt is replaced by
|
||||||
|
attaching the new one and unlinking the old with a reason; unwanted
|
||||||
|
receipts and documents are withdrawn with a reason. Unlinking may never
|
||||||
|
leave an entry the policy (R-13) requires a receipt for with none. Every
|
||||||
|
step is a journal event; no blob is deleted.
|
||||||
- **R-13 — Receipt enforcement.** The MCP layer (not the engine — the engine
|
- **R-13 — Receipt enforcement.** The MCP layer (not the engine — the engine
|
||||||
stays policy-free) enforces a configurable receipt policy: postings at or
|
stays policy-free) enforces a configurable receipt policy: postings at or
|
||||||
above a threshold (`require_receipts_above`, default `"25.00"` in base
|
above a threshold (`require_receipts_above`, default `"25.00"` in base
|
||||||
|
|||||||
@@ -151,3 +151,13 @@ interface is MCP, not a cloned GUI. The first storage + MCP slice is done
|
|||||||
(QBO/CSV/email/sheets, conversational). 5 integration tests green; clippy
|
(QBO/CSV/email/sheets, conversational). 5 integration tests green; clippy
|
||||||
clean. Bootstrap: `mise trust && mise install`, `mise run build`,
|
clean. Bootstrap: `mise trust && mise install`, `mise run build`,
|
||||||
`mise run mcp-add`, then `claude` in the dir.
|
`mise run mcp-add`, then `claude` in the dir.
|
||||||
|
- 2026-10-11: Receipts are mutable through events, never edits (Stephen:
|
||||||
|
"the journal is just a history of every single thing that happens").
|
||||||
|
`books.unlink_receipt` and `books.withdraw_attachment` (both require a
|
||||||
|
reason) journal `receipt.unlinked` / `attachment.withdrawn`; nothing is
|
||||||
|
erased from the journal or blob store. Unlinking can't strand an entry the
|
||||||
|
receipt policy requires evidence for (attach the replacement first).
|
||||||
|
`link_receipt` now refuses an already-linked or withdrawn attachment, which
|
||||||
|
closes a silent path for moving a receipt off a posted entry; disposals
|
||||||
|
check their receipt ids before posting. Schema v5. First real use: swapping
|
||||||
|
the migrated Wanderland Labs `.eml` receipts for their PDF renders.
|
||||||
Reference in new issue
Block a user