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
@@ -56,6 +56,12 @@ impl AccountType {
|
||||
}
|
||||
}
|
||||
|
||||
/// The longest name a new account (or a rename) may take, in characters:
|
||||
/// one line that every report prints, as long as the company's own name
|
||||
/// may be ([`crate::settings::MAX_TEXT`]). The command's bound, never
|
||||
/// replay's (rules.md B-28): a journaled name of any length replays.
|
||||
pub const MAX_ACCOUNT_NAME: usize = crate::settings::MAX_TEXT;
|
||||
|
||||
/// A single account in the chart of accounts.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Account {
|
||||
@@ -143,6 +149,18 @@ impl ChartOfAccounts {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Give an account a new name. Only the account must exist: what a
|
||||
/// new name may be is the command's to check
|
||||
/// ([`crate::Ledger::rename_account`]), never replay's (rules.md B-28).
|
||||
pub fn set_name(&mut self, id: AccountId, name: &str) -> Result<(), LedgerError> {
|
||||
let a = self
|
||||
.accounts
|
||||
.get_mut(&id)
|
||||
.ok_or(LedgerError::UnknownAccount(id))?;
|
||||
a.name = name.to_string();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set (or clear) an account's tax category and deductible percent.
|
||||
pub fn set_tax(
|
||||
&mut self,
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
//! ```
|
||||
|
||||
use crate::LedgerError;
|
||||
use chrono::NaiveDate;
|
||||
use crate::settings::Company;
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
|
||||
/// The first day the books keep.
|
||||
pub const FIRST_DAY: NaiveDate = match NaiveDate::from_ymd_opt(1, 1, 1) {
|
||||
@@ -46,6 +47,207 @@ pub fn check(d: NaiveDate) -> Result<NaiveDate, LedgerError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The month and day a fiscal year starts on, as one every year has: the
|
||||
/// books' `company.fiscal_year_start` always is (a new value is the first
|
||||
/// of a month; a journaled one is a day 2001 has, and 2001 is no leap
|
||||
/// year). Any other counts as January 1.
|
||||
fn year_start(md: (u32, u32)) -> (u32, u32) {
|
||||
if NaiveDate::from_ymd_opt(2001, md.0, md.1).is_some() {
|
||||
md
|
||||
} else {
|
||||
(1, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// The first day of the fiscal year that holds `d` (a fiscal year
|
||||
/// starting on month-day `md`), before any clamp: in year 0 for a day
|
||||
/// early in year 1, which chrono holds and the books' calendar doesn't.
|
||||
pub(crate) fn fiscal_year_start_unclamped(md: (u32, u32), d: NaiveDate) -> NaiveDate {
|
||||
let (m, day) = year_start(md);
|
||||
// A day every year has (year_start), in d's year or the one before:
|
||||
// chrono holds both for every date it holds but its very first year,
|
||||
// where the year before falls back to d itself.
|
||||
let on = |y: i32| NaiveDate::from_ymd_opt(y, m, day).unwrap_or(d);
|
||||
let this = on(d.year());
|
||||
if this <= d { this } else { on(d.year() - 1) }
|
||||
}
|
||||
|
||||
/// The fiscal year that holds `d`, `(first day, last day)`, unclamped
|
||||
/// (`Company::fiscal_year_containing`): the last day is the day before
|
||||
/// the next start, so the two can't disagree.
|
||||
pub(crate) fn fiscal_year_unclamped(md: (u32, u32), d: NaiveDate) -> (NaiveDate, NaiveDate) {
|
||||
let start = fiscal_year_start_unclamped(md, d);
|
||||
let (m, day) = year_start(md);
|
||||
let end = NaiveDate::from_ymd_opt(start.year() + 1, m, day)
|
||||
.and_then(|next| next.pred_opt())
|
||||
.unwrap_or(NaiveDate::MAX);
|
||||
(start, end)
|
||||
}
|
||||
|
||||
/// The first day of the fiscal year that holds `d`, for a fiscal year
|
||||
/// starting on month-day `md` (`company.fiscal_year_start`), clamped to
|
||||
/// [`FIRST_DAY`].
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: the books' annual period: the one fact the fiscal year to date,
|
||||
/// the CPA package's year, and every default period derive from.
|
||||
/// SOURCE: IRS Pub. 538 (docs/spec/sources/irs-periods-and-methods.md):
|
||||
/// "A tax year is an annual accounting period for keeping records
|
||||
/// and reporting income and expenses"; rules.md B-28 (no date
|
||||
/// outside the calendar).
|
||||
/// INVARIANTS:
|
||||
/// 1. For d in the calendar: FIRST_DAY <= result <= d, and d is before
|
||||
/// the same month-day a year after the result (unless clamped).
|
||||
/// 2. The result is md in d's year when that is on or before d, else md
|
||||
/// in the year before; clamped to FIRST_DAY only when that is year
|
||||
/// 0 (a non-January fiscal year, d early in year 1).
|
||||
/// 3. NEVER panics, for any md and any chrono date.
|
||||
/// ```
|
||||
pub fn fiscal_year_start_on(md: (u32, u32), d: NaiveDate) -> NaiveDate {
|
||||
fiscal_year_start_unclamped(md, d).max(FIRST_DAY)
|
||||
}
|
||||
|
||||
/// What an omitted date means, by the kind of report (rules.md B-30): the
|
||||
/// text every tool description and date parameter shows, so none can say
|
||||
/// other than [`PeriodDefaults`] does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PeriodKind {
|
||||
/// A report over a period (`start`, `end`): profit and loss, cash
|
||||
/// flow, tax summary, members' equity, the general ledger and
|
||||
/// journal, an account's register.
|
||||
Statement,
|
||||
/// A report at an instant (`as_of`): balance sheet, trial balance,
|
||||
/// aging, the fixed asset register, the CPA package.
|
||||
AsOf,
|
||||
/// A count by calendar year (`year`): 1099s and party totals.
|
||||
CalendarYear,
|
||||
}
|
||||
|
||||
impl PeriodKind {
|
||||
/// The one sentence a tool's description gives for its dates.
|
||||
pub const fn describe(self) -> &'static str {
|
||||
match self {
|
||||
PeriodKind::Statement => {
|
||||
"Dates are YYYY-MM-DD. Omitted, the period is the fiscal year to date (company.fiscal_year_start): end defaults to today, start to the first day of the fiscal year that holds end. A start after the end is refused."
|
||||
}
|
||||
PeriodKind::AsOf => "as_of is YYYY-MM-DD; omitted, today in the company's time zone.",
|
||||
PeriodKind::CalendarYear => {
|
||||
"year defaults to this calendar year (1099s and party totals count by calendar year)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A period report's `start` parameter, as every schema documents it.
|
||||
pub const START_DOC: &str = "First day, YYYY-MM-DD. Omitted: the first day of the fiscal year that holds end (company.fiscal_year_start). A start after the end is refused.";
|
||||
/// A period report's `end` parameter, as every schema documents it.
|
||||
pub const END_DOC: &str = "Last day, YYYY-MM-DD. Omitted: today in the company's time zone.";
|
||||
/// A point-in-time report's `as_of` parameter, as every schema documents
|
||||
/// it.
|
||||
pub const AS_OF_DOC: &str =
|
||||
"As of this day, YYYY-MM-DD. Omitted: today in the company's time zone.";
|
||||
/// A calendar-year count's `year` parameter, as every schema documents it.
|
||||
pub const YEAR_DOC: &str =
|
||||
"Calendar year. Omitted: this calendar year (1099s and party totals count by calendar year).";
|
||||
|
||||
/// The defaults an omitted date takes, today, under the company's fiscal
|
||||
/// year: the one rule (rules.md B-30) the report tools, the viewer and
|
||||
/// the tool descriptions share. Built by [`period_defaults`].
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: one meaning for "no dates", everywhere: a report asked for
|
||||
/// without dates covers this fiscal year so far.
|
||||
/// SOURCE: rules.md B-30; IRS Pub. 538 ("an annual accounting period for
|
||||
/// keeping records and reporting income and expenses"); 26 U.S.C.
|
||||
/// §6041(a) ("in any calendar year") and §6041A(a)(2) ("during
|
||||
/// such calendar year") for the calendar-year counts; DECISION
|
||||
/// (owner, 2026-10-20, plan/served.md P3 "Settled in design"):
|
||||
/// the fiscal year to date, everywhere.
|
||||
/// INVARIANTS:
|
||||
/// 1. statement Ok implies start <= end, both in the calendar.
|
||||
/// 2. An omitted end is today; an omitted as_of is today.
|
||||
/// 3. An omitted start is the first day of the fiscal year that holds
|
||||
/// the resolved end, clamped to FIRST_DAY.
|
||||
/// 4. Never swapped or clamped to fit: Err iff the resolved start is
|
||||
/// after the resolved end.
|
||||
/// 5. statement_range() == statement(None, None).
|
||||
/// SCENARIOS:
|
||||
/// 1. IF both dates are given and start > end: SHOULD refuse with
|
||||
/// InvalidDateRange.
|
||||
/// 2. IF only start is given and it is after today: SHOULD refuse with
|
||||
/// StartAfterToday, saying the end defaulted to today.
|
||||
/// 3. IF a given date is outside the calendar: SHOULD refuse with
|
||||
/// DateOutOfRange.
|
||||
/// ```
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PeriodDefaults {
|
||||
fiscal_year_start: (u32, u32),
|
||||
today: NaiveDate,
|
||||
}
|
||||
|
||||
/// The defaults an omitted date takes for `company`, on `today` (in the
|
||||
/// company's time zone).
|
||||
pub fn period_defaults(company: &Company, today: NaiveDate) -> PeriodDefaults {
|
||||
PeriodDefaults {
|
||||
fiscal_year_start: company.fiscal_year_start,
|
||||
today,
|
||||
}
|
||||
}
|
||||
|
||||
impl PeriodDefaults {
|
||||
/// The day the defaults are for.
|
||||
pub fn today(&self) -> NaiveDate {
|
||||
self.today
|
||||
}
|
||||
|
||||
/// A period report with both dates omitted: the first day of the
|
||||
/// fiscal year that holds today, through today (INVARIANT 5).
|
||||
pub fn statement_range(&self) -> (NaiveDate, NaiveDate) {
|
||||
(
|
||||
fiscal_year_start_on(self.fiscal_year_start, self.today),
|
||||
self.today,
|
||||
)
|
||||
}
|
||||
|
||||
/// A period report's dates as given (`None`: omitted), resolved
|
||||
/// (INVARIANTS 1-4).
|
||||
pub fn statement(
|
||||
&self,
|
||||
start: Option<NaiveDate>,
|
||||
end: Option<NaiveDate>,
|
||||
) -> Result<(NaiveDate, NaiveDate), LedgerError> {
|
||||
let end_given = end.map(check).transpose()?;
|
||||
let start_given = start.map(check).transpose()?;
|
||||
let end = end_given.unwrap_or(self.today);
|
||||
let start =
|
||||
start_given.unwrap_or_else(|| fiscal_year_start_on(self.fiscal_year_start, end));
|
||||
if start > end {
|
||||
return Err(if end_given.is_some() {
|
||||
LedgerError::InvalidDateRange {
|
||||
start: start.to_string(),
|
||||
end: end.to_string(),
|
||||
}
|
||||
} else {
|
||||
LedgerError::StartAfterToday {
|
||||
start: start.to_string(),
|
||||
today: end.to_string(),
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok((start, end))
|
||||
}
|
||||
|
||||
/// A point-in-time report's day: the one given, else today.
|
||||
pub fn as_of(&self, given: Option<NaiveDate>) -> NaiveDate {
|
||||
given.unwrap_or(self.today)
|
||||
}
|
||||
|
||||
/// A calendar-year count's year when none is given: today's.
|
||||
pub fn calendar_year(&self) -> i32 {
|
||||
self.today.year()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -79,4 +281,185 @@ mod tests {
|
||||
assert!(d.pred_opt().is_some() && d.succ_opt().is_some());
|
||||
}
|
||||
}
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn company(md: (u32, u32)) -> Company {
|
||||
Company {
|
||||
fiscal_year_start: md,
|
||||
..Company::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// fiscal_year_start_on INVARIANT 2: md in d's year when on or before
|
||||
/// d, else the year before; for a calendar year, a July year and a
|
||||
/// December year, on the start day and the day before it.
|
||||
#[test]
|
||||
fn the_fiscal_year_start_is_the_last_start_on_or_before() {
|
||||
for (md, day, want) in [
|
||||
((1, 1), d(2026, 10, 20), d(2026, 1, 1)),
|
||||
((1, 1), d(2026, 1, 1), d(2026, 1, 1)),
|
||||
((1, 1), d(2025, 12, 31), d(2025, 1, 1)),
|
||||
((7, 1), d(2026, 10, 20), d(2026, 7, 1)),
|
||||
((7, 1), d(2026, 7, 1), d(2026, 7, 1)),
|
||||
((7, 1), d(2026, 6, 30), d(2025, 7, 1)),
|
||||
((7, 1), d(2026, 2, 28), d(2025, 7, 1)),
|
||||
((12, 1), d(2026, 11, 30), d(2025, 12, 1)),
|
||||
((12, 1), d(2026, 12, 1), d(2026, 12, 1)),
|
||||
((12, 1), d(2026, 12, 31), d(2026, 12, 1)),
|
||||
// A journaled start that isn't the first (replay is lenient).
|
||||
((7, 15), d(2026, 7, 14), d(2025, 7, 15)),
|
||||
((7, 15), d(2026, 7, 15), d(2026, 7, 15)),
|
||||
// The calendar's last day.
|
||||
((7, 1), LAST_DAY, d(9999, 7, 1)),
|
||||
((1, 1), LAST_DAY, d(9999, 1, 1)),
|
||||
] {
|
||||
assert_eq!(fiscal_year_start_on(md, day), want, "{md:?} {day}");
|
||||
}
|
||||
}
|
||||
|
||||
/// INVARIANT 2's clamp: a July year holding a day early in year 1
|
||||
/// began in year 0, which the books' calendar doesn't keep.
|
||||
#[test]
|
||||
fn a_fiscal_year_from_year_zero_starts_on_the_first_day() {
|
||||
assert_eq!(fiscal_year_start_unclamped((7, 1), d(1, 3, 1)), d(0, 7, 1));
|
||||
assert_eq!(fiscal_year_start_on((7, 1), d(1, 3, 1)), FIRST_DAY);
|
||||
assert_eq!(fiscal_year_start_on((7, 1), d(1, 7, 1)), d(1, 7, 1));
|
||||
assert_eq!(fiscal_year_start_on((1, 1), FIRST_DAY), FIRST_DAY);
|
||||
}
|
||||
|
||||
/// INVARIANT 3: no month-day and no chrono date panics; a month-day
|
||||
/// no year has counts as January 1.
|
||||
#[test]
|
||||
fn no_month_day_or_date_panics() {
|
||||
assert_eq!(fiscal_year_start_on((2, 30), d(2026, 5, 5)), d(2026, 1, 1));
|
||||
assert_eq!(fiscal_year_start_on((13, 1), d(2026, 5, 5)), d(2026, 1, 1));
|
||||
assert_eq!(fiscal_year_start_on((0, 0), d(2026, 5, 5)), d(2026, 1, 1));
|
||||
for md in [(1, 1), (7, 1), (12, 31), (2, 28)] {
|
||||
for day in [NaiveDate::MIN, NaiveDate::MAX, FIRST_DAY, LAST_DAY] {
|
||||
let _ = fiscal_year_start_on(md, day);
|
||||
let _ = fiscal_year_start_unclamped(md, day);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// PeriodDefaults INVARIANTS 2, 3, 5: omitted dates.
|
||||
#[test]
|
||||
fn omitted_dates_are_the_fiscal_year_to_date() {
|
||||
let today = d(2026, 10, 20);
|
||||
let p = period_defaults(&company((1, 1)), today);
|
||||
assert_eq!(p.statement_range(), (d(2026, 1, 1), today));
|
||||
assert_eq!(p.statement(None, None), Ok(p.statement_range()));
|
||||
// Only end: that end's fiscal year, to it.
|
||||
assert_eq!(
|
||||
p.statement(None, Some(d(2025, 6, 30))),
|
||||
Ok((d(2025, 1, 1), d(2025, 6, 30)))
|
||||
);
|
||||
// Only start: through today.
|
||||
assert_eq!(
|
||||
p.statement(Some(d(2024, 3, 15)), None),
|
||||
Ok((d(2024, 3, 15), today))
|
||||
);
|
||||
let july = period_defaults(&company((7, 1)), today);
|
||||
assert_eq!(july.statement_range(), (d(2026, 7, 1), today));
|
||||
assert_eq!(
|
||||
july.statement(None, Some(d(2026, 6, 30))),
|
||||
Ok((d(2025, 7, 1), d(2026, 6, 30)))
|
||||
);
|
||||
assert_eq!(
|
||||
july.statement(None, Some(d(1, 3, 1))),
|
||||
Ok((FIRST_DAY, d(1, 3, 1)))
|
||||
);
|
||||
assert_eq!(
|
||||
july.statement(None, Some(LAST_DAY)),
|
||||
Ok((d(9999, 7, 1), LAST_DAY))
|
||||
);
|
||||
assert_eq!(p.as_of(None), today);
|
||||
assert_eq!(p.as_of(Some(d(2025, 12, 31))), d(2025, 12, 31));
|
||||
assert_eq!(p.calendar_year(), 2026);
|
||||
assert_eq!(july.calendar_year(), 2026);
|
||||
assert_eq!(p.today(), today);
|
||||
}
|
||||
|
||||
/// PeriodDefaults INVARIANT 4 and SCENARIOS 1-3: never swapped.
|
||||
#[test]
|
||||
fn a_start_after_the_end_is_refused_never_swapped() {
|
||||
let today = d(2026, 10, 20);
|
||||
let p = period_defaults(&company((1, 1)), today);
|
||||
// One day long is a period.
|
||||
assert_eq!(p.statement(Some(today), Some(today)), Ok((today, today)));
|
||||
assert_eq!(
|
||||
p.statement(Some(d(2026, 1, 2)), Some(d(2026, 1, 1))),
|
||||
Err(LedgerError::InvalidDateRange {
|
||||
start: "2026-01-02".into(),
|
||||
end: "2026-01-01".into()
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
p.statement(Some(d(2026, 12, 31)), Some(d(2026, 1, 1))),
|
||||
Err(LedgerError::InvalidDateRange {
|
||||
start: "2026-12-31".into(),
|
||||
end: "2026-01-01".into()
|
||||
})
|
||||
);
|
||||
// The end defaulted: the message says so.
|
||||
let e = p.statement(Some(d(2027, 1, 1)), None).unwrap_err();
|
||||
assert_eq!(
|
||||
e,
|
||||
LedgerError::StartAfterToday {
|
||||
start: "2027-01-01".into(),
|
||||
today: "2026-10-20".into()
|
||||
}
|
||||
);
|
||||
assert!(e.to_string().contains("today"), "{e}");
|
||||
assert!(e.to_string().contains("pass end"), "{e}");
|
||||
assert_eq!(
|
||||
p.statement(Some(d(2026, 10, 21)), None).map(|_| ()),
|
||||
Err(LedgerError::StartAfterToday {
|
||||
start: "2026-10-21".into(),
|
||||
today: "2026-10-20".into()
|
||||
})
|
||||
);
|
||||
assert_eq!(p.statement(Some(today), None), Ok((today, today)));
|
||||
// Outside the calendar.
|
||||
assert_eq!(
|
||||
p.statement(None, Some(NaiveDate::MAX)),
|
||||
Err(LedgerError::DateOutOfRange(NaiveDate::MAX))
|
||||
);
|
||||
assert_eq!(
|
||||
p.statement(Some(NaiveDate::MIN), Some(today)),
|
||||
Err(LedgerError::DateOutOfRange(NaiveDate::MIN))
|
||||
);
|
||||
}
|
||||
|
||||
/// The descriptions say what the resolver does (B-30), in the words
|
||||
/// every tool shows.
|
||||
#[test]
|
||||
fn the_descriptions_name_the_rule() {
|
||||
let s = PeriodKind::Statement.describe();
|
||||
assert!(s.contains("fiscal year to date"));
|
||||
assert!(s.contains("end defaults to today"));
|
||||
assert!(s.contains("A start after the end is refused"));
|
||||
assert!(PeriodKind::AsOf.describe().contains("today"));
|
||||
assert!(
|
||||
PeriodKind::CalendarYear
|
||||
.describe()
|
||||
.contains("calendar year")
|
||||
);
|
||||
for text in [
|
||||
s,
|
||||
PeriodKind::AsOf.describe(),
|
||||
PeriodKind::CalendarYear.describe(),
|
||||
START_DOC,
|
||||
END_DOC,
|
||||
AS_OF_DOC,
|
||||
YEAR_DOC,
|
||||
] {
|
||||
for stale in ["all time", "beginning of time", "first entry"] {
|
||||
assert!(!text.contains(stale), "{text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -306,6 +306,59 @@ pub enum LedgerError {
|
||||
#[error("account name must not be empty")]
|
||||
EmptyAccountName,
|
||||
|
||||
/// A rename to the name the account already has: there is nothing to
|
||||
/// journal.
|
||||
#[error("account {code} is already named {name:?}: nothing to rename")]
|
||||
AccountNameUnchanged {
|
||||
/// The account's code.
|
||||
code: String,
|
||||
/// Its name.
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// A new name (a new account's, or a rename's) that another account
|
||||
/// under the same parent already has, ignoring capitals: QuickBooks
|
||||
/// matches accounts by name (docs/spec/sources/intuit-iif.md), so the
|
||||
/// CPA's import would merge the two.
|
||||
#[error(
|
||||
"{sibling} already has the name {name:?} under the same parent (names compare ignoring capitals): QuickBooks matches accounts by name and would merge the two, so choose another name"
|
||||
)]
|
||||
DuplicateAccountName {
|
||||
/// The name refused.
|
||||
name: String,
|
||||
/// The account that has it, `"CODE Name"`.
|
||||
sibling: String,
|
||||
},
|
||||
|
||||
/// A new account name holding a run of nine digits: it reads as a full
|
||||
/// SSN, EIN or account number, and every report prints it.
|
||||
#[error(
|
||||
"the account name holds nine digits in a row, which read as a full SSN, EIN or account number, and every report prints it: write only the last four (a full TIN goes only into the vault, books.set_tin)"
|
||||
)]
|
||||
AccountNameHoldsTin,
|
||||
|
||||
/// A new account name (a new account's, or a rename's) that isn't one
|
||||
/// line of text within the bound ([`crate::account::MAX_ACCOUNT_NAME`]
|
||||
/// characters): every report, export and the IIF print it on one line.
|
||||
#[error("account name {0}")]
|
||||
InvalidAccountName(String),
|
||||
|
||||
/// A rename's reason the journal can't keep: blank, too long, holding
|
||||
/// a control character or a full TIN.
|
||||
#[error("rename reason: {0}")]
|
||||
InvalidRenameReason(String),
|
||||
|
||||
/// `policy.require_tax_category = refuse`, and a revenue or expense
|
||||
/// account (not a placeholder) would be created, or left, without a
|
||||
/// tax category.
|
||||
#[error("{}", tax_category_required_text(account, *clearing))]
|
||||
TaxCategoryRequired {
|
||||
/// The account, `"CODE Name"`.
|
||||
account: String,
|
||||
/// Whether the command would clear the category it has.
|
||||
clearing: bool,
|
||||
},
|
||||
|
||||
/// The referenced parent account does not exist.
|
||||
#[error("unknown parent account id {0}")]
|
||||
UnknownParentAccount(u64),
|
||||
@@ -388,7 +441,7 @@ pub enum LedgerError {
|
||||
#[error("invalid members: {0}")]
|
||||
InvalidMembers(String),
|
||||
|
||||
/// Cash-flow period end is before period start.
|
||||
/// A report period's end is before its start.
|
||||
#[error("invalid date range: end {end} is before start {start}")]
|
||||
InvalidDateRange {
|
||||
/// Period start, YYYY-MM-DD.
|
||||
@@ -420,6 +473,18 @@ pub enum LedgerError {
|
||||
#[error("cash flow account roles: {0}")]
|
||||
CashFlowRoles(String),
|
||||
|
||||
/// A report period given only a start, after today: an omitted end is
|
||||
/// today (rules.md B-30), so the period would end before it starts.
|
||||
#[error(
|
||||
"invalid date range: start {start} is after the end, which defaults to today ({today}): pass end"
|
||||
)]
|
||||
StartAfterToday {
|
||||
/// Period start, YYYY-MM-DD.
|
||||
start: String,
|
||||
/// Today, the end an omitted end means, YYYY-MM-DD.
|
||||
today: String,
|
||||
},
|
||||
|
||||
/// The referenced accounting period does not exist.
|
||||
#[error("unknown period id {0}")]
|
||||
UnknownPeriod(u64),
|
||||
@@ -567,3 +632,17 @@ pub enum LedgerError {
|
||||
)]
|
||||
ApprovalNeedsDraft(String),
|
||||
}
|
||||
|
||||
/// The refusal [`LedgerError::TaxCategoryRequired`] gives: what to pass,
|
||||
/// and where the categories are listed.
|
||||
fn tax_category_required_text(account: &str, clearing: bool) -> String {
|
||||
if clearing {
|
||||
format!(
|
||||
"policy.require_tax_category is refuse: {account} keeps a tax category (give it another with books.set_tax_category; books.tax_categories lists them)"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"policy.require_tax_category is refuse: give {account} a tax_category (books.tax_categories lists them)"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -749,9 +749,13 @@ impl Ledger {
|
||||
/// Add an account to the chart. Returns its id.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: register an account under a unique code.
|
||||
/// GOAL: register an account under a unique code and a name no
|
||||
/// sibling has.
|
||||
/// SOURCE: rules.md B-4 (the five elements); R-4; DECISION (owner,
|
||||
/// 2026-10-13): a child has its parent's type.
|
||||
/// 2026-10-13): a child has its parent's type; rules.md B-31
|
||||
/// (names: QuickBooks matches accounts by name,
|
||||
/// docs/spec/sources/intuit-iif.md; no full TIN in what every
|
||||
/// report prints, B-26; DECISION, owner, 2026-10-20).
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the code is empty or taken: SHOULD refuse.
|
||||
/// 2. IF the parent doesn't exist: SHOULD refuse.
|
||||
@@ -759,10 +763,23 @@ impl Ledger {
|
||||
/// - SHOULD refuse with ParentTypeMismatch
|
||||
/// - NEVER add the account
|
||||
/// BECAUSE a child subdivides its parent.
|
||||
/// 4. IF the name is blank: SHOULD refuse with EmptyAccountName.
|
||||
/// 5. IF an account under the same parent (or, for a top-level
|
||||
/// account, another top-level one) has the name, ignoring
|
||||
/// capitals and surrounding space: SHOULD refuse with
|
||||
/// DuplicateAccountName.
|
||||
/// 6. IF the name holds a run of nine digits (a full SSN, EIN or
|
||||
/// account number): SHOULD refuse with AccountNameHoldsTin.
|
||||
/// 7. IF the name, trimmed, is longer than MAX_ACCOUNT_NAME
|
||||
/// characters, or holds a line break, tab or other control
|
||||
/// character: SHOULD refuse with InvalidAccountName
|
||||
/// BECAUSE every report, export and the IIF print it on one line.
|
||||
/// 8. The name is kept trimmed, as a rename keeps it.
|
||||
/// ```
|
||||
///
|
||||
/// Replay of journals written before the parent rule uses
|
||||
/// [`Self::replay_add_account`].
|
||||
/// Replay of journals written before these rules uses
|
||||
/// [`Self::replay_add_account`]; the accounts the engine creates for a
|
||||
/// control role use [`Self::add_control_account`].
|
||||
pub fn add_account(
|
||||
&mut self,
|
||||
code: &str,
|
||||
@@ -777,7 +794,8 @@ impl Ledger {
|
||||
if code.trim().is_empty() {
|
||||
return Err(LedgerError::EmptyAccountCode);
|
||||
}
|
||||
if name.trim().is_empty() {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(LedgerError::EmptyAccountName);
|
||||
}
|
||||
if let Some(p) = parent.and_then(|p| self.chart.get(p))
|
||||
@@ -790,9 +808,66 @@ impl Ledger {
|
||||
found: account_type,
|
||||
});
|
||||
}
|
||||
if self.chart.find_by_code(code).is_some() {
|
||||
return Err(LedgerError::DuplicateAccountCode(code.to_string()));
|
||||
}
|
||||
self.check_account_name(None, name, parent)?;
|
||||
self.replay_add_account(code, name, account_type, parent, placeholder)
|
||||
}
|
||||
|
||||
/// Create the account a control role posts to, at its code with its
|
||||
/// name (invoices, bills, payroll and closes create them when first
|
||||
/// needed). The engine's own names: never refused for a sibling's
|
||||
/// name, and never for a missing tax category (rules.md B-32), so an
|
||||
/// invoice is never refused over a chart label.
|
||||
pub fn add_control_account(&mut self, c: &ControlAccount) -> Result<AccountId, LedgerError> {
|
||||
self.replay_add_account(c.code, c.name, c.account_type, None, false)
|
||||
}
|
||||
|
||||
/// The name rules for a new name (a new account's, or a rename's),
|
||||
/// given trimmed and not blank: one line of at most
|
||||
/// [`crate::account::MAX_ACCOUNT_NAME`] characters, no run of nine
|
||||
/// digits, and none of `account`'s siblings under `parent` has it,
|
||||
/// ignoring capitals and surrounding space ([`Self::add_account`]
|
||||
/// SCENARIOS 5-7). `account` is the one being renamed, never its own
|
||||
/// sibling.
|
||||
fn check_account_name(
|
||||
&self,
|
||||
account: Option<AccountId>,
|
||||
name: &str,
|
||||
parent: Option<AccountId>,
|
||||
) -> Result<(), LedgerError> {
|
||||
let max = crate::account::MAX_ACCOUNT_NAME;
|
||||
if name.chars().count() > max {
|
||||
return Err(LedgerError::InvalidAccountName(format!(
|
||||
"is longer than {max} characters: every report prints it on one line"
|
||||
)));
|
||||
}
|
||||
if name.chars().any(char::is_control) {
|
||||
return Err(LedgerError::InvalidAccountName(
|
||||
"holds a line break, tab or other control character: every report prints it on one line".into(),
|
||||
));
|
||||
}
|
||||
if crate::parties::has_full_tin(name) {
|
||||
return Err(LedgerError::AccountNameHoldsTin);
|
||||
}
|
||||
let key = crate::parties::name_key(name);
|
||||
let mut siblings: Vec<&Account> = self
|
||||
.chart
|
||||
.all()
|
||||
.filter(|a| a.parent == parent && Some(a.id) != account)
|
||||
.filter(|a| crate::parties::name_key(&a.name) == key)
|
||||
.collect();
|
||||
siblings.sort_by_key(|a| a.id);
|
||||
match siblings.first() {
|
||||
Some(a) => Err(LedgerError::DuplicateAccountName {
|
||||
name: name.trim().to_string(),
|
||||
sibling: format!("{} {}", a.code, a.name),
|
||||
}),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::add_account`] without the rules added on 2026-10-13 (a
|
||||
/// child's type; a blank code or name), for replaying journals written before them. Never
|
||||
/// for new work.
|
||||
@@ -846,6 +921,141 @@ impl Ledger {
|
||||
self.chart.set_code(account, code)
|
||||
}
|
||||
|
||||
/// Give an account a new name: `name`, trimmed.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: let the human rename an account without touching its history.
|
||||
/// SOURCE: rules.md B-31; DECISION (owner, plan/served.md 4.9 and P3
|
||||
/// "Settled in design", 2026-10-20); rules.md B-8 (posted is
|
||||
/// forever: entries name accounts by id, so a name is a label).
|
||||
/// INVARIANTS:
|
||||
/// 1. Only the name changes: the account's id, code, type, parent,
|
||||
/// placeholder flag, tax category, and every entry posted to it
|
||||
/// stay as they were, so no entry, balance or statement amount
|
||||
/// changes; every report, past periods included, prints the new
|
||||
/// name.
|
||||
/// 2. A refusal MUST leave the books unchanged.
|
||||
/// 3. The rules are the command's: replay takes any journaled name
|
||||
/// for an account that exists (Self::replay_rename_account,
|
||||
/// rules.md B-28).
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the account is unknown: SHOULD refuse with UnknownAccount.
|
||||
/// 2. IF the name is blank: SHOULD refuse with EmptyAccountName.
|
||||
/// 3. IF the name, trimmed, is the one it has: SHOULD refuse with
|
||||
/// AccountNameUnchanged (nothing to journal).
|
||||
/// 4. IF another account under the same parent has it, ignoring
|
||||
/// capitals and surrounding space: SHOULD refuse with
|
||||
/// DuplicateAccountName.
|
||||
/// 5. IF it holds a run of nine digits: SHOULD refuse with
|
||||
/// AccountNameHoldsTin.
|
||||
/// 6. IF it is longer than MAX_ACCOUNT_NAME characters or holds a
|
||||
/// control character: SHOULD refuse with InvalidAccountName (the
|
||||
/// rules a new account's name meets, Self::add_account).
|
||||
/// 7. Approval rules, roles, settings and closed periods never
|
||||
/// block it: they name accounts by code or id, and a name is no
|
||||
/// posting.
|
||||
/// ```
|
||||
pub fn rename_account(&mut self, account: AccountId, name: &str) -> Result<(), LedgerError> {
|
||||
self.check_rename_account(account, name)?;
|
||||
self.chart.set_name(account, name.trim())
|
||||
}
|
||||
|
||||
/// The validation behind [`Self::rename_account`], for stores that
|
||||
/// journal before mutating.
|
||||
pub fn check_rename_account(&self, account: AccountId, name: &str) -> Result<(), LedgerError> {
|
||||
let a = self
|
||||
.chart
|
||||
.get(account)
|
||||
.ok_or(LedgerError::UnknownAccount(account))?;
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(LedgerError::EmptyAccountName);
|
||||
}
|
||||
if name == a.name {
|
||||
return Err(LedgerError::AccountNameUnchanged {
|
||||
code: a.code.clone(),
|
||||
name: a.name.clone(),
|
||||
});
|
||||
}
|
||||
self.check_account_name(Some(account), name, a.parent)
|
||||
}
|
||||
|
||||
/// Replay a journaled rename: the account must exist; the name is
|
||||
/// taken as journaled, whatever later versions refuse in a new one
|
||||
/// (rules.md B-28).
|
||||
pub fn replay_rename_account(
|
||||
&mut self,
|
||||
account: AccountId,
|
||||
name: &str,
|
||||
) -> Result<(), LedgerError> {
|
||||
self.chart.set_name(account, name)
|
||||
}
|
||||
|
||||
/// Check a rename's reason before it is journaled: one to
|
||||
/// [`crate::settings::MAX_LONG_TEXT`] characters, line breaks and tabs
|
||||
/// allowed but no other control character, and no full TIN (rules.md
|
||||
/// B-26). Returns it trimmed.
|
||||
pub fn check_rename_reason(reason: &str) -> Result<String, LedgerError> {
|
||||
let r = crate::settings::long_text("reason", reason, crate::settings::MAX_LONG_TEXT)
|
||||
.map_err(LedgerError::InvalidRenameReason)?;
|
||||
if crate::parties::has_full_tin(&r) {
|
||||
return Err(LedgerError::InvalidRenameReason(
|
||||
"reason holds nine digits in a row, which read as a full SSN or EIN: write only the last four".into(),
|
||||
));
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Whether `policy.require_tax_category` lets an account be created
|
||||
/// with `category` (rules.md B-32): under `refuse`, a revenue or
|
||||
/// expense account that isn't a header needs one. The command's check
|
||||
/// only, never replay's (rules.md B-28); the accounts the engine
|
||||
/// creates for a control role never meet it.
|
||||
pub fn check_tax_category_required(
|
||||
&self,
|
||||
code: &str,
|
||||
name: &str,
|
||||
account_type: AccountType,
|
||||
placeholder: bool,
|
||||
category: Option<&str>,
|
||||
) -> Result<(), LedgerError> {
|
||||
if self.settings.require_tax_category() == crate::settings::TaxCategoryPolicy::Refuse
|
||||
&& crate::tax::needs_category(account_type, placeholder)
|
||||
&& category.is_none()
|
||||
{
|
||||
return Err(LedgerError::TaxCategoryRequired {
|
||||
account: format!("{} {}", code.trim(), name.trim()),
|
||||
clearing: false,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether `policy.require_tax_category` lets `account`'s category be
|
||||
/// set to `category` (rules.md B-32): under `refuse`, an account that
|
||||
/// needs one ([`crate::tax::account_needs_category`]) keeps one. The
|
||||
/// command's check only, never replay's.
|
||||
pub fn check_tax_category_kept(
|
||||
&self,
|
||||
account: AccountId,
|
||||
category: Option<&str>,
|
||||
) -> Result<(), LedgerError> {
|
||||
let a = self
|
||||
.chart
|
||||
.get(account)
|
||||
.ok_or(LedgerError::UnknownAccount(account))?;
|
||||
if self.settings.require_tax_category() == crate::settings::TaxCategoryPolicy::Refuse
|
||||
&& category.is_none()
|
||||
&& crate::tax::account_needs_category(a)
|
||||
{
|
||||
return Err(LedgerError::TaxCategoryRequired {
|
||||
account: format!("{} {}", a.code, a.name),
|
||||
clearing: a.tax_category.is_some(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The validation behind [`Self::renumber_account`], for stores that
|
||||
/// journal before mutating.
|
||||
pub fn check_renumber_account(
|
||||
@@ -2582,18 +2792,10 @@ impl Ledger {
|
||||
if let Some(&id) = self.roles.get(RETAINED_EARNINGS.key) {
|
||||
return Ok(id);
|
||||
}
|
||||
Ok(
|
||||
match self.chart.find_by_code(periods::RETAINED_EARNINGS_CODE) {
|
||||
Some(a) => a.id,
|
||||
None => self.add_account(
|
||||
periods::RETAINED_EARNINGS_CODE,
|
||||
"Retained Earnings",
|
||||
AccountType::Equity,
|
||||
None,
|
||||
false,
|
||||
)?,
|
||||
},
|
||||
)
|
||||
Ok(match self.chart.find_by_code(RETAINED_EARNINGS.code) {
|
||||
Some(a) => a.id,
|
||||
None => self.add_control_account(&RETAINED_EARNINGS)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate a close target: every account an equity account, targets
|
||||
@@ -3777,18 +3979,18 @@ impl Ledger {
|
||||
fn ensure_control(&mut self, c: &ControlAccount) -> AccountId {
|
||||
match self.roles.get(c.key) {
|
||||
Some(&id) => id,
|
||||
None => self.ensure_account(c.code, c.name, c.account_type),
|
||||
None => self.ensure_account(c),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure an account exists by code, creating it with the given type
|
||||
/// and name if missing. Returns the account id.
|
||||
fn ensure_account(&mut self, code: &str, name: &str, account_type: AccountType) -> AccountId {
|
||||
if let Some(a) = self.chart.find_by_code(code) {
|
||||
/// Ensure a control role's account exists at its code, creating it
|
||||
/// (with the role's name and type: [`Self::add_control_account`]) if
|
||||
/// missing. Returns the account id.
|
||||
fn ensure_account(&mut self, c: &ControlAccount) -> AccountId {
|
||||
if let Some(a) = self.chart.find_by_code(c.code) {
|
||||
return a.id;
|
||||
}
|
||||
self.add_account(code, name, account_type, None, false)
|
||||
.expect("fresh code cannot fail")
|
||||
self.add_control_account(c).expect("fresh code cannot fail")
|
||||
}
|
||||
|
||||
/// Create a sales invoice. Auto-posts: Dr `1200` Accounts Receivable,
|
||||
|
||||
@@ -92,4 +92,6 @@ pub use schedules::{
|
||||
ScheduleId, ScheduleKind, TemplateLine,
|
||||
};
|
||||
pub use shares::{PartyMember, PartyShare, Share};
|
||||
pub use tax::{TaxCategory, TaxForm, TaxKind, TaxSummary, TaxSummaryLine, tax_summary};
|
||||
pub use tax::{
|
||||
BookOnlyLine, TaxCategory, TaxForm, TaxKind, TaxSummary, TaxSummaryLine, tax_summary,
|
||||
};
|
||||
@@ -30,11 +30,12 @@
|
||||
//! 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, approval rules off, the computer's time zone.
|
||||
//! parties suggested, a missing tax category warned of (not
|
||||
//! refused), approval rules off, the computer's time zone.
|
||||
//! ```
|
||||
|
||||
use crate::TaxForm;
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use chrono::NaiveDate;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
@@ -150,6 +151,8 @@ 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"])];
|
||||
|
||||
/// A return's key as a setting keeps it.
|
||||
pub fn tax_form_key(f: TaxForm) -> &'static str {
|
||||
@@ -359,6 +362,13 @@ pub static KEYS: &[Key] = &[
|
||||
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"),
|
||||
@@ -491,22 +501,29 @@ impl Company {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The fiscal year containing `d`: `(first day, last day)`, inclusive.
|
||||
/// 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) {
|
||||
let (m, day) = self.fiscal_year_start;
|
||||
let on = |year| NaiveDate::from_ymd_opt(year, m, day).expect("validated month-day");
|
||||
let start = if on(d.year()) <= d {
|
||||
on(d.year())
|
||||
} else {
|
||||
on(d.year() - 1)
|
||||
};
|
||||
let end = on(start.year() + 1)
|
||||
.pred_opt()
|
||||
.expect("not the minimum date");
|
||||
(start, end)
|
||||
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).
|
||||
@@ -1286,6 +1303,14 @@ impl Settings {
|
||||
.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") {
|
||||
@@ -1563,6 +1588,61 @@ mod tests {
|
||||
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() {
|
||||
|
||||
@@ -148,7 +148,23 @@ pub const CATEGORIES: &[TaxCategory] = &[
|
||||
100,
|
||||
"Portfolio interest: partnerships and S corps state it separately (Schedule K). Interest earned in the business itself (on customer accounts, say) is ordinary income: use other_income.",
|
||||
),
|
||||
cat(
|
||||
"form_4797_gain_loss",
|
||||
"Gain or loss on business property (Form 4797)",
|
||||
Income,
|
||||
ALL,
|
||||
100,
|
||||
"Gain or loss on selling or disposing of business property (the gain or loss account of books.dispose_asset). Form 4797 figures the tax gain from the tax basis, not the book one, and splits it: the ordinary part goes on Form 1065 line 6, Form 1120-S line 4 or Form 1120 line 9, and a partnership or S corp states net section 1231 gain separately (Schedule K line 10 or 9). On Schedule C books it goes on Form 4797, never Schedule C. The tax summary lists it apart, at its book amount and in no total: the CPA figures it.",
|
||||
),
|
||||
cat("other_income", "Other income", Income, ALL, 100, ""),
|
||||
cat(
|
||||
"tax_exempt_income",
|
||||
"Tax-exempt income",
|
||||
Income,
|
||||
ENTITIES,
|
||||
0,
|
||||
"Tax-exempt interest and other tax-exempt income: on no line of page 1 (Form 1065 Schedule K lines 18a-18b, Form 1120-S Schedule K lines 16a-16b, Form 1120 Schedule K item 9 and Schedule M-1 line 7). None of it is taxable: a book-to-tax difference, the income twin of nondeductible.",
|
||||
),
|
||||
cat(
|
||||
"cost_of_goods_sold",
|
||||
"Cost of goods sold",
|
||||
@@ -379,6 +395,44 @@ pub fn check_category(
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
/// Whether an account of `account_type` maps to a line of the return:
|
||||
/// revenue and expense accounts do, unless a header (a placeholder takes
|
||||
/// no postings, so nothing of its own reaches the return).
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: the accounts a tax category is for (rules.md B-32).
|
||||
/// SOURCE: IRS Pub. 583, "Your books must show your gross income, as well
|
||||
/// as your deductions and credits"; Rev. Proc. 98-25 §5.01, a
|
||||
/// relationship between "the amounts ... by account and the
|
||||
/// taxpayer's return" (docs/spec/sources/irs-records.md); DECISION
|
||||
/// (owner, 2026-10-20): header accounts are exempt.
|
||||
/// ```
|
||||
pub fn needs_category(account_type: AccountType, placeholder: bool) -> bool {
|
||||
matches!(account_type, AccountType::Revenue | AccountType::Expense) && !placeholder
|
||||
}
|
||||
|
||||
/// Whether `account` maps to a line of the return ([`needs_category`]
|
||||
/// of its type and header flag). A header never has a posting of its own
|
||||
/// to report: no entry posts to one, now or on replay
|
||||
/// (`JournalEntry::validate` refuses it), and an account's header flag
|
||||
/// never changes.
|
||||
pub fn account_needs_category(account: &crate::Account) -> bool {
|
||||
needs_category(account.account_type, account.placeholder)
|
||||
}
|
||||
|
||||
/// Every account that maps to a line of the return and has no tax
|
||||
/// category ([`account_needs_category`]), with or without activity,
|
||||
/// sorted by code: what Needs attention and the digest list.
|
||||
pub fn uncategorized_accounts(ledger: &Ledger) -> Vec<&crate::Account> {
|
||||
let mut out: Vec<&crate::Account> = ledger
|
||||
.chart()
|
||||
.all()
|
||||
.filter(|a| a.tax_category.is_none() && account_needs_category(a))
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.code.cmp(&b.code).then(a.id.cmp(&b.id)));
|
||||
out
|
||||
}
|
||||
|
||||
/// One category's line on the tax summary.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct TaxSummaryLine {
|
||||
@@ -405,6 +459,20 @@ pub struct Uncategorized {
|
||||
pub book: Decimal,
|
||||
}
|
||||
|
||||
/// A category's book amount that the summary can't place on the return
|
||||
/// or tax: see [`TaxSummary::form_4797`].
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct BookOnlyLine {
|
||||
/// Category key.
|
||||
pub category: String,
|
||||
/// Category label.
|
||||
pub label: String,
|
||||
/// The accounts that roll into it, `"CODE Name"`.
|
||||
pub accounts: Vec<String>,
|
||||
/// Book amount: a gain positive, a loss negative.
|
||||
pub book: Decimal,
|
||||
}
|
||||
|
||||
/// The tax summary for a period: book amounts by tax category, deductible
|
||||
/// amounts, and every account still needing a category.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -427,6 +495,17 @@ pub struct TaxSummary {
|
||||
/// irs-tax-lines.md). Not in [`TaxSummary::book_net`] or `tax_net`.
|
||||
#[serde(default)]
|
||||
pub separately_stated: Vec<TaxSummaryLine>,
|
||||
/// Gains and losses on business property ([`on_form_4797`]), at their
|
||||
/// book amounts, with no tax amount and in no total
|
||||
/// ([`TaxSummary::book_net`], `tax_net`): Form 4797 figures the tax
|
||||
/// gain from the tax basis (book and tax depreciation differ) and
|
||||
/// splits ordinary gain from net section 1231 gain, and the split
|
||||
/// decides where it goes (Form 1065 line 6 or Schedule K line 10; Form
|
||||
/// 1120-S line 4 or Schedule K line 9; Form 1120 line 9; on Schedule C
|
||||
/// books Form 4797 itself, never Schedule C: docs/spec/sources/
|
||||
/// irs-tax-lines.md). The CPA figures it, on every return alike.
|
||||
#[serde(default)]
|
||||
pub form_4797: Vec<BookOnlyLine>,
|
||||
/// P&L accounts with activity and no category.
|
||||
pub uncategorized: Vec<Uncategorized>,
|
||||
}
|
||||
@@ -506,11 +585,21 @@ pub fn tax_summary(
|
||||
cost_of_goods: vec![],
|
||||
deductions: vec![],
|
||||
separately_stated: vec![],
|
||||
form_4797: vec![],
|
||||
uncategorized,
|
||||
};
|
||||
// Catalog order, so the summary reads like the return.
|
||||
for c in CATEGORIES {
|
||||
if let Some(line) = by_cat.remove(c.key) {
|
||||
if on_form_4797(c.key) {
|
||||
out.form_4797.push(BookOnlyLine {
|
||||
category: line.category,
|
||||
label: line.label,
|
||||
accounts: line.accounts,
|
||||
book: line.book,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if separately_stated(c.key, form) {
|
||||
out.separately_stated.push(line);
|
||||
continue;
|
||||
@@ -525,6 +614,17 @@ pub fn tax_summary(
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Whether this category is a gain or loss on business property, which
|
||||
/// Form 4797 figures from the tax basis, on every return
|
||||
/// ([`TaxSummary::form_4797`]). Form 1065 line 6 and Form 1120-S line 4:
|
||||
/// "Include only ordinary gains or losses"; Schedule K: "Net section 1231
|
||||
/// gain (loss) (attach Form 4797)"; the Schedule C instructions: "Form
|
||||
/// 4797 to report sales, exchanges, and involuntary conversions ... of
|
||||
/// trade or business property".
|
||||
pub fn on_form_4797(key: &str) -> bool {
|
||||
key == "form_4797_gain_loss"
|
||||
}
|
||||
|
||||
/// Whether a partnership or S corporation states this category separately
|
||||
/// to its owners (Schedule K) rather than in ordinary income. Form 1065
|
||||
/// and 1120-S instructions: Schedule K takes "only taxable portfolio
|
||||
|
||||
Reference in new issue
Block a user