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 = ?",
|
||||
|
||||
Reference in new issue
Block a user