P3: rename accounts, one set of period defaults, tax categories warn or refuse (defects 15, 16, 8)
check / check (push) Canceled after 0s

books.rename_account; account names trimmed, bounded, refused when a
sibling's or TIN-shaped; books.show refuses what its target ignores. A
report asked for without dates covers this fiscal year so far, everywhere
(one rule for tools, descriptions and the viewer; the IIF and CPA package
keep their own dates). policy.require_tax_category warns or refuses; a
Form 4797 gain is shown apart on every return.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-21 10:41:24 -06:00
1 parent 8255ac2103
commit 9ab003d0d1
69 files changed
+8063 -570

No files matched your search

+36 -7
View File
@@ -18,6 +18,12 @@
//! whitespace). A newer format adds its fields inside `payload`. So this
//! version can always follow the chain one line past the first line it
//! can't read, and say whether that line is intact.
//!
//! A line an earlier version can't read is announced by a feature
//! ([`KNOWN_FEATURES`]): the writer declares it (`journal.format_set`,
//! the features in force and the new one) just before the first line that
//! needs it, so the earlier reader stops at the declaration, and books
//! that never use the feature stay readable by it.
use crate::error::StoreError;
use crate::journal::{JournalLine, ReadLine};
@@ -26,13 +32,18 @@ use serde_json::{Value, json};
/// The newest journal format this version reads and writes.
pub const SUPPORTED_JOURNAL_FORMAT: u32 = 2;
/// Optional journal features this version reads. Each is a field or value
/// on an event type an older version knows, which that version would
/// misread silently; a writer declares the feature (`journal.format_set`)
/// before its first such line, so the older version stops there instead
/// (rules.md B-27). Books that never use one stay readable by versions
/// before it.
pub const KNOWN_FEATURES: &[&str] = &[MEMBERS_CUMULATIVE];
/// Optional journal features this version reads. Each marks lines an
/// older version can't read: a new event type (which it would refuse as
/// unknown), or a field or value on an event type it knows (which it
/// would misread silently). A writer declares the feature
/// (`journal.format_set`) before its first such line, so the older
/// version stops there instead (rules.md B-27). Books that never use one
/// stay readable by versions before it.
pub const KNOWN_FEATURES: &[&str] = &[
MEMBERS_CUMULATIVE,
FEATURE_ACCOUNT_RENAMED,
FEATURE_REQUIRE_TAX_CATEGORY,
];
/// Feature: `period.closed` closes to members with `"rounding":
/// "cumulative_since"` and `since_seq`, the count of members closes
@@ -42,6 +53,23 @@ pub const KNOWN_FEATURES: &[&str] = &[MEMBERS_CUMULATIVE];
/// rule, which can split it differently.
pub const MEMBERS_CUMULATIVE: &str = "members_cumulative";
/// The feature declared before the first `account.renamed` (P3, defect
/// 15).
pub const FEATURE_ACCOUNT_RENAMED: &str = "account_renamed";
/// The feature declared before the first `settings.set` naming
/// `policy.require_tax_category` (P3, defect 8).
pub const FEATURE_REQUIRE_TAX_CATEGORY: &str = "require_tax_category";
/// The feature a `settings.set` naming `key` needs declared first: a key
/// an earlier version doesn't know.
pub(crate) fn setting_feature(key: &str) -> Option<&'static str> {
match key {
"policy.require_tax_category" => Some(FEATURE_REQUIRE_TAX_CATEGORY),
_ => None,
}
}
/// The line type that declares the journal format.
pub const FORMAT_SET: &str = "journal.format_set";
@@ -49,6 +77,7 @@ pub const FORMAT_SET: &str = "journal.format_set";
/// projection arm in `store.rs` (`tests/format.rs` checks both ways).
pub const EVENT_TYPES: &[&str] = &[
"account.added",
"account.renamed",
"account.renumbered",
"account.tax_set",
"asset.acquisition_linked",
+33 -6
View File
@@ -36,6 +36,28 @@ pub const VIEWER_LOCK: &str = "viewer.lock";
/// The most show requests kept.
pub const KEPT: usize = 32;
/// The most columns the viewer splits a report into: past it, the
/// viewer shows the report in one total column and says why, so
/// `books.show` refuses such a split (it would be an option the viewer
/// ignores).
pub const MAX_REPORT_COLUMNS: i64 = 240;
/// How many columns splitting `start..=end` into periods of
/// `months_per_column` months makes (each started period a column),
/// counted from the months, before building any: a range across chrono's
/// calendar is millions of months. The one count `books.show` and the
/// viewer share.
pub fn report_columns(
start: chrono::NaiveDate,
end: chrono::NaiveDate,
months_per_column: u32,
) -> i64 {
use chrono::Datelike;
let months = (i64::from(end.year()) - i64::from(start.year())) * 12 + i64::from(end.month())
- i64::from(start.month());
months / i64::from(months_per_column.max(1)) + 1
}
/// Where to go.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "show", rename_all = "snake_case")]
@@ -51,24 +73,29 @@ pub enum ViewerTarget {
/// Entry id.
entry_id: u64,
},
/// An account's register, optionally for a date range.
/// An account's register, for a date range.
Account {
/// Account code.
code: String,
/// First date, YYYY-MM-DD.
/// First date, YYYY-MM-DD. `books.show` always sends both dates,
/// resolved (rules.md B-30); `None` only from an earlier writer,
/// which the viewer resolves by the same rule.
start: Option<String>,
/// Last date, YYYY-MM-DD.
/// Last date, YYYY-MM-DD (see `start`).
end: Option<String>,
},
/// A report, optionally for a date range.
/// A report, for a date range.
Report {
/// profit_and_loss, balance_sheet, cash_flow, members_equity,
/// trial_balance, general_ledger, tax_summary, vendor_1099,
/// ar_aging, ap_aging, fixed_asset_register.
report: String,
/// First date, YYYY-MM-DD.
/// First date, YYYY-MM-DD. `books.show` always sends both dates,
/// resolved (rules.md B-30); `None` only from an earlier writer,
/// which the viewer resolves by the same rule.
start: Option<String>,
/// Last date, YYYY-MM-DD.
/// Last date, YYYY-MM-DD (see `start`): the as-of day of a report
/// as of a day.
end: Option<String>,
/// Split into columns: total, month, quarter, or year.
#[serde(default)]
+186 -17
View File
@@ -584,24 +584,36 @@ impl Store {
}
/// Declare a journal feature (crate::format::KNOWN_FEATURES) before the
/// first event that needs it: a field or value on a known event type
/// that an older version would otherwise misread silently. Declared
/// with the format in force (at least 2) and every feature already in
/// force; nothing is journaled when it is already declared. Books that
/// never use the feature stay readable by older versions.
/// first line that needs it: a new event type, or a field or value on
/// a known event type that an older version would otherwise misread
/// silently. Declared with the format in force (at least format 2,
/// crate::meta::FORMAT) and every feature already in force; nothing is
/// journaled when it is already declared. Books that never use the
/// feature stay readable by older versions.
///
/// ```text
/// GOAL: an older wanderbooks stops at the declaration (NewerJournal)
/// instead of misreading a line it thinks it knows.
/// instead of misreading, or failing on, a line it can't read.
/// SOURCE: rules.md B-27; plan/served.md 5.1 (format gate).
/// ```
fn ensure_feature(&mut self, feature: &str) -> Result<(), StoreError> {
if self.format.features.iter().any(|f| f == feature) {
self.ensure_features(&[feature])
}
/// [`Self::ensure_feature`] for several at once: one declaration
/// naming every one not yet in force, or none when all are.
fn ensure_features(&mut self, needed: &[&str]) -> Result<(), StoreError> {
let mut features: Vec<String> = self.format.features.clone();
let before = features.len();
for f in needed {
if !features.iter().any(|g| g == f) {
features.push(f.to_string());
}
}
if features.len() == before {
return Ok(());
}
let format = self.format.format.max(crate::meta::FORMAT);
let mut features = self.format.features.clone();
features.push(feature.to_string());
let features: Vec<&str> = features.iter().map(String::as_str).collect();
self.declare_journal_format(format, &features)
}
@@ -642,6 +654,14 @@ impl Store {
if changes.is_empty() && !(adopt && !self.ledger.settings().is_journaled()) {
return Ok(changes);
}
// A key an older version doesn't know needs its feature declared
// first (crate::format::setting_feature), in the one declaration
// that also brings the books to format 2.
let needed: Vec<&str> = changes
.iter()
.filter_map(|c| crate::format::setting_feature(&c.key))
.collect();
self.ensure_features(&needed)?;
self.ensure_format()?;
let line =
self.append_journal("settings.set", crate::meta::settings_set(&changes, source))?;
@@ -827,7 +847,8 @@ impl Store {
self.journal.append(line_type, payload)
}
/// Add an account to the chart.
/// Add an account to the chart, with no tax category (see
/// [`Self::add_account_with_tax`]).
pub fn add_account(
&mut self,
code: &str,
@@ -836,7 +857,73 @@ impl Store {
parent: Option<AccountId>,
placeholder: bool,
) -> Result<AccountId, StoreError> {
self.rehearse(|l| l.add_account(code, name, account_type, parent, placeholder))?;
self.add_account_with_tax(code, name, account_type, parent, placeholder, None)
}
/// Add an account to the chart (`Ledger::add_account`) with, when
/// `tax` is given, its tax category and deductible percent:
/// `account.added`, then `account.tax_set`, the two events
/// `books.import_chart_csv` has always written.
///
/// ```text
/// GOAL: an account created with the line of the return it maps to.
/// SOURCE: rules.md B-32 (policy.require_tax_category).
/// INVARIANTS:
/// 1. Rehearsed whole first: a refusal (of the account, or of its
/// category) journals nothing.
/// 2. Under policy.require_tax_category = refuse, a revenue or
/// expense account that isn't a header and has no category is
/// refused (TaxCategoryRequired), journaling nothing.
/// 3. No new event and no new field: a power cut between the two
/// appends leaves an account without a category, the warn
/// state, which Needs attention lists; never a damaged journal.
/// 4. The name is journaled trimmed, as `Ledger::add_account` keeps
/// it and a rename journals one, so live equals replay.
/// ```
pub fn add_account_with_tax(
&mut self,
code: &str,
name: &str,
account_type: AccountType,
parent: Option<AccountId>,
placeholder: bool,
tax: Option<(&str, Option<Decimal>)>,
) -> Result<AccountId, StoreError> {
let name = name.trim();
let (category, pct) = match tax {
Some((c, p)) => (Some(c), p),
None => (None, None),
};
self.rehearse(|l| {
let id = l.add_account(code, name, account_type, parent, placeholder)?;
if category.is_some() {
l.set_account_tax(id, category, pct)?;
}
Ok(id)
})?;
self.ledger
.check_tax_category_required(code, name, account_type, placeholder, category)?;
let id =
self.journal_account_added(code, name, account_type, parent, placeholder, |l| {
l.add_account(code, name, account_type, parent, placeholder)
})?;
if category.is_some() {
self.journal_account_tax(id, category, pct)?;
}
Ok(id)
}
/// Journal `account.added` and apply it with `add` (the command the
/// caller rehearsed).
fn journal_account_added(
&mut self,
code: &str,
name: &str,
account_type: AccountType,
parent: Option<AccountId>,
placeholder: bool,
add: impl FnOnce(&mut Ledger) -> Result<AccountId, LedgerError>,
) -> Result<AccountId, StoreError> {
// Pure pre-validation replicating ChartOfAccounts::add, so the
// journal write below cannot be followed by a failed mutation.
let chart = self.ledger.chart();
@@ -864,9 +951,7 @@ impl Store {
"currency": self.base_currency.code(),
}),
)?;
let live = self
.ledger
.add_account(code, name, account_type, parent, placeholder)?;
let live = add(&mut self.ledger)?;
debug_assert_eq!(live, id, "id allocator diverged from journal");
apply_line_to_sqlite(&self.conn, &line)?;
Ok(live)
@@ -1970,7 +2055,10 @@ impl Store {
/// code), creating it at its code through the journaled
/// account path when missing. The ledger's internal ensure_account
/// then no-ops, which keeps replay deterministic: every account
/// creation is an `account.added` event.
/// creation is an `account.added` event. The engine's account
/// (`Ledger::add_control_account`): never refused for its name or a
/// missing tax category (rules.md B-32), so an invoice, a bill or a
/// payroll run is never refused over a chart label.
fn ensure_control_account(
&mut self,
c: &ledger_core::ControlAccount,
@@ -1978,7 +2066,10 @@ impl Store {
if let Some(id) = self.ledger.role_account(c) {
return Ok(id);
}
self.add_account(c.code, c.name, c.account_type, None, false)
self.rehearse(|l| l.add_control_account(c))?;
self.journal_account_added(c.code, c.name, c.account_type, None, false, |l| {
l.add_control_account(c)
})
}
/// Journal an auto-posted two-line entry (debit one account, credit
@@ -2854,6 +2945,58 @@ impl Store {
Ok(())
}
/// Give an account a new name (see `Ledger::rename_account`), with an
/// optional reason. Journaled as `account.renamed`, keyed by the
/// account's id (a code can change; an id can't), with its code, the
/// new name, the name it had and the reason. The first rename declares
/// the journal feature `account_renamed`
/// ([`crate::format::FEATURE_ACCOUNT_RENAMED`]), so an earlier version
/// stops at the declaration.
///
/// ```text
/// GOAL: rename an account, keeping the name it had in the journal.
/// SOURCE: rules.md B-31; Rev. Proc. 98-25 (docs/spec/sources/
/// irs-records.md): a report printed under the old name can
/// be tied to the new one.
/// INVARIANTS:
/// 1. Only the account's name changes (Ledger::rename_account
/// INVARIANT 1): every entry, line, hash and earlier journal line
/// stays as it was.
/// 2. Rehearsed and checked first: a refusal journals nothing, not
/// even the feature's declaration.
/// ```
pub fn rename_account(
&mut self,
account: AccountId,
name: &str,
reason: Option<&str>,
) -> Result<(), StoreError> {
self.rehearse(|l| l.rename_account(account, name))?;
self.ledger.check_rename_account(account, name)?;
let reason = reason.map(Ledger::check_rename_reason).transpose()?;
let a = self
.ledger
.chart()
.get(account)
.ok_or(LedgerError::UnknownAccount(account))?;
let (code, previous) = (a.code.clone(), a.name.clone());
let name = name.trim();
self.ensure_feature(crate::format::FEATURE_ACCOUNT_RENAMED)?;
let line = self.append_journal(
"account.renamed",
json!({
"account_id": account,
"code": code,
"name": name,
"previous": previous,
"reason": reason,
}),
)?;
self.ledger.rename_account(account, name)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Assign a control role an account, or (`None`) return it to its code
/// (see `Ledger::assign_role`). Journaled as `role.assigned`, with the
/// account that filled the role before.
@@ -2874,7 +3017,10 @@ impl Store {
}
/// Set (or clear) an account's tax category and deductible percent.
/// Journaled as `account.tax_set`.
/// Journaled as `account.tax_set`. Under
/// `policy.require_tax_category = refuse`, an account that needs a
/// category is never left without one (rules.md B-32;
/// `Ledger::check_tax_category_kept`).
pub fn set_account_tax(
&mut self,
account: AccountId,
@@ -2882,6 +3028,17 @@ impl Store {
deductible_percent: Option<Decimal>,
) -> Result<(), StoreError> {
self.rehearse(|l| l.set_account_tax(account, category, deductible_percent))?;
self.ledger.check_tax_category_kept(account, category)?;
self.journal_account_tax(account, category, deductible_percent)
}
/// Journal `account.tax_set` and apply it (the policy is the caller's).
fn journal_account_tax(
&mut self,
account: AccountId,
category: Option<&str>,
deductible_percent: Option<Decimal>,
) -> Result<(), StoreError> {
self.ledger
.check_account_tax(account, category, deductible_percent)?;
let line = self.append_journal(
@@ -4585,6 +4742,12 @@ pub(crate) fn replay_line_into_ledger(
"account.renumbered" => {
ledger.renumber_account(get_u64(p, "account_id")?, get_str(p, "code")?)?;
}
"account.renamed" => {
// Only that the account exists: the name rules are the
// command's (rules.md B-28). `code`, `previous` and `reason`
// are the record, not state.
ledger.replay_rename_account(get_u64(p, "account_id")?, get_str(p, "name")?)?;
}
"role.assigned" => {
let account = match p.get("account_id") {
None | Some(Value::Null) => None,
@@ -5535,6 +5698,12 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
params![get_str(p, "code")?, get_u64(p, "account_id")? as i64],
)?;
}
"account.renamed" => {
conn.execute(
"UPDATE accounts SET name = ? WHERE id = ?",
params![get_str(p, "name")?, get_u64(p, "account_id")? as i64],
)?;
}
"account.tax_set" => {
conn.execute(
"UPDATE accounts SET tax_category = ?, deductible_percent = ? WHERE id = ?",
@@ -0,0 +1,107 @@
[
{
"account_type": "Asset",
"code": "1000",
"currency": [
85,
83,
68
],
"deductible_percent": null,
"id": 1,
"name": "Bank",
"parent": null,
"placeholder": false,
"tax_category": null
},
{
"account_type": "Equity",
"code": "3000",
"currency": [
85,
83,
68
],
"deductible_percent": null,
"id": 2,
"name": "Capital",
"parent": null,
"placeholder": false,
"tax_category": null
},
{
"account_type": "Expense",
"code": "6000",
"currency": [
85,
83,
68
],
"deductible_percent": null,
"id": 3,
"name": "Operating expenses",
"parent": null,
"placeholder": true,
"tax_category": null
},
{
"account_type": "Expense",
"code": "6150",
"currency": [
85,
83,
68
],
"deductible_percent": null,
"id": 4,
"name": "Software",
"parent": 3,
"placeholder": false,
"tax_category": "other_deductions"
},
{
"account_type": "Expense",
"code": "6160",
"currency": [
85,
83,
68
],
"deductible_percent": null,
"id": 5,
"name": "Hosting",
"parent": 3,
"placeholder": false,
"tax_category": null
},
{
"account_type": "Revenue",
"code": "4000",
"currency": [
85,
83,
68
],
"deductible_percent": null,
"id": 6,
"name": "Sales",
"parent": null,
"placeholder": false,
"tax_category": "gross_receipts"
},
{
"account_type": "Expense",
"code": "6170",
"currency": [
85,
83,
68
],
"deductible_percent": null,
"id": 7,
"name": "Domains",
"parent": 3,
"placeholder": false,
"tax_category": "other_deductions"
}
]
@@ -0,0 +1,154 @@
{
"assets": [],
"bills": [],
"entries": [
{
"date": "2026-01-05",
"description": "fixture",
"id": 1,
"lines": [
{
"account": 1,
"amount": {
"amount": "5000.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 2,
"amount": {
"amount": "5000.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-02-03",
"description": "fixture",
"id": 2,
"lines": [
{
"account": 4,
"amount": {
"amount": "49.99",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 1,
"amount": {
"amount": "49.99",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-03-09",
"description": "fixture",
"id": 3,
"lines": [
{
"account": 4,
"amount": {
"amount": "49.99",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 1,
"amount": {
"amount": "49.99",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
}
],
"entry_parties": [],
"invoices": [],
"payroll_runs": [],
"periods": [],
"recurring": [],
"schedules": [],
"snapshot": {
"Ok": {
"balance_sheet": {
"assets": "4900.02",
"balanced": true,
"current_earnings": "-99.98",
"equity": "4900.02",
"liabilities": "0"
},
"profit_and_loss": {
"expenses": "99.98",
"net_income": "-99.98",
"revenue": "0"
},
"trial_balance": {
"lines": [
{
"code": "1000",
"credit": "0",
"debit": "4900.02",
"name": "Bank"
},
{
"code": "3000",
"credit": "5000",
"debit": "0",
"name": "Capital"
},
{
"code": "6150",
"credit": "0",
"debit": "99.98",
"name": "Software"
}
],
"total_credits": "5000",
"total_debits": "5000"
}
}
},
"verify": {
"Ok": null
}
}
@@ -0,0 +1,30 @@
{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-21T02:31:59Z","type":"store.created"}
{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Bank","parent":null,"placeholder":false},"prev":"b94228016ddcf3ce47c1682e75a6d7e78b63aa5894a2503701d990d55f7b99e1","seq":2,"ts":"2026-10-21T02:31:59Z","type":"account.added"}
{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":2,"name":"Capital","parent":null,"placeholder":false},"prev":"41e78ca2067b2531e40aa27b89632ed1d29ea2f4c47ab05f4ea0fe7ed448358a","seq":3,"ts":"2026-10-21T02:31:59Z","type":"account.added"}
{"payload":{"account_type":"Expense","code":"6000","currency":"USD","id":3,"name":"Operating","parent":null,"placeholder":true},"prev":"7144fbc2d1ddca0f84b1b40c4be7c2ed20dfd3eaa5e85d82d21ee8e76f03d568","seq":4,"ts":"2026-10-21T02:31:59Z","type":"account.added"}
{"payload":{"account_type":"Expense","code":"6150","currency":"USD","id":4,"name":"Software","parent":3,"placeholder":false},"prev":"e35fee15325262d429dea1bb6dd0c2f462ac3fc476714d2a93cd328c506f0dca","seq":5,"ts":"2026-10-21T02:31:59Z","type":"account.added"}
{"payload":{"account_type":"Expense","code":"6160","currency":"USD","id":5,"name":"Hosting","parent":3,"placeholder":false},"prev":"48dbed424f0aa33a85bb9b9b1b9c2904e7c2f853c6d261bd14624b178cdf14f3","seq":6,"ts":"2026-10-21T02:31:59Z","type":"account.added"}
{"payload":{"account_type":"Revenue","code":"4000","currency":"USD","id":6,"name":"Sales","parent":null,"placeholder":false},"prev":"3565bf2bd6033efa6addb4bc757e21612b154ba5aac19a675977238d713f522f","seq":7,"ts":"2026-10-21T02:31:59Z","type":"account.added"}
{"payload":{"account_id":6,"deductible_percent":null,"tax_category":"gross_receipts"},"prev":"c668734a18c316009542e1b0f81c6f41b0b1bb67acc80a224931bd0f5f22b255","seq":8,"ts":"2026-10-21T02:31:59Z","type":"account.tax_set"}
{"payload":{"features":["require_tax_category"],"format":2},"prev":"fe53bfcaeb90665ff374ce22173e9719786601649aabfd527d4a1cd080386dc3","seq":9,"ts":"2026-10-21T02:31:59Z","type":"journal.format_set"}
{"payload":{"changes":[{"key":"policy.require_tax_category","value":"refuse"}],"source":{"decided_by":"Owner","decided_on":"2026-10-20"}},"prev":"3fd65f0c9fceecf686ae855e8a36565c701b65029a48a9919623e79156dfc19f","seq":10,"ts":"2026-10-21T02:31:59Z","type":"settings.set"}
{"payload":{"account_type":"Expense","code":"6170","currency":"USD","id":7,"name":"Domains","parent":3,"placeholder":false},"prev":"8349d624d6c4e5d5cb6e68c7e457be09c95e094f51660acdc8c9911552df2caf","seq":11,"ts":"2026-10-21T02:31:59Z","type":"account.added"}
{"payload":{"account_id":7,"deductible_percent":null,"tax_category":"other_deductions"},"prev":"6f0a5105551325ff8c46a0e6ae5db60a74a813396c2021b5b42c2162d39c9deb","seq":12,"ts":"2026-10-21T02:31:59Z","type":"account.tax_set"}
{"payload":{"date":"2026-01-05","description":"fixture","id":1},"prev":"11ada13dda80e0a91a1bfa8b87b96fd1e818b5e3e5c52960e098ae8c3cd32675","seq":13,"ts":"2026-10-21T02:31:59Z","type":"entry.created"}
{"payload":{"account":1,"amount":"5000.00","currency":"USD","id":1,"side":"debit"},"prev":"83871fcd54722182038b10357f628f37fb2d2562b9eb9f793f7a36d865c60f73","seq":14,"ts":"2026-10-21T02:31:59Z","type":"entry.line_added"}
{"payload":{"account":2,"amount":"5000.00","currency":"USD","id":1,"side":"credit"},"prev":"5cbc319bf98bc3b490572af050aa42e5f36e9eb21ea02d333cdcdaedbdd61b53","seq":15,"ts":"2026-10-21T02:31:59Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-05","description":"fixture","id":1,"lines":[{"account":1,"amount":"5000.00","currency":"USD","side":"debit"},{"account":2,"amount":"5000.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"dfec8a0b1b77bdd9de9993d8f5cd5408275c49480d7ba562d5033e606d0cab0f","seq":16,"ts":"2026-10-21T02:31:59Z","type":"entry.posted"}
{"payload":{"date":"2026-02-03","description":"fixture","id":2},"prev":"94895f542d8b6eff47ab2e78b4b1983205e601b6dfafa3e47e87cce869876c94","seq":17,"ts":"2026-10-21T02:31:59Z","type":"entry.created"}
{"payload":{"account":4,"amount":"49.99","currency":"USD","id":2,"side":"debit"},"prev":"70b7d2bd0ba00224ce8bad014c6ecbc0b9921b45dfc48cdb1c44a16fe722951c","seq":18,"ts":"2026-10-21T02:31:59Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"49.99","currency":"USD","id":2,"side":"credit"},"prev":"7145687d599c654a7402f37a189cb96aaf2aac03463a48519a4c8b1f5b8ec0cc","seq":19,"ts":"2026-10-21T02:31:59Z","type":"entry.line_added"}
{"payload":{"date":"2026-02-03","description":"fixture","id":2,"lines":[{"account":4,"amount":"49.99","currency":"USD","side":"debit"},{"account":1,"amount":"49.99","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"104f0938e13f9811c8c4963145a415947740dc3e4efc9dfc2120661494a82fee","seq":20,"ts":"2026-10-21T02:31:59Z","type":"entry.posted"}
{"payload":{"date":"2026-03-09","description":"fixture","id":3},"prev":"6dd47d7450a5e451545e8fbca6b9a13d4d898f8571e9ba93dfa5e4b6b6ccd473","seq":21,"ts":"2026-10-21T02:31:59Z","type":"entry.created"}
{"payload":{"account":4,"amount":"49.99","currency":"USD","id":3,"side":"debit"},"prev":"dc00c733f073a6c1ef74b2461df69cb2642981c73f86efdc2d5513e4674766ca","seq":22,"ts":"2026-10-21T02:31:59Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"49.99","currency":"USD","id":3,"side":"credit"},"prev":"5065fceb3a44ccb628ea44b509c4c98642445b2fa99585761e0dfc38816cffdd","seq":23,"ts":"2026-10-21T02:31:59Z","type":"entry.line_added"}
{"payload":{"date":"2026-03-09","description":"fixture","id":3,"lines":[{"account":4,"amount":"49.99","currency":"USD","side":"debit"},{"account":1,"amount":"49.99","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"79d9e4751f740203c4a14cc25d7a352ac7e576e6c31723d82844e7b3c7f1e192","seq":24,"ts":"2026-10-21T02:31:59Z","type":"entry.posted"}
{"payload":{"features":["account_renamed","require_tax_category"],"format":2},"prev":"561f49d94956a7e4c6192116a22e9152e516f1cbe01591ec6f0b7e674cd3d7c9","seq":25,"ts":"2026-10-21T02:31:59Z","type":"journal.format_set"}
{"payload":{"account_id":4,"code":"6150","name":"Software subscriptions","previous":"Software","reason":"owner asked 2026-10-20"},"prev":"f8cc150e9ce15a0cfb8ecf52c4dcfb72703d0e93fb3d9c4eb08ee3ad54f4a9b3","seq":26,"ts":"2026-10-21T02:31:59Z","type":"account.renamed"}
{"payload":{"account_id":3,"code":"6000","name":"Operating expenses","previous":"Operating","reason":null},"prev":"1311f98abb25afb140df36046a53d146d5a800f5bd54605f15a904b26794a61c","seq":27,"ts":"2026-10-21T02:31:59Z","type":"account.renamed"}
{"payload":{"account_id":4,"deductible_percent":null,"tax_category":"other_deductions"},"prev":"b2a4b494604d991a0ebb99b0d69a14f2bdd11876b693810a4896ff55eecb0145","seq":28,"ts":"2026-10-21T02:31:59Z","type":"account.tax_set"}
{"payload":{"account_id":4,"code":"6150","name":"Software","previous":"Software subscriptions","reason":"back to the short name"},"prev":"3058d2fb9b9b0d94253126e083e23ded7f51ffb6b5ffc64fedeb42a0ca753749","seq":29,"ts":"2026-10-21T02:31:59Z","type":"account.renamed"}
{"payload":{"changes":[{"key":"policy.require_tax_category","value":null}],"source":{"decided_by":"Owner","decided_on":"2026-10-20"}},"prev":"f86c03b68da3205f73d131c2032ea918614b99e2ae016cebc86af7e614112bb7","seq":30,"ts":"2026-10-21T02:31:59Z","type":"settings.set"}
+106
View File
@@ -883,3 +883,109 @@ fn read_journal_and_format_gives_every_line_and_the_format() {
}
);
}
/// P3's journal extensions (rules.md B-27; plan D-39): each is announced
/// by a feature this version knows, declared once, just before the first
/// line that needs it, with the features in force kept: `account_renamed`
/// before the first `account.renamed`, `require_tax_category` before the
/// first `settings.set` naming `policy.require_tax_category`. A reader
/// whose features don't include it (every earlier version: none) stops at
/// the declaration as newer, never at the line, and never calls it
/// corrupt; books that never use either stay format 1 or 2 with no
/// feature.
#[test]
fn p3_features_are_declared_once_before_the_first_line_that_needs_them() {
for f in ["account_renamed", "require_tax_category"] {
assert!(KNOWN_FEATURES.contains(&f), "{f}");
}
let dir = test_dir("p3-features");
books(&dir);
let mut store = Store::open(&dir).unwrap();
assert!(store.journal_format().features.is_empty());
store.rename_account(1, "Business Checking", None).unwrap();
store.rename_account(1, "Checking", None).unwrap();
store
.change_settings(
&[ledger_core::settings::Change {
key: "policy.require_tax_category".into(),
value: Some(json!("refuse")),
}],
&ledger_core::settings::Source {
decided_by: "Owner".into(),
decided_on: NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(),
citation: None,
note: None,
},
)
.unwrap();
assert_eq!(
store.journal_format().features,
["account_renamed", "require_tax_category"]
);
drop(store);
let text = std::fs::read_to_string(dir.join(JOURNAL_FILE)).unwrap();
let lines: Vec<serde_json::Value> = text
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
let kinds: Vec<(&str, serde_json::Value)> = lines
.iter()
.map(|l| (l["type"].as_str().unwrap(), l["payload"].clone()))
.filter(|(t, _)| matches!(*t, FORMAT_SET | "account.renamed" | "settings.set"))
.collect();
assert_eq!(kinds[0].0, FORMAT_SET);
assert_eq!(
kinds[0].1,
json!({"format": 2, "features": ["account_renamed"]})
);
assert_eq!(kinds[1].0, "account.renamed");
assert_eq!(kinds[2].0, "account.renamed");
assert_eq!(kinds[3].0, FORMAT_SET);
assert_eq!(
kinds[3].1,
json!({"format": 2, "features": ["account_renamed", "require_tax_category"]})
);
assert_eq!(kinds[4].0, "settings.set");
assert_eq!(kinds.len(), 5);
// An earlier reader knows no feature (P2: KNOWN_FEATURES was empty),
// so the gate stops it at the first declaration, before the rename:
// the gate's refusal of a feature it doesn't know is pinned by
// an_unknown_feature_before_a_rename_is_newer_at_the_declaration.
// This version opens them, in the writer and the viewer.
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
assert_eq!(store.ledger().chart().get(1).unwrap().name, "Checking");
drop(store);
let snap = Snapshot::load(&dir).unwrap();
assert_eq!(snap.ledger().chart().get(1).unwrap().name, "Checking");
}
/// A feature this version doesn't know, declared before an account.renamed
/// (as a later version would), is newer at the declaration: the line after
/// it is never read.
#[test]
fn an_unknown_feature_before_a_rename_is_newer_at_the_declaration() {
let dir = test_dir("p3-unknown-feature");
books(&dir);
let seqs = append(
&dir,
&[
(
FORMAT_SET,
json!({"format": 2, "features": ["account_renamed", "later"]}),
),
(
"account.renamed",
json!({"account_id": 1, "code": "1000", "name": "x", "previous": "Cash", "reason": null}),
),
],
);
let _ = std::fs::remove_file(dir.join(DB_FILE));
match Store::open(&dir) {
Err(StoreError::NewerJournal { seq, need, .. }) => {
assert_eq!(seq, seqs[0]);
assert_eq!(need, "journal features later");
}
other => panic!("expected NewerJournal, got {:?}", other.map(|_| ())),
}
}
+803 -3
View File
@@ -975,6 +975,11 @@ mod refusals {
SetVendor1099(u64, bool),
SetAccountTax(u64, u8),
Renumber(u64, u8),
// P3 (defects 8, 15): renames, accounts added with a category,
// and the tax category policy turned on and off.
Rename(u64, u8),
AddAccountTax(u8, u8, u8, bool),
SetTaxPolicy(bool),
// The rest of the command surface (2026-10-13).
AttachReceipt(Option<u64>, u8),
FileDocument(u8),
@@ -1049,6 +1054,10 @@ mod refusals {
(0u64..4, any::<bool>()).prop_map(|(v, t)| Op::SetVendor1099(v, t)),
(0u64..10, 0u8..4).prop_map(|(a, k)| Op::SetAccountTax(a, k)),
(0u64..10, 0u8..9).prop_map(|(a, c)| Op::Renumber(a, c)),
(0u64..12, 0u8..8).prop_map(|(a, n)| Op::Rename(a, n)),
(0u8..8, 0u8..5, 0u8..4, any::<bool>())
.prop_map(|(c, t, k, h)| Op::AddAccountTax(c, t, k, h)),
any::<bool>().prop_map(Op::SetTaxPolicy),
(prop::option::of(id.clone()), 0u8..4).prop_map(|(e, b)| Op::AttachReceipt(e, b)),
(0u8..4).prop_map(Op::FileDocument),
(0u64..6, id.clone()).prop_map(|(a, e)| Op::LinkReceipt(a, e)),
@@ -1138,8 +1147,56 @@ mod refusals {
}]
};
match *op {
// Named for the code: a sibling's name is refused (B-31), and
// a code is the first refusal.
Op::AddAccount(c, t, p, h) => store
.add_account(CODES[c as usize], "acct", TYPES[t as usize], p, h)
.add_account(
CODES[c as usize],
&format!("Account {}", CODES[c as usize]),
TYPES[t as usize],
p,
h,
)
.map(drop),
// A setup account's own name, another's (in other capitals),
// a blank, nine digits, and fresh names.
Op::Rename(a, n) => store.rename_account(
a,
[
"Sales",
"sales",
"CASH",
"",
"Card 123456789",
"Renamed",
"Office",
"Rent ",
][n as usize],
(n % 2 == 0).then_some("owner asked"),
),
Op::AddAccountTax(c, t, k, h) => store
.add_account_with_tax(
CODES[c as usize],
&format!("Taxed {}", CODES[c as usize]),
TYPES[t as usize],
None,
h,
TAX[k as usize].map(|cat| (cat, None)),
)
.map(drop),
Op::SetTaxPolicy(refuse) => store
.change_settings(
&[ledger_core::settings::Change {
key: "policy.require_tax_category".into(),
value: Some(serde_json::json!(if refuse { "refuse" } else { "warn" })),
}],
&ledger_core::settings::Source {
decided_by: "Owner".into(),
decided_on: day(0),
citation: None,
note: None,
},
)
.map(drop),
Op::CreateEntry(d) => store.create_entry(day(d), "e").map(drop),
Op::AddLine(e, a, d, c) => {
@@ -1468,8 +1525,14 @@ mod refusals {
}
}
// The converse law: an accepted command is on the record. Only
// a batch run may find nothing to do.
if result.is_ok() && !matches!(op, Op::RunDepreciation(..) | Op::RunRecurring(..)) {
// a batch run may find nothing to do, and a setting changed to
// the value it has changes nothing.
if result.is_ok()
&& !matches!(
op,
Op::RunDepreciation(..) | Op::RunRecurring(..) | Op::SetTaxPolicy(..)
)
{
let after = std::fs::read_to_string(&journal).unwrap();
prop_assert!(
after.len() > before.len(),
@@ -2776,3 +2839,740 @@ fn a_database_with_damaged_pages_is_rebuilt() {
.count();
assert_eq!(kept, 1);
}
// ---------------------------------------------------------------------------
// P3: renaming an account, and the tax category policy (defects 15 and 8;
// rules.md B-31, B-32).
// ---------------------------------------------------------------------------
/// Every row of every projection table but the journal's own (`events`,
/// `meta`), as text, with `accounts.name` blanked: what a rename may
/// change is that one column.
fn projection_but_names(dir: &std::path::Path) -> Vec<(String, Vec<String>)> {
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
let mut tables: Vec<String> = conn
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
.unwrap()
.query_map([], |r| r.get(0))
.unwrap()
.map(Result::unwrap)
.collect();
// The journal's own rows (every line, and its head) grow with every
// command: what a rename adds there is checked from the journal.
tables.retain(|t| !t.starts_with("sqlite_") && t != "events" && t != "meta");
tables
.into_iter()
.map(|t| {
let mut stmt = conn.prepare(&format!("SELECT * FROM \"{t}\"")).unwrap();
let names: Vec<String> = stmt.column_names().iter().map(|c| c.to_string()).collect();
let mut rows: Vec<String> = stmt
.query_map([], |r| {
Ok((0..names.len())
.map(|i| {
if t == "accounts" && names[i] == "name" {
"-".to_string()
} else {
format!("{:?}", r.get_ref(i).unwrap())
}
})
.collect::<Vec<_>>()
.join("|"))
})
.unwrap()
.map(Result::unwrap)
.collect();
rows.sort();
(t, rows)
})
.collect()
}
/// Every figure of the books with account names left out: the trial
/// balance, the P&L and balance sheet (whole and for January), and every
/// entry's lines.
fn figures_but_names(l: &ledger_core::Ledger) -> serde_json::Value {
use ledger_core::reports;
let jan = |d: u32| NaiveDate::from_ymd_opt(2026, 1, d).unwrap();
let lines = |v: &[ledger_core::StatementLine]| {
v.iter()
.map(|x| (x.code.clone(), x.amount.normalize().to_string()))
.collect::<Vec<_>>()
};
let tb = reports::trial_balance(l);
let pl = reports::profit_and_loss_for_period(l, jan(1), jan(31), None).unwrap();
let bs = reports::balance_sheet_as_of(l, jan(31)).unwrap();
serde_json::json!({
"tb": tb.lines.iter().map(|x| (x.code.clone(), x.debit.normalize().to_string(), x.credit.normalize().to_string())).collect::<Vec<_>>(),
"tb_totals": (tb.total_debits.normalize().to_string(), tb.total_credits.normalize().to_string()),
"pl": (lines(&pl.revenue), lines(&pl.expenses), pl.net_income().normalize().to_string()),
"bs": (lines(&bs.assets), lines(&bs.liabilities), lines(&bs.equity), bs.total_assets.normalize().to_string()),
"entries": (1..l.next_entry_id()).filter_map(|id| l.get_entry(id)).collect::<Vec<_>>(),
})
}
/// rename_account (repro 1.2; rules.md B-31): a rename changes the
/// account's name and nothing else: no figure, entry or line, no earlier
/// journal line, no projection row but `accounts.name`; the journal gains
/// one `account.renamed` line (keyed by id, with the old name and the
/// reason) after the feature's declaration; a refused rename (unknown,
/// blank, unchanged, a sibling's name, nine digits, a bad reason) appends
/// nothing; the name survives a reopen, a rebuild and the viewer's read.
#[test]
fn rename_account_changes_only_the_name_defect_15() {
let dir = test_dir("rename-only-name");
let mut store = build_books(&dir);
let journal = dir.join(ledger_store::JOURNAL_FILE);
let text = std::fs::read_to_string(&journal).unwrap();
let figures = figures_but_names(store.ledger());
let rows = projection_but_names(&dir);
let rent = store.ledger().chart().find_by_code("6000").unwrap().id;
for (id, name, reason) in [
(999, "Office Rent", None),
(rent, "", None),
(rent, " Rent Expense ", None),
(rent, "cash", None),
(rent, "Lease 123456789", None),
(rent, "Office Rent", Some("")),
(rent, "Office Rent", Some("lease no. 123-45-6789")),
] {
assert!(
store.rename_account(id, name, reason).is_err(),
"{name:?} {reason:?}"
);
assert_eq!(std::fs::read_to_string(&journal).unwrap(), text, "{name:?}");
assert_eq!(projection_but_names(&dir), rows, "{name:?}");
}
let (seq, _) = store.head();
store
.rename_account(rent, " Office Rent ", Some("owner asked 2026-10-21"))
.unwrap();
let after = std::fs::read_to_string(&journal).unwrap();
// Every earlier line kept, byte for byte; two lines appended: the
// feature's declaration, then the rename.
assert!(after.starts_with(&text));
let added: Vec<serde_json::Value> = after[text.len()..]
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(added.len(), 2);
assert_eq!(added[0]["type"], "journal.format_set");
assert_eq!(
added[0]["payload"],
serde_json::json!({"format": 2, "features": ["account_renamed"]})
);
assert_eq!(added[1]["type"], "account.renamed");
assert_eq!(
added[1]["payload"],
serde_json::json!({
"account_id": rent, "code": "6000", "name": "Office Rent",
"previous": "Rent Expense", "reason": "owner asked 2026-10-21"
})
);
assert_eq!(store.head().0, seq + 2);
assert_eq!(
store.ledger().chart().get(rent).unwrap().name,
"Office Rent"
);
assert_eq!(figures_but_names(store.ledger()), figures);
assert_eq!(projection_but_names(&dir), rows);
// The one column a rename changes, read from the projection itself
// (projection_but_names blanks it).
let db_name = |dir: &std::path::Path| -> String {
rusqlite::Connection::open(dir.join(ledger_store::DB_FILE))
.unwrap()
.query_row(
"SELECT name FROM accounts WHERE id = ?",
[rent as i64],
|r| r.get(0),
)
.unwrap()
};
assert_eq!(db_name(&dir), "Office Rent");
store.verify().unwrap();
// A second rename declares nothing more.
store.rename_account(rent, "Rent", None).unwrap();
let again = std::fs::read_to_string(&journal).unwrap();
let last: serde_json::Value = serde_json::from_str(again.lines().last().unwrap()).unwrap();
assert_eq!(again.lines().count(), after.lines().count() + 1);
assert_eq!(last["payload"]["previous"], "Office Rent");
assert_eq!(last["payload"]["reason"], serde_json::Value::Null);
drop(store);
let reopened = Store::open(&dir).unwrap();
reopened.verify().unwrap();
assert_eq!(reopened.ledger().chart().get(rent).unwrap().name, "Rent");
assert_eq!(figures_but_names(reopened.ledger()), figures);
drop(reopened);
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
let rebuilt = Store::open(&dir).unwrap();
assert_eq!(rebuilt.ledger().chart().get(rent).unwrap().name, "Rent");
assert_eq!(projection_but_names(&dir), rows);
assert_eq!(db_name(&dir), "Rent");
drop(rebuilt);
let snap = ledger_store::Snapshot::load(&dir).unwrap();
assert_eq!(snap.ledger().chart().get(rent).unwrap().name, "Rent");
}
/// rules.md B-28's pair for renames (review R5): the command refuses a
/// blank name, a sibling's or nine digits; replay takes a journaled line
/// holding any of them (a later version may tighten the rules, never lock
/// out books it wrote), and refuses only an account that doesn't exist.
#[test]
fn a_journaled_rename_replays_past_the_command_rules() {
use serde_json::json;
for (name, opens) in [
("", true),
("Cash", true),
("Lease 123456789", true),
("Rent Expense", true),
] {
let dir = test_dir("rename-replay");
let store = build_books(&dir);
let rent = store.ledger().chart().find_by_code("6000").unwrap().id;
drop(store);
let mut w = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
w.append(
"journal.format_set",
json!({"format": 2, "features": ["account_renamed"]}),
)
.unwrap();
w.append(
"account.renamed",
json!({"account_id": rent, "code": "6000", "name": name, "previous": "Rent Expense", "reason": null}),
)
.unwrap();
drop(w);
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
let store = Store::open(&dir);
assert_eq!(store.is_ok(), opens, "{name:?}");
let store = store.unwrap();
store.verify().unwrap();
assert_eq!(store.ledger().chart().get(rent).unwrap().name, name);
}
// An account that doesn't exist: the line can't be replayed.
let dir = test_dir("rename-replay-unknown");
drop(build_books(&dir));
let mut w = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
w.append(
"journal.format_set",
json!({"format": 2, "features": ["account_renamed"]}),
)
.unwrap();
w.append(
"account.renamed",
json!({"account_id": 99, "code": "9", "name": "x", "previous": "y", "reason": null}),
)
.unwrap();
drop(w);
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
assert!(Store::open(&dir).is_err());
}
fn tax_policy(store: &mut Store, value: Option<&str>) {
store
.change_settings(
&[ledger_core::settings::Change {
key: "policy.require_tax_category".into(),
value: value.map(|v| serde_json::json!(v)),
}],
&ledger_core::settings::Source {
decided_by: "Owner".into(),
decided_on: NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(),
citation: None,
note: None,
},
)
.unwrap();
}
/// add_account_with_tax and the policy (rules.md B-32): under warn an
/// account is created without a category; under refuse a revenue or
/// expense account that isn't a header is refused without one, journaling
/// nothing, and created with one as account.added then account.tax_set;
/// a needed category can't be cleared; the accounts the engine creates
/// for a control role are never refused. The first setting of the key
/// declares the feature require_tax_category, once.
#[test]
fn the_tax_category_policy_refuses_only_what_it_says() {
let dir = test_dir("tax-policy");
let mut store = build_books(&dir);
let journal = dir.join(ledger_store::JOURNAL_FILE);
store
.add_account("6150", "Software", AccountType::Expense, None, false)
.unwrap();
let before = std::fs::read_to_string(&journal).unwrap();
tax_policy(&mut store, Some("refuse"));
let declared = std::fs::read_to_string(&journal).unwrap();
let new: Vec<serde_json::Value> = declared[before.len()..]
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(new[0]["type"], "journal.format_set");
assert_eq!(
new[0]["payload"],
serde_json::json!({"format": 2, "features": ["require_tax_category"]})
);
assert_eq!(new.last().unwrap()["type"], "settings.set");
for (code, t, header, tax, refused) in [
("6160", AccountType::Expense, false, None, true),
("4100", AccountType::Revenue, false, None, true),
("6170", AccountType::Expense, true, None, false),
("1100", AccountType::Asset, false, None, false),
("2100", AccountType::Liability, false, None, false),
("3100", AccountType::Equity, false, None, false),
(
"6180",
AccountType::Expense,
false,
Some(("bogus", None)),
true,
),
(
"4200",
AccountType::Revenue,
false,
Some(("supplies", None)),
true,
),
] {
let text = std::fs::read_to_string(&journal).unwrap();
let got = store.add_account_with_tax(code, &format!("Acct {code}"), t, None, header, tax);
assert_eq!(got.is_err(), refused, "{code} {got:?}");
if refused {
assert_eq!(std::fs::read_to_string(&journal).unwrap(), text, "{code}");
assert!(store.ledger().chart().find_by_code(code).is_none());
}
}
let err = store
.add_account("6160", "Hosting", AccountType::Expense, None, false)
.unwrap_err()
.to_string();
assert!(
err.contains("policy.require_tax_category is refuse"),
"{err}"
);
assert!(err.contains("6160 Hosting"), "{err}");
// With a category: two lines, account.added then account.tax_set.
let text = std::fs::read_to_string(&journal).unwrap();
let id = store
.add_account_with_tax(
"6160",
"Hosting",
AccountType::Expense,
None,
false,
Some(("other_deductions", Some(Decimal::from(80)))),
)
.unwrap();
let added: Vec<serde_json::Value> = std::fs::read_to_string(&journal).unwrap()[text.len()..]
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(
added
.iter()
.map(|l| l["type"].as_str().unwrap())
.collect::<Vec<_>>(),
["account.added", "account.tax_set"]
);
let a = store.ledger().chart().get(id).unwrap();
assert_eq!(a.tax_category.as_deref(), Some("other_deductions"));
assert_eq!(a.deductible_percent, Some(Decimal::from(80)));
// A needed category is never cleared; a header's or an asset's is no
// concern; an existing uncategorized account is left as it was.
let text = std::fs::read_to_string(&journal).unwrap();
assert!(store.set_account_tax(id, None, None).is_err());
let software = store.ledger().chart().find_by_code("6150").unwrap().id;
assert!(store.set_account_tax(software, None, None).is_err());
assert_eq!(std::fs::read_to_string(&journal).unwrap(), text);
assert!(
store
.ledger()
.chart()
.get(software)
.unwrap()
.tax_category
.is_none()
);
store
.set_account_tax(software, Some("other_deductions"), None)
.unwrap();
// The engine's own accounts: a bill makes 6999 General Expenses with
// no category, under refuse too.
let v = store.add_vendor("Coastline Hosting").unwrap();
store
.create_bill(
v,
NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
vec![ledger_core::DocumentLine {
description: "hosting".into(),
amount: Decimal::new(4_200, 2),
}],
)
.unwrap();
let general = store.ledger().chart().find_by_code("6999").unwrap();
assert!(general.tax_category.is_none());
// Back to warn: no second declaration, and the account is created.
let text = std::fs::read_to_string(&journal).unwrap();
tax_policy(&mut store, Some("warn"));
let lines: Vec<serde_json::Value> = std::fs::read_to_string(&journal).unwrap()[text.len()..]
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0]["type"], "settings.set");
store
.add_account("6190", "Domains", AccountType::Expense, None, false)
.unwrap();
store.verify().unwrap();
drop(store);
Store::open(&dir).unwrap().verify().unwrap();
}
/// rules.md B-28's pair for the policy (review R5): the policy is the
/// commands', never replay's. A journal that sets refuse and then clears
/// a category, or adds an uncategorized expense account, replays.
#[test]
fn a_journaled_clear_replays_under_refuse() {
use serde_json::json;
let dir = test_dir("tax-policy-replay");
let mut store = build_books(&dir);
let rent = store.ledger().chart().find_by_code("6000").unwrap().id;
store
.set_account_tax(rent, Some("rent_property"), None)
.unwrap();
tax_policy(&mut store, Some("refuse"));
let next = store.ledger().next_account_id();
drop(store);
let mut w = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
w.append(
"account.tax_set",
json!({"account_id": rent, "tax_category": null, "deductible_percent": null}),
)
.unwrap();
w.append(
"account.added",
json!({"id": next, "code": "6150", "name": "Software", "account_type": "Expense",
"parent": null, "placeholder": false, "currency": "USD"}),
)
.unwrap();
drop(w);
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
assert!(
store
.ledger()
.chart()
.get(rent)
.unwrap()
.tax_category
.is_none()
);
assert!(store.ledger().chart().find_by_code("6150").is_some());
assert_eq!(
store.ledger().settings().require_tax_category(),
ledger_core::settings::TaxCategoryPolicy::Refuse
);
}
mod p3_properties {
use super::*;
use proptest::prelude::*;
const NAMES: [&str; 8] = [
"Office",
"office",
"Cash",
"",
"Lease 123456789",
"Rent Expense",
"Studio",
"Tools",
];
proptest! {
#![proptest_config(ProptestConfig {
cases: std::env::var("STORE_CASES").ok().and_then(|v| v.parse().ok()).unwrap_or(64),
failure_persistence: None,
..ProptestConfig::default()
})]
/// rules.md B-31 (the engine-side counterpart to the Lean note,
/// docs/verification.md 13a): on books with postings, any sequence
/// of renames changes no figure (the ledger's statements, without
/// names), no entry and no earlier journal line; live equals
/// replay; the projection equals a fresh one. The sets picked by
/// name (cash, card accounts) are presentation, outside this law.
#[test]
fn prop_rename_changes_no_figure(
renames in prop::collection::vec((1u64..6, 0usize..NAMES.len()), 1..12),
seed in any::<u64>(),
) {
let dir = test_dir(&format!("prop-rename-{seed}"));
let mut store = build_books(&dir);
let journal = dir.join(ledger_store::JOURNAL_FILE);
let figures = figures_but_names(store.ledger());
let rows = projection_but_names(&dir);
let mut text = std::fs::read_to_string(&journal).unwrap();
for (account, n) in renames {
let ok = store.rename_account(account, NAMES[n], None).is_ok();
let now = std::fs::read_to_string(&journal).unwrap();
prop_assert!(now.starts_with(&text));
prop_assert_eq!(ok, now.len() > text.len());
text = now;
prop_assert_eq!(&figures_but_names(store.ledger()), &figures);
prop_assert_eq!(&projection_but_names(&dir), &rows);
}
prop_assert!(store.verify().is_ok());
let live = store.ledger().clone();
drop(store);
let reopened = Store::open(&dir).unwrap();
prop_assert!(live.differs_from(reopened.ledger()).is_empty());
}
/// rules.md B-32: add_account_with_tax is refused exactly when the
/// policy is refuse, the account needs a category (revenue or
/// expense, not a header) and none is given, or when the category
/// doesn't fit; a refusal journals nothing.
#[test]
fn prop_tax_policy_refuses_exactly(
refuse in any::<bool>(),
t in 0usize..5,
header in any::<bool>(),
cat in 0usize..4,
seed in any::<u64>(),
) {
let types = [
AccountType::Asset,
AccountType::Liability,
AccountType::Equity,
AccountType::Revenue,
AccountType::Expense,
];
let cats = [None, Some("other_income"), Some("supplies"), Some("bogus")];
let dir = test_dir(&format!("prop-tax-{seed}"));
let mut store = build_books(&dir);
tax_policy(&mut store, Some(if refuse { "refuse" } else { "warn" }));
let journal = dir.join(ledger_store::JOURNAL_FILE);
let text = std::fs::read_to_string(&journal).unwrap();
let t = types[t];
let needs = matches!(t, AccountType::Revenue | AccountType::Expense) && !header;
let fits = match cats[cat] {
None => true,
Some("other_income") => matches!(t, AccountType::Revenue | AccountType::Expense),
Some("supplies") => t == AccountType::Expense,
_ => false,
};
let want_refused = !fits || (refuse && needs && cats[cat].is_none());
let got = store.add_account_with_tax(
"7000", "Prop", t, None, header, cats[cat].map(|c| (c, None)),
);
prop_assert_eq!(got.is_err(), want_refused, "{:?}", got);
let after = std::fs::read_to_string(&journal).unwrap();
if want_refused {
prop_assert_eq!(after, text);
} else {
let lines = after[text.len()..].lines().count();
prop_assert_eq!(lines, if cats[cat].is_some() { 2 } else { 1 });
}
}
}
}
/// Each account as the books hold it, by id: what chart.json in the
/// p3_rename fixture records (expected.json's figures carry no names).
fn chart_of(l: &ledger_core::Ledger) -> serde_json::Value {
let mut accounts: Vec<_> = l.chart().all().collect();
accounts.sort_by_key(|a| a.id);
serde_json::json!(accounts)
}
fn p3_fixture() -> PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/old-books/p3_rename")
}
/// How the p3_rename fixture was written (2026-10-20, P3): through the
/// Store API of the version that introduced `account.renamed` and
/// `policy.require_tax_category`, with their journal features, a header
/// and its children, refused commands between them. Run once with
/// `--ignored`; the journal it wrote is frozen in the fixture, and later
/// versions must open it to the same figures
/// (books_an_earlier_version_wrote_open_to_its_figures) and names
/// (books_written_under_p3_keep_their_names).
#[test]
#[ignore]
fn write_p3_rename_fixture() {
let dir = test_dir("p3-fixture");
let mut s = Store::create(&dir, Currency::USD).unwrap();
let day = |m: u32, d: u32| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
let bank = s
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let capital = s
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let ops = s
.add_account("6000", "Operating", AccountType::Expense, None, true)
.unwrap();
let software = s
.add_account("6150", "Software", AccountType::Expense, Some(ops), false)
.unwrap();
s.add_account("6160", "Hosting", AccountType::Expense, Some(ops), false)
.unwrap();
s.add_account_with_tax(
"4000",
"Sales",
AccountType::Revenue,
None,
false,
Some(("gross_receipts", None)),
)
.unwrap();
tax_policy(&mut s, Some("refuse"));
assert!(
s.add_account("6170", "Domains", AccountType::Expense, Some(ops), false)
.is_err()
);
s.add_account_with_tax(
"6170",
"Domains",
AccountType::Expense,
Some(ops),
false,
Some(("other_deductions", None)),
)
.unwrap();
for (m, d, dr, cr, cents) in [
(1u32, 5u32, bank, capital, 500_000i64),
(2, 3, software, bank, 4_999),
(3, 9, software, bank, 4_999),
] {
let e = s.create_entry(day(m, d), "fixture").unwrap();
let money = Money::new(Decimal::new(cents, 2), Currency::USD);
s.add_line(e, dr, Side::Debit, money).unwrap();
s.add_line(e, cr, Side::Credit, money).unwrap();
s.post_entry(e).unwrap();
}
assert!(s.rename_account(software, "hosting", None).is_err());
s.rename_account(
software,
"Software subscriptions",
Some("owner asked 2026-10-20"),
)
.unwrap();
s.rename_account(ops, "Operating expenses", None).unwrap();
assert!(s.set_account_tax(software, None, None).is_err());
s.set_account_tax(software, Some("other_deductions"), None)
.unwrap();
s.rename_account(software, "Software", Some("back to the short name"))
.unwrap();
tax_policy(&mut s, None);
s.verify().unwrap();
let out = p3_fixture();
let _ = std::fs::remove_dir_all(&out);
std::fs::create_dir_all(&out).unwrap();
std::fs::copy(
dir.join(ledger_store::JOURNAL_FILE),
out.join(ledger_store::JOURNAL_FILE),
)
.unwrap();
let mut figures = old_books_figures(s.ledger());
figures["verify"] = serde_json::json!({"Ok": null});
std::fs::write(
out.join("expected.json"),
serde_json::to_string_pretty(&figures).unwrap() + "\n",
)
.unwrap();
std::fs::write(
out.join("chart.json"),
serde_json::to_string_pretty(&chart_of(s.ledger())).unwrap() + "\n",
)
.unwrap();
}
/// Books written under P3 (fixture p3_rename): every account's name,
/// code, parent and tax category, as journaled through renames and the
/// tax category policy, read back the same by the writer, a rebuild and
/// the viewer; and the features it declared are the ones this version
/// knows.
#[test]
fn books_written_under_p3_keep_their_names() {
let fixture = p3_fixture();
let want: serde_json::Value =
serde_json::from_slice(&std::fs::read(fixture.join("chart.json")).unwrap()).unwrap();
let dir = old_books_copy(&fixture, "p3-names");
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
assert_eq!(chart_of(store.ledger()), want);
assert_eq!(
store.journal_format().features,
["account_renamed", "require_tax_category"]
);
let names: Vec<&str> = want
.as_array()
.unwrap()
.iter()
.map(|a| a["name"].as_str().unwrap())
.collect();
assert_eq!(
names,
[
"Bank",
"Capital",
"Operating expenses",
"Software",
"Hosting",
"Sales",
"Domains"
]
);
drop(store);
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
let rebuilt = Store::open(&dir).unwrap();
assert_eq!(chart_of(rebuilt.ledger()), want);
drop(rebuilt);
let snap = ledger_store::Snapshot::load(&dir).unwrap();
assert_eq!(chart_of(snap.ledger()), want);
}
/// rules.md B-31: a new account's name is journaled trimmed, as a rename
/// journals one, so the live books, a reopen and the projection all read
/// the same name; the command's text rules refuse a name a report can't
/// print on one line, journaling nothing.
#[test]
fn a_new_accounts_name_is_journaled_trimmed() {
let dir = test_dir("add-trimmed");
let mut store = build_books(&dir);
let journal = dir.join(ledger_store::JOURNAL_FILE);
let text = std::fs::read_to_string(&journal).unwrap();
for name in ["Two\nlines", &"N".repeat(201)] {
assert!(
store
.add_account("6150", name, AccountType::Expense, None, false)
.is_err(),
"{name:?}"
);
assert_eq!(std::fs::read_to_string(&journal).unwrap(), text);
}
let id = store
.add_account("6150", " Hosting\t", AccountType::Expense, None, false)
.unwrap();
let after = std::fs::read_to_string(&journal).unwrap();
let line: serde_json::Value = serde_json::from_str(after.lines().last().unwrap()).unwrap();
assert_eq!(line["type"], "account.added");
assert_eq!(line["payload"]["name"], "Hosting");
assert_eq!(store.ledger().chart().get(id).unwrap().name, "Hosting");
let live = store.ledger().clone();
drop(store);
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
let reopened = Store::open(&dir).unwrap();
assert!(live.differs_from(reopened.ledger()).is_empty());
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
let name: String = conn
.query_row("SELECT name FROM accounts WHERE id = ?", [id as i64], |r| {
r.get(0)
})
.unwrap();
assert_eq!(name, "Hosting");
}