Parties, attachment links, statements, reconciliations (store), TIN vault crate

Parties: one directory for vendors, contractors, customers, employees,
members, platforms, agencies, banks, landlords (ledger-core parties.rs).
W-9/W-4 facts without the number (last four only; any nine-digit run in a
party field is refused, Treas. Reg. 301.6109-4). Vendor and customer
records link to parties on replay; core tags every bill, invoice, payment,
and reversal with its counterparty. Journal events party.*,
entry.party_set; payroll lines name the employee party.

MCP: books.add_party, update_party, set_party_roles, set_w9, set_w4,
parties, party, set_entry_party; draft_entry party; run_payroll employee;
show party. Viewer: Parties directory and party pages, search, journal
text, W-9 attention, demo data.

One definition of a party's money (ledger_core::parties::activity) for
MCP and viewer. 1099 amounts count payments from cash and financing
accounts (a member paying for the company counts; a card doesn't; a
prepayment counts when made): vendor_1099_report_with, B-19.

Store: attachment links to any subject, statements, reconciliations
(schema v11), with replay and projection. ledger-vault: encrypted TIN and
W-9 records (ChaCha20-Poly1305, key file outside the books).

Spec-blind tests for parties (core, store, MCP, viewer) and attachments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-15 14:43:40 -06:00
1 parent 2a188bd039
commit a299077991
54 files changed
+10961 -178

No files matched your search

+25
View File
@@ -5,6 +5,31 @@ use thiserror::Error;
/// Every way a store operation can fail.
#[derive(Debug, Error)]
pub enum StoreError {
/// An attachment link names a subject that doesn't exist.
#[error("no {0} to link to")]
UnknownSubject(String),
/// The attachment is already linked to that subject.
#[error("attachment {0} is already linked to {1}")]
AlreadyLinked(u64, String),
/// The attachment isn't linked to that subject.
#[error("attachment {0} isn't linked to {1}")]
NotLinkedTo(u64, String),
/// A statement record that doesn't fit its account or itself.
#[error("bad statement: {0}")]
InvalidStatement(String),
/// The books don't agree with the statement yet.
#[error(
"not reconciled: the cleared balance differs from the statement by {difference}; \
clear the lines the statement shows (or find what's missing) first"
)]
NotReconciled {
/// Cleared balance less the statement balance.
difference: rust_decimal::Decimal,
},
/// A reconciliation command that breaks the sequence.
#[error("{0}")]
ReconciliationRefused(String),
/// An asset can't be corrected once depreciation for it is posted: the
/// posted entries would disagree with the corrected schedule.
#[error("asset {0} already has depreciation posted; correct it with entries instead")]
+1 -1
View File
@@ -32,5 +32,5 @@ pub use journal::{GENESIS_PREV, JournalLine, JournalWriter};
pub use snapshot::{DiskCheck, Snapshot};
pub use store::{
Attachment, DB_FILE, DepreciationRun, DisposalPreview, EntryRow, EventRow, JOURNAL_FILE,
LOCK_FILE, PayrollLineInput, Store, VerifyReport,
LOCK_FILE, PayrollLineInput, Reconciliation, Statement, Store, Subject, VerifyReport,
};
+40 -1
View File
@@ -5,7 +5,7 @@
//! is the current version.
/// Current schema version.
pub const SCHEMA_VERSION: u32 = 10;
pub const SCHEMA_VERSION: u32 = 11;
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
pub const MIGRATIONS: &[&str] = &[
@@ -227,5 +227,44 @@ CREATE TABLE IF NOT EXISTS asset_tags_log(
-- (TEXT, exact decimal, never REAL).
ALTER TABLE assets ADD COLUMN tax_rule TEXT;
ALTER TABLE assets ADD COLUMN contributor_basis TEXT;
"#,
r#"
-- v11: attachments link to any subject (docs/parties-and-attachments.md):
-- entries beyond the first, accounts, assets, parties, invoices, bills,
-- periods, payroll runs, schedules, reconciliations, the company. Bank and
-- card statements carry their account, period, and balances; a
-- reconciliation records the statement, its balance, and the lines it
-- cleared. Amounts are TEXT, exact decimal.
CREATE TABLE IF NOT EXISTS attachment_links(
attachment_id INTEGER NOT NULL,
subject_kind TEXT NOT NULL,
subject_id INTEGER NOT NULL,
PRIMARY KEY(attachment_id, subject_kind, subject_id)
);
CREATE INDEX IF NOT EXISTS idx_attachment_links_subject ON attachment_links(subject_kind, subject_id);
-- Payroll lines name their employee party (Ledger PayrollLine.employee).
ALTER TABLE payroll_lines ADD COLUMN employee_id INTEGER;
CREATE TABLE IF NOT EXISTS statements(
attachment_id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL,
period_start TEXT NOT NULL,
period_end TEXT NOT NULL,
opening_balance TEXT NOT NULL,
closing_balance TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS reconciliations(
id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL,
statement_date TEXT NOT NULL,
statement_balance TEXT NOT NULL,
attachment_id INTEGER NOT NULL,
voided_reason TEXT NULL
);
CREATE TABLE IF NOT EXISTS reconciliation_lines(
reconciliation_id INTEGER NOT NULL,
entry_id INTEGER NOT NULL,
line_index INTEGER NOT NULL,
PRIMARY KEY(reconciliation_id, entry_id, line_index)
);
"#,
];
+823 -8
View File
@@ -725,6 +725,102 @@ impl Store {
Ok(())
}
// ------------------------------------------------------------------
// Parties (ledger_core::parties)
// ------------------------------------------------------------------
/// Add a party. Journaled as `party.added`.
pub fn add_party(
&mut self,
name: &str,
roles: &[ledger_core::Role],
) -> Result<ledger_core::PartyId, StoreError> {
self.ledger.check_add_party(name, roles)?;
let id = self.ledger.next_party_id();
let line = self.append_journal(
"party.added",
json!({"id": id, "name": name.trim(), "roles": roles.iter().map(|r| r.key()).collect::<Vec<_>>()}),
)?;
let got = self.ledger.add_party(name, roles)?;
debug_assert_eq!(got, id, "party id allocator diverged from journal");
apply_line_to_sqlite(&self.conn, &line)?;
Ok(got)
}
/// Change one of a party's facts. Journaled as `party.updated`.
pub fn update_party(
&mut self,
id: ledger_core::PartyId,
field: ledger_core::PartyField,
) -> Result<(), StoreError> {
self.ledger.check_update_party(id, &field)?;
let line = self.append_journal("party.updated", json!({"id": id, "change": field}))?;
self.ledger.update_party(id, field)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Give or take a party role. Journaled as `party.role_set`.
pub fn set_party_role(
&mut self,
id: ledger_core::PartyId,
role: ledger_core::Role,
on: bool,
) -> Result<(), StoreError> {
self.ledger.check_set_party_role(id, role, on)?;
let line = self.append_journal(
"party.role_set",
json!({"id": id, "role": role.key(), "on": on}),
)?;
self.ledger.set_party_role(id, role, on)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Record or clear a party's W-9 facts. Journaled as `party.w9_set`.
pub fn set_party_w9(
&mut self,
id: ledger_core::PartyId,
facts: Option<ledger_core::W9Facts>,
) -> Result<(), StoreError> {
self.ledger.check_set_party_w9(id, facts.as_ref())?;
let line = self.append_journal("party.w9_set", json!({"id": id, "facts": facts}))?;
self.ledger.set_party_w9(id, facts)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Record or clear a party's W-4 facts. Journaled as `party.w4_set`.
pub fn set_party_w4(
&mut self,
id: ledger_core::PartyId,
facts: Option<ledger_core::W4Facts>,
) -> Result<(), StoreError> {
self.ledger.check_set_party_w4(id, facts.as_ref())?;
let line = self.append_journal("party.w4_set", json!({"id": id, "facts": facts}))?;
self.ledger.set_party_w4(id, facts)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Tag an entry with its party, or clear the tag. Journaled as
/// `entry.party_set`.
pub fn set_entry_party(
&mut self,
entry: EntryId,
party: Option<ledger_core::PartyId>,
) -> Result<(), StoreError> {
let mut rehearsal = self.ledger.clone();
rehearsal.set_entry_party(entry, party)?;
let line = self.append_journal(
"entry.party_set",
json!({"entry_id": entry, "party_id": party}),
)?;
self.ledger.set_entry_party(entry, party)?;
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
@@ -2416,7 +2512,10 @@ impl Store {
/// while they may be needed).
/// SCENARIOS:
/// 1. IF the attachment is unknown or already withdrawn: SHOULD refuse.
/// 2. IF it is linked to an entry: SHOULD refuse (AttachmentLinked).
/// 2. IF it is filed on an entry: SHOULD refuse (AttachmentLinked,
/// undone with unlink_receipt). IF it is linked to any subject
/// (link_attachment): SHOULD refuse (AlreadyLinked, naming the
/// first subject; undone with unlink_attachment).
/// 3. IF the reason is blank: SHOULD refuse.
/// 4. Otherwise: SHOULD journal `attachment.withdrawn`; the store's
/// listing marks it withdrawn with the reason (the MCP listing
@@ -2432,6 +2531,9 @@ impl Store {
return Err(StoreError::ReasonRequired);
}
self.check_linkable(attachment_id)?;
if let Some(sub) = self.attachment_links(attachment_id)?.first() {
return Err(StoreError::AlreadyLinked(attachment_id, sub.describe()));
}
let line = self.append_journal(
"attachment.withdrawn",
json!({ "attachment_id": attachment_id, "reason": reason }),
@@ -2440,6 +2542,443 @@ impl Store {
Ok(())
}
// ------------------------------------------------------------------
// Attachment links, statements, reconciliations
// ------------------------------------------------------------------
/// Whether `subject` exists in the books.
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(),
Subject::Account(i) => l.chart().get(i).is_some(),
Subject::Asset(i) => l.get_asset(i).is_some(),
Subject::Party(i) => l.get_party(i).is_some(),
Subject::Invoice(i) => l.get_invoice(i).is_some(),
Subject::Bill(i) => l.get_bill(i).is_some(),
Subject::Period(i) => l.list_periods().iter().any(|p| p.id == i),
Subject::PayrollRun(i) => l.get_payroll_run(i).is_some(),
Subject::Schedule(i) => l.get_schedule(i).is_some(),
Subject::Reconciliation(i) => self
.conn
.query_row(
"SELECT 1 FROM reconciliations WHERE id = ?",
params![i as i64],
|_| Ok(()),
)
.optional()?
.is_some(),
Subject::Company => true,
})
}
/// The subjects an attachment is linked to (its filing entry, if any,
/// first).
pub fn attachment_links(&self, attachment_id: u64) -> Result<Vec<Subject>, StoreError> {
let (entry, _) = self.attachment_state(attachment_id)?;
let mut out: Vec<Subject> = entry.map(Subject::Entry).into_iter().collect();
let mut stmt = self.conn.prepare(
"SELECT subject_kind, subject_id FROM attachment_links WHERE attachment_id = ? ORDER BY subject_kind, subject_id",
)?;
let rows = stmt.query_map(params![attachment_id as i64], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))
})?;
for row in rows {
let (kind, id) = row?;
if let Some(sub) = Subject::from_parts(&kind, id as u64) {
out.push(sub);
}
}
Ok(out)
}
/// The attachments linked to a subject (for an entry: filed on it or
/// linked to it), not withdrawn.
pub fn attachments_for(&self, subject: Subject) -> Result<Vec<Attachment>, StoreError> {
if let Subject::Entry(e) = subject {
return Ok(query_attachments(&self.conn, Some(e))?
.into_iter()
.filter(|a| a.withdrawn_at.is_none())
.collect());
}
let ids: Vec<i64> = self
.conn
.prepare(
"SELECT attachment_id FROM attachment_links WHERE subject_kind = ? AND subject_id = ? ORDER BY attachment_id",
)?
.query_map(params![subject.kind(), subject.id() as i64], |r| r.get(0))?
.collect::<Result<_, _>>()?;
Ok(query_attachments(&self.conn, None)?
.into_iter()
.filter(|a| ids.contains(&(a.id as i64)) && a.withdrawn_at.is_none())
.collect())
}
/// Link a stored attachment to a subject: another entry, an account, an
/// asset, a party, an invoice, a bill, a period, a payroll run, a
/// schedule, a reconciliation, or the company.
///
/// ```text
/// GOAL: one file, linked wherever it's evidence, without refiling.
/// SOURCE: docs/parties-and-attachments.md "Attachments"; DECISION
/// (owner, 2026-10-15).
/// INVARIANTS:
/// 1. Journaled as `attachment.linked {attachment_id, subject}`.
/// 2. An entry's receipts are the files filed on it and the files
/// linked to it; the receipt policy counts both.
/// 3. A refusal journals nothing.
/// SCENARIOS:
/// 1. IF the attachment is unknown or withdrawn, or the subject
/// doesn't exist: SHOULD refuse.
/// 2. IF it is already linked to that subject (or filed on that
/// entry): SHOULD refuse.
/// ```
pub fn link_attachment(
&mut self,
attachment_id: u64,
subject: Subject,
) -> Result<(), StoreError> {
let (entry, withdrawn) = self.attachment_state(attachment_id)?;
if withdrawn {
return Err(StoreError::AttachmentWithdrawn(attachment_id));
}
if !self.subject_exists(subject)? {
return Err(StoreError::UnknownSubject(subject.describe()));
}
if entry.map(Subject::Entry) == Some(subject)
|| self.attachment_links(attachment_id)?.contains(&subject)
{
return Err(StoreError::AlreadyLinked(attachment_id, subject.describe()));
}
let line = self.append_journal(
"attachment.linked",
json!({"attachment_id": attachment_id, "subject": subject}),
)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Take a link off, with the reason. The file stays filed; a receipt
/// filed on an entry comes off with `unlink_receipt` instead.
///
/// ```text
/// SCENARIOS:
/// 1. IF the reason is blank, or the attachment isn't linked to the
/// subject (through this table): SHOULD refuse.
/// 2. Otherwise: journaled as `attachment.unlinked {attachment_id,
/// subject, reason}`.
/// ```
pub fn unlink_attachment(
&mut self,
attachment_id: u64,
subject: Subject,
reason: &str,
) -> Result<(), StoreError> {
let reason = reason.trim();
if reason.is_empty() {
return Err(StoreError::ReasonRequired);
}
self.attachment_state(attachment_id)?;
let linked = self
.conn
.query_row(
"SELECT 1 FROM attachment_links WHERE attachment_id = ? AND subject_kind = ? AND subject_id = ?",
params![attachment_id as i64, subject.kind(), subject.id() as i64],
|_| Ok(()),
)
.optional()?
.is_some();
if !linked {
return Err(StoreError::NotLinkedTo(attachment_id, subject.describe()));
}
let line = self.append_journal(
"attachment.unlinked",
json!({"attachment_id": attachment_id, "subject": subject, "reason": reason}),
)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Record what a filed statement says: its account, period, and
/// balances. Links it to the account.
///
/// ```text
/// GOAL: the bank and card statements are in the books, so a
/// reconciliation names the one it agrees with.
/// INVARIANTS:
/// 1. Journaled as `statement.recorded {attachment_id, account_id,
/// period_start, period_end, opening_balance, closing_balance}`,
/// then `attachment.linked` to the account.
/// SCENARIOS:
/// 1. IF the attachment is unknown or withdrawn, already records a
/// statement, the account is unknown or not an asset or liability
/// (a bank, cash, or card account), the period runs backwards, or
/// a balance has more than two decimal places: SHOULD refuse,
/// journaling nothing.
/// ```
pub fn record_statement(&mut self, st: Statement) -> Result<(), StoreError> {
let (_, withdrawn) = self.attachment_state(st.attachment_id)?;
if withdrawn {
return Err(StoreError::AttachmentWithdrawn(st.attachment_id));
}
let bad = |why: &str| Err(StoreError::InvalidStatement(why.to_string()));
let acct = self
.ledger
.chart()
.get(st.account_id)
.ok_or(LedgerError::UnknownAccount(st.account_id))?;
if !matches!(
acct.account_type,
AccountType::Asset | AccountType::Liability
) {
return bad("a statement is for a bank, cash, or card account (an asset or liability)");
}
if st.period_end < st.period_start {
return bad("the period ends before it starts");
}
if [st.opening_balance, st.closing_balance]
.iter()
.any(|b| b.normalize().scale() > 2)
{
return bad("balances are in cents at most");
}
if self.statement(st.attachment_id)?.is_some() {
return bad("that attachment already records a statement");
}
let line = self.append_journal(
"statement.recorded",
json!({
"attachment_id": st.attachment_id,
"account_id": st.account_id,
"period_start": st.period_start.to_string(),
"period_end": st.period_end.to_string(),
"opening_balance": st.opening_balance.to_string(),
"closing_balance": st.closing_balance.to_string(),
}),
)?;
apply_line_to_sqlite(&self.conn, &line)?;
if !self
.attachment_links(st.attachment_id)?
.contains(&Subject::Account(st.account_id))
{
self.link_attachment(st.attachment_id, Subject::Account(st.account_id))?;
}
Ok(())
}
/// The statement an attachment records, if any.
pub fn statement(&self, attachment_id: u64) -> Result<Option<Statement>, StoreError> {
Ok(self
.statements(None)?
.into_iter()
.find(|s| s.attachment_id == attachment_id))
}
/// 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)
}
/// Reconcile an account to a recorded statement: the books' cleared
/// balance through the statement date must equal its closing balance.
///
/// ```text
/// GOAL: a monthly close that leaves proof: which statement, which
/// balance, which lines it cleared.
/// SOURCE: CONVENTION (bank reconciliation); docs/parties-and-
/// attachments.md "Reconciliations"; DECISION (owner,
/// 2026-10-15).
/// INVARIANTS:
/// 1. Recorded only when the difference is exactly zero
/// (Ledger::reconciliation_report against the statement's date and
/// closing balance).
/// 2. It records the cleared lines on the account dated on or before
/// the statement date, and links the statement to it.
/// 3. Reconciliations of an account go forward: a new one's date is
/// after every one not voided.
/// 4. Journaled as `reconciliation.completed`; never edited (void and
/// redo).
/// SCENARIOS:
/// 1. IF the attachment records no statement: SHOULD refuse.
/// 2. IF the books differ from the statement: SHOULD refuse with
/// NotReconciled naming the difference.
/// 3. IF a reconciliation not voided is dated on or after it: SHOULD
/// refuse.
/// ```
pub fn complete_reconciliation(
&mut self,
statement_attachment: u64,
) -> Result<Reconciliation, StoreError> {
let st = self.statement(statement_attachment)?.ok_or_else(|| {
StoreError::InvalidStatement(format!(
"attachment {statement_attachment} records no statement (record_statement first)"
))
})?;
if let Some(last) = self
.reconciliations(Some(st.account_id))?
.into_iter()
.filter(|r| r.voided.is_none())
.map(|r| r.statement_date)
.max()
&& last >= st.period_end
{
return Err(StoreError::ReconciliationRefused(format!(
"account {} is already reconciled through {last}; void that reconciliation to redo it",
st.account_id
)));
}
let report =
self.ledger
.reconciliation_report(st.account_id, st.period_end, st.closing_balance)?;
if !report.difference.is_zero() {
return Err(StoreError::NotReconciled {
difference: report.difference,
});
}
let mut cleared = Vec::new();
for e in self.ledger.posted_entries() {
if e.date() > st.period_end {
continue;
}
for (i, l) in e.lines().iter().enumerate() {
if l.account == st.account_id && self.ledger.is_cleared(e.id(), i) {
cleared.push((e.id(), i));
}
}
}
cleared.sort_unstable();
let id: u64 = self.conn.query_row(
"SELECT COALESCE(MAX(id), 0) + 1 FROM reconciliations",
[],
|r| r.get::<_, i64>(0),
)? as u64;
let line = self.append_journal(
"reconciliation.completed",
json!({
"id": id,
"account_id": st.account_id,
"statement_date": st.period_end.to_string(),
"statement_balance": st.closing_balance.to_string(),
"attachment_id": statement_attachment,
"cleared": cleared,
}),
)?;
apply_line_to_sqlite(&self.conn, &line)?;
self.link_attachment(statement_attachment, Subject::Reconciliation(id))?;
Ok(Reconciliation {
id,
account_id: st.account_id,
statement_date: st.period_end,
statement_balance: st.closing_balance,
attachment_id: statement_attachment,
cleared,
voided: None,
})
}
/// Void a reconciliation, with the reason (to redo it). Journaled as
/// `reconciliation.voided`.
pub fn void_reconciliation(&mut self, id: u64, reason: &str) -> Result<(), StoreError> {
let reason = reason.trim();
if reason.is_empty() {
return Err(StoreError::ReasonRequired);
}
let r = self
.reconciliations(None)?
.into_iter()
.find(|r| r.id == id)
.ok_or_else(|| StoreError::UnknownSubject(format!("reconciliation {id}")))?;
if r.voided.is_some() {
return Err(StoreError::ReconciliationRefused(format!(
"reconciliation {id} is already voided"
)));
}
let line =
self.append_journal("reconciliation.voided", json!({"id": id, "reason": reason}))?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Reconciliations, optionally one account's, by date.
pub fn reconciliations(
&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)
}
/// `(linked entry, withdrawn?)` for an attachment.
fn attachment_state(&self, attachment_id: u64) -> Result<(Option<EntryId>, bool), StoreError> {
self.conn
@@ -3002,7 +3541,7 @@ impl Store {
for r in run_rows {
let (id, date, entry_id) = r?;
let mut pl_stmt = self.conn.prepare(
"SELECT employee_name, gross_pay, federal_withholding, state_withholding, ss_employee, medicare_employee, ss_employer, medicare_employer, other_deductions FROM payroll_lines WHERE run_id = ? ORDER BY id",
"SELECT employee_name, gross_pay, federal_withholding, state_withholding, ss_employee, medicare_employee, ss_employer, medicare_employer, other_deductions, employee_id FROM payroll_lines WHERE run_id = ? ORDER BY id",
)?;
let pl_rows = pl_stmt.query_map(params![id], |lr| {
Ok((
@@ -3015,13 +3554,15 @@ impl Store {
lr.get::<_, String>(6)?,
lr.get::<_, String>(7)?,
lr.get::<_, String>(8)?,
lr.get::<_, Option<i64>>(9)?,
))
})?;
let mut lines = Vec::new();
for lr in pl_rows {
let (name, gross, fed, state, ss_e, med_e, ss_r, med_r, other) = lr?;
let (name, gross, fed, state, ss_e, med_e, ss_r, med_r, other, emp) = lr?;
lines.push(PayrollLine {
employee_name: name,
employee: emp.map(|e| e as u64),
gross_pay: parse_payroll_amount(gross)?,
federal_withholding: parse_payroll_amount(fed)?,
state_withholding: parse_payroll_amount(state)?,
@@ -3150,6 +3691,7 @@ fn flip_line_json(l: &ledger_core::JournalLine) -> Value {
fn payroll_line_json(l: &PayrollLine) -> Value {
json!({
"employee_name": l.employee_name,
"employee": l.employee,
"gross_pay": l.gross_pay.to_string(),
"federal_withholding": l.federal_withholding.to_string(),
"state_withholding": l.state_withholding.to_string(),
@@ -3177,6 +3719,12 @@ fn parse_payroll_lines(p: &Value) -> Result<Vec<PayrollLine>, StoreError> {
};
Ok(PayrollLine {
employee_name: get_str(l, "employee_name")?.to_string(),
employee: match l.get("employee") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_u64().ok_or_else(|| {
StoreError::JournalCorrupt("payroll line employee".into())
})?),
},
gross_pay: amount("gross_pay")?,
federal_withholding: amount("federal_withholding")?,
state_withholding: amount("state_withholding")?,
@@ -3498,6 +4046,13 @@ pub(crate) fn replay_line_into_ledger(
pct,
)?;
}
"attachment.linked"
| "attachment.unlinked"
| "statement.recorded"
| "reconciliation.completed"
| "reconciliation.voided" => {
// Evidence, not accounting state: the projection records them.
}
"receipt.attached"
| "receipt.linked"
| "receipt.unlinked"
@@ -3530,6 +4085,59 @@ pub(crate) fn replay_line_into_ledger(
"asset.tagged" => {
ledger.set_asset_tag(get_u64(p, "asset_id")?, get_str(p, "tag")?)?;
}
"party.added" => {
let want = get_u64(p, "id")?;
let roles = p
.get("roles")
.and_then(|v| v.as_array())
.ok_or_else(|| StoreError::JournalCorrupt("party.added roles".into()))?
.iter()
.map(|r| {
r.as_str()
.and_then(ledger_core::Role::parse)
.ok_or_else(|| StoreError::JournalCorrupt("party.added role".into()))
})
.collect::<Result<Vec<_>, _>>()?;
let got = ledger.add_party(get_str(p, "name")?, &roles)?;
if got != want {
return Err(StoreError::ReplayMismatch(format!(
"party id: journal says {want}, replay got {got}"
)));
}
}
"party.updated" => {
let field: ledger_core::PartyField = serde_json::from_value(p["change"].clone())
.map_err(|_| StoreError::JournalCorrupt("party.updated change".into()))?;
ledger.update_party(get_u64(p, "id")?, field)?;
}
"party.role_set" => {
let role = ledger_core::Role::parse(get_str(p, "role")?)
.ok_or_else(|| StoreError::JournalCorrupt("party.role_set role".into()))?;
let on = p
.get("on")
.and_then(|v| v.as_bool())
.ok_or_else(|| StoreError::JournalCorrupt("party.role_set on".into()))?;
ledger.set_party_role(get_u64(p, "id")?, role, on)?;
}
"party.w9_set" => {
let facts: Option<ledger_core::W9Facts> = serde_json::from_value(p["facts"].clone())
.map_err(|_| StoreError::JournalCorrupt("party.w9_set facts".into()))?;
ledger.set_party_w9(get_u64(p, "id")?, facts)?;
}
"party.w4_set" => {
let facts: Option<ledger_core::W4Facts> = serde_json::from_value(p["facts"].clone())
.map_err(|_| StoreError::JournalCorrupt("party.w4_set facts".into()))?;
ledger.set_party_w4(get_u64(p, "id")?, facts)?;
}
"entry.party_set" => {
let party = match p.get("party_id") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_u64().ok_or_else(|| {
StoreError::JournalCorrupt("entry.party_set party_id".into())
})?),
};
ledger.set_entry_party(get_u64(p, "entry_id")?, party)?;
}
"asset.corrected" => {
ledger.correct_asset(
get_u64(p, "asset_id")?,
@@ -4030,9 +4638,82 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
"payroll.taxes_remitted" => {
// The entry was projected by its own events; this only names it.
}
"role.assigned" => {
// Roles live in the ledger (rebuilt by replay); the events
// mirror below keeps the history.
"attachment.linked" | "attachment.unlinked" => {
let sub = &p["subject"];
let kind = get_str(sub, "kind")?;
let id = sub.get("id").and_then(|v| v.as_u64()).unwrap_or(0);
if Subject::from_parts(kind, id).is_none() {
return Err(StoreError::JournalCorrupt(format!(
"attachment subject {kind}"
)));
}
let att = get_u64(p, "attachment_id")? as i64;
if line.line_type == "attachment.linked" {
conn.execute(
"INSERT INTO attachment_links(attachment_id, subject_kind, subject_id) VALUES (?,?,?)",
params![att, kind, id as i64],
)?;
} else {
conn.execute(
"DELETE FROM attachment_links WHERE attachment_id = ? AND subject_kind = ? AND subject_id = ?",
params![att, kind, id as i64],
)?;
}
}
"statement.recorded" => {
conn.execute(
"INSERT INTO statements(attachment_id, account_id, period_start, period_end, opening_balance, closing_balance) VALUES (?,?,?,?,?,?)",
params![
get_u64(p, "attachment_id")? as i64,
get_u64(p, "account_id")? as i64,
get_str(p, "period_start")?,
get_str(p, "period_end")?,
get_str(p, "opening_balance")?,
get_str(p, "closing_balance")?,
],
)?;
}
"reconciliation.completed" => {
let id = get_u64(p, "id")? as i64;
conn.execute(
"INSERT INTO reconciliations(id, account_id, statement_date, statement_balance, attachment_id) VALUES (?,?,?,?,?)",
params![
id,
get_u64(p, "account_id")? as i64,
get_str(p, "statement_date")?,
get_str(p, "statement_balance")?,
get_u64(p, "attachment_id")? as i64,
],
)?;
let cleared = p
.get("cleared")
.and_then(|v| v.as_array())
.ok_or_else(|| StoreError::JournalCorrupt("reconciliation cleared".into()))?;
for pair in cleared {
let e = pair.get(0).and_then(|v| v.as_u64());
let l = pair.get(1).and_then(|v| v.as_u64());
let (Some(e), Some(l)) = (e, l) else {
return Err(StoreError::JournalCorrupt(
"reconciliation cleared line".into(),
));
};
conn.execute(
"INSERT INTO reconciliation_lines(reconciliation_id, entry_id, line_index) VALUES (?,?,?)",
params![id, e as i64, l as i64],
)?;
}
}
"reconciliation.voided" => {
conn.execute(
"UPDATE reconciliations SET voided_reason = ? WHERE id = ?",
params![get_str(p, "reason")?, get_u64(p, "id")? as i64],
)?;
}
"role.assigned" | "party.added" | "party.updated" | "party.role_set" | "party.w9_set"
| "party.w4_set" | "entry.party_set" => {
// Control roles and the party directory live in the ledger
// (rebuilt by replay); the events mirror below keeps the
// history.
}
"asset.tagged" => {
let (id, tag) = (get_u64(p, "asset_id")? as i64, get_str(p, "tag")?);
@@ -4373,7 +5054,7 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
)?;
for l in parse_payroll_lines(p)? {
conn.execute(
"INSERT INTO payroll_lines(run_id, employee_name, gross_pay, federal_withholding, state_withholding, ss_employee, medicare_employee, ss_employer, medicare_employer, other_deductions) VALUES (?,?,?,?,?,?,?,?,?,?)",
"INSERT INTO payroll_lines(run_id, employee_name, gross_pay, federal_withholding, state_withholding, ss_employee, medicare_employee, ss_employer, medicare_employer, other_deductions, employee_id) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
params![
run_id,
l.employee_name,
@@ -4385,6 +5066,7 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
l.ss_employer.to_string(),
l.medicare_employer.to_string(),
l.other_deductions.to_string(),
l.employee.map(|e| e as i64),
],
)?;
}
@@ -4517,6 +5199,135 @@ pub(crate) fn compare_projections(
Ok(())
}
/// What an attachment can be linked to (docs/parties-and-attachments.md).
#[derive(
Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
#[serde(tag = "kind", content = "id", rename_all = "snake_case")]
pub enum Subject {
/// An entry (beyond the one a receipt is filed on).
Entry(u64),
/// An account (e.g. its statements, the agreement that opened it).
Account(u64),
/// A fixed asset (photos, warranty, manuals).
Asset(u64),
/// A party (contracts, correspondence).
Party(u64),
/// An invoice.
Invoice(u64),
/// A bill.
Bill(u64),
/// An accounting period (its close package).
Period(u64),
/// A payroll run.
PayrollRun(u64),
/// An amortization schedule (the prepaid contract).
Schedule(u64),
/// A reconciliation (its statement).
Reconciliation(u64),
/// The company itself (formation documents).
Company,
}
impl Subject {
/// Its kind's wire name.
pub fn kind(self) -> &'static str {
match self {
Subject::Entry(_) => "entry",
Subject::Account(_) => "account",
Subject::Asset(_) => "asset",
Subject::Party(_) => "party",
Subject::Invoice(_) => "invoice",
Subject::Bill(_) => "bill",
Subject::Period(_) => "period",
Subject::PayrollRun(_) => "payroll_run",
Subject::Schedule(_) => "schedule",
Subject::Reconciliation(_) => "reconciliation",
Subject::Company => "company",
}
}
/// Its id (0 for the company).
pub fn id(self) -> u64 {
match self {
Subject::Entry(i)
| Subject::Account(i)
| Subject::Asset(i)
| Subject::Party(i)
| Subject::Invoice(i)
| Subject::Bill(i)
| Subject::Period(i)
| Subject::PayrollRun(i)
| Subject::Schedule(i)
| Subject::Reconciliation(i) => i,
Subject::Company => 0,
}
}
/// The subject of `kind` and `id`.
pub fn from_parts(kind: &str, id: u64) -> Option<Subject> {
Some(match kind {
"entry" => Subject::Entry(id),
"account" => Subject::Account(id),
"asset" => Subject::Asset(id),
"party" => Subject::Party(id),
"invoice" => Subject::Invoice(id),
"bill" => Subject::Bill(id),
"period" => Subject::Period(id),
"payroll_run" => Subject::PayrollRun(id),
"schedule" => Subject::Schedule(id),
"reconciliation" => Subject::Reconciliation(id),
"company" => Subject::Company,
_ => return None,
})
}
/// Human words, e.g. "account 1", for messages.
pub fn describe(self) -> String {
match self {
Subject::Company => "the company".into(),
s => format!("{} {}", s.kind().replace('_', " "), s.id()),
}
}
}
/// A bank or card statement: the attachment that holds it, and what it
/// says.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Statement {
/// The attachment holding the statement.
pub attachment_id: u64,
/// The account it is for.
pub account_id: AccountId,
/// First day it covers.
pub period_start: NaiveDate,
/// Last day it covers (its statement date).
pub period_end: NaiveDate,
/// Its opening balance, in the account's normal direction.
pub opening_balance: Decimal,
/// Its closing balance, in the account's normal direction.
pub closing_balance: Decimal,
}
/// A completed reconciliation.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Reconciliation {
/// Its id.
pub id: u64,
/// The account reconciled.
pub account_id: AccountId,
/// The statement date it reconciles through.
pub statement_date: NaiveDate,
/// The statement's closing balance.
pub statement_balance: Decimal,
/// The statement's attachment.
pub attachment_id: u64,
/// The lines it found cleared, (entry, line index).
pub cleared: Vec<(EntryId, usize)>,
/// Why it was voided, if it was.
pub voided: Option<String>,
}
/// Attachments in the projection, optionally only one entry's, by id.
pub(crate) fn query_attachments(
conn: &Connection,
@@ -4530,7 +5341,11 @@ pub(crate) fn query_attachments(
JOIN blobs b ON b.sha256 = a.blob_sha",
);
if entry_id.is_some() {
sql.push_str(" WHERE a.entry_id = ?");
// Filed on the entry, or linked to it.
sql.push_str(
" WHERE a.entry_id = ?1 OR a.id IN \
(SELECT attachment_id FROM attachment_links WHERE subject_kind = 'entry' AND subject_id = ?1)",
);
}
sql.push_str(" ORDER BY a.id");
let mut stmt = conn.prepare(&sql)?;
+7 -1
View File
@@ -16,7 +16,7 @@ use std::path::{Path, PathBuf};
#[serde(tag = "show", rename_all = "snake_case")]
pub enum ViewerTarget {
/// A top-level page: overview, cash, profit, transactions, reports,
/// accounts, assets, documents, company, journal.
/// accounts, assets, parties, documents, company, journal.
Page {
/// Page name.
page: String,
@@ -62,6 +62,12 @@ pub enum ViewerTarget {
/// Asset id.
asset_id: u64,
},
/// One party: who they are, their roles, and everything the company
/// did with them.
Party {
/// Party id.
party_id: u64,
},
/// The transactions list, searched.
Search {
/// Search text: description, account, vendor, amount.
+28
View File
@@ -669,9 +669,37 @@ fn tax_basis_replays_on_reopen() {
}
}
/// Downgrade the database to schema v10 (none of v11's attachment links,
/// statements, reconciliations, or payroll_lines.employee_id).
fn downgrade_db_to_v10(dir: &Path) {
let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
conn.execute_batch(
"DROP TABLE attachment_links;
DROP TABLE statements;
DROP TABLE reconciliations;
DROP TABLE reconciliation_lines;
ALTER TABLE payroll_lines DROP COLUMN employee_id;
UPDATE meta SET value = '10' WHERE key = 'schema_version';",
)
.unwrap();
}
/// An existing v10 database migrates on open and verifies.
#[test]
fn a_v10_database_migrates_and_verifies() {
let (dir, mut s, _, x, _) = fixture("v10");
s.set_asset_tag(x, "FL-0001").unwrap();
drop(s);
downgrade_db_to_v10(&dir);
let s = Store::open(&dir).expect("a v10 database opens");
s.verify().unwrap();
assert_eq!(tag_of(&s, x).as_deref(), Some("FL-0001"));
}
/// Downgrade the database to schema v9 (no assets.tax_rule /
/// assets.contributor_basis), as an older wanderbooks left it.
fn downgrade_db_to_v9(dir: &Path) {
downgrade_db_to_v10(dir);
let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
conn.execute_batch(
"ALTER TABLE assets DROP COLUMN tax_rule;
File diff suppressed because it is too large. Load diff
+754
View File
@@ -0,0 +1,754 @@
//! Spec-derived tests for the party directory in the store.
//!
//! Oracles: the `Store` party methods' doc comments (each "journaled as"
//! its event: `party.added {id, name, roles}`, `party.updated {id,
//! change}`, `party.role_set {id, role, on}`, `party.w9_set {id, facts}`,
//! `party.w4_set {id, facts}`, `entry.party_set {entry_id, party_id}`;
//! `payroll.run` lines carry `employee`), the `ledger_core::parties` spec
//! (INVARIANTS 1–4), `Store::verify` (a fresh replay equals the live
//! ledger), and `docs/parties-and-attachments.md` "Migration" (old journals
//! replay unchanged; vendors and customers get their parties by replay).
//! Expected values are worked by hand from those texts.
use chrono::NaiveDate;
use ledger_core::{
AccountId, AccountType, Currency, EntryId, Ledger, Money, Party, PartyField, PartyId,
PayrollLine, PayrollRun, Role, Side, W4Facts, W9Facts,
};
use ledger_store::journal::read_and_verify_journal;
use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Store};
use rust_decimal::Decimal;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::str::FromStr;
// ---------------------------------------------------------------- helpers
fn test_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("wb-parties-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn dec(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
fn usd(s: &str) -> Money {
Money::new(dec(s), Currency::USD)
}
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
/// The journal's (type, payload) pairs, verified.
fn journal(dir: &Path) -> Vec<(String, Value)> {
read_and_verify_journal(&dir.join(JOURNAL_FILE))
.unwrap()
.into_iter()
.map(|l| (l.line_type, l.payload))
.collect()
}
fn journal_bytes(dir: &Path) -> Vec<u8> {
std::fs::read(dir.join(JOURNAL_FILE)).unwrap()
}
/// Run `f`; it must journal exactly one line, of type `ty`. Returns that
/// line's payload.
fn one_event<T>(
s: &mut Store,
dir: &Path,
ty: &str,
f: impl FnOnce(&mut Store) -> T,
) -> (T, Value) {
let seq = s.journal_seq();
let len = journal(dir).len();
let out = f(s);
assert_eq!(s.journal_seq(), seq + 1, "{ty}: exactly one line");
let j = journal(dir);
assert_eq!(j.len(), len + 1, "{ty}: exactly one line on disk");
let (t, p) = j.last().unwrap().clone();
assert_eq!(t, ty);
(out, p)
}
/// Run `f`; it must fail and journal nothing.
fn refused_nothing_journaled<T: std::fmt::Debug, E>(
s: &mut Store,
dir: &Path,
what: &str,
f: impl FnOnce(&mut Store) -> Result<T, E>,
) {
let seq = s.journal_seq();
let bytes = journal_bytes(dir);
let before = s.ledger().clone();
let r = f(s);
assert!(r.is_err(), "{what}: expected refusal, got {:?}", r.ok());
assert_eq!(s.journal_seq(), seq, "{what}: journaled");
assert_eq!(journal_bytes(dir), bytes, "{what}: journal changed");
assert!(*s.ledger() == before, "{what}: ledger changed");
}
fn w9(last4: &str) -> W9Facts {
W9Facts {
received: date(2026, 3, 1),
entity_type: "individual/sole proprietor".to_string(),
exempt_payee_code: None,
fatca_code: None,
tin_last4: last4.to_string(),
kept_at: "wanderbooks vault".to_string(),
vault_record: None,
}
}
fn w4(state: &str) -> W4Facts {
W4Facts {
received: date(2026, 3, 1),
state: state.to_string(),
kept_at: "CPA portal".to_string(),
}
}
fn pay_line(name: &str, employee: Option<PartyId>) -> PayrollLine {
PayrollLine {
employee_name: name.to_string(),
employee,
gross_pay: dec("1000.00"),
federal_withholding: dec("100.00"),
state_withholding: dec("0"),
ss_employee: dec("62.00"),
medicare_employee: dec("14.50"),
ss_employer: dec("62.00"),
medicare_employer: dec("14.50"),
other_deductions: dec("0"),
}
}
struct Fx {
cash: AccountId,
entry: EntryId,
}
/// A store with cash, capital, and one posted $500.00 entry.
fn fixture(dir: &Path) -> (Store, Fx) {
let mut s = Store::create(dir, Currency::USD).unwrap();
let cash = s
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let capital = s
.add_account("3000", "Owner Capital", AccountType::Equity, None, false)
.unwrap();
let entry = s.create_entry(date(2026, 1, 1), "Opening").unwrap();
s.add_line(entry, cash, Side::Debit, usd("500.00")).unwrap();
s.add_line(entry, capital, Side::Credit, usd("500.00"))
.unwrap();
s.post_entry(entry).unwrap();
(s, Fx { cash, entry })
}
/// What "replays to an equal ledger" compares: every party, every entry's
/// party and vendor tags, every payroll run.
#[derive(Debug, PartialEq)]
struct View {
parties: Vec<Party>,
tags: Vec<(EntryId, Option<PartyId>, Option<u64>)>,
runs: Vec<PayrollRun>,
}
fn view(l: &Ledger) -> View {
View {
parties: l.parties().cloned().collect(),
tags: (0..l.next_entry_id() + 2)
.map(|e| (e, l.entry_party(e), l.entry_vendor(e)))
.collect(),
runs: l.list_payroll_runs().into_iter().cloned().collect(),
}
}
fn delete_db(dir: &Path) {
for suffix in ["", "-wal", "-shm", "-journal"] {
let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}")));
}
}
/// A store exercising every party event.
/// `named_employee`: whether the payroll run names Alice's party.
fn rich(dir: &Path, named_employee: bool) -> Store {
let (mut s, fx) = fixture(dir);
let irs = s.add_party("IRS", &[Role::TaxAgency]).unwrap();
let dev = s.add_party("Dev Shop", &[Role::Contractor]).unwrap();
let alice = s.add_party("Alice", &[Role::Employee]).unwrap();
s.update_party(dev, PartyField::Website(Some("https://dev.example".into())))
.unwrap();
s.update_party(dev, PartyField::DisplayName(Some("Dev".into())))
.unwrap();
s.update_party(irs, PartyField::Notes(Some("EFTPS".into())))
.unwrap();
s.set_party_role(dev, Role::Landlord, true).unwrap();
s.set_party_role(dev, Role::Landlord, false).unwrap();
s.set_party_w9(dev, Some(w9("6789"))).unwrap();
s.set_party_w4(alice, Some(w4("UT"))).unwrap();
let v = s.add_vendor("dev shop").unwrap();
let c = s.add_customer("Acme").unwrap();
s.add_vendor("Acme").unwrap();
let _ = c;
s.set_entry_party(fx.entry, Some(irs)).unwrap();
s.set_entry_vendor(fx.entry, Some(v)).unwrap();
let old = s.add_party("Old Name", &[]).unwrap();
s.update_party(old, PartyField::Archived(true)).unwrap();
s.add_vendor("Old Name").unwrap();
s.run_payroll(
date(2026, 2, 27),
vec![
pay_line("Alice", named_employee.then_some(alice)),
pay_line("Bob", None),
],
fx.cash,
)
.unwrap();
s
}
// ---------------------------------------------------------------- journaling
/// Oracle: Store::add_party doc ("journaled as party.added") and the
/// task's payload {id, name, roles}; roles by wire key.
#[test]
fn add_party_journals_party_added() {
let dir = test_dir("added");
let (mut s, _) = fixture(&dir);
let next = s.ledger().next_party_id();
let (id, p) = one_event(&mut s, &dir, "party.added", |s| {
s.add_party("Internal Revenue Service", &[Role::TaxAgency])
.unwrap()
});
assert_eq!(id, next);
assert_eq!(p["id"], json!(id));
assert_eq!(p["name"], json!("Internal Revenue Service"));
assert_eq!(p["roles"], json!(["tax_agency"]));
let (id2, p) = one_event(&mut s, &dir, "party.added", |s| {
s.add_party("Tobin", &[Role::Member, Role::Employee])
.unwrap()
});
assert_eq!(p["id"], json!(id2));
let mut roles: Vec<String> = p["roles"]
.as_array()
.unwrap()
.iter()
.map(|r| r.as_str().unwrap().to_string())
.collect();
roles.sort();
assert_eq!(roles, vec!["employee".to_string(), "member".to_string()]);
}
/// Oracle: Store::update_party doc ("journaled as party.updated") with
/// {id, change}; PartyField's wire form is `{field, value}` (its serde
/// attributes: snake_case, tag "field", content "value").
#[test]
fn update_party_journals_party_updated() {
let dir = test_dir("updated");
let (mut s, _) = fixture(&dir);
let id = s.add_party("Acme", &[]).unwrap();
let cases = [
(
PartyField::Website(Some("https://acme.example".into())),
json!({"field": "website", "value": "https://acme.example"}),
),
(
PartyField::DisplayName(Some("ACME".into())),
json!({"field": "display_name", "value": "ACME"}),
),
(
PartyField::DisplayName(None),
json!({"field": "display_name", "value": null}),
),
(
PartyField::Address(Some("1 Main St".into())),
json!({"field": "address", "value": "1 Main St"}),
),
(
PartyField::Notes(Some("net 30".into())),
json!({"field": "notes", "value": "net 30"}),
),
(
PartyField::Name("Acme LLC".into()),
json!({"field": "name", "value": "Acme LLC"}),
),
(
PartyField::Archived(true),
json!({"field": "archived", "value": true}),
),
];
for (field, want) in cases {
let ((), p) = one_event(&mut s, &dir, "party.updated", |s| {
s.update_party(id, field.clone()).unwrap()
});
assert_eq!(p["id"], json!(id));
assert_eq!(p["change"], want, "{field:?}");
}
let got = s.ledger().get_party(id).unwrap();
assert_eq!(got.name, "Acme LLC");
assert!(got.archived);
assert_eq!(got.notes.as_deref(), Some("net 30"));
}
/// Oracle: Store::set_party_role doc ("journaled as party.role_set") with
/// {id, role, on}.
#[test]
fn set_party_role_journals_party_role_set() {
let dir = test_dir("role");
let (mut s, _) = fixture(&dir);
let id = s.add_party("Mo", &[]).unwrap();
let ((), p) = one_event(&mut s, &dir, "party.role_set", |s| {
s.set_party_role(id, Role::PayoutPlatform, true).unwrap()
});
assert_eq!(p, json!({"id": id, "role": "payout_platform", "on": true}));
let ((), p) = one_event(&mut s, &dir, "party.role_set", |s| {
s.set_party_role(id, Role::PayoutPlatform, false).unwrap()
});
assert_eq!(p, json!({"id": id, "role": "payout_platform", "on": false}));
}
/// Oracle: Store::set_party_w9 / set_party_w4 docs (`party.w9_set` /
/// `party.w4_set` {id, facts}); facts null when cleared.
#[test]
fn w9_and_w4_journal_their_facts() {
let dir = test_dir("facts");
let (mut s, _) = fixture(&dir);
let c = s.add_party("Contractor", &[Role::Contractor]).unwrap();
let ((), p) = one_event(&mut s, &dir, "party.w9_set", |s| {
s.set_party_w9(c, Some(w9("6789"))).unwrap()
});
assert_eq!(p["id"], json!(c));
assert_eq!(p["facts"]["tin_last4"], json!("6789"));
assert_eq!(p["facts"]["received"], json!("2026-03-01"));
assert_eq!(
p["facts"]["entity_type"],
json!("individual/sole proprietor")
);
assert_eq!(p["facts"]["kept_at"], json!("wanderbooks vault"));
let back: W9Facts = serde_json::from_value(p["facts"].clone()).unwrap();
assert_eq!(back, w9("6789"));
let ((), p) = one_event(&mut s, &dir, "party.w9_set", |s| {
s.set_party_w9(c, None).unwrap()
});
assert_eq!(p, json!({"id": c, "facts": null}));
let e = s.add_party("Employee", &[Role::Employee]).unwrap();
let ((), p) = one_event(&mut s, &dir, "party.w4_set", |s| {
s.set_party_w4(e, Some(w4("UT"))).unwrap()
});
assert_eq!(
p,
json!({"id": e, "facts": {"received": "2026-03-01", "state": "UT", "kept_at": "CPA portal"}})
);
let ((), p) = one_event(&mut s, &dir, "party.w4_set", |s| {
s.set_party_w4(e, None).unwrap()
});
assert_eq!(p, json!({"id": e, "facts": null}));
}
/// Oracle: Store::set_entry_party doc (`entry.party_set {entry_id,
/// party_id}`, null when cleared).
#[test]
fn set_entry_party_journals_entry_party_set() {
let dir = test_dir("entry");
let (mut s, fx) = fixture(&dir);
let p = s.add_party("Utah", &[Role::TaxAgency]).unwrap();
let ((), pl) = one_event(&mut s, &dir, "entry.party_set", |s| {
s.set_entry_party(fx.entry, Some(p)).unwrap()
});
assert_eq!(pl, json!({"entry_id": fx.entry, "party_id": p}));
assert_eq!(s.ledger().entry_party(fx.entry), Some(p));
let ((), pl) = one_event(&mut s, &dir, "entry.party_set", |s| {
s.set_entry_party(fx.entry, None).unwrap()
});
assert_eq!(pl, json!({"entry_id": fx.entry, "party_id": null}));
assert_eq!(s.ledger().entry_party(fx.entry), None);
}
/// Oracle: add_vendor / add_customer docs ("journaled as vendor.added" /
/// "customer.added"): one event each; the party comes from the core
/// auto-link (so from replay), not a second event.
#[test]
fn add_vendor_and_customer_journal_one_event_and_link_a_party() {
let dir = test_dir("records");
let (mut s, fx) = fixture(&dir);
let (v, _) = one_event(&mut s, &dir, "vendor.added", |s| {
s.add_vendor("Acme").unwrap()
});
let (c, _) = one_event(&mut s, &dir, "customer.added", |s| {
s.add_customer("ACME").unwrap()
});
let pv = s.ledger().party_of_vendor(v).unwrap();
assert_eq!(s.ledger().party_of_customer(c), Some(pv));
// set_entry_vendor: one entry.vendor_set line, and the party is tagged.
let ((), pl) = one_event(&mut s, &dir, "entry.vendor_set", |s| {
s.set_entry_vendor(fx.entry, Some(v)).unwrap()
});
assert_eq!(pl["entry_id"], json!(fx.entry));
assert_eq!(s.ledger().entry_party(fx.entry), Some(pv));
}
/// Oracle: payroll.run lines carry "employee" (the party id, or null).
#[test]
fn payroll_run_lines_carry_employee() {
let dir = test_dir("payroll");
let (mut s, fx) = fixture(&dir);
let alice = s.add_party("Alice", &[Role::Employee]).unwrap();
s.run_payroll(
date(2026, 2, 27),
vec![pay_line("Alice", Some(alice)), pay_line("Bob", None)],
fx.cash,
)
.unwrap();
let j = journal(&dir);
let (_, p) = j.iter().rev().find(|(t, _)| t == "payroll.run").unwrap();
let lines = p["lines"].as_array().unwrap();
assert_eq!(lines.len(), 2);
let by_name = |n: &str| {
lines
.iter()
.find(|l| l["employee_name"] == json!(n))
.unwrap()
.clone()
};
assert_eq!(by_name("Alice")["employee"], json!(alice));
assert_eq!(by_name("Bob")["employee"], Value::Null);
}
// ---------------------------------------------------------------- refusals
/// Oracle: the core SCENARIOS (refusals) and the store's journal-first
/// contract: a refusal journals nothing.
#[test]
fn refusals_journal_nothing() {
let dir = test_dir("refusals");
let (mut s, fx) = fixture(&dir);
let a = s.add_party("Alpha", &[Role::Member]).unwrap();
s.add_party("Beta", &[]).unwrap();
let emp = s.add_party("Emp", &[Role::Employee]).unwrap();
let ghost = s.ledger().next_party_id() + 50;
refused_nothing_journaled(&mut s, &dir, "blank", |s| s.add_party(" ", &[]));
refused_nothing_journaled(&mut s, &dir, "tin", |s| s.add_party("Bob 123-45-6789", &[]));
refused_nothing_journaled(&mut s, &dir, "dup", |s| s.add_party(" alpha", &[]));
refused_nothing_journaled(&mut s, &dir, "vendor role", |s| {
s.add_party("Gamma", &[Role::Vendor])
});
refused_nothing_journaled(&mut s, &dir, "update unknown", |s| {
s.update_party(ghost, PartyField::Notes(None))
});
refused_nothing_journaled(&mut s, &dir, "update tin", |s| {
s.update_party(a, PartyField::Notes(Some("12-3456789".into())))
});
refused_nothing_journaled(&mut s, &dir, "rename taken", |s| {
s.update_party(a, PartyField::Name("BETA".into()))
});
refused_nothing_journaled(&mut s, &dir, "customer role", |s| {
s.set_party_role(a, Role::Customer, true)
});
refused_nothing_journaled(&mut s, &dir, "w9 without 1099 role", |s| {
s.set_party_w9(a, Some(w9("6789")))
});
refused_nothing_journaled(&mut s, &dir, "w4 without employee", |s| {
s.set_party_w4(a, Some(w4("UT")))
});
refused_nothing_journaled(&mut s, &dir, "bad w4", |s| {
s.set_party_w4(emp, Some(w4("Utah")))
});
s.set_party_w4(emp, Some(w4("UT"))).unwrap();
refused_nothing_journaled(&mut s, &dir, "drop employee with w4", |s| {
s.set_party_role(emp, Role::Employee, false)
});
refused_nothing_journaled(&mut s, &dir, "entry party unknown", |s| {
s.set_entry_party(fx.entry, Some(ghost))
});
refused_nothing_journaled(&mut s, &dir, "entry unknown", |s| {
s.set_entry_party(fx.entry + 99, Some(a))
});
refused_nothing_journaled(&mut s, &dir, "payroll non-employee", |s| {
s.run_payroll(date(2026, 2, 27), vec![pay_line("Alpha", Some(a))], fx.cash)
});
refused_nothing_journaled(&mut s, &dir, "payroll unknown party", |s| {
s.run_payroll(
date(2026, 2, 27),
vec![pay_line("Ghost", Some(ghost))],
fx.cash,
)
});
}
// ---------------------------------------------------------------- replay
/// Oracle: Store::verify INVARIANT 2 (a fresh replay equals the live
/// ledger) and the journal as system of record: reopening, with or
/// without the database, gives the same directory, tags, and runs.
fn replay_check(name: &str, named_employee: bool, verify: bool) {
let dir = test_dir(name);
let s = rich(&dir, named_employee);
if verify {
s.verify().unwrap();
}
let live = view(s.ledger());
let live_ledger = s.ledger().clone();
assert!(live.parties.len() >= 5);
drop(s);
let s = Store::open(&dir).unwrap();
assert_eq!(view(s.ledger()), live, "reopen");
assert!(
*s.ledger() == live_ledger,
"reopen: {:?}",
s.ledger().differs_from(&live_ledger)
);
if verify {
s.verify().unwrap();
}
drop(s);
delete_db(&dir);
let s = Store::open(&dir).unwrap();
assert_eq!(view(s.ledger()), live, "replay without the database");
assert!(
*s.ledger() == live_ledger,
"no db: {:?}",
s.ledger().differs_from(&live_ledger)
);
if verify {
s.verify().unwrap();
}
}
#[test]
fn replay_on_reopen_and_without_db_gives_an_equal_ledger() {
replay_check("replay", false, true);
}
/// Same, with the payroll run naming its employee's party: replay must
/// still agree (verify is the next test).
#[test]
fn replay_with_a_named_employee_gives_an_equal_ledger() {
replay_check("replay-employee", true, false);
}
/// Oracle: Store::verify INVARIANT 2 ("the SQLite read-back's ... payroll
/// runs equal the replay's") with a run whose line names its employee.
#[test]
fn verify_passes_with_a_named_employee() {
replay_check("verify-employee", true, true);
}
// ---------------------------------------------------------------- corruption
/// Append `ty`/`payload` with a valid chain, then expect open to refuse.
fn assert_corrupt_on_open(name: &str, setup: impl FnOnce(&mut Store) -> Value, ty: &str) {
let dir = test_dir(name);
let (mut s, _) = fixture(&dir);
let payload = setup(&mut s);
drop(s);
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
w.append(ty, payload.clone()).unwrap();
drop(w);
if let Ok(_s) = Store::open(&dir) {
panic!("{name}: a corrupt {ty} line {payload} opened");
}
delete_db(&dir);
if let Ok(_s) = Store::open(&dir) {
panic!("{name}: a corrupt {ty} line {payload} opened without the db");
}
}
/// Oracle: the payloads' documented shapes (Role keys, W9Facts/W4Facts
/// and their check, integer ids, party.added's id = the next id) and the
/// invariants: a line that cannot replay is refused on open.
#[test]
fn corrupt_party_payloads_are_refused_on_open() {
assert_corrupt_on_open(
"bad-role",
|s| json!({"id": s.add_party("A", &[]).unwrap(), "role": "boss", "on": true}),
"party.role_set",
);
assert_corrupt_on_open(
"record-role",
|s| json!({"id": s.add_party("A", &[]).unwrap(), "role": "vendor", "on": true}),
"party.role_set",
);
assert_corrupt_on_open(
"role-on-not-bool",
|s| json!({"id": s.add_party("A", &[]).unwrap(), "role": "member", "on": "yes"}),
"party.role_set",
);
assert_corrupt_on_open(
"w9-shape",
|s| json!({"id": s.add_party("A", &[Role::Contractor]).unwrap(), "facts": {"nonsense": 1}}),
"party.w9_set",
);
assert_corrupt_on_open(
"w9-full-tin",
|s| {
let mut f = serde_json::to_value(w9("6789")).unwrap();
f["tin_last4"] = json!("123456789");
json!({"id": s.add_party("A", &[Role::Contractor]).unwrap(), "facts": f})
},
"party.w9_set",
);
assert_corrupt_on_open(
"w9-no-1099-role",
|s| {
json!({"id": s.add_party("A", &[Role::Member]).unwrap(),
"facts": serde_json::to_value(w9("6789")).unwrap()})
},
"party.w9_set",
);
assert_corrupt_on_open(
"w4-bad-state",
|s| {
json!({"id": s.add_party("A", &[Role::Employee]).unwrap(),
"facts": {"received": "2026-03-01", "state": "Utah", "kept_at": "x"}})
},
"party.w4_set",
);
assert_corrupt_on_open(
"updated-bad-field",
|s| json!({"id": s.add_party("A", &[]).unwrap(), "change": {"field": "ssn", "value": "x"}}),
"party.updated",
);
for (i, bad) in [json!("3"), json!(-3), json!({"id": 1}), json!(true)]
.into_iter()
.enumerate()
{
assert_corrupt_on_open(
&format!("party-id-{i}"),
|s| {
s.add_party("A", &[]).unwrap();
json!({"entry_id": 0, "party_id": bad})
},
"entry.party_set",
);
}
assert_corrupt_on_open(
"added-id-mismatch",
|s| json!({"id": s.ledger().next_party_id() + 5, "name": "X", "roles": []}),
"party.added",
);
assert_corrupt_on_open(
"added-bad-role",
|s| json!({"id": s.ledger().next_party_id(), "name": "X", "roles": ["boss"]}),
"party.added",
);
assert_corrupt_on_open(
"added-full-tin",
|s| json!({"id": s.ledger().next_party_id(), "name": "X 123456789", "roles": []}),
"party.added",
);
}
/// Control for the corruption test: a well-formed hand-appended
/// `party.added` and `entry.party_set` open and replay (so the refusals
/// above are about the payloads, not about appending by hand).
#[test]
fn a_well_formed_appended_party_line_opens() {
let dir = test_dir("well-formed");
let (s, fx) = fixture(&dir);
let next = s.ledger().next_party_id();
drop(s);
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
w.append(
"party.added",
json!({"id": next, "name": "Apple", "roles": ["payout_platform"]}),
)
.unwrap();
w.append(
"entry.party_set",
json!({"entry_id": fx.entry, "party_id": next}),
)
.unwrap();
drop(w);
let s = Store::open(&dir).unwrap();
let p = s.ledger().get_party(next).unwrap();
assert_eq!(p.name, "Apple");
assert_eq!(p.roles, [Role::PayoutPlatform].into_iter().collect());
assert_eq!(s.ledger().entry_party(fx.entry), Some(next));
s.verify().unwrap();
}
// ---------------------------------------------------------------- migration
/// Copy `from`'s journal into a fresh dir via `JournalWriter`, letting
/// `edit` drop or rewrite lines (a valid chain, old-format payloads).
fn rewrite_journal(from: &Path, to: &Path, edit: impl Fn(&str, Value) -> Option<Value>) {
let mut w = JournalWriter::open(&to.join(JOURNAL_FILE)).unwrap();
for (t, p) in journal(from) {
if let Some(p) = edit(&t, p) {
w.append(&t, p).unwrap();
}
}
}
/// Oracle: docs "Migration": old journals replay unchanged; vendor.added
/// and customer.added create parties; payroll lines from before carry the
/// name only (`employee` absent). INVARIANT 4 holds after replay.
#[test]
fn books_from_before_parties_open_and_get_parties_by_replay() {
let src = test_dir("old-src");
let (mut s, fx) = fixture(&src);
let v1 = s.add_vendor("Namecheap").unwrap();
let v2 = s.add_vendor("Apple").unwrap();
let c1 = s.add_customer("apple").unwrap();
let c2 = s.add_customer("Beta Tester").unwrap();
s.set_entry_vendor(fx.entry, Some(v1)).unwrap();
s.run_payroll(date(2026, 2, 27), vec![pay_line("Alice", None)], fx.cash)
.unwrap();
drop(s);
let dst = test_dir("old-dst");
rewrite_journal(&src, &dst, |t, mut p| {
assert!(
!t.starts_with("party.") && t != "entry.party_set",
"{t} in old books"
);
if t == "payroll.run" {
for l in p["lines"].as_array_mut().unwrap() {
l.as_object_mut().unwrap().remove("employee");
}
}
Some(p)
});
let s = Store::open(&dst).unwrap();
let l = s.ledger();
// INVARIANT 4: each record links to exactly one party.
for (vid, name) in [(v1, "Namecheap"), (v2, "Apple")] {
let pid = l.party_of_vendor(vid).expect("vendor party");
let owners: Vec<_> = l.parties().filter(|p| p.vendor == Some(vid)).collect();
assert_eq!(owners.len(), 1);
assert_eq!(owners[0].id, pid);
assert_eq!(owners[0].name, name);
assert!(owners[0].all_roles().contains(&Role::Vendor));
}
for cid in [c1, c2] {
let owners: Vec<_> = l.parties().filter(|p| p.customer == Some(cid)).collect();
assert_eq!(owners.len(), 1);
assert_eq!(l.party_of_customer(cid), Some(owners[0].id));
}
// The auto-link spec: "apple" the customer joins "Apple" the vendor.
assert_eq!(l.party_of_customer(c1), l.party_of_vendor(v2));
assert_eq!(l.parties().count(), 3);
// entry.vendor_set replays as the party tag too.
assert_eq!(l.entry_vendor(fx.entry), Some(v1));
assert_eq!(l.entry_party(fx.entry), l.party_of_vendor(v1));
// The old payroll run has the name only.
let runs = l.list_payroll_runs();
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].lines[0].employee_name, "Alice");
assert_eq!(runs[0].lines[0].employee, None);
s.verify().unwrap();
let before = view(s.ledger());
drop(s);
delete_db(&dst);
let s = Store::open(&dst).unwrap();
assert_eq!(view(s.ledger()), before);
}
+1
View File
@@ -1197,6 +1197,7 @@ mod refusals {
day(d),
vec![ledger_core::PayrollLine {
employee_name: "Pat".into(),
employee: None,
gross_pay: Decimal::new(g, 2),
federal_withholding: Decimal::new(w, 2),
state_withholding: Decimal::ZERO,