Attachments, statements, reconciliations, TIN vault, counterparty policy
MCP: books.link_attachment, unlink_attachment, attachments; link_to on attach_receipt and file_document; record_statement, statements; books.reconcile previews and, confirmed against a recorded statement, records the reconciliation; void_reconciliation, reconciliations; import_bank_csv links its source file; digest lists unreconciled accounts. TIN vault: books.set_w9 takes the TIN and the form into the encrypted vault (last four in the books); books.vendor_1099 include_tins and save_csv_to for the CPA's copy; books.get_w9; books.purge_tin. A leak test scans every tool output, the journal, the database, and the blobs. [books] parties = off | suggest | require (default suggest): every preview names the entry's party or suggests likely ones; require refuses to post until it names one or is marked as having none (entry.no_party). Viewer: documents on every subject page; statements and reconciliation history per account; reconciliation pages; reconcile and no-counterparty attention items; party money by period (paid out, received in, net, what for, by month); Needs attention gets its own full-width section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
1 parent
a299077991
commit
08ce558ed3
41 files changed
+8463
-465
No files matched your search
@@ -18,9 +18,12 @@ use crate::blob::BlobStore;
|
||||
use crate::error::StoreError;
|
||||
use crate::journal::{GENESIS_PREV, JournalLine, sha256_hex};
|
||||
use crate::store::{
|
||||
Attachment, BLOBS_DIR, DB_FILE, JOURNAL_FILE, Store, apply_line_to_sqlite, compare_projections,
|
||||
projection_rows, query_attachments, read_attachment_bytes, replay_line_into_ledger,
|
||||
Attachment, BLOBS_DIR, DB_FILE, JOURNAL_FILE, Reconciliation, Statement, Store, Subject,
|
||||
apply_line_to_sqlite, compare_projections, projection_rows, query_attachment_links,
|
||||
query_attachments, query_reconciliations, query_statements, read_attachment_bytes,
|
||||
replay_line_into_ledger,
|
||||
};
|
||||
use ledger_core::AccountId;
|
||||
use ledger_core::{Currency, EntryId, Ledger};
|
||||
use rusqlite::Connection;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
@@ -216,6 +219,28 @@ impl Snapshot {
|
||||
query_attachments(&self.conn, entry_id)
|
||||
}
|
||||
|
||||
/// Every attachment link beyond a receipt's filing entry, as
|
||||
/// (attachment id, subject), by attachment then subject: what
|
||||
/// [`Store::attachment_links`] reads, for every attachment at once.
|
||||
pub fn attachment_links(&self) -> Result<Vec<(u64, Subject)>, StoreError> {
|
||||
query_attachment_links(&self.conn)
|
||||
}
|
||||
|
||||
/// Recorded bank and card statements, optionally one account's, by
|
||||
/// period end (as [`Store::statements`]).
|
||||
pub fn statements(&self, account: Option<AccountId>) -> Result<Vec<Statement>, StoreError> {
|
||||
query_statements(&self.conn, account)
|
||||
}
|
||||
|
||||
/// Reconciliations, voided ones included, optionally one account's,
|
||||
/// by date (as [`Store::reconciliations`]).
|
||||
pub fn reconciliations(
|
||||
&self,
|
||||
account: Option<AccountId>,
|
||||
) -> Result<Vec<Reconciliation>, StoreError> {
|
||||
query_reconciliations(&self.conn, account)
|
||||
}
|
||||
|
||||
/// Compare the writer's SQLite database (opened read-only) with this
|
||||
/// snapshot's own projection of the journal, every table, row for row.
|
||||
///
|
||||
|
||||
@@ -821,6 +821,20 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark an entry as having no counterparty, with why
|
||||
/// (`Ledger::set_entry_no_party`). Journaled as `entry.no_party
|
||||
/// {entry_id, reason}`.
|
||||
pub fn set_entry_no_party(&mut self, entry: EntryId, reason: &str) -> Result<(), StoreError> {
|
||||
self.ledger.check_set_entry_no_party(entry, reason)?;
|
||||
let line = self.append_journal(
|
||||
"entry.no_party",
|
||||
json!({"entry_id": entry, "reason": reason.trim()}),
|
||||
)?;
|
||||
self.ledger.set_entry_no_party(entry, reason)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Correct a registered asset's cost, salvage, life, or in-service date
|
||||
/// (see `Ledger::correct_asset`), with the reason. Refused once any
|
||||
/// depreciation for it is posted. Journaled as `asset.corrected`, with
|
||||
@@ -2546,8 +2560,9 @@ impl Store {
|
||||
// Attachment links, statements, reconciliations
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Whether `subject` exists in the books.
|
||||
fn subject_exists(&self, subject: Subject) -> Result<bool, StoreError> {
|
||||
/// Whether `subject` exists in the books (what [`Store::link_attachment`]
|
||||
/// checks, so callers can validate several subjects before linking any).
|
||||
pub fn subject_exists(&self, subject: Subject) -> Result<bool, StoreError> {
|
||||
let l = &self.ledger;
|
||||
Ok(match subject {
|
||||
Subject::Entry(i) => l.get_entry(i).is_some(),
|
||||
@@ -2776,39 +2791,7 @@ impl Store {
|
||||
|
||||
/// Recorded statements, optionally one account's, by period end.
|
||||
pub fn statements(&self, account: Option<AccountId>) -> Result<Vec<Statement>, StoreError> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT attachment_id, account_id, period_start, period_end, opening_balance, closing_balance FROM statements ORDER BY period_end, attachment_id",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, i64>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
r.get::<_, String>(4)?,
|
||||
r.get::<_, String>(5)?,
|
||||
))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (att, acct, start, end, open, close) = row?;
|
||||
if account.is_some_and(|a| a != acct as u64) {
|
||||
continue;
|
||||
}
|
||||
let dec = |t: &str| {
|
||||
t.parse::<Decimal>()
|
||||
.map_err(|_| StoreError::JournalCorrupt(format!("statement amount {t}")))
|
||||
};
|
||||
out.push(Statement {
|
||||
attachment_id: att as u64,
|
||||
account_id: acct as u64,
|
||||
period_start: parse_date(&start)?,
|
||||
period_end: parse_date(&end)?,
|
||||
opening_balance: dec(&open)?,
|
||||
closing_balance: dec(&close)?,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
query_statements(&self.conn, account)
|
||||
}
|
||||
|
||||
/// Reconcile an account to a recorded statement: the books' cleared
|
||||
@@ -2936,47 +2919,7 @@ impl Store {
|
||||
&self,
|
||||
account: Option<AccountId>,
|
||||
) -> Result<Vec<Reconciliation>, StoreError> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, account_id, statement_date, statement_balance, attachment_id, voided_reason FROM reconciliations ORDER BY statement_date, id",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, i64>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
r.get::<_, i64>(4)?,
|
||||
r.get::<_, Option<String>>(5)?,
|
||||
))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (id, acct, date, bal, att, voided) = row?;
|
||||
if account.is_some_and(|a| a != acct as u64) {
|
||||
continue;
|
||||
}
|
||||
let cleared: Vec<(EntryId, usize)> = self
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT entry_id, line_index FROM reconciliation_lines WHERE reconciliation_id = ? ORDER BY entry_id, line_index",
|
||||
)?
|
||||
.query_map(params![id], |r| {
|
||||
Ok((r.get::<_, i64>(0)? as u64, r.get::<_, i64>(1)? as usize))
|
||||
})?
|
||||
.collect::<Result<_, _>>()?;
|
||||
out.push(Reconciliation {
|
||||
id: id as u64,
|
||||
account_id: acct as u64,
|
||||
statement_date: parse_date(&date)?,
|
||||
statement_balance: bal.parse().map_err(|_| {
|
||||
StoreError::JournalCorrupt(format!("reconciliation balance {bal}"))
|
||||
})?,
|
||||
attachment_id: att as u64,
|
||||
cleared,
|
||||
voided,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
query_reconciliations(&self.conn, account)
|
||||
}
|
||||
|
||||
/// `(linked entry, withdrawn?)` for an attachment.
|
||||
@@ -4138,6 +4081,9 @@ pub(crate) fn replay_line_into_ledger(
|
||||
};
|
||||
ledger.set_entry_party(get_u64(p, "entry_id")?, party)?;
|
||||
}
|
||||
"entry.no_party" => {
|
||||
ledger.set_entry_no_party(get_u64(p, "entry_id")?, get_str(p, "reason")?)?;
|
||||
}
|
||||
"asset.corrected" => {
|
||||
ledger.correct_asset(
|
||||
get_u64(p, "asset_id")?,
|
||||
@@ -4710,7 +4656,7 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
||||
)?;
|
||||
}
|
||||
"role.assigned" | "party.added" | "party.updated" | "party.role_set" | "party.w9_set"
|
||||
| "party.w4_set" | "entry.party_set" => {
|
||||
| "party.w4_set" | "entry.party_set" | "entry.no_party" => {
|
||||
// Control roles and the party directory live in the ledger
|
||||
// (rebuilt by replay); the events mirror below keeps the
|
||||
// history.
|
||||
@@ -5328,6 +5274,120 @@ pub struct Reconciliation {
|
||||
pub voided: Option<String>,
|
||||
}
|
||||
|
||||
/// Recorded statements in the projection, optionally one account's, by
|
||||
/// period end (shared by [`Store::statements`] and the read-only
|
||||
/// snapshot).
|
||||
pub(crate) fn query_statements(
|
||||
conn: &Connection,
|
||||
account: Option<AccountId>,
|
||||
) -> Result<Vec<Statement>, StoreError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT attachment_id, account_id, period_start, period_end, opening_balance, closing_balance FROM statements ORDER BY period_end, attachment_id",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, i64>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
r.get::<_, String>(4)?,
|
||||
r.get::<_, String>(5)?,
|
||||
))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (att, acct, start, end, open, close) = row?;
|
||||
if account.is_some_and(|a| a != acct as u64) {
|
||||
continue;
|
||||
}
|
||||
let dec = |t: &str| {
|
||||
t.parse::<Decimal>()
|
||||
.map_err(|_| StoreError::JournalCorrupt(format!("statement amount {t}")))
|
||||
};
|
||||
out.push(Statement {
|
||||
attachment_id: att as u64,
|
||||
account_id: acct as u64,
|
||||
period_start: parse_date(&start)?,
|
||||
period_end: parse_date(&end)?,
|
||||
opening_balance: dec(&open)?,
|
||||
closing_balance: dec(&close)?,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Reconciliations in the projection, optionally one account's, by date
|
||||
/// (shared by [`Store::reconciliations`] and the read-only snapshot).
|
||||
pub(crate) fn query_reconciliations(
|
||||
conn: &Connection,
|
||||
account: Option<AccountId>,
|
||||
) -> Result<Vec<Reconciliation>, StoreError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, account_id, statement_date, statement_balance, attachment_id, voided_reason FROM reconciliations ORDER BY statement_date, id",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, i64>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
r.get::<_, i64>(4)?,
|
||||
r.get::<_, Option<String>>(5)?,
|
||||
))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (id, acct, date, bal, att, voided) = row?;
|
||||
if account.is_some_and(|a| a != acct as u64) {
|
||||
continue;
|
||||
}
|
||||
let cleared: Vec<(EntryId, usize)> = conn
|
||||
.prepare(
|
||||
"SELECT entry_id, line_index FROM reconciliation_lines WHERE reconciliation_id = ? ORDER BY entry_id, line_index",
|
||||
)?
|
||||
.query_map(params![id], |r| {
|
||||
Ok((r.get::<_, i64>(0)? as u64, r.get::<_, i64>(1)? as usize))
|
||||
})?
|
||||
.collect::<Result<_, _>>()?;
|
||||
out.push(Reconciliation {
|
||||
id: id as u64,
|
||||
account_id: acct as u64,
|
||||
statement_date: parse_date(&date)?,
|
||||
statement_balance: bal
|
||||
.parse()
|
||||
.map_err(|_| StoreError::JournalCorrupt(format!("reconciliation balance {bal}")))?,
|
||||
attachment_id: att as u64,
|
||||
cleared,
|
||||
voided,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Every row of `attachment_links`: (attachment, subject), by attachment
|
||||
/// then subject. A receipt's filing entry isn't a row here (it is the
|
||||
/// attachment's `entry_id`).
|
||||
pub(crate) fn query_attachment_links(conn: &Connection) -> Result<Vec<(u64, Subject)>, StoreError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT attachment_id, subject_kind, subject_id FROM attachment_links ORDER BY attachment_id, subject_kind, subject_id",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, i64>(2)?,
|
||||
))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (att, kind, id) = row?;
|
||||
if let Some(sub) = Subject::from_parts(&kind, id as u64) {
|
||||
out.push((att as u64, sub));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Attachments in the projection, optionally only one entry's, by id.
|
||||
pub(crate) fn query_attachments(
|
||||
conn: &Connection,
|
||||
|
||||
@@ -68,6 +68,12 @@ pub enum ViewerTarget {
|
||||
/// Party id.
|
||||
party_id: u64,
|
||||
},
|
||||
/// One reconciliation: its account, with the account's statements and
|
||||
/// reconciliations.
|
||||
Reconciliation {
|
||||
/// Reconciliation id.
|
||||
id: u64,
|
||||
},
|
||||
/// The transactions list, searched.
|
||||
Search {
|
||||
/// Search text: description, account, vendor, amount.
|
||||
|
||||
Reference in new issue
Block a user