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 = ?",