P3: rename accounts, one set of period defaults, tax categories warn or refuse (defects 15, 16, 8)
check / check (push) Canceled after 0s
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:
69 files changed
+8063
-570
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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"}
|
||||
@@ -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(|_| ())),
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user