Files
wanderbooks/crates/ledger-core/src/settings.rs
T
swaits 962b52c991 refactor(settings): the slice's contracts and notes (P4b S4.2)
invoicing.first_number's refusal (LedgerError::FirstNumberLocked) lands
as the contract the store's check raises once invoices carry numbers
(S4.3): a series that has begun is never renumbered (B-40, A-2).

Also: the settings invariants name the number prefix's bound, update_party's
doc says which facts wait for documents_v2, and the email check no longer
reads as a single-arm match. Terms is S4.1's as it landed; this slice no
longer carries a copy.
2026-10-27 03:44:24 -06:00

2252 lines
84 KiB
Rust

//! Settings journaled in the books (plan/served.md 5.3).
//!
//! A company's settings (its name, fiscal year, members, receipt rule,
//! cash flow roles, ...) are facts about the books, so the books keep
//! them: each change is a `settings.set` journal line, replayed into
//! [`Settings`] like every other fact. A settings file is only text to
//! import from or export to (`ledger-config`).
//!
//! ```text
//! GOAL: one record of how the books are kept, with who decided each
//! setting and when, that every reader (server, viewer, replay)
//! sees the same way.
//! SOURCE: plan/served.md I-3 (the journal holds all books state) and 5.3
//! (setting keys); the validators carried over from
//! ledger-config's wanderbooks.toml parser, whose SOURCE lines
//! they keep (IRS Pub. 538, Treas. Reg. §301.7701-3, IRC §704(a)).
//! INVARIANTS:
//! 1. Only keys in KEYS exist; a value is checked by its key's kind
//! before it is journaled ([`check`]), and stored in one canonical
//! spelling, so two spellings of one value are one value.
//! 2. A change is all or nothing: the settings after every change in it
//! must hold together ([`Settings::check_whole`]), or none is made.
//! 3. Replay takes any value of the right shape ([`shape`]): a check a
//! later version tightens never makes older books unreadable
//! (rules.md B-28).
//! 4. A setting never holds a full TIN, except the company's own EIN
//! (`company.ein`, decided 2026-10-16, D-5): nine digits standing
//! alone, bare or printed the way an SSN or EIN is (3-2-4 or 2-7,
//! joined by a dash or a space), are refused everywhere else, the
//! D-U-N-S number's own key aside (nine digits that are no TIN).
//! 5. Unset keys mean the defaults the settings file had: "Unnamed
//! company", a calendar year, accrual basis, receipts above 25.00,
//! parties suggested, a missing tax category warned of (not
//! refused), approval rules off, the computer's time zone; and, for
//! invoicing, the prefix "INV-", the first number 1, "Net 30", and
//! day counts of 2, 14, 7 and 3 (P4b S4.2).
//! 6. Payment terms keep one spelling ([`crate::Terms::describe`]):
//! "net 30" is stored as "Net 30", never as it was typed.
//! 7. A payment method's bank-detail lines (an `ach` or `wire` method's
//! `lines`) are the one text the full-TIN check doesn't scan, because
//! a routing number is nine digits (A-3); every other text in the
//! list is scanned.
//! 8. An invoice number's prefix is 1 to 12 visible ASCII characters:
//! longer, blank or holding a space would not read as one number.
//! ```
use crate::TaxForm;
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::BTreeMap;
/// Who decided a change, and on what authority: kept with every setting,
/// convention, decision and open item.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Source {
/// Who decided: a member's or employee's name, "onboarding", ...
pub decided_by: String,
/// The day they decided, `YYYY-MM-DD`.
pub decided_on: NaiveDate,
/// The rule or document it rests on, if any ("IRS Pub. 538").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub citation: Option<String>,
/// Anything else worth keeping with it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
/// Longest name or other one-line text a setting or source takes.
pub const MAX_TEXT: usize = 200;
/// Longest note, citation, address or other long text.
pub const MAX_LONG_TEXT: usize = 2_000;
impl Source {
/// Check a source before it is journaled: who decided is named, the
/// day is in the calendar the books keep, text is bounded and holds no
/// full TIN (INVARIANT 4).
pub fn check(&self) -> Result<(), String> {
line_text("decided_by", &self.decided_by, MAX_TEXT)?;
crate::calendar::check(self.decided_on).map_err(|e| e.to_string())?;
if let Some(c) = &self.citation {
long_text("citation", c, MAX_LONG_TEXT)?;
}
if let Some(n) = &self.note {
long_text("note", n, MAX_LONG_TEXT)?;
}
Ok(())
}
}
/// How visible a setting's value is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Sensitivity {
/// Shown as it is.
Public,
/// An identity number (the company's EIN): shown in full only in
/// reports and packages, masked to its last four elsewhere.
Identity,
}
/// What a setting holds.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
/// One line of text, 1 to [`MAX_TEXT`] characters.
Text,
/// Longer text (an address): 1 to [`MAX_LONG_TEXT`] characters.
LongText,
/// One of a fixed set of words; each canonical word may have aliases.
Choice(&'static [(&'static str, &'static [&'static str])]),
/// The first day of a month, `"MM-01"`.
MonthStart,
/// A day, `YYYY-MM-DD`, in the calendar the books keep.
Date,
/// A decimal amount at least zero.
Amount,
/// One account code.
Code,
/// A list of account codes.
Codes,
/// The members of a multi-member LLC.
Members,
/// An IANA time zone name ("America/Denver").
TimeZone,
/// The income tax return, as `TaxForm::parse` reads it, kept as
/// `schedule_c`, `1065`, `1120s` or `1120`.
TaxForm,
/// An EIN, `NN-NNNNNNN`.
Ein,
/// A D-U-N-S number: nine digits.
Duns,
/// The IRS name control: one to four letters, digits, `&` or `-`.
NameControl,
/// An asset tag prefix: one to eight letters or digits.
TagPrefix,
/// A repository's address, as git takes it: one line, never one that
/// reads as an option.
Repository,
/// A folder named from the root of its disk: one line, an absolute
/// path.
Folder,
/// A positive whole number (a day count, the first invoice number).
Count,
/// Payment terms, in their one canonical spelling ([`crate::Terms`],
/// rules.md B-39).
Terms,
/// Payment methods (A-3): a list of `{kind, label, lines, link?, qr?}`.
PaymentMethods,
}
/// One setting the books know.
#[derive(Debug)]
pub struct Key {
/// Its name, e.g. `company.name`.
pub name: &'static str,
/// Where a settings file keeps it: `(table, key)`, e.g.
/// `("company", "name")`.
pub toml: (&'static str, &'static str),
/// What it holds.
pub kind: Kind,
/// How visible its value is.
pub sensitivity: Sensitivity,
/// What it means, for `books.settings`.
pub doc: &'static str,
}
const ACCRUAL_CASH: &[(&str, &[&str])] = &[("accrual", &[]), ("cash", &[])];
const PARTY_POLICY: &[(&str, &[&str])] =
&[("off", &[]), ("suggest", &[]), ("require", &["required"])];
const APPROVAL_MODE: &[(&str, &[&str])] = &[("off", &[]), ("enforce", &[])];
const TAX_CATEGORY_POLICY: &[(&str, &[&str])] =
&[("warn", &[]), ("refuse", &["required", "require"])];
const SCHEDULE_CONVENTION: &[(&str, &[&str])] = &[("daily", &[]), ("monthly_even", &[])];
/// A return's key as a setting keeps it.
pub fn tax_form_key(f: TaxForm) -> &'static str {
match f {
TaxForm::ScheduleC => "schedule_c",
TaxForm::Form1065 => "1065",
TaxForm::Form1120S => "1120s",
TaxForm::Form1120 => "1120",
}
}
/// Every setting the books know, in the order they are shown.
pub static KEYS: &[Key] = &[
Key {
name: "company.name",
toml: ("company", "name"),
kind: Kind::Text,
sensitivity: Sensitivity::Public,
doc: "Legal name, for report headers. Unset: \"Unnamed company\".",
},
Key {
name: "company.entity_type",
toml: ("company", "entity_type"),
kind: Kind::Text,
sensitivity: Sensitivity::Public,
doc: "The kind of entity, as the formation papers say (\"LLC, multi-member, member-managed\").",
},
Key {
name: "company.tax_classification",
toml: ("company", "tax_classification"),
kind: Kind::Text,
sensitivity: Sensitivity::Public,
doc: "Federal tax classification: partnership, S corporation, C corporation, or disregarded entity. Names the return unless company.tax_form is set.",
},
Key {
name: "company.tax_form",
toml: ("company", "tax_form"),
kind: Kind::TaxForm,
sensitivity: Sensitivity::Public,
doc: "The income tax return: schedule_c, 1065, 1120s, or 1120. Wins over company.tax_classification.",
},
Key {
name: "company.fiscal_year_start",
toml: ("company", "fiscal_year_start"),
kind: Kind::MonthStart,
sensitivity: Sensitivity::Public,
doc: "First day of the fiscal year, \"MM-01\" (a fiscal year ends on the last day of a month: IRS Pub. 538). Unset: \"01-01\".",
},
Key {
name: "company.timezone",
toml: ("company", "timezone"),
kind: Kind::TimeZone,
sensitivity: Sensitivity::Public,
doc: "The company's time zone (IANA, e.g. \"America/Denver\"): what \"today\" means for the books. Unset: the computer's.",
},
Key {
name: "company.formation_state",
toml: ("company", "formation_state"),
kind: Kind::Text,
sensitivity: Sensitivity::Public,
doc: "State (or country) of formation.",
},
Key {
name: "company.formed",
toml: ("company", "formed"),
kind: Kind::Date,
sensitivity: Sensitivity::Public,
doc: "Date of formation, YYYY-MM-DD.",
},
Key {
name: "company.state_entity_number",
toml: ("company", "state_entity_number"),
kind: Kind::Text,
sensitivity: Sensitivity::Public,
doc: "The formation state's entity number.",
},
Key {
name: "company.ein",
toml: ("company", "ein"),
kind: Kind::Ein,
sensitivity: Sensitivity::Identity,
doc: "The company's EIN, NN-NNNNNNN: in full only in reports and packages, masked elsewhere.",
},
Key {
name: "company.irs_name_control",
toml: ("company", "irs_name_control"),
kind: Kind::NameControl,
sensitivity: Sensitivity::Public,
doc: "The IRS name control (up to four characters, from the EIN letter): an e-filed return must match it.",
},
Key {
name: "company.duns",
toml: ("company", "duns"),
kind: Kind::Duns,
sensitivity: Sensitivity::Public,
doc: "D-U-N-S number, nine digits.",
},
Key {
name: "company.address",
toml: ("company", "address"),
kind: Kind::LongText,
sensitivity: Sensitivity::Public,
doc: "Principal business address.",
},
Key {
name: "company.registered_agent",
toml: ("company", "registered_agent"),
kind: Kind::Text,
sensitivity: Sensitivity::Public,
doc: "Registered agent.",
},
Key {
name: "company.partnership_representative",
toml: ("company", "partnership_representative"),
kind: Kind::Text,
sensitivity: Sensitivity::Public,
doc: "Partnership representative (BBA, IRC §6223), for partnerships.",
},
Key {
name: "company.cpa",
toml: ("company", "cpa"),
kind: Kind::Text,
sensitivity: Sensitivity::Public,
doc: "The company's CPA.",
},
Key {
name: "company.members",
toml: ("company", "members"),
kind: Kind::Members,
sensitivity: Sensitivity::Public,
doc: "Members of a multi-member LLC: [{name, share (\"50%\" or \"0.5\"), capital_account, distribution_account?, files_jointly_with?}]. Names and capital accounts distinct; shares sum to exactly 1 (IRC §704(a): shares come from the agreement).",
},
Key {
name: "books.accounting_basis",
toml: ("books", "accounting_basis"),
kind: Kind::Choice(ACCRUAL_CASH),
sensitivity: Sensitivity::Public,
doc: "accrual or cash, for report footers. Unset: accrual.",
},
Key {
name: "books.cash_accounts",
toml: ("books", "cash_accounts"),
kind: Kind::Codes,
sensitivity: Sensitivity::Public,
doc: "Asset account codes that hold cash (bank, savings, cash on hand): the cash flow statement explains their change.",
},
Key {
name: "books.financing_accounts",
toml: ("books", "financing_accounts"),
kind: Kind::Codes,
sensitivity: Sensitivity::Public,
doc: "Liability account codes the cash flow statement treats as financing (loans, and what owners advanced: ASC 230-10-45-14(b)); equity is always financing. What a member or lender pays for the company through one is shown as borrowed and spent (D-42).",
},
Key {
name: "books.investing_accounts",
toml: ("books", "investing_accounts"),
kind: Kind::Codes,
sensitivity: Sensitivity::Public,
doc: "Asset account codes the cash flow statement treats as investing (loans the company makes, investments it buys: ASC 230-10-20); registered fixed assets are investing unlisted.",
},
Key {
name: "books.close_into",
toml: ("books", "close_into"),
kind: Kind::Code,
sensitivity: Sensitivity::Public,
doc: "The equity account a period close puts net income in (single-owner businesses). Unset: Retained Earnings, or the members' capital accounts.",
},
Key {
name: "books.close_sweep",
toml: ("books", "close_sweep"),
kind: Kind::Codes,
sensitivity: Sensitivity::Public,
doc: "Equity accounts (draws, dividends, distributions) a close zeroes into books.close_into.",
},
Key {
name: "books.asset_tag_prefix",
toml: ("books", "asset_tag_prefix"),
kind: Kind::TagPrefix,
sensitivity: Sensitivity::Public,
doc: "Prefix of generated asset tags (\"AC\" for AC-0001): one to eight letters or digits. Unset: the company name's initials.",
},
Key {
name: "books.schedule_convention",
toml: ("books", "schedule_convention"),
kind: Kind::Choice(SCHEDULE_CONVENTION),
sensitivity: Sensitivity::Public,
doc: "How a new prepaid-expense or deferred-revenue schedule spreads its total: daily (from its start date: full months equal, the first and last partial months by their days, like depreciation) or monthly_even (equal months; the schedule starts on the 1st of a month). Unset: daily (D-11). Existing schedules keep theirs.",
},
Key {
name: "policy.evidence.threshold",
toml: ("books", "receipt_threshold"),
kind: Kind::Amount,
sensitivity: Sensitivity::Public,
doc: "An entry whose debits are strictly greater than this needs a receipt before it posts; \"0.00\": every entry. Unset: \"25.00\".",
},
Key {
name: "policy.evidence.required_accounts",
toml: ("books", "receipt_required_accounts"),
kind: Kind::Codes,
sensitivity: Sensitivity::Public,
doc: "Account codes whose entries always need a receipt, whatever the amount.",
},
Key {
name: "policy.evidence.exempt_accounts",
toml: ("books", "receipt_exempt_accounts"),
kind: Kind::Codes,
sensitivity: Sensitivity::Public,
doc: "Account codes exempt from the receipt rule: an entry is exempt only when every line is in one.",
},
Key {
name: "policy.parties",
toml: ("books", "parties"),
kind: Kind::Choice(PARTY_POLICY),
sensitivity: Sensitivity::Public,
doc: "Whether entries name their counterparty: off, suggest (a draft without one names the likely one), or require (posting is refused until an entry names a party or is marked as having none). Unset: suggest.",
},
Key {
name: "policy.require_tax_category",
toml: ("books", "require_tax_category"),
kind: Kind::Choice(TAX_CATEGORY_POLICY),
sensitivity: Sensitivity::Public,
doc: "Whether a revenue or expense account (not a header) must have a tax category, the line of the return it maps to: warn (it is created, the result and Needs attention say so) or refuse (books.add_account, books.import_chart_csv and books.set_tax_category won't leave one without; accounts invoices, bills and payroll create are never refused, and are listed). Existing accounts are untouched. Unset: warn.",
},
Key {
name: "policy.approval_rules",
toml: ("books", "approval_rules"),
kind: Kind::Choice(APPROVAL_MODE),
sensitivity: Sensitivity::Public,
doc: "off: approval_rule conventions are recorded but never block a post. enforce: an entry a rule covers posts only once its approver approves it (books.approve_entry). Unset: off.",
},
Key {
name: "backup.git_remote",
toml: ("backup", "git_remote"),
kind: Kind::Repository,
sensitivity: Sensitivity::Public,
doc: "A private repository the books back themselves up to (plan D-30): its address as its host shows it for cloning, over ssh or https (such as https://host/owner/books), or a path on this computer. Unset: none.",
},
Key {
name: "backup.folder",
toml: ("backup", "folder"),
kind: Kind::Folder,
sensitivity: Sensitivity::Public,
doc: "A folder the books back themselves up to as well (D-30): an absolute path, an external disk or a synced folder; the backup is a repository in it named for the book. Unset: none.",
},
// P4b S4.2: invoicing, bills and the company's contact details.
Key {
name: "invoicing.number_prefix",
toml: ("invoicing", "number_prefix"),
kind: Kind::Text,
sensitivity: Sensitivity::Public,
doc: "The prefix of an invoice number (\"INV-\" gives INV-0001): 1 to 12 visible ASCII characters. Unset: \"INV-\".",
},
Key {
name: "invoicing.first_number",
toml: ("invoicing", "first_number"),
kind: Kind::Count,
sensitivity: Sensitivity::Public,
doc: "The number the first numbered invoice takes (the series starts there): a positive whole number, settable only while no numbered invoice exists. Unset: 1.",
},
Key {
name: "invoicing.default_terms",
toml: ("invoicing", "default_terms"),
kind: Kind::Terms,
sensitivity: Sensitivity::Public,
doc: "The terms an invoice takes when neither the invoice nor the customer gives any (\"Net 30\", \"Net 15 EOM\", \"2/10 net 30\", \"Due on receipt\"). Unset: \"Net 30\".",
},
Key {
name: "invoicing.payment_methods",
toml: ("invoicing", "payment_methods"),
kind: Kind::PaymentMethods,
sensitivity: Sensitivity::Public,
doc: "The ways the company takes payment, printed on an invoice (A-3): [{kind: ach|wire|link|check|p2p|crypto|other, label, lines, link?, qr?}], at most 8; a bank-detail line of an ach or wire method may hold a routing number. Unset: none.",
},
Key {
name: "invoicing.footer",
toml: ("invoicing", "footer"),
kind: Kind::LongText,
sensitivity: Sensitivity::Public,
doc: "Text printed at the foot of each invoice (thank-you, payment note). Unset: none.",
},
Key {
name: "invoicing.unsent_days",
toml: ("invoicing", "unsent_days"),
kind: Kind::Count,
sensitivity: Sensitivity::Public,
doc: "How many days after issue an invoice nobody marked sent is listed as unsent: a positive whole number. Unset: 2.",
},
Key {
name: "invoicing.reminder_days",
toml: ("invoicing", "reminder_days"),
kind: Kind::Count,
sensitivity: Sensitivity::Public,
doc: "How many days after a reminder an overdue invoice is reminded again: a positive whole number. Unset: 14.",
},
Key {
name: "bills.remind_days",
toml: ("bills", "remind_days"),
kind: Kind::Count,
sensitivity: Sensitivity::Public,
doc: "A bill due within this many days is listed as due soon: a positive whole number. Unset: 7.",
},
Key {
name: "bills.discount_warn_days",
toml: ("bills", "discount_warn_days"),
kind: Kind::Count,
sensitivity: Sensitivity::Public,
doc: "A bill whose early-payment discount ends within this many days is listed: a positive whole number. Unset: 3.",
},
Key {
name: "company.email",
toml: ("company", "email"),
kind: Kind::Text,
sensitivity: Sensitivity::Public,
doc: "The company's email address, printed on an invoice as the seller's.",
},
Key {
name: "company.website",
toml: ("company", "website"),
kind: Kind::Text,
sensitivity: Sensitivity::Public,
doc: "The company's website, printed on an invoice as the seller's.",
},
];
/// The key named `name`.
pub fn key(name: &str) -> Option<&'static Key> {
KEYS.iter().find(|k| k.name == name)
}
/// One member of a multi-member LLC.
#[derive(Clone, Debug, PartialEq)]
pub struct Member {
/// Name, as it appears on reports.
pub name: String,
/// Share of income, e.g. 0.50.
pub share: Decimal,
/// Code of their capital account in the chart.
pub capital_account: String,
/// Code of their distributions account, if distributions are recorded
/// separately from capital (zeroed into capital at close).
pub distribution_account: Option<String>,
/// `files_jointly_with`: the other member this member files a joint
/// individual return with (spouses). Each still gets their own K-1
/// (a partnership reports each partner separately, IRS Instructions
/// for Form 1065, Schedule K-1); both K-1s go on the one Form 1040
/// (Schedule E, Part II). Informational: the CPA package says so.
pub files_jointly_with: Option<String>,
}
/// The company the books belong to.
#[derive(Clone, Debug, PartialEq)]
pub struct Company {
/// Legal name, for report headers.
pub name: String,
/// Month and day the fiscal year starts. Default January 1.
pub fiscal_year_start: (u32, u32),
/// Members of a multi-member LLC; empty for other entities.
pub members: Vec<Member>,
/// "Accrual" or "Cash", for report footers. Default "Accrual".
pub basis: String,
/// The income tax return the business files, if known: from
/// `tax_form`, else from `tax_classification`.
pub tax_form: Option<TaxForm>,
/// `books.close_into`: the equity account a period close puts net
/// income in (a sole proprietor's capital, say). Unset: Retained
/// Earnings, or members' capital accounts when there are members.
pub close_into: Option<String>,
/// `books.close_sweep`: equity accounts (draws, dividends,
/// distributions) a close zeroes into `close_into`.
pub close_sweep: Vec<String>,
/// Every simple company fact (name, EIN, formation date, ...), as
/// `(name, value)`: the full profile for display.
pub profile: Vec<(String, String)>,
}
impl Default for Company {
fn default() -> Self {
Self {
name: DEFAULT_NAME.to_string(),
fiscal_year_start: (1, 1),
members: Vec::new(),
basis: "Accrual".to_string(),
tax_form: None,
close_into: None,
close_sweep: Vec::new(),
profile: Vec::new(),
}
}
}
/// The company name of books that never set one.
pub const DEFAULT_NAME: &str = "Unnamed company";
/// The receipt threshold of books that never set one.
pub const DEFAULT_THRESHOLD: &str = "25.00";
impl Company {
/// The members as party shares see them ([`crate::PartyMember`]):
/// each member whose name (ignoring case and surrounding space) is a
/// party with the member role, and whose capital account is in the
/// chart. The party not archived of that name when there is one, else
/// the most recent archived one: archiving a member ("no longer dealt
/// with; history stays") never takes their history away. Members
/// missing either are left out, so their shares can't be derived (and
/// the viewer shows no capital statement for them).
pub fn party_members(&self, ledger: &crate::Ledger) -> Vec<crate::PartyMember> {
let is_member = |p: &&crate::Party| p.roles.contains(&crate::Role::Member);
self.members
.iter()
.filter_map(|m| {
let key = crate::parties::name_key(&m.name);
let party = match ledger.party_by_name(&m.name) {
Some(p) => Some(p).filter(is_member)?,
None => ledger
.parties()
.filter(|p| p.archived && crate::parties::name_key(&p.name) == key)
.filter(is_member)
.max_by_key(|p| p.id)?,
};
Some(crate::PartyMember {
party: party.id,
share: m.share,
capital: ledger.chart().find_by_code(&m.capital_account)?.id,
distributions: m
.distribution_account
.as_deref()
.and_then(|c| ledger.chart().find_by_code(c))
.map(|a| a.id),
})
})
.collect()
}
/// The fiscal year containing `d`: `(first day, last day)`, inclusive,
/// as chrono counts them (unclamped: a July year holding 0001-03-01
/// starts 0000-07-01). The first day is
/// [`crate::calendar::fiscal_year_start_on`]'s, before its clamp.
pub fn fiscal_year_containing(&self, d: NaiveDate) -> (NaiveDate, NaiveDate) {
crate::calendar::fiscal_year_unclamped(self.fiscal_year_start, d)
}
}
/// `policy.require_tax_category`: whether a revenue or expense account
/// must map to a line of the return (rules.md B-32; DECISION, owner,
/// 2026-10-20: warn by default; a company may refuse).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TaxCategoryPolicy {
/// An account is created without one, and the result and Needs
/// attention say so.
#[default]
Warn,
/// The chart tools refuse to create or leave a revenue or expense
/// account (not a header) without one.
Refuse,
}
/// `policy.parties`: whether entries name their counterparty
/// (docs/parties-and-attachments.md "Counterparties"; DECISION, owner,
/// 2026-10-15: the default suggests, books may require or turn it off).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PartyPolicy {
/// Never mention counterparties.
Off,
/// A draft or preview without a party names the likely one; nothing
/// is refused.
#[default]
Suggest,
/// Posting is refused until the entry names a party or is marked as
/// having none.
Require,
}
impl PartyPolicy {
/// Its wire name.
pub fn key(self) -> &'static str {
match self {
PartyPolicy::Off => "off",
PartyPolicy::Suggest => "suggest",
PartyPolicy::Require => "require",
}
}
/// From its wire name ("required" too).
pub fn parse(s: &str) -> Option<Self> {
match s {
"off" => Some(PartyPolicy::Off),
"suggest" => Some(PartyPolicy::Suggest),
"require" | "required" => Some(PartyPolicy::Require),
_ => None,
}
}
}
/// `policy.approval_rules`: whether approval rules block posting.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ApprovalMode {
/// Rules are recorded, never enforced (the default: light governance,
/// decided 2026-10-16).
#[default]
Off,
/// An entry a rule covers posts only once approved.
Enforce,
}
/// A decimal string as the books take one: exactly `-?digits[.digits]`.
/// The decimal parser alone also takes "1_000", "+5", ".5", and
/// exponents ("1e3"); a setting or tool argument never means those.
pub fn decimal(s: &str) -> Option<Decimal> {
let digits = s.strip_prefix('-').unwrap_or(s);
let (whole, frac) = match digits.split_once('.') {
Some((w, f)) => (w, Some(f)),
None => (digits, None),
};
let all_digits = |t: &str| !t.is_empty() && t.bytes().all(|b| b.is_ascii_digit());
if !all_digits(whole) || frac.is_some_and(|f| !all_digits(f)) {
return None;
}
s.parse().ok()
}
/// Whether `text` holds a number that reads as a full TIN (INVARIANT 4):
/// nine digits standing alone (no digit right before or after), bare or
/// printed the way an SSN (3-2-4) or an EIN (2-7) is, the groups joined by
/// a hyphen or any other dash, a dot, or a space (or a dash with spaces
/// around it); full-width digits count as digits.
pub fn holds_printed_tin(text: &str) -> bool {
printed_tin(text, false)
}
/// [`holds_printed_tin`] for a web address: the printed SSN (3-2-4) and
/// EIN (2-7) forms count as there, but a bare run of nine digits joined
/// to a letter or to the URL punctuation `/ = ? & #` does not: it is an
/// id in a path or query (an app store listing's `id284882215`), not a
/// TIN someone wrote down (rules.md B-26).
pub fn holds_printed_tin_in_link(text: &str) -> bool {
printed_tin(text, true)
}
/// [`holds_printed_tin`], with a link's bare runs let through when `link`.
fn printed_tin(text: &str, link: bool) -> bool {
// Full-width digits read as the digits they are.
let c: Vec<char> = text
.chars()
.map(|ch| match ch {
'\u{ff10}'..='\u{ff19}' => char::from_digit(ch as u32 - 0xff10, 10).unwrap_or(ch),
_ => ch,
})
.collect();
let digit = |i: usize| c.get(i).is_some_and(char::is_ascii_digit);
let dash = |ch: char| matches!(ch, '-' | '\u{2010}'..='\u{2015}' | '\u{2212}' | '.');
// A joiner at `i`: a dash, dot or space, or a dash with spaces around
// it; returns where the next group starts.
let joiner = |i: usize| -> Option<usize> {
let mut j = i;
while c.get(j) == Some(&' ') {
j += 1;
}
let mut dashes = 0;
while c.get(j).copied().is_some_and(dash) {
j += 1;
dashes += 1;
}
while c.get(j) == Some(&' ') {
j += 1;
}
let spaces = j - i - dashes;
(j > i && dashes <= 1 && spaces <= 2).then_some(j)
};
let groups_at = |at: usize, groups: &[usize]| -> Option<usize> {
let mut i = at;
for (n, g) in groups.iter().enumerate() {
if n > 0 {
i = joiner(i)?;
}
for _ in 0..*g {
if !digit(i) {
return None;
}
i += 1;
}
}
Some(i)
};
let url_joined = |i: Option<usize>| {
i.and_then(|i| c.get(i))
.is_some_and(|ch| ch.is_alphabetic() || matches!(ch, '/' | '=' | '?' | '&' | '#'))
};
(0..c.len()).any(|at| {
(at == 0 || !digit(at - 1))
&& [&[9][..], &[3, 2, 4][..], &[2, 7][..]].iter().any(|g| {
groups_at(at, g).is_some_and(|end| {
!digit(end)
&& !(link
&& g.len() == 1
&& (url_joined(at.checked_sub(1)) || url_joined(Some(end))))
})
})
})
}
fn no_tin(what: &str, s: &str) -> Result<(), String> {
if holds_printed_tin(s) {
return Err(format!(
"{what} holds a number that reads as a full SSN or EIN (nine digits, bare or printed 3-2-4 or 2-7): a full TIN goes only into the vault (books.set_tin), the company's EIN into company.ein and its D-U-N-S number into company.duns; for any other nine-digit number, write its last four"
));
}
Ok(())
}
/// One line of text: trimmed non-empty, at most `max` characters, no line
/// breaks or other control characters, no full TIN.
pub fn line_text(what: &str, s: &str, max: usize) -> Result<String, String> {
let t = bare_line_text(what, s, max)?;
no_tin(what, &t)?;
Ok(t)
}
/// [`line_text`] without the full-TIN check: for the one place a nine-digit
/// number is legitimate (an `ach` or `wire` payment method's bank-detail
/// line, where it is a routing number: A-3).
fn bare_line_text(what: &str, s: &str, max: usize) -> Result<String, String> {
let t = s.trim();
if t.is_empty() {
return Err(format!("{what} is blank"));
}
if t.chars().count() > max {
return Err(format!("{what} is longer than {max} characters"));
}
if t.chars().any(char::is_control) {
return Err(format!(
"{what} holds a line break or other control character"
));
}
Ok(t.to_string())
}
/// Longer text: trimmed non-empty, at most `max` characters, line breaks
/// and tabs allowed but no other control characters, no full TIN.
pub fn long_text(what: &str, s: &str, max: usize) -> Result<String, String> {
let t = s.trim();
if t.is_empty() {
return Err(format!("{what} is blank"));
}
if t.chars().count() > max {
return Err(format!("{what} is longer than {max} characters"));
}
if t.chars()
.any(|c| c.is_control() && !matches!(c, '\n' | '\t'))
{
return Err(format!("{what} holds a control character"));
}
no_tin(what, t)?;
Ok(t.to_string())
}
/// Parse a share: "50%" or "0.5", in (0, 1].
pub fn parse_share(name: &str, s: &str) -> Result<Decimal, String> {
let s = s.trim();
let bad = || format!("member {name}: share {s:?} must be like \"50%\" or \"0.5\"");
let share = match s.strip_suffix('%') {
Some(pct) => decimal(pct.trim())
.ok_or_else(bad)?
.checked_div(Decimal::from(100))
.ok_or_else(bad)?,
None => decimal(s).ok_or_else(bad)?,
};
if share <= Decimal::ZERO || share > Decimal::ONE {
return Err(bad());
}
Ok(share)
}
/// Parse `"MM-DD"`: the first day of a month (a fiscal year ends on the
/// last day of a month: IRS Pub. 538, docs/spec/sources/
/// irs-periods-and-methods.md).
pub fn parse_month_day(s: &str) -> Result<(u32, u32), String> {
let bad = || format!("fiscal_year_start {s:?}: expected \"MM-DD\", e.g. \"01-01\"");
let (m, d) = s.split_once('-').ok_or_else(bad)?;
let all_digits = |t: &str| !t.is_empty() && t.bytes().all(|b| b.is_ascii_digit());
if !all_digits(m) || !all_digits(d) {
return Err(bad());
}
let (m, d): (u32, u32) = (m.parse().map_err(|_| bad())?, d.parse().map_err(|_| bad())?);
if !(1..=12).contains(&m) {
return Err(bad());
}
if d != 1 {
return Err(format!(
"fiscal_year_start {s:?}: a fiscal year starts on the first of a month (it ends on the last day of one: IRS Pub. 538)"
));
}
Ok((m, d))
}
/// The return a federal tax classification files (docs/spec/sources/
/// irs-entity-classification.md), read by whole words, ignoring case:
/// "partnership" → 1065; "S corporation" (S corp, S-corp, scorp) →
/// 1120-S; "C corporation", or "corporation", "corp", or "association"
/// alone → 1120; "disregarded", "sole proprietor(ship)", or
/// "single-member" → Schedule C. Anything else, or words naming two
/// returns ("corporation or S corporation"), names none.
pub fn form_for_classification(c: &str) -> Option<TaxForm> {
let lower = c.to_lowercase();
let words: Vec<&str> = lower
.split(|ch: char| !ch.is_alphanumeric())
.filter(|w| !w.is_empty())
.collect();
let has = |w: &str| words.contains(&w);
let mut found = Vec::new();
if has("partnership") {
found.push(TaxForm::Form1065);
}
for (i, w) in words.iter().enumerate() {
let prev = i.checked_sub(1).map(|p| words[p]);
let form = match *w {
"scorp" => Some(TaxForm::Form1120S),
"ccorp" | "association" => Some(TaxForm::Form1120),
"corp" | "corporation" if prev == Some("s") => Some(TaxForm::Form1120S),
"corp" | "corporation" => Some(TaxForm::Form1120),
_ => None,
};
found.extend(form);
}
if has("disregarded") || has("sole") || words.windows(2).any(|p| p == ["single", "member"]) {
found.push(TaxForm::ScheduleC);
}
found.sort();
found.dedup();
match found.as_slice() {
[one] => Some(*one),
_ => None,
}
}
/// An account code as written, trimmed; a blank one is refused.
fn code(what: &str, s: &str) -> Result<String, String> {
let c = s.trim();
if c.is_empty() {
return Err(format!("{what}: an account code is blank"));
}
line_text(what, c, 64)
}
fn want_str<'a>(name: &str, v: &'a Value) -> Result<&'a str, String> {
v.as_str()
.ok_or_else(|| format!("{name} takes a string, not {v}"))
}
fn want_list<'a>(name: &str, v: &'a Value) -> Result<&'a Vec<Value>, String> {
v.as_array()
.ok_or_else(|| format!("{name} takes a list, not {v}"))
}
/// The payment-method kinds A-3 allows.
const PAYMENT_KINDS: &[&str] = &["ach", "wire", "link", "check", "p2p", "crypto", "other"];
/// A positive whole number: a JSON number, or the digits a settings file
/// writes it as.
fn count_of(name: &str, v: &Value) -> Result<u32, String> {
let n = match v {
Value::Number(n) => n.as_u64().and_then(|n| u32::try_from(n).ok()),
Value::String(s) => {
let s = s.trim();
(s.bytes().all(|b| b.is_ascii_digit()) && !s.is_empty())
.then(|| s.parse::<u32>().ok())
.flatten()
}
_ => None,
};
n.filter(|n| *n >= 1)
.ok_or_else(|| format!("{name} {v}: expected a positive whole number (1 or more)"))
}
/// A `qr` flag: a boolean, or the word a settings file writes it as.
fn flag_of(name: &str, v: &Value) -> Result<bool, String> {
match v {
Value::Bool(b) => Ok(*b),
Value::String(s) if s == "true" => Ok(true),
Value::String(s) if s == "false" => Ok(false),
_ => Err(format!("{name} {v}: expected true or false")),
}
}
/// `invoicing.payment_methods` in its canonical form (A-3): at most 8
/// methods, each `{kind, label, lines, link?, qr?}`. An `ach` or `wire`
/// method's `lines` are its bank details, where a nine-digit routing
/// number is legitimate; every other text in the list is scanned for a
/// full TIN like every other setting.
fn payment_methods(name: &str, list: &[Value]) -> Result<Value, String> {
if list.len() > 8 {
return Err(format!("{name}: at most 8 payment methods"));
}
let mut out = Vec::new();
for (i, m) in list.iter().enumerate() {
let n = format!("{name} method {}", i + 1);
let o = m
.as_object()
.ok_or_else(|| format!("{n}: expected {{kind, label, lines}}"))?;
for k in o.keys() {
if !["kind", "label", "lines", "link", "qr"].contains(&k.as_str()) {
return Err(format!("{n}: unknown field {k:?}"));
}
}
let kind = o
.get("kind")
.and_then(Value::as_str)
.map(|k| k.trim().to_lowercase())
.filter(|k| PAYMENT_KINDS.contains(&k.as_str()))
.ok_or_else(|| format!("{n}: kind is one of {}", PAYMENT_KINDS.join(", ")))?;
let label = line_text(
&format!("{n} label"),
o.get("label").and_then(Value::as_str).unwrap_or_default(),
MAX_TEXT,
)?;
let lines = o
.get("lines")
.and_then(Value::as_array)
.ok_or_else(|| format!("{n}: lines is a list of strings"))?;
let bank_details = matches!(kind.as_str(), "ach" | "wire");
let mut checked_lines = Vec::new();
for l in lines {
let text = l
.as_str()
.ok_or_else(|| format!("{n}: each line is a string"))?;
// A routing number is nine digits: bank-detail lines are the
// one text the TIN check doesn't scan (A-3). Length and
// control characters are still checked.
let text = if bank_details {
bare_line_text(&format!("{n} line"), text, 120)?
} else {
line_text(&format!("{n} line"), text, 120)?
};
checked_lines.push(json!(text));
}
let mut method = serde_json::Map::new();
method.insert("kind".into(), json!(kind));
method.insert("label".into(), json!(label));
method.insert("lines".into(), Value::Array(checked_lines));
if let Some(link) = o.get("link").filter(|l| !l.is_null()) {
let link = line_text(
&format!("{n} link"),
want_str(&format!("{n} link"), link)?,
MAX_TEXT,
)?;
method.insert("link".into(), json!(link));
}
if let Some(qr) = o.get("qr").filter(|q| !q.is_null()) {
method.insert("qr".into(), json!(flag_of(&format!("{n} qr"), qr)?));
}
out.push(Value::Object(method));
}
Ok(Value::Array(out))
}
/// Check a value for `key` before it is journaled, and return it in its
/// canonical spelling (INVARIANT 1). `null` is never a value: clearing a
/// key is its own change.
pub fn check(key: &Key, v: &Value) -> Result<Value, String> {
let name = key.name;
match key.kind {
Kind::Text => {
let text = line_text(name, want_str(name, v)?, MAX_TEXT)?;
// One key's own bound: an invoice number's prefix is short and
// visible (a space or a control character would break the
// number's reading, and the file's).
if name == "invoicing.number_prefix"
&& (text.chars().count() > 12 || !text.chars().all(|c| c.is_ascii_graphic()))
{
return Err(format!(
"{name} {text:?}: 1 to 12 visible ASCII characters (no spaces)"
));
}
Ok(json!(text))
}
Kind::LongText => Ok(json!(long_text(name, want_str(name, v)?, MAX_LONG_TEXT)?)),
Kind::Choice(choices) => {
let s = want_str(name, v)?.trim().to_lowercase();
choices
.iter()
.find(|(canon, aliases)| *canon == s || aliases.contains(&s.as_str()))
.map(|(canon, _)| json!(canon))
.ok_or_else(|| {
let names: Vec<&str> = choices.iter().map(|(c, _)| *c).collect();
format!("{name} {s:?}: expected one of {}", names.join(", "))
})
}
Kind::MonthStart => {
let (m, d) = parse_month_day(want_str(name, v)?.trim())?;
Ok(json!(format!("{m:02}-{d:02}")))
}
Kind::Date => {
let s = want_str(name, v)?.trim();
let d = NaiveDate::parse_from_str(s, "%Y-%m-%d")
.ok()
.filter(|d| d.format("%Y-%m-%d").to_string() == s)
.ok_or_else(|| format!("{name} {s:?}: expected a date, YYYY-MM-DD"))?;
crate::calendar::check(d).map_err(|e| format!("{name}: {e}"))?;
Ok(json!(s))
}
Kind::Amount => {
let s = want_str(name, v)?.trim();
let a = decimal(s).ok_or_else(|| format!("{name} {s:?} is not a decimal"))?;
if a.is_sign_negative() && !a.is_zero() {
return Err(format!("{name} {s:?} is below zero"));
}
if a > crate::kernel::MAX_LINE_AMOUNT {
return Err(format!("{name} {s:?} is out of range"));
}
// Kept to its places ("0.00" stays "0.00": the rule quotes
// it), without leading zeros or a sign ("025.00" is "25.00").
Ok(json!(a.abs().to_string()))
}
Kind::Code => Ok(json!(code(name, want_str(name, v)?)?)),
Kind::Codes => {
let mut out = Vec::new();
for c in want_list(name, v)? {
let c = code(name, want_str(name, c)?)?;
if out.contains(&c) {
return Err(format!("{name}: account {c} is listed twice"));
}
out.push(c);
}
Ok(json!(out))
}
Kind::Members => Ok(members_json(&check_members(want_list(name, v)?)?)),
Kind::Count => Ok(json!(count_of(name, v)?)),
Kind::Terms => {
let s = want_str(name, v)?.trim();
let terms = crate::Terms::parse(s).map_err(|e| format!("{name}: {e}"))?;
Ok(json!(terms.describe()))
}
Kind::PaymentMethods => payment_methods(name, want_list(name, v)?),
Kind::TimeZone => {
let s = want_str(name, v)?.trim();
let zone = jiff::tz::TimeZone::get(s).map_err(|_| {
format!("{name} {s:?} is not a time zone (an IANA name, like \"America/Denver\")")
})?;
// The database's own spelling ("america/denver" is
// "America/Denver"): one zone, one value.
let canonical = zone.iana_name().unwrap_or(s);
line_text(name, canonical, 64).map(|s| json!(s))
}
Kind::Ein => {
let s = want_str(name, v)?.trim();
let digits: String = s.chars().filter(char::is_ascii_digit).collect();
let shape_ok = s.chars().all(|c| c.is_ascii_digit() || c == '-')
&& digits.len() == 9
&& (s.len() == 9 || (s.len() == 10 && s.as_bytes()[2] == b'-'));
if !shape_ok {
return Err(format!("{name}: expected an EIN, NN-NNNNNNN"));
}
Ok(json!(format!("{}-{}", &digits[..2], &digits[2..])))
}
Kind::Duns => {
// Nine digits, bare or printed as D-U-N-S numbers are (2-3-4);
// never in an SSN's or EIN's shape (INVARIANT 4).
let s = want_str(name, v)?.trim();
let printed = s.len() == 11
&& s.as_bytes()[2] == b'-'
&& s.as_bytes()[6] == b'-'
&& s.chars().filter(char::is_ascii_digit).count() == 9;
let bare = s.len() == 9 && s.chars().all(|c| c.is_ascii_digit());
if !(bare || printed) {
return Err(format!(
"{name}: expected a D-U-N-S number, nine digits (or NN-NNN-NNNN)"
));
}
Ok(json!(s.replace('-', "")))
}
Kind::TaxForm => {
let s = want_str(name, v)?.trim();
TaxForm::parse(s)
.map(|f| json!(tax_form_key(f)))
.ok_or_else(|| format!("{name} {s:?}: expected schedule_c, 1065, 1120s, or 1120"))
}
Kind::NameControl => {
let s = want_str(name, v)?.trim().to_uppercase();
if s.is_empty()
|| s.len() > 4
|| !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '&' || c == '-')
{
return Err(format!("{name} {s:?}: one to four letters, digits, & or -"));
}
Ok(json!(s))
}
Kind::TagPrefix => {
let s = want_str(name, v)?;
if s.is_empty() || s.len() > 8 || !s.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(format!("{name} {s:?}: 1 to 8 letters or digits"));
}
Ok(json!(s))
}
Kind::Repository => {
let s = line_text(name, want_str(name, v)?, MAX_TEXT)?;
if s.starts_with('-') {
return Err(format!(
"{name} {s:?}: a repository's address never starts with -"
));
}
// A path on this computer is named from the root of its disk:
// the server's working folder is no place to name one from.
let networked = match s.split_once("://") {
Some((scheme, _)) => scheme != "file",
None => s
.split_once(':')
.is_some_and(|(host, _)| !host.contains('/')),
};
if !networked && !s.starts_with("file://") && !std::path::Path::new(&s).is_absolute() {
return Err(format!(
"{name} {s:?}: a repository on this computer is named from the root of its disk (such as /srv/books)"
));
}
Ok(json!(s))
}
Kind::Folder => {
let s = line_text(name, want_str(name, v)?, MAX_TEXT)?;
if !std::path::Path::new(&s).is_absolute() {
return Err(format!(
"{name} {s:?}: name the folder from the root of its disk (such as /media/backup)"
));
}
Ok(json!(s))
}
}
}
/// Whether a journaled value has its key's shape (INVARIANT 3): the JSON
/// type, and for members each one's fields. Replay asks no more.
pub fn shape(key: &Key, v: &Value) -> Result<(), String> {
let name = key.name;
match key.kind {
Kind::Codes => {
for c in want_list(name, v)? {
want_str(name, c)?;
}
Ok(())
}
Kind::Members => {
// Shares in (0, 1], as every version wrote them: a share past
// that could overflow the figures that multiply by it.
for m in members_from_json(v)? {
if m.share <= Decimal::ZERO || m.share > Decimal::ONE {
return Err(format!(
"company.members: {}'s share {} is outside (0, 1]",
m.name, m.share
));
}
}
Ok(())
}
Kind::Amount => {
let s = want_str(name, v)?;
decimal(s)
.map(|_| ())
.ok_or_else(|| format!("{name} {s:?} is not a decimal"))
}
Kind::Count => count_of(name, v).map(|_| ()),
Kind::PaymentMethods => {
for (i, m) in want_list(name, v)?.iter().enumerate() {
let n = format!("{name} method {}", i + 1);
let o = m
.as_object()
.ok_or_else(|| format!("{n}: expected an object"))?;
let field = |f: &str| o.get(f).ok_or_else(|| format!("{n}: no {f}"));
want_str(&format!("{n} kind"), field("kind")?)?;
want_str(&format!("{n} label"), field("label")?)?;
let lines = want_list(&format!("{n} lines"), field("lines")?)?;
for l in lines {
want_str(&format!("{n} line"), l)?;
}
if let Some(link) = o.get("link").filter(|l| !l.is_null()) {
want_str(&format!("{n} link"), link)?;
}
if o.get("qr").is_some_and(|q| !q.is_boolean() && !q.is_null()) {
return Err(format!("{n} qr: expected true or false"));
}
}
Ok(())
}
Kind::MonthStart => parse_month_start_lenient(want_str(name, v)?).map(|_| ()),
_ => want_str(name, v).map(|_| ()),
}
}
/// A journaled fiscal year start, "MM-DD", any month and day that exist in
/// every year (replay: the first of a month is checked for new changes).
fn parse_month_start_lenient(s: &str) -> Result<(u32, u32), String> {
let bad = || format!("fiscal_year_start {s:?}: expected \"MM-DD\"");
let (m, d) = s.split_once('-').ok_or_else(bad)?;
let (m, d): (u32, u32) = (m.parse().map_err(|_| bad())?, d.parse().map_err(|_| bad())?);
// A day that exists in every year, so fiscal_year_containing can't fail.
NaiveDate::from_ymd_opt(2001, m, d).ok_or_else(bad)?;
Ok((m, d))
}
fn check_members(list: &[Value]) -> Result<Vec<Member>, String> {
let mut members = Vec::new();
for (i, m) in list.iter().enumerate() {
let o = m.as_object().ok_or_else(|| {
format!(
"member {}: expected {{name, share, capital_account}}",
i + 1
)
})?;
for k in o.keys() {
if ![
"name",
"share",
"capital_account",
"distribution_account",
"files_jointly_with",
]
.contains(&k.as_str())
{
return Err(format!("member {}: unknown field {k:?}", i + 1));
}
}
let field = |f: &str| -> Result<Option<String>, String> {
match o.get(f) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) if s.trim().is_empty() => Ok(None),
Some(Value::String(s)) => {
line_text(&format!("member {} {f}", i + 1), s, MAX_TEXT).map(Some)
}
Some(other) => Err(format!(
"member {} {f}: expected a string, not {other}",
i + 1
)),
}
};
let name = field("name")?.ok_or_else(|| format!("member {}: name is blank", i + 1))?;
let share = parse_share(
&name,
field("share")?
.ok_or_else(|| format!("member {name}: share is required"))?
.as_str(),
)?;
let capital_account = field("capital_account")?
.ok_or_else(|| format!("member {name}: capital_account is required"))?;
members.push(Member {
name,
share,
capital_account,
distribution_account: field("distribution_account")?,
files_jointly_with: field("files_jointly_with")?,
});
}
for (i, m) in members.iter().enumerate() {
for other in &members[..i] {
if other.name == m.name {
return Err(format!("member {} is listed twice", m.name));
}
if other.capital_account == m.capital_account {
return Err(format!(
"members {} and {} share capital account {}",
other.name, m.name, m.capital_account
));
}
}
for other in &members {
if other.distribution_account.as_deref() == Some(m.capital_account.as_str()) {
return Err(format!(
"{}'s distributions account {} is {}'s capital account",
other.name, m.capital_account, m.name
));
}
}
}
// files_jointly_with names another member, and both say so.
for m in &members {
if let Some(j) = &m.files_jointly_with {
if *j == m.name {
return Err(format!(
"member {}: files_jointly_with names themself",
m.name
));
}
match members.iter().find(|o| o.name == *j) {
None => {
return Err(format!(
"member {}: files_jointly_with {j:?} isn't a member",
m.name
));
}
Some(o) if o.files_jointly_with.as_deref() != Some(m.name.as_str()) => {
return Err(format!(
"member {} files jointly with {j}, but {j}'s files_jointly_with doesn't name {}: set it on both",
m.name, m.name
));
}
Some(_) => {}
}
}
}
if !members.is_empty() {
let total = members
.iter()
.try_fold(Decimal::ZERO, |t, m| t.checked_add(m.share))
.ok_or("member shares are too large to add")?;
if total != Decimal::ONE {
return Err(format!("member shares sum to {total}, not 100%"));
}
}
Ok(members)
}
fn members_json(members: &[Member]) -> Value {
Value::Array(
members
.iter()
.map(|m| {
let mut o = serde_json::Map::new();
o.insert("name".into(), json!(m.name));
o.insert("share".into(), json!(m.share.normalize().to_string()));
o.insert("capital_account".into(), json!(m.capital_account));
if let Some(d) = &m.distribution_account {
o.insert("distribution_account".into(), json!(d));
}
if let Some(j) = &m.files_jointly_with {
o.insert("files_jointly_with".into(), json!(j));
}
Value::Object(o)
})
.collect(),
)
}
/// Members as journaled (shape only: the fields' types, and a share that
/// is a decimal).
fn members_from_json(v: &Value) -> Result<Vec<Member>, String> {
let list = want_list("company.members", v)?;
list.iter()
.enumerate()
.map(|(i, m)| {
let s = |f: &str| m.get(f).and_then(Value::as_str).map(str::to_string);
let bad = |f: &str| format!("company.members {}: bad {f}", i + 1);
Ok(Member {
name: s("name").ok_or_else(|| bad("name"))?,
share: s("share")
.as_deref()
.and_then(decimal)
.ok_or_else(|| bad("share"))?,
capital_account: s("capital_account").ok_or_else(|| bad("capital_account"))?,
distribution_account: s("distribution_account"),
files_jointly_with: s("files_jointly_with"),
})
})
.collect()
}
/// A setting as journaled, with where it came from.
#[derive(Clone, Debug, PartialEq)]
pub struct Recorded {
/// The value, canonical (INVARIANT 1).
pub value: Value,
/// The journal line that set it.
pub seq: u64,
/// Who decided it.
pub source: Source,
}
/// One change in a `settings.set`: a key and its new value, or `None` to
/// clear it back to its default.
#[derive(Clone, Debug, PartialEq)]
pub struct Change {
/// The key.
pub key: String,
/// The new value; `None` clears it.
pub value: Option<Value>,
}
/// The settings the journal holds.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Settings {
values: BTreeMap<String, Recorded>,
/// The seq of the last `settings.set` replayed or made, if any: books
/// whose journal holds one keep their settings there (and nowhere
/// else).
last_seq: Option<u64>,
}
impl Settings {
/// Whether the journal holds settings (any `settings.set`). Books
/// whose journal holds none still read a settings file (legacy mode,
/// until they import one).
pub fn is_journaled(&self) -> bool {
self.last_seq.is_some()
}
/// The seq of the last `settings.set`, if any.
pub fn last_seq(&self) -> Option<u64> {
self.last_seq
}
/// The setting `name`, if set.
pub fn get(&self, name: &str) -> Option<&Recorded> {
self.values.get(name)
}
/// Every setting that is set, in [`KEYS`] order.
pub fn iter(&self) -> impl Iterator<Item = (&'static Key, &Recorded)> {
KEYS.iter()
.filter_map(|k| self.values.get(k.name).map(|r| (k, r)))
}
fn str(&self, name: &str) -> Option<&str> {
self.values.get(name).and_then(|r| r.value.as_str())
}
fn codes(&self, name: &str) -> Vec<String> {
self.values
.get(name)
.and_then(|r| r.value.as_array())
.map(|a| {
a.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
/// The company, as the settings describe it (INVARIANT 5 for what is
/// unset). `base_currency` is the books' own (fixed when they were
/// created): the profile shows it.
pub fn company(&self, base_currency: &str) -> Company {
let mut c = Company::default();
if let Some(n) = self.str("company.name") {
c.name = n.to_string();
}
if let Some(fy) = self.str("company.fiscal_year_start")
&& let Ok(md) = parse_month_start_lenient(fy)
{
c.fiscal_year_start = md;
}
if let Some(m) = self.values.get("company.members") {
c.members = members_from_json(&m.value).unwrap_or_default();
}
if self.str("books.accounting_basis") == Some("cash") {
c.basis = "Cash".to_string();
}
c.tax_form = match (
self.str("company.tax_form"),
self.str("company.tax_classification"),
) {
(Some(f), _) => TaxForm::parse(f),
(None, Some(cl)) => form_for_classification(cl),
(None, None) => None,
};
c.close_into = self.str("books.close_into").map(str::to_string);
c.close_sweep = self.codes("books.close_sweep");
let mut profile = vec![("base_currency".to_string(), base_currency.to_string())];
for (k, r) in self.iter() {
if k.toml.0 == "company"
&& k.kind != Kind::Members
&& let Some(s) = r.value.as_str()
{
profile.push((k.toml.1.to_string(), s.to_string()));
}
}
// The profile in a settings file's order: name first.
profile.sort_by_key(|(k, _)| (k != "name", k != "base_currency"));
c.profile = profile;
c
}
/// `policy.evidence.threshold` as set.
pub fn receipt_threshold(&self) -> Option<&str> {
self.str("policy.evidence.threshold")
}
/// `policy.evidence.required_accounts`.
pub fn receipt_required_accounts(&self) -> Vec<String> {
self.codes("policy.evidence.required_accounts")
}
/// `policy.evidence.exempt_accounts`.
pub fn receipt_exempt_accounts(&self) -> Vec<String> {
self.codes("policy.evidence.exempt_accounts")
}
/// `books.cash_accounts`.
pub fn cash_accounts(&self) -> Vec<String> {
self.codes("books.cash_accounts")
}
/// `books.financing_accounts`.
pub fn financing_accounts(&self) -> Vec<String> {
self.codes("books.financing_accounts")
}
/// `books.investing_accounts`.
pub fn investing_accounts(&self) -> Vec<String> {
self.codes("books.investing_accounts")
}
/// `books.asset_tag_prefix`, if set.
pub fn asset_tag_prefix(&self) -> Option<&str> {
self.str("books.asset_tag_prefix")
}
/// `books.schedule_convention`: how a new schedule spreads its total.
/// Unset: daily (plan/served.md D-11, confirmed in D-37).
pub fn schedule_convention(&self) -> crate::ScheduleConvention {
self.str("books.schedule_convention")
.and_then(crate::ScheduleConvention::parse)
.unwrap_or(crate::ScheduleConvention::Daily)
}
/// `policy.parties`.
pub fn parties(&self) -> PartyPolicy {
self.str("policy.parties")
.and_then(PartyPolicy::parse)
.unwrap_or_default()
}
/// `policy.require_tax_category` (unset: warn).
pub fn require_tax_category(&self) -> TaxCategoryPolicy {
match self.str("policy.require_tax_category") {
Some("refuse") => TaxCategoryPolicy::Refuse,
_ => TaxCategoryPolicy::Warn,
}
}
/// `policy.approval_rules`.
pub fn approval_rules(&self) -> ApprovalMode {
match self.str("policy.approval_rules") {
Some("enforce") => ApprovalMode::Enforce,
_ => ApprovalMode::Off,
}
}
/// `backup.git_remote`, if set.
pub fn backup_git_remote(&self) -> Option<&str> {
self.str("backup.git_remote")
}
/// `backup.folder`, if set.
pub fn backup_folder(&self) -> Option<&str> {
self.str("backup.folder")
}
/// `company.timezone`, if set.
pub fn timezone(&self) -> Option<&str> {
self.str("company.timezone")
}
/// Today in the company's time zone (`company.timezone`), else in the
/// computer's. Not UTC: in the evening (US time) UTC is already
/// tomorrow, and "through today" must not include it.
pub fn today(&self) -> NaiveDate {
today_in(self.timezone())
}
/// Check that the settings hold together (INVARIANT 2): what no one
/// key's check can see.
///
/// ```text
/// INVARIANTS:
/// 1. With members, books.close_into is unset (a close goes to each
/// member's capital account).
/// 2. Without company.tax_form, a company.tax_classification names
/// exactly one return.
/// ```
pub fn check_whole(&self) -> Result<(), String> {
let has_members = self
.values
.get("company.members")
.and_then(|r| r.value.as_array())
.is_some_and(|a| !a.is_empty());
if has_members && self.values.contains_key("books.close_into") {
return Err(
"books.close_into is for single-owner businesses; with company.members a close goes to each member's capital account"
.to_string(),
);
}
if self.str("company.tax_form").is_none()
&& let Some(c) = self.str("company.tax_classification")
&& form_for_classification(c).is_none()
{
return Err(format!(
"company.tax_classification {c:?}: expected partnership, S corporation, C corporation, or disregarded entity (or set company.tax_form)"
));
}
Ok(())
}
/// Check a change before it is journaled, and return it with every
/// value canonical and every change that changes nothing left out
/// (INVARIANTS 1, 2, 4). An empty result changes nothing.
pub fn check_changes(
&self,
changes: &[Change],
source: &Source,
) -> Result<Vec<Change>, String> {
source.check()?;
let mut next = self.clone();
let mut out: Vec<Change> = Vec::new();
for ch in changes {
let k = key(&ch.key).ok_or_else(|| {
format!(
"unknown setting {:?}: books.settings lists every key",
ch.key
)
})?;
if out.iter().any(|o| o.key == ch.key) {
return Err(format!("{} is changed twice", ch.key));
}
let value = match &ch.value {
None | Some(Value::Null) => None,
Some(v) => Some(check(k, v)?),
};
if next.values.get(k.name).map(|r| &r.value) == value.as_ref() {
continue;
}
next.apply_one(k.name, value.clone(), 0, source);
out.push(Change {
key: k.name.to_string(),
value,
});
}
next.check_whole()?;
Ok(out)
}
fn apply_one(&mut self, name: &str, value: Option<Value>, seq: u64, source: &Source) {
match value {
Some(value) => {
self.values.insert(
name.to_string(),
Recorded {
value,
seq,
source: source.clone(),
},
);
}
None => {
self.values.remove(name);
}
}
}
/// Apply a journaled `settings.set` (replay, or a change already
/// checked and journaled): every key known and every value of its
/// key's shape (INVARIANT 3), or nothing changes.
pub fn apply(&mut self, changes: &[Change], source: &Source, seq: u64) -> Result<(), String> {
for ch in changes {
let k = key(&ch.key).ok_or_else(|| format!("unknown setting {:?}", ch.key))?;
if let Some(v) = &ch.value {
shape(k, v)?;
}
}
for ch in changes {
self.apply_one(&ch.key, ch.value.clone(), seq, source);
}
self.last_seq = Some(seq);
Ok(())
}
}
/// Today in `timezone` (an IANA name), else in the computer's time zone;
/// an unknown name (books a later version wrote) counts as unset.
pub fn today_in(timezone: Option<&str>) -> NaiveDate {
let zone = timezone
.and_then(|z| jiff::tz::TimeZone::get(z).ok())
.unwrap_or_else(jiff::tz::TimeZone::system);
let d = jiff::Timestamp::now().to_zoned(zone).date();
NaiveDate::from_ymd_opt(i32::from(d.year()), d.month() as u32, d.day() as u32)
.unwrap_or_else(|| chrono::Local::now().date_naive())
}
/// Mask an identity value to its last four: "**-***5893".
pub fn mask(value: &str) -> String {
let n = value.chars().count();
value
.chars()
.enumerate()
.map(|(i, c)| {
if i + 4 < n && c.is_ascii_digit() {
'*'
} else {
c
}
})
.collect()
}
/// A setting's value as text, for display (`books.start`, the viewer):
/// strings as they are, lists joined, identity values masked.
pub fn display(key: &Key, v: &Value, full: bool) -> String {
let s = match v {
Value::String(s) => s.clone(),
Value::Array(a) if key.kind == Kind::Members => a
.iter()
.map(|m| {
format!(
"{} {}",
m.get("name").and_then(Value::as_str).unwrap_or("?"),
m.get("share").and_then(Value::as_str).unwrap_or("?")
)
})
.collect::<Vec<_>>()
.join(", "),
Value::Array(a) if key.kind == Kind::PaymentMethods => a
.iter()
.filter_map(|m| m.get("label").and_then(Value::as_str))
.collect::<Vec<_>>()
.join(", "),
Value::Array(a) => a
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join(", "),
other => other.to_string(),
};
if key.sensitivity == Sensitivity::Identity && !full {
mask(&s)
} else {
s
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn where_backups_go_is_checked() {
let remote = key("backup.git_remote").unwrap();
let folder = key("backup.folder").unwrap();
for ok in [
"git@host:owner/books.git",
"https://host/owner/books",
"/srv/books.git",
] {
assert_eq!(check(remote, &json!(ok)).unwrap(), json!(ok), "{ok}");
}
assert_eq!(
check(remote, &json!(" ssh://h/b ")).unwrap(),
json!("ssh://h/b")
);
for bad in [
"--upload-pack=x",
"-",
"",
"a\nb",
"acme.git",
"backups/acme.git",
"~/x.git",
"./x",
] {
assert!(check(remote, &json!(bad)).is_err(), "{bad}");
}
assert!(check(remote, &json!("file:///srv/b.git")).is_ok());
#[cfg(unix)]
assert_eq!(
check(folder, &json!("/media/backup")).unwrap(),
json!("/media/backup")
);
for bad in ["backup", "./backup", "~/backup", "-x", ""] {
assert!(check(folder, &json!(bad)).is_err(), "{bad}");
}
}
fn src() -> Source {
Source {
decided_by: "Owner".into(),
decided_on: NaiveDate::from_ymd_opt(2026, 10, 17).unwrap(),
citation: None,
note: None,
}
}
fn set(s: &Settings, k: &str, v: Value) -> Result<Vec<Change>, String> {
s.check_changes(
&[Change {
key: k.into(),
value: Some(v),
}],
&src(),
)
}
/// INVARIANT 1: one value, one spelling; a change to the same value
/// changes nothing.
#[test]
fn values_are_canonical() {
let s = Settings::default();
let got = set(&s, "policy.parties", json!(" Required ")).unwrap();
assert_eq!(got[0].value, Some(json!("require")));
let got = set(&s, "company.ein", json!("288525893")).unwrap();
assert_eq!(got[0].value, Some(json!("28-8525893")));
let got = set(&s, "company.tax_form", json!("Form 1120-S")).unwrap();
assert_eq!(got[0].value, Some(json!("1120s")));
let mut s = Settings::default();
let ch = set(&s, "company.name", json!(" Acme LLC ")).unwrap();
s.apply(&ch, &src(), 7).unwrap();
assert!(
set(&s, "company.name", json!("Acme LLC"))
.unwrap()
.is_empty()
);
assert_eq!(s.get("company.name").unwrap().seq, 7);
}
/// policy.require_tax_category (rules.md B-32): warn or refuse, one
/// spelling each ("required" and "require" are refuse), nothing else
/// ("off" is no value: the warning is the floor); unset is warn.
#[test]
fn the_tax_category_policy_is_warn_or_refuse() {
let s = Settings::default();
assert_eq!(s.require_tax_category(), TaxCategoryPolicy::Warn);
let one = |v: &str| set(&s, "policy.require_tax_category", json!(v));
for (spelling, canon) in [
("warn", "warn"),
(" Warn ", "warn"),
("refuse", "refuse"),
("REFUSE", "refuse"),
("required", "refuse"),
("require", "refuse"),
] {
assert_eq!(
one(spelling).unwrap()[0].value,
Some(json!(canon)),
"{spelling}"
);
}
for bad in ["off", "none", "", "yes", "true"] {
assert!(one(bad).is_err(), "{bad}");
}
let mut s = Settings::default();
s.apply(&one("refuse").unwrap(), &src(), 3).unwrap();
assert_eq!(s.require_tax_category(), TaxCategoryPolicy::Refuse);
s.apply(&one("warn").unwrap(), &src(), 4).unwrap();
assert_eq!(s.require_tax_category(), TaxCategoryPolicy::Warn);
// Cleared: back to the default.
s.apply(
&[Change {
key: "policy.require_tax_category".into(),
value: None,
}],
&src(),
5,
)
.unwrap();
assert_eq!(s.require_tax_category(), TaxCategoryPolicy::Warn);
// A value only a later version writes reads as the default here
// (replay takes any string, INVARIANT 3).
s.apply(
&[Change {
key: "policy.require_tax_category".into(),
value: Some(json!("refuse_later")),
}],
&src(),
6,
)
.unwrap();
assert_eq!(s.require_tax_category(), TaxCategoryPolicy::Warn);
}
/// INVARIANT 4: no full TIN outside company.ein.
#[test]
fn no_full_tin_but_the_companys_ein() {
let s = Settings::default();
assert!(set(&s, "company.address", json!("SSN 123-45-6789")).is_err());
assert!(set(&s, "company.cpa", json!("EIN 12-3456789")).is_err());
assert!(set(&s, "company.ein", json!("12-3456789")).is_ok());
// Not TIN-shaped: a state number, a phone number, a DUNS.
assert!(set(&s, "company.state_entity_number", json!("14745117-0160")).is_ok());
assert!(set(&s, "company.address", json!("call 801-555-0142")).is_ok());
assert!(set(&s, "company.duns", json!("149976972")).is_ok());
// Bare nine digits read as an SSN anywhere but the D-U-N-S key.
assert!(set(&s, "company.cpa", json!("Pat 149976972")).is_err());
let mut bad = src();
bad.note = Some("their SSN is 123-45-6789".into());
assert!(
Settings::default()
.check_changes(
&[Change {
key: "company.name".into(),
value: Some(json!("X"))
}],
&bad
)
.is_err()
);
}
/// Review of P1 (2026-10-17): one value, one spelling, for zones,
/// amounts and returns; a D-U-N-S never in a TIN's shape; members'
/// shares, as journaled, in (0, 1].
#[test]
fn spellings_and_shapes() {
let s = Settings::default();
let one = |k: &str, v: Value| set(&s, k, v).map(|c| c[0].value.clone().unwrap());
assert_eq!(
one("company.timezone", json!("america/denver")).unwrap(),
json!("America/Denver")
);
assert_eq!(
one("company.timezone", json!("utc")).unwrap(),
one("company.timezone", json!("UTC")).unwrap()
);
assert_eq!(
one("policy.evidence.threshold", json!("025.00")).unwrap(),
json!("25.00")
);
assert_eq!(
one("policy.evidence.threshold", json!("-0.00")).unwrap(),
json!("0.00")
);
for (spelling, key) in [
("Schedule-C", "schedule_c"),
("Form_1065", "1065"),
("form 1120-S", "1120s"),
("1120", "1120"),
] {
assert_eq!(
one("company.tax_form", json!(spelling)).unwrap(),
json!(key),
"{spelling}"
);
}
assert!(one("company.tax_form", json!("990")).is_err());
assert_eq!(
one("company.duns", json!("14-997-6972")).unwrap(),
json!("149976972")
);
for tin in ["123-45-6789", "12-3456789", "1234-56789"] {
assert!(one("company.duns", json!(tin)).is_err(), "{tin}");
}
let k = key("company.members").unwrap();
assert!(
shape(
k,
&json!([{"name": "A", "share": "1", "capital_account": "3000"}])
)
.is_ok()
);
for share in ["0", "-0.5", "1.0001", "79228162514264337593543950335"] {
assert!(
shape(
k,
&json!([{"name": "A", "share": share, "capital_account": "3000"}])
)
.is_err(),
"{share}"
);
}
}
#[test]
fn printed_tins() {
for yes in [
"123.45.6789",
"\u{ff11}\u{ff12}\u{ff13}-\u{ff14}\u{ff15}-\u{ff16}\u{ff17}\u{ff18}\u{ff19}",
"123 \u{2013} 45 \u{2013} 6789",
"123-45-6789",
"a 12-3456789.",
"(123-45-6789)",
"1-123-45-6789",
"12-3456789-0",
"123\u{2010}45\u{2010}6789",
"123 45 6789",
"SSN 123456789",
"12\u{2212}3456789",
] {
assert!(holds_printed_tin(yes), "{yes}");
}
for no in [
"1123-45-6789",
"123-45-67890",
"12-34567890",
"1234567890",
"14745117-0160",
"801-555-0142",
"2026-10-04",
"100 Example Way, Midvale, UT 84047",
] {
assert!(!holds_printed_tin(no), "{no}");
}
}
/// INVARIANT 2: the settings after a change hold together.
#[test]
fn changes_hold_together() {
let s = Settings::default();
let members = json!([
{"name": "A", "share": "50%", "capital_account": "3000"},
{"name": "B", "share": "0.5", "capital_account": "3001"}
]);
let both = s.check_changes(
&[
Change {
key: "company.members".into(),
value: Some(members.clone()),
},
Change {
key: "books.close_into".into(),
value: Some(json!("3000")),
},
],
&src(),
);
assert!(both.unwrap_err().contains("close_into"));
assert!(
set(&s, "company.tax_classification", json!("trust"))
.unwrap_err()
.contains("tax_classification")
);
// A tax form makes any classification acceptable (it isn't read).
let ok = s.check_changes(
&[
Change {
key: "company.tax_classification".into(),
value: Some(json!("trust")),
},
Change {
key: "company.tax_form".into(),
value: Some(json!("1120")),
},
],
&src(),
);
assert!(ok.is_ok());
let got = set(&s, "company.members", members).unwrap();
assert_eq!(
got[0].value.as_ref().unwrap()[0]["share"],
json!("0.5"),
"\"50%\" is kept as 0.5"
);
}
/// The members' checks carried over from the settings file.
#[test]
fn members_are_checked() {
let s = Settings::default();
let two = |a: Value, b: Value| set(&s, "company.members", json!([a, b]));
let m = |n: &str, sh: &str, c: &str| json!({"name": n, "share": sh, "capital_account": c});
assert!(two(m("A", "50%", "3000"), m("B", "40%", "3001")).is_err());
assert!(two(m("A", "60%", "3000"), m("B", "40%", "3001")).is_ok());
assert!(two(m("A", "50%", "3000"), m("A", "50%", "3001")).is_err());
assert!(two(m("A", "50%", "3000"), m("B", "50%", "3000")).is_err());
assert!(
set(
&s,
"company.members",
json!([{"name": "A", "share": "100%"}])
)
.is_err()
);
assert!(
set(
&s,
"company.members",
json!([{"name": "A", "share": "1e0", "capital_account": "3000"}])
)
.is_err()
);
let j = |a: &str, b: &str| {
two(
json!({"name": "A", "share": "50%", "capital_account": "3000", "files_jointly_with": a}),
json!({"name": "B", "share": "50%", "capital_account": "3001", "files_jointly_with": b}),
)
};
assert!(j("B", "A").is_ok());
assert!(j("B", "").unwrap_err().contains("set it on both"));
assert!(j("C", "").unwrap_err().contains("isn't a member"));
assert!(j("A", "").unwrap_err().contains("themself"));
}
#[test]
fn kinds_refuse_what_they_cant_honor() {
let s = Settings::default();
for (k, v) in [
("company.fiscal_year_start", json!("02-29")),
("company.fiscal_year_start", json!("13-01")),
("company.fiscal_year_start", json!("07-15")),
("policy.evidence.threshold", json!("25 dollars")),
("policy.evidence.threshold", json!("-1")),
("policy.evidence.threshold", json!("1e3")),
("books.accounting_basis", json!("modified")),
("company.timezone", json!("Mars/Olympus")),
("company.formed", json!("2026-02-30")),
("company.formed", json!("10000-01-01")),
("company.duns", json!("12345678")),
("company.irs_name_control", json!("WALNX")),
("books.asset_tag_prefix", json!("W-L")),
("books.cash_accounts", json!(["1000", "1000"])),
("books.cash_accounts", json!(["1000", " "])),
("company.name", json!("two\nlines")),
("company.name", json!(5)),
("no.such.key", json!("x")),
] {
assert!(set(&s, k, v.clone()).is_err(), "{k} = {v}");
}
assert_eq!(
set(&s, "company.timezone", json!("America/Denver")).unwrap()[0].value,
Some(json!("America/Denver"))
);
assert_eq!(
set(&s, "policy.evidence.threshold", json!("0.00")).unwrap()[0].value,
Some(json!("0.00"))
);
}
/// INVARIANT 5: unset keys are the old file's defaults.
#[test]
fn unset_is_the_default() {
let s = Settings::default();
let c = s.company("USD");
assert_eq!(
(c.name.as_str(), c.fiscal_year_start, c.basis.as_str()),
(DEFAULT_NAME, (1, 1), "Accrual")
);
assert_eq!(s.parties(), PartyPolicy::Suggest);
assert_eq!(s.approval_rules(), ApprovalMode::Off);
assert!(!s.is_journaled());
}
/// INVARIANT 3: replay takes any value of the right shape, and refuses
/// a wrong shape or an unknown key without changing anything.
#[test]
fn replay_takes_shapes() {
let mut s = Settings::default();
// A threshold no command would write, but a decimal: replayed.
s.apply(
&[Change {
key: "policy.evidence.threshold".into(),
value: Some(json!("-5")),
}],
&src(),
3,
)
.unwrap();
assert_eq!(s.last_seq(), Some(3));
let before = s.clone();
assert!(
s.apply(
&[
Change {
key: "company.name".into(),
value: Some(json!("X"))
},
Change {
key: "books.cash_accounts".into(),
value: Some(json!("1000"))
},
],
&src(),
4
)
.is_err()
);
assert_eq!(s, before, "all or nothing");
assert!(
s.apply(
&[Change {
key: "no.such".into(),
value: Some(json!("x"))
}],
&src(),
5
)
.is_err()
);
}
#[test]
fn masks_identity() {
let k = key("company.ein").unwrap();
assert_eq!(display(k, &json!("28-8525893"), false), "**-***5893");
assert_eq!(display(k, &json!("28-8525893"), true), "28-8525893");
}
}